@visorcraft/idlehands 1.2.7 → 1.3.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.
package/dist/agent.js CHANGED
@@ -157,41 +157,41 @@ class AgentLoopBreak extends Error {
157
157
  this.name = 'AgentLoopBreak';
158
158
  }
159
159
  }
160
- const SYSTEM_PROMPT = `You are a coding agent with filesystem and shell access. Execute the user's request using the provided tools.
161
-
162
- Rules:
163
- - Work in the current directory. Use relative paths for all file operations.
164
- - 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.
165
- - 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.
166
- - Read the target file before editing. You need the exact text for search/replace.
167
- - Use read_file with search=... to jump to relevant code; avoid reading whole files.
168
- - Never call read_file/read_files/list_dir twice in a row with identical arguments (same path/options). Reuse the previous result instead.
169
- - Prefer apply_patch or edit_range for code edits (token-efficient). Use edit_file only when exact old_text replacement is necessary.
170
- - Tool-call arguments MUST be strict JSON (double-quoted keys/strings, no comments, no trailing commas).
171
- - edit_range example: {"path":"src/foo.ts","start_line":10,"end_line":14,"replacement":"line A\nline B"}
172
- - 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"]}
173
- - write_file is for new files or explicit full rewrites only. Existing non-empty files require overwrite=true/force=true.
174
- - Use insert_file for insertions (prepend/append/line).
175
- - Use exec to run commands, tests, builds; check results before reporting success.
176
- - 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.
177
- - Batch work: read all files you need, then apply all edits, then verify.
178
- - Be concise. Report what you changed and why.
179
- - 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.
180
- - If search_files returns 0 matches, try a broader pattern or use: exec grep -rn "keyword" path/
181
- - 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.
182
-
183
- Tool call format:
184
- - Use tool_calls. Do not write JSON tool invocations in your message text.
160
+ const SYSTEM_PROMPT = `You are a coding agent with filesystem and shell access. Execute the user's request using the provided tools.
161
+
162
+ Rules:
163
+ - Work in the current directory. Use relative paths for all file operations.
164
+ - 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.
165
+ - 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.
166
+ - Read the target file before editing. You need the exact text for search/replace.
167
+ - Use read_file with search=... to jump to relevant code; avoid reading whole files.
168
+ - Never call read_file/read_files/list_dir twice in a row with identical arguments (same path/options). Reuse the previous result instead.
169
+ - Prefer apply_patch or edit_range for code edits (token-efficient). Use edit_file only when exact old_text replacement is necessary.
170
+ - Tool-call arguments MUST be strict JSON (double-quoted keys/strings, no comments, no trailing commas).
171
+ - edit_range example: {"path":"src/foo.ts","start_line":10,"end_line":14,"replacement":"line A\nline B"}
172
+ - 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"]}
173
+ - write_file is for new files or explicit full rewrites only. Existing non-empty files require overwrite=true/force=true.
174
+ - Use insert_file for insertions (prepend/append/line).
175
+ - Use exec to run commands, tests, builds; check results before reporting success.
176
+ - 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.
177
+ - Batch work: read all files you need, then apply all edits, then verify.
178
+ - Be concise. Report what you changed and why.
179
+ - 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.
180
+ - If search_files returns 0 matches, try a broader pattern or use: exec grep -rn "keyword" path/
181
+ - 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.
182
+
183
+ Tool call format:
184
+ - Use tool_calls. Do not write JSON tool invocations in your message text.
185
185
  `;
186
186
  const MCP_TOOLS_REQUEST_TOKEN = '[[MCP_TOOLS_REQUEST]]';
187
- const DEFAULT_SUB_AGENT_SYSTEM_PROMPT = `You are a focused coding sub-agent. Execute only the delegated task.
188
- - Work in the current directory. Use relative paths for all file operations.
189
- - Read the target file before editing. You need the exact text for search/replace.
190
- - Keep tool usage tight and efficient.
191
- - Prefer surgical edits over rewrites.
192
- - Do NOT create files outside the working directory unless explicitly requested.
193
- - When running commands in a subdirectory, use exec's cwd parameter — NOT "cd /path && cmd".
194
- - Run verification commands when relevant.
187
+ const DEFAULT_SUB_AGENT_SYSTEM_PROMPT = `You are a focused coding sub-agent. Execute only the delegated task.
188
+ - Work in the current directory. Use relative paths for all file operations.
189
+ - Read the target file before editing. You need the exact text for search/replace.
190
+ - Keep tool usage tight and efficient.
191
+ - Prefer surgical edits over rewrites.
192
+ - Do NOT create files outside the working directory unless explicitly requested.
193
+ - When running commands in a subdirectory, use exec's cwd parameter — NOT "cd /path && cmd".
194
+ - Run verification commands when relevant.
195
195
  - Return a concise outcome summary.`;
196
196
  const DEFAULT_SUB_AGENT_RESULT_TOKEN_CAP = 4000;
197
197
  const APPROVAL_MODE_SET = new Set(['plan', 'reject', 'default', 'auto-edit', 'yolo']);
@@ -844,9 +844,6 @@ export async function createSession(opts) {
844
844
  catch { }
845
845
  }
846
846
  }
847
- if (harness.systemPromptSuffix) {
848
- sessionMeta += '\n\n' + harness.systemPromptSuffix;
849
- }
850
847
  // Phase 9: sys-eager — inject full system snapshot into first message
851
848
  if (cfg.sys_eager && cfg.mode === 'sys') {
852
849
  try {
@@ -857,30 +854,42 @@ export async function createSession(opts) {
857
854
  console.warn(`[warn] sys-eager snapshot failed: ${e?.message ?? e}`);
858
855
  }
859
856
  }
860
- const defaultSystemPrompt = SYSTEM_PROMPT;
861
- let activeSystemPrompt = (cfg.system_prompt_override ?? '').trim() || defaultSystemPrompt;
857
+ const defaultSystemPromptBase = SYSTEM_PROMPT;
858
+ let activeSystemPromptBase = (cfg.system_prompt_override ?? '').trim() || defaultSystemPromptBase;
859
+ let systemPromptOverridden = (cfg.system_prompt_override ?? '').trim().length > 0;
860
+ const buildEffectiveSystemPrompt = () => {
861
+ let p = activeSystemPromptBase;
862
+ if (harness.systemPromptSuffix && !systemPromptOverridden) {
863
+ p += '\n\n' + harness.systemPromptSuffix;
864
+ }
865
+ return p;
866
+ };
862
867
  let messages = [
863
- { role: 'system', content: activeSystemPrompt }
868
+ { role: 'system', content: buildEffectiveSystemPrompt() }
864
869
  ];
865
870
  let sessionMetaPending = sessionMeta;
866
871
  const setSystemPrompt = (prompt) => {
867
872
  const next = String(prompt ?? '').trim();
868
873
  if (!next)
869
874
  throw new Error('system prompt cannot be empty');
870
- activeSystemPrompt = next;
875
+ activeSystemPromptBase = next;
876
+ systemPromptOverridden = true;
877
+ const effective = buildEffectiveSystemPrompt();
871
878
  if (messages.length > 0 && messages[0].role === 'system') {
872
- messages[0] = { role: 'system', content: activeSystemPrompt };
879
+ messages[0] = { role: 'system', content: effective };
873
880
  }
874
881
  else {
875
- messages.unshift({ role: 'system', content: activeSystemPrompt });
882
+ messages.unshift({ role: 'system', content: effective });
876
883
  }
877
884
  };
878
885
  const resetSystemPrompt = () => {
879
- setSystemPrompt(defaultSystemPrompt);
886
+ systemPromptOverridden = false;
887
+ setSystemPrompt(defaultSystemPromptBase);
880
888
  };
881
889
  const reset = () => {
890
+ const effective = buildEffectiveSystemPrompt();
882
891
  messages = [
883
- { role: 'system', content: activeSystemPrompt }
892
+ { role: 'system', content: effective }
884
893
  ];
885
894
  sessionMetaPending = sessionMeta;
886
895
  lastEditedPath = undefined;
@@ -895,7 +904,9 @@ export async function createSession(opts) {
895
904
  throw new Error('restore: first message must be system');
896
905
  }
897
906
  messages = next;
898
- activeSystemPrompt = String(next[0].content ?? defaultSystemPrompt);
907
+ activeSystemPromptBase = String(next[0].content ?? defaultSystemPromptBase);
908
+ // Note: we don't force buildEffectiveSystemPrompt() here because the restore
909
+ // data might already have a customized system prompt we want to respect.
899
910
  if (mcpManager) {
900
911
  const usedMcpTool = next.some((msg) => {
901
912
  if (msg?.role !== 'assistant' || !Array.isArray(msg.tool_calls))
@@ -1125,6 +1136,7 @@ export async function createSession(opts) {
1125
1136
  dirPinned: cfg.dir_pinned,
1126
1137
  repoCandidates: cfg.repo_candidates,
1127
1138
  confirm: overrides?.confirmBridge ?? defaultConfirmBridge,
1139
+ maxReadLines: cfg.max_read_lines,
1128
1140
  replay,
1129
1141
  vault,
1130
1142
  lens,
@@ -1132,6 +1144,41 @@ export async function createSession(opts) {
1132
1144
  onMutation: overrides?.onMutation ?? ((absPath) => { lastEditedPath = absPath; }),
1133
1145
  };
1134
1146
  };
1147
+ const buildLspLensSymbolOutput = async (filePathRaw) => {
1148
+ if (!lspManager)
1149
+ return '[lsp] unavailable';
1150
+ const semantic = await lspManager.getSymbols(filePathRaw);
1151
+ if (!lens)
1152
+ return semantic;
1153
+ const cwd = cfg.dir ?? process.cwd();
1154
+ const absPath = filePathRaw.startsWith('/') ? filePathRaw : path.resolve(cwd, filePathRaw);
1155
+ const body = await fs.readFile(absPath, 'utf8').catch(() => '');
1156
+ if (!body)
1157
+ return semantic;
1158
+ const projection = await lens.projectFile(absPath, body).catch(() => '');
1159
+ const structural = extractLensBody(projection);
1160
+ if (!structural)
1161
+ return semantic;
1162
+ return `${semantic}\n\n[lens] Structural skeleton:\n${structural}`;
1163
+ };
1164
+ const dispatchLspTool = async (name, args) => {
1165
+ if (!lspManager)
1166
+ return '[lsp] unavailable';
1167
+ switch (name) {
1168
+ case 'lsp_diagnostics':
1169
+ return lspManager.getDiagnostics(typeof args?.path === 'string' ? args.path : undefined, typeof args?.severity === 'number' ? args.severity : undefined);
1170
+ case 'lsp_symbols':
1171
+ return buildLspLensSymbolOutput(String(args?.path ?? ''));
1172
+ case 'lsp_hover':
1173
+ return lspManager.getHover(String(args?.path ?? ''), Number(args?.line ?? 0), Number(args?.character ?? 0));
1174
+ case 'lsp_definition':
1175
+ return lspManager.getDefinition(String(args?.path ?? ''), Number(args?.line ?? 0), Number(args?.character ?? 0));
1176
+ case 'lsp_references':
1177
+ return lspManager.getReferences(String(args?.path ?? ''), Number(args?.line ?? 0), Number(args?.character ?? 0), typeof args?.max_results === 'number' ? args.max_results : 50);
1178
+ default:
1179
+ throw new Error(`unknown LSP tool: ${name}`);
1180
+ }
1181
+ };
1135
1182
  const executePlanStep = async (index) => {
1136
1183
  if (!planSteps.length)
1137
1184
  return ['No plan steps to execute.'];
@@ -1154,21 +1201,7 @@ export async function createSession(opts) {
1154
1201
  content = await runSpawnTaskCore(step.args, { signal: inFlight?.signal });
1155
1202
  }
1156
1203
  else if (LSP_TOOL_NAME_SET.has(step.tool) && lspManager) {
1157
- if (step.tool === 'lsp_diagnostics') {
1158
- content = await lspManager.getDiagnostics(typeof step.args?.path === 'string' ? step.args.path : undefined, typeof step.args?.severity === 'number' ? step.args.severity : undefined);
1159
- }
1160
- else if (step.tool === 'lsp_symbols') {
1161
- content = await lspManager.getSymbols(String(step.args?.path ?? ''));
1162
- }
1163
- else if (step.tool === 'lsp_hover') {
1164
- content = await lspManager.getHover(String(step.args?.path ?? ''), Number(step.args?.line ?? 0), Number(step.args?.character ?? 0));
1165
- }
1166
- else if (step.tool === 'lsp_definition') {
1167
- content = await lspManager.getDefinition(String(step.args?.path ?? ''), Number(step.args?.line ?? 0), Number(step.args?.character ?? 0));
1168
- }
1169
- else if (step.tool === 'lsp_references') {
1170
- content = await lspManager.getReferences(String(step.args?.path ?? ''), Number(step.args?.line ?? 0), Number(step.args?.character ?? 0), typeof step.args?.max_results === 'number' ? step.args.max_results : 50);
1171
- }
1204
+ content = await dispatchLspTool(step.tool, step.args);
1172
1205
  }
1173
1206
  else if (mcpManager?.hasTool(step.tool)) {
1174
1207
  const callArgs = step.args && typeof step.args === 'object' && !Array.isArray(step.args)
@@ -1230,11 +1263,39 @@ export async function createSession(opts) {
1230
1263
  };
1231
1264
  const buildCompactionSystemNote = (kind, dropped) => {
1232
1265
  const prefix = kind === 'auto'
1233
- ? `[auto-compacted: ${dropped} old messages dropped to stay within context budget.]`
1266
+ ? `[auto-compacted: ${dropped} old messages dropped to stay within context budget. Continue current task.]`
1234
1267
  : `[compacted: ${dropped} messages dropped.]`;
1235
1268
  const guidance = compactionVaultGuidance();
1236
1269
  return guidance ? `${prefix} ${guidance}` : prefix;
1237
1270
  };
1271
+ const buildCompactionSummaryPrompt = (dropped) => {
1272
+ const parts = [];
1273
+ for (const m of dropped) {
1274
+ if (m.role === 'assistant') {
1275
+ const text = typeof m.content === 'string' ? m.content : '';
1276
+ const toolCalls = m.tool_calls;
1277
+ if (toolCalls?.length) {
1278
+ for (const tc of toolCalls) {
1279
+ const args = typeof tc.function?.arguments === 'string'
1280
+ ? tc.function.arguments.slice(0, 200)
1281
+ : '';
1282
+ parts.push(`[tool_call: ${tc.function?.name}(${args})]`);
1283
+ }
1284
+ }
1285
+ if (text.trim())
1286
+ parts.push(`[assistant]: ${text.slice(0, 500)}`);
1287
+ }
1288
+ else if (m.role === 'tool') {
1289
+ const content = typeof m.content === 'string' ? m.content : '';
1290
+ parts.push(`[tool_result]: ${content.slice(0, 300)}`);
1291
+ }
1292
+ }
1293
+ let combined = parts.join('\n');
1294
+ if (combined.length > 4000) {
1295
+ combined = combined.slice(0, 4000) + '\n[...truncated]';
1296
+ }
1297
+ return combined;
1298
+ };
1238
1299
  let lastAskInstructionText = '';
1239
1300
  let lastCompactionReminderObjective = '';
1240
1301
  const injectCompactionReminder = (reason) => {
@@ -1584,6 +1645,10 @@ export async function createSession(opts) {
1584
1645
  configuredTopP: cfg.top_p,
1585
1646
  baseMaxTokens: BASE_MAX_TOKENS,
1586
1647
  }));
1648
+ // Update system prompt for the new model/harness
1649
+ if (messages.length > 0 && messages[0].role === 'system') {
1650
+ messages[0].content = buildEffectiveSystemPrompt();
1651
+ }
1587
1652
  emitDetached(hookManager.emit('model_changed', {
1588
1653
  previousModel,
1589
1654
  nextModel: model,
@@ -1711,7 +1776,7 @@ export async function createSession(opts) {
1711
1776
  const spinnerStart = Date.now();
1712
1777
  let spinnerIdx = 0;
1713
1778
  let spinnerTimer;
1714
- if (process.stderr.isTTY) {
1779
+ if (process.stderr.isTTY && !process.env.IDLEHANDS_QUIET) {
1715
1780
  spinnerTimer = setInterval(() => {
1716
1781
  const elapsedSec = Math.floor((Date.now() - spinnerStart) / 1000);
1717
1782
  const frame = frames[spinnerIdx % frames.length];
@@ -1719,13 +1784,14 @@ export async function createSession(opts) {
1719
1784
  process.stderr.write(`\r${frame} Server unavailable - waiting for reconnect (${elapsedSec}s)...`);
1720
1785
  }, 120);
1721
1786
  }
1722
- else {
1787
+ else if (!process.env.IDLEHANDS_QUIET) {
1723
1788
  console.warn('[model] Server unavailable - waiting for reconnect...');
1724
1789
  }
1725
1790
  try {
1726
1791
  await client.waitForReady({ timeoutMs: 120_000, pollMs: 2_000 });
1727
1792
  fresh = normalizeModelsResponse(await client.models());
1728
- console.warn('[model] Reconnected to server.');
1793
+ if (!process.env.IDLEHANDS_QUIET)
1794
+ console.warn('[model] Reconnected to server.');
1729
1795
  }
1730
1796
  catch {
1731
1797
  return;
@@ -1733,7 +1799,8 @@ export async function createSession(opts) {
1733
1799
  finally {
1734
1800
  if (spinnerTimer) {
1735
1801
  clearInterval(spinnerTimer);
1736
- process.stderr.write('\r\x1b[K');
1802
+ if (process.stderr.isTTY)
1803
+ process.stderr.write('\r\x1b[K');
1737
1804
  }
1738
1805
  }
1739
1806
  }
@@ -1795,6 +1862,9 @@ export async function createSession(opts) {
1795
1862
  // best effort
1796
1863
  }
1797
1864
  };
1865
+ const isReadOnlyToolDynamic = (toolName) => {
1866
+ return isReadOnlyTool(toolName) || LSP_TOOL_NAME_SET.has(toolName) || Boolean(mcpManager?.isToolReadOnly(toolName));
1867
+ };
1798
1868
  const emitToolResult = async (result) => {
1799
1869
  await hookObj.onToolResult?.(result);
1800
1870
  await hookManager.emit('tool_result', { askId, turn: turns, result });
@@ -1934,6 +2004,8 @@ export async function createSession(opts) {
1934
2004
  let noToolTurns = 0;
1935
2005
  const NO_TOOL_REPROMPT_THRESHOLD = 2;
1936
2006
  let repromptUsed = false;
2007
+ let readBudgetWarned = false;
2008
+ let noToolNudgeUsed = false;
1937
2009
  // Track blocked command loops by exact reason+command signature.
1938
2010
  const blockedExecAttemptsBySig = new Map();
1939
2011
  // Cache successful read-only exec observations by exact signature.
@@ -2056,23 +2128,6 @@ export async function createSession(opts) {
2056
2128
  const tail = detail ? ` — ${detail}` : '';
2057
2129
  hookObj.onToken(`\n[sub-agent #${taskId}] ${status}${tail}\n`);
2058
2130
  };
2059
- const buildLspLensSymbolOutput = async (filePathRaw) => {
2060
- if (!lspManager)
2061
- return '[lsp] unavailable';
2062
- const semantic = await lspManager.getSymbols(filePathRaw);
2063
- if (!lens)
2064
- return semantic;
2065
- const cwd = cfg.dir ?? process.cwd();
2066
- const absPath = filePathRaw.startsWith('/') ? filePathRaw : path.resolve(cwd, filePathRaw);
2067
- const body = await fs.readFile(absPath, 'utf8').catch(() => '');
2068
- if (!body)
2069
- return semantic;
2070
- const projection = await lens.projectFile(absPath, body).catch(() => '');
2071
- const structural = extractLensBody(projection);
2072
- if (!structural)
2073
- return semantic;
2074
- return `${semantic}\n\n[lens] Structural skeleton:\n${structural}`;
2075
- };
2076
2131
  const runSpawnTask = async (args) => {
2077
2132
  if (delegationForbiddenByUser) {
2078
2133
  throw new Error('spawn_task: blocked — user explicitly asked for no delegation/sub-agents in this request. Continue directly in the current session.');
@@ -2102,7 +2157,7 @@ export async function createSession(opts) {
2102
2157
  messages: beforeMsgs,
2103
2158
  contextWindow,
2104
2159
  maxTokens: maxTokens,
2105
- minTailMessages: 12,
2160
+ minTailMessages: cfg.compact_min_tail ?? 12,
2106
2161
  compactAt: cfg.compact_at ?? 0.8,
2107
2162
  toolSchemaTokens: estimateToolSchemaTokens(getToolsSchema()),
2108
2163
  });
@@ -2137,13 +2192,43 @@ export async function createSession(opts) {
2137
2192
  }
2138
2193
  }
2139
2194
  messages = compacted;
2140
- // Update current context token count after auto compaction
2141
- currentContextTokens = estimateTokensFromMessages(compacted);
2142
2195
  if (dropped.length) {
2143
- messages.push({ role: 'system', content: buildCompactionSystemNote('auto', dropped.length) });
2144
- await injectVaultContext().catch(() => { });
2145
- injectCompactionReminder('auto context-budget compaction');
2196
+ const droppedTokens = estimateTokensFromMessages(dropped);
2197
+ if (cfg.compact_summary !== false && droppedTokens > 200) {
2198
+ try {
2199
+ const summaryContent = buildCompactionSummaryPrompt(dropped);
2200
+ const summaryMaxTokens = cfg.compact_summary_max_tokens ?? 300;
2201
+ const resp = await client.chat({
2202
+ model,
2203
+ messages: [
2204
+ { role: 'system', content: 'Summarize this agent session progress concisely. List: files read, key findings, decisions made, current approach. Be terse.' },
2205
+ { role: 'user', content: summaryContent },
2206
+ ],
2207
+ max_tokens: summaryMaxTokens,
2208
+ temperature: 0,
2209
+ responseTimeoutMs: 5_000,
2210
+ });
2211
+ const summary = resp.choices?.[0]?.message?.content ?? '';
2212
+ if (summary.trim()) {
2213
+ messages.push({
2214
+ role: 'system',
2215
+ content: `[Compacted ${dropped.length} messages (~${droppedTokens} tokens). Progress summary:]\n${summary.trim()}\n[Continue from where you left off. Do not repeat completed work.]`,
2216
+ });
2217
+ }
2218
+ else {
2219
+ messages.push({ role: 'system', content: buildCompactionSystemNote('auto', dropped.length) });
2220
+ }
2221
+ }
2222
+ catch {
2223
+ messages.push({ role: 'system', content: buildCompactionSystemNote('auto', dropped.length) });
2224
+ }
2225
+ }
2226
+ else {
2227
+ messages.push({ role: 'system', content: buildCompactionSystemNote('auto', dropped.length) });
2228
+ }
2146
2229
  }
2230
+ // Update token count AFTER injections so downstream reads are accurate
2231
+ currentContextTokens = estimateTokensFromMessages(messages);
2147
2232
  const afterTokens = estimateTokensFromMessages(compacted);
2148
2233
  return {
2149
2234
  beforeMessages: beforeMsgs.length,
@@ -2178,8 +2263,8 @@ export async function createSession(opts) {
2178
2263
  let resp;
2179
2264
  try {
2180
2265
  try {
2181
- const toolsForTurn = forceToollessRecoveryTurn ? [] : getToolsSchema();
2182
- const toolChoiceForTurn = forceToollessRecoveryTurn ? 'none' : 'auto';
2266
+ const toolsForTurn = (cfg.no_tools || forceToollessRecoveryTurn) ? [] : getToolsSchema();
2267
+ const toolChoiceForTurn = (cfg.no_tools || forceToollessRecoveryTurn) ? 'none' : 'auto';
2183
2268
  resp = await client.chatStream({
2184
2269
  model,
2185
2270
  messages,
@@ -2209,8 +2294,7 @@ export async function createSession(opts) {
2209
2294
  const mode = useHardCompaction ? 'hard' : 'force';
2210
2295
  messages.push({
2211
2296
  role: 'system',
2212
- content: `[auto-recovery] Previous request exceeded model context window. Ran ${mode} compaction ` +
2213
- `(freed ~${compacted.freedTokens} tokens, dropped ${compacted.droppedMessages} messages). Continue from latest state; do not restart work.`,
2297
+ content: `[auto-recovery] Context overflow. Ran ${mode} compaction (freed ~${compacted.freedTokens} tokens). Continue current work.`,
2214
2298
  });
2215
2299
  continue;
2216
2300
  }
@@ -2348,7 +2432,7 @@ export async function createSession(opts) {
2348
2432
  }
2349
2433
  messages.push({
2350
2434
  role: 'user',
2351
- content: '[system] Your previous response was empty (no text, no tool calls). Continue by either calling a tool with valid JSON arguments or giving a final answer.',
2435
+ content: '[system] Empty response. Call a tool or give final answer.',
2352
2436
  });
2353
2437
  await emitTurnEnd({
2354
2438
  turn: turns,
@@ -2377,10 +2461,14 @@ export async function createSession(opts) {
2377
2461
  for (const tc of toolCallsArr) {
2378
2462
  const n = tc.function?.name ?? '';
2379
2463
  let argCount = 0;
2380
- try {
2381
- argCount = Object.keys(parseJsonArgs(tc.function?.arguments ?? '{}')).length;
2464
+ // Extract arg count without full parse if possible
2465
+ const argStr = tc.function?.arguments ?? '{}';
2466
+ if (argStr.length > 2) {
2467
+ try {
2468
+ argCount = Object.keys(parseJsonArgs(argStr)).length;
2469
+ }
2470
+ catch { }
2382
2471
  }
2383
- catch { }
2384
2472
  if (!byName.has(n))
2385
2473
  byName.set(n, []);
2386
2474
  byName.get(n).push({ tc, argCount });
@@ -2440,9 +2528,7 @@ export async function createSession(opts) {
2440
2528
  mutationVersion++;
2441
2529
  },
2442
2530
  });
2443
- const isReadOnlyToolDynamic = (toolName) => {
2444
- return isReadOnlyTool(toolName) || LSP_TOOL_NAME_SET.has(toolName) || Boolean(mcpManager?.isToolReadOnly(toolName));
2445
- };
2531
+ // Tool-call argument parsing and validation logic
2446
2532
  const fileMutationsInTurn = toolCallsArr.filter((tc) => FILE_MUTATION_TOOL_SET.has(tc.function?.name)).length;
2447
2533
  if (fileMutationsInTurn >= 3 && isGitDirty(ctx.cwd)) {
2448
2534
  const shouldStash = confirmBridge
@@ -2502,7 +2588,7 @@ export async function createSession(opts) {
2502
2588
  });
2503
2589
  messages.push({
2504
2590
  role: 'system',
2505
- content: `[tool-loop ${warning.level}] ${warning.message}. Stop repeating ${warning.toolName} with unchanged inputs; continue with analysis or next step.`,
2591
+ content: `[tool-loop ${warning.level}] ${warning.message}. Use existing results; move on.`,
2506
2592
  });
2507
2593
  }
2508
2594
  }
@@ -2591,9 +2677,8 @@ export async function createSession(opts) {
2591
2677
  const consec = consecutiveCounts.get(sig) ?? 1;
2592
2678
  const isReadFileTool = READ_FILE_CACHE_TOOLS.has(toolName);
2593
2679
  const hardBreakAt = isReadFileTool ? 6 : 4;
2594
- // At 3x, inject vault context and first warning
2680
+ // At 3x, first warning
2595
2681
  if (consec >= 3) {
2596
- await injectVaultContext().catch(() => { });
2597
2682
  if (consec === 3) {
2598
2683
  let warningMsg = null;
2599
2684
  if (toolName === 'read_file') {
@@ -2608,7 +2693,7 @@ export async function createSession(opts) {
2608
2693
  if (warningMsg) {
2609
2694
  messages.push({
2610
2695
  role: 'system',
2611
- content: `${warningMsg} The content has not changed between reads. Reuse the prior result and move to the next step.`,
2696
+ content: `${warningMsg} Content unchanged. Reuse prior result.`,
2612
2697
  });
2613
2698
  }
2614
2699
  }
@@ -2625,7 +2710,7 @@ export async function createSession(opts) {
2625
2710
  resourceType = 'directory';
2626
2711
  messages.push({
2627
2712
  role: 'system',
2628
- content: `CRITICAL: DO NOT make another identical call for this ${resourceType}. It HAS NOT CHANGED. You already have the content. Move on to the NEXT step NOW.`,
2713
+ content: `CRITICAL: ${resourceType} unchanged. Move on NOW.`,
2629
2714
  });
2630
2715
  }
2631
2716
  const argsForSig = sigMetaBySig.get(sig)?.args ?? {};
@@ -2640,8 +2725,7 @@ export async function createSession(opts) {
2640
2725
  shouldForceToollessRecovery = true;
2641
2726
  messages.push({
2642
2727
  role: 'system',
2643
- content: `[tool-loop critical] ${toolName} repeated ${consec}x with unchanged inputs. ` +
2644
- 'Next turn will run with tools disabled so you must use existing results and provide a concrete next step/final response.',
2728
+ content: `[tool-loop critical] ${toolName} repeated ${consec}x unchanged. Tools disabled next turn; use existing results.`,
2645
2729
  });
2646
2730
  }
2647
2731
  continue;
@@ -2668,8 +2752,7 @@ export async function createSession(opts) {
2668
2752
  toollessRecoveryUsed = true;
2669
2753
  messages.push({
2670
2754
  role: 'user',
2671
- content: '[system] Critical tool loop detected. Next turn will run with tools disabled. ' +
2672
- 'Use already available tool results to provide a concrete next step or final response; do not request more tools.',
2755
+ content: '[system] Tool loop detected. Tools disabled. Use existing results for next step.',
2673
2756
  });
2674
2757
  await emitTurnEnd({
2675
2758
  turn: turns,
@@ -2894,21 +2977,7 @@ export async function createSession(opts) {
2894
2977
  }
2895
2978
  else if (isLspTool && lspManager) {
2896
2979
  // LSP tool dispatch
2897
- if (name === 'lsp_diagnostics') {
2898
- content = await lspManager.getDiagnostics(typeof args.path === 'string' ? args.path : undefined, typeof args.severity === 'number' ? args.severity : undefined);
2899
- }
2900
- else if (name === 'lsp_symbols') {
2901
- content = await buildLspLensSymbolOutput(String(args.path ?? ''));
2902
- }
2903
- else if (name === 'lsp_hover') {
2904
- content = await lspManager.getHover(String(args.path ?? ''), Number(args.line ?? 0), Number(args.character ?? 0));
2905
- }
2906
- else if (name === 'lsp_definition') {
2907
- content = await lspManager.getDefinition(String(args.path ?? ''), Number(args.line ?? 0), Number(args.character ?? 0));
2908
- }
2909
- else if (name === 'lsp_references') {
2910
- content = await lspManager.getReferences(String(args.path ?? ''), Number(args.line ?? 0), Number(args.character ?? 0), typeof args.max_results === 'number' ? args.max_results : 50);
2911
- }
2980
+ content = await dispatchLspTool(name, args);
2912
2981
  }
2913
2982
  else {
2914
2983
  if (mcpManager == null) {
@@ -3201,11 +3270,11 @@ export async function createSession(opts) {
3201
3270
  }
3202
3271
  // ── Escalating cumulative read budget (§ anti-scan guardrails) ──
3203
3272
  // Warn zone: append warnings to each read result when approaching the hard cap
3204
- if (cumulativeReadOnlyCalls > READ_BUDGET_WARN && cumulativeReadOnlyCalls <= READ_BUDGET_HARD) {
3205
- const remaining = READ_BUDGET_HARD - cumulativeReadOnlyCalls;
3273
+ if (!readBudgetWarned && cumulativeReadOnlyCalls > READ_BUDGET_WARN && cumulativeReadOnlyCalls <= READ_BUDGET_HARD) {
3274
+ readBudgetWarned = true;
3206
3275
  messages.push({
3207
3276
  role: 'user',
3208
- content: `[System] Read budget: ${cumulativeReadOnlyCalls}/${READ_BUDGET_HARD}. ${remaining} reads remaining before hard stop. Use search_files or exec grep — do NOT continue reading files one at a time.`,
3277
+ content: `[system] Read budget: ${cumulativeReadOnlyCalls}/${READ_BUDGET_HARD}. Use search_files instead of reading files individually.`,
3209
3278
  });
3210
3279
  }
3211
3280
  // One bounded automatic repair attempt for invalid tool args.
@@ -3248,7 +3317,8 @@ export async function createSession(opts) {
3248
3317
  const assistantText = visible || content || '';
3249
3318
  // Recovery fuse: if the model keeps narrating/planning without tool use,
3250
3319
  // nudge it once with the original task. Never resend more than once per ask().
3251
- if (looksLikePlanningNarration(assistantText, finishReason)) {
3320
+ // Skip this check entirely when no_tools is set — text IS the final answer.
3321
+ if (!cfg.no_tools && looksLikePlanningNarration(assistantText, finishReason)) {
3252
3322
  noToolTurns += 1;
3253
3323
  messages.push({ role: 'assistant', content: assistantText });
3254
3324
  if (noToolTurns >= NO_TOOL_REPROMPT_THRESHOLD) {
@@ -3256,12 +3326,10 @@ export async function createSession(opts) {
3256
3326
  repromptUsed = true;
3257
3327
  noToolTurns = 0;
3258
3328
  const reminder = userContentToText(instruction).trim();
3259
- const clippedReminder = reminder.length > 4000 ? `${reminder.slice(0, 4000)}\n[truncated]` : reminder;
3329
+ const clippedReminder = reminder.length > 1600 ? `${reminder.slice(0, 1600)}\n[truncated]` : reminder;
3260
3330
  messages.push({
3261
3331
  role: 'user',
3262
- content: `[system] You seem to be stuck narrating without using tools. Resume execution now.\n` +
3263
- `Original task:\n${clippedReminder}\n\n` +
3264
- `Call the needed tools directly. If everything is truly complete, provide the final answer.`
3332
+ content: `[system] Stuck narrating. Resume with tools.\nTask:\n${clippedReminder}`
3265
3333
  });
3266
3334
  await emitTurnEnd({
3267
3335
  turn: turns,
@@ -3279,10 +3347,28 @@ export async function createSession(opts) {
3279
3347
  }
3280
3348
  throw new Error(`no-tool loop detected: model produced planning/narration without tool calls for ${NO_TOOL_REPROMPT_THRESHOLD} turns even after one recovery reprompt`);
3281
3349
  }
3282
- messages.push({
3283
- role: 'user',
3284
- content: '[system] Continue executing the task. Use tools now (do not just narrate plans). If complete, give the final answer.'
3285
- });
3350
+ if (!noToolNudgeUsed) {
3351
+ noToolNudgeUsed = true;
3352
+ messages.push({
3353
+ role: 'user',
3354
+ content: '[system] Use tools now or give final answer.'
3355
+ });
3356
+ await emitTurnEnd({
3357
+ turn: turns,
3358
+ toolCalls,
3359
+ promptTokens: cumulativeUsage.prompt,
3360
+ completionTokens: cumulativeUsage.completion,
3361
+ promptTokensTurn,
3362
+ completionTokensTurn,
3363
+ ttftMs,
3364
+ ttcMs,
3365
+ ppTps,
3366
+ tgTps,
3367
+ });
3368
+ continue;
3369
+ }
3370
+ // Nudge already used — fall through to next iteration which will
3371
+ // increment noToolTurns and hit the reprompt threshold.
3286
3372
  await emitTurnEnd({
3287
3373
  turn: turns,
3288
3374
  toolCalls,
@@ -3402,7 +3488,7 @@ export async function createSession(opts) {
3402
3488
  get capturePath() {
3403
3489
  return capturePath;
3404
3490
  },
3405
- getSystemPrompt: () => activeSystemPrompt,
3491
+ getSystemPrompt: () => messages[0]?.role === 'system' ? String(messages[0].content) : activeSystemPromptBase,
3406
3492
  setSystemPrompt,
3407
3493
  resetSystemPrompt,
3408
3494
  listMcpServers,