@visorcraft/idlehands 1.1.2 → 1.1.4

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
@@ -14,7 +14,8 @@ import { MCPManager } from './mcp.js';
14
14
  import { LspManager, detectInstalledLspServers } from './lsp.js';
15
15
  import fs from 'node:fs/promises';
16
16
  import path from 'node:path';
17
- import { stateDir } from './utils.js';
17
+ import { spawnSync } from 'node:child_process';
18
+ import { stateDir, BASH_PATH as BASH } from './utils.js';
18
19
  function makeAbortController() {
19
20
  // Node 24: AbortController is global.
20
21
  return new AbortController();
@@ -115,6 +116,66 @@ function toolResultSummary(name, args, content, success) {
115
116
  return content.slice(0, 80);
116
117
  }
117
118
  }
119
+ const CACHED_EXEC_OBSERVATION_HINT = '[idlehands hint] Reused cached output for repeated read-only exec call (unchanged observation).';
120
+ function execCommandFromSig(sig) {
121
+ if (!sig.startsWith('exec:'))
122
+ return '';
123
+ const raw = sig.slice('exec:'.length);
124
+ try {
125
+ const parsed = JSON.parse(raw);
126
+ return typeof parsed?.command === 'string' ? parsed.command : '';
127
+ }
128
+ catch {
129
+ return '';
130
+ }
131
+ }
132
+ function looksLikeReadOnlyExecCommand(command) {
133
+ const cmd = String(command || '').trim().toLowerCase();
134
+ if (!cmd)
135
+ return false;
136
+ // Shell redirects are likely writes.
137
+ if (/(^|\s)(?:>>?|<<?)\s*/.test(cmd))
138
+ return false;
139
+ // Obvious mutators.
140
+ if (/\b(?:rm|mv|cp|touch|mkdir|rmdir|chmod|chown|truncate|dd)\b/.test(cmd))
141
+ return false;
142
+ if (/\b(?:sed|perl)\b[^\n]*\s-i\b/.test(cmd))
143
+ return false;
144
+ if (/\btee\b/.test(cmd))
145
+ return false;
146
+ // Git: allow common read-only subcommands, block mutating verbs.
147
+ if (/\bgit\b/.test(cmd)) {
148
+ if (/\bgit\b[^\n|;&]*\b(?:add|am|apply|bisect|checkout|switch|clean|clone|commit|fetch|merge|pull|push|rebase|reset|revert|stash)\b/.test(cmd)) {
149
+ return false;
150
+ }
151
+ if (/\bgit\b[^\n|;&]*\b(?:log|show|status|diff|rev-parse|branch(?:\s+--list)?|tag(?:\s+--list)?|ls-files|grep)\b/.test(cmd)) {
152
+ return true;
153
+ }
154
+ }
155
+ if (/^\s*(?:grep|rg|ag|ack|find|ls|cat|head|tail|wc|stat)\b/.test(cmd))
156
+ return true;
157
+ if (/\|\s*(?:grep|rg|ag|ack)\b/.test(cmd))
158
+ return true;
159
+ return false;
160
+ }
161
+ function withCachedExecObservationHint(content) {
162
+ if (!content)
163
+ return content;
164
+ try {
165
+ const parsed = JSON.parse(content);
166
+ const out = typeof parsed?.out === 'string' ? parsed.out : '';
167
+ if (out.includes(CACHED_EXEC_OBSERVATION_HINT))
168
+ return content;
169
+ parsed.out = out ? `${out}\n${CACHED_EXEC_OBSERVATION_HINT}` : CACHED_EXEC_OBSERVATION_HINT;
170
+ parsed.cached_observation = true;
171
+ return JSON.stringify(parsed);
172
+ }
173
+ catch {
174
+ if (content.includes(CACHED_EXEC_OBSERVATION_HINT))
175
+ return content;
176
+ return `${content}\n${CACHED_EXEC_OBSERVATION_HINT}`;
177
+ }
178
+ }
118
179
  /** Errors that should break the outer agent loop, not be caught by per-tool handlers */
119
180
  class AgentLoopBreak extends Error {
120
181
  constructor(message) {
@@ -766,6 +827,123 @@ function userDisallowsDelegation(content) {
766
827
  /\b(?:spawn[_\-\s]?task|sub[\-\s]?agents?|delegate|delegation)\b[^\n.]{0,50}\b(?:do not|don't|dont|not allowed|forbidden|no)\b/.test(text);
767
828
  return negationNearDelegation;
768
829
  }
830
+ function reviewArtifactKeys(projectDir) {
831
+ const { projectId } = projectIndexKeys(projectDir);
832
+ return {
833
+ projectId,
834
+ latestKey: `artifact:review:latest:${projectId}`,
835
+ byIdPrefix: `artifact:review:item:${projectId}:`,
836
+ };
837
+ }
838
+ function looksLikeCodeReviewRequest(text) {
839
+ const t = text.toLowerCase();
840
+ if (!t.trim())
841
+ return false;
842
+ if (/^\s*\/review\b/.test(t))
843
+ return true;
844
+ if (/\b(?:code\s+review|security\s+review|review\s+the\s+(?:code|diff|changes|repo|repository|pr)|audit\s+the\s+code)\b/.test(t))
845
+ return true;
846
+ return /\breview\b/.test(t) && /\b(?:code|repo|repository|diff|changes|pull\s*request|pr)\b/.test(t);
847
+ }
848
+ function looksLikeReviewRetrievalRequest(text) {
849
+ const t = text.toLowerCase();
850
+ if (!t.trim())
851
+ return false;
852
+ if (/^\s*\/review\s+(?:print|show|replay|latest|last|full)\b/.test(t))
853
+ return true;
854
+ if (!/\breview\b/.test(t))
855
+ return false;
856
+ if (/\bprint\s+stale\s+review\s+anyway\b/.test(t))
857
+ return true;
858
+ if (/\b(?:print|show|display|repeat|paste|send|output|give)\b[^\n.]{0,80}\breview\b[^\n.]{0,40}\b(?:again|back)\b/.test(t))
859
+ return true;
860
+ if (/\b(?:print|show|display|repeat|paste|send|output|give)\b[^\n.]{0,80}\b(?:full|entire|complete|whole)\b[^\n.]{0,80}\breview\b/.test(t))
861
+ return true;
862
+ if (/\b(?:full|entire|complete|whole)\b[^\n.]{0,30}\bcode\s+review\b/.test(t) && /\b(?:print|show|display|repeat|paste|send|output|give)\b/.test(t))
863
+ return true;
864
+ if (/\b(?:print|show|display|repeat|paste|send|output|give)\b[^\n.]{0,80}\b(?:last|latest|previous)\b[^\n.]{0,40}\breview\b/.test(t))
865
+ return true;
866
+ return false;
867
+ }
868
+ function retrievalAllowsStaleArtifact(text) {
869
+ const t = text.toLowerCase();
870
+ if (!t.trim())
871
+ return false;
872
+ if (/\bprint\s+stale\s+review\s+anyway\b/.test(t))
873
+ return true;
874
+ if (/\b(?:force|override|ignore)\b[^\n.]{0,80}\b(?:stale|old|previous)\b[^\n.]{0,80}\breview\b/.test(t))
875
+ return true;
876
+ if (/\b(?:stale|old|previous)\b[^\n.]{0,80}\breview\b[^\n.]{0,80}\b(?:anyway|still|force|override|ignore)\b/.test(t))
877
+ return true;
878
+ return false;
879
+ }
880
+ function parseReviewArtifactStalePolicy(raw) {
881
+ const v = typeof raw === 'string' ? raw.toLowerCase().trim() : '';
882
+ if (v === 'block')
883
+ return 'block';
884
+ return 'warn';
885
+ }
886
+ function parseReviewArtifact(raw) {
887
+ try {
888
+ const parsed = JSON.parse(raw);
889
+ if (!parsed || typeof parsed !== 'object')
890
+ return null;
891
+ if (parsed.kind !== 'code_review')
892
+ return null;
893
+ if (typeof parsed.id !== 'string' || !parsed.id)
894
+ return null;
895
+ if (typeof parsed.createdAt !== 'string' || !parsed.createdAt)
896
+ return null;
897
+ if (typeof parsed.model !== 'string')
898
+ return null;
899
+ if (typeof parsed.projectId !== 'string' || !parsed.projectId)
900
+ return null;
901
+ if (typeof parsed.projectDir !== 'string' || !parsed.projectDir)
902
+ return null;
903
+ if (typeof parsed.prompt !== 'string')
904
+ return null;
905
+ if (typeof parsed.content !== 'string')
906
+ return null;
907
+ return parsed;
908
+ }
909
+ catch {
910
+ return null;
911
+ }
912
+ }
913
+ function gitHead(cwd) {
914
+ const inside = spawnSync(BASH, ['-lc', 'git rev-parse --is-inside-work-tree'], {
915
+ cwd,
916
+ encoding: 'utf8',
917
+ timeout: 1000,
918
+ });
919
+ if (inside.status !== 0 || !String(inside.stdout || '').trim().startsWith('true'))
920
+ return undefined;
921
+ const head = spawnSync(BASH, ['-lc', 'git rev-parse HEAD'], {
922
+ cwd,
923
+ encoding: 'utf8',
924
+ timeout: 1000,
925
+ });
926
+ if (head.status !== 0)
927
+ return undefined;
928
+ const sha = String(head.stdout || '').trim();
929
+ return sha || undefined;
930
+ }
931
+ function shortSha(sha) {
932
+ if (!sha)
933
+ return 'unknown';
934
+ return sha.slice(0, 8);
935
+ }
936
+ function reviewArtifactStaleReason(artifact, cwd) {
937
+ const currentHead = gitHead(cwd);
938
+ const currentDirty = isGitDirty(cwd);
939
+ if (artifact.gitHead && currentHead && artifact.gitHead !== currentHead) {
940
+ return `Stored review was generated at commit ${shortSha(artifact.gitHead)}; repository is now at ${shortSha(currentHead)}.`;
941
+ }
942
+ if (artifact.gitDirty === false && currentDirty) {
943
+ return 'Stored review was generated on a clean tree; working tree now has uncommitted changes.';
944
+ }
945
+ return '';
946
+ }
769
947
  function supportsVisionModel(model, modelMeta, harness) {
770
948
  if (typeof harness.supportsVision === 'boolean')
771
949
  return harness.supportsVision;
@@ -917,7 +1095,11 @@ export async function createSession(opts) {
917
1095
  mcpTools: mcpToolsLoaded ? (mcpManager?.getEnabledToolSchemas() ?? []) : [],
918
1096
  allowSpawnTask: spawnTaskEnabled,
919
1097
  });
920
- const vault = vaultEnabled ? (opts.runtime?.vault ?? new VaultStore()) : undefined;
1098
+ const vault = vaultEnabled
1099
+ ? (opts.runtime?.vault ?? new VaultStore({
1100
+ immutableReviewArtifactsPerProject: cfg?.trifecta?.vault?.immutable_review_artifacts_per_project,
1101
+ }))
1102
+ : undefined;
921
1103
  if (vault) {
922
1104
  // Scope vault entries by project directory to prevent cross-project context leaks
923
1105
  vault.setProjectDir(cfg.dir ?? process.cwd());
@@ -1457,9 +1639,10 @@ export async function createSession(opts) {
1457
1639
  messages,
1458
1640
  contextWindow,
1459
1641
  maxTokens,
1460
- minTailMessages: 12,
1461
- compactAt: cfg.compact_at ?? 0.8,
1642
+ minTailMessages: opts?.force ? 2 : 12,
1643
+ compactAt: opts?.force ? 0.5 : (cfg.compact_at ?? 0.8),
1462
1644
  toolSchemaTokens: estimateToolSchemaTokens(getToolsSchema()),
1645
+ force: opts?.force,
1463
1646
  });
1464
1647
  }
1465
1648
  const compactedByRefs = new Set(compacted);
@@ -1824,6 +2007,88 @@ export async function createSession(opts) {
1824
2007
  const hookObj = typeof hooks === 'function' ? { onToken: hooks } : hooks ?? {};
1825
2008
  let turns = 0;
1826
2009
  let toolCalls = 0;
2010
+ const rawInstructionText = userContentToText(instruction).trim();
2011
+ const projectDir = cfg.dir ?? process.cwd();
2012
+ const reviewKeys = reviewArtifactKeys(projectDir);
2013
+ const retrievalRequested = looksLikeReviewRetrievalRequest(rawInstructionText);
2014
+ const shouldPersistReviewArtifact = looksLikeCodeReviewRequest(rawInstructionText) && !retrievalRequested;
2015
+ if (retrievalRequested) {
2016
+ const latest = vault
2017
+ ? await vault.getLatestByKey(reviewKeys.latestKey, 'system').catch(() => null)
2018
+ : null;
2019
+ const parsedArtifact = latest?.value ? parseReviewArtifact(latest.value) : null;
2020
+ const artifact = parsedArtifact && parsedArtifact.projectId === reviewKeys.projectId
2021
+ ? parsedArtifact
2022
+ : null;
2023
+ if (artifact?.content?.trim()) {
2024
+ const stale = reviewArtifactStaleReason(artifact, projectDir);
2025
+ const stalePolicy = parseReviewArtifactStalePolicy(cfg?.trifecta?.vault?.stale_policy);
2026
+ if (stale && stalePolicy === 'block' && !retrievalAllowsStaleArtifact(rawInstructionText)) {
2027
+ const blocked = `Stored review is stale and retrieval policy is set to block. ${stale}\n` +
2028
+ 'Reply with "print stale review anyway" to override, or request a fresh review.';
2029
+ messages.push({ role: 'assistant', content: blocked });
2030
+ hookObj.onToken?.(blocked);
2031
+ await hookObj.onTurnEnd?.({
2032
+ turn: turns,
2033
+ toolCalls,
2034
+ promptTokens: cumulativeUsage.prompt,
2035
+ completionTokens: cumulativeUsage.completion,
2036
+ });
2037
+ return { text: blocked, turns, toolCalls };
2038
+ }
2039
+ const text = stale
2040
+ ? `${artifact.content}\n\n[artifact note] ${stale}`
2041
+ : artifact.content;
2042
+ messages.push({ role: 'assistant', content: text });
2043
+ hookObj.onToken?.(text);
2044
+ await hookObj.onTurnEnd?.({
2045
+ turn: turns,
2046
+ toolCalls,
2047
+ promptTokens: cumulativeUsage.prompt,
2048
+ completionTokens: cumulativeUsage.completion,
2049
+ });
2050
+ return { text, turns, toolCalls };
2051
+ }
2052
+ const miss = 'No stored full code review found yet. Ask me to run a code review first, then I can replay it verbatim.';
2053
+ messages.push({ role: 'assistant', content: miss });
2054
+ hookObj.onToken?.(miss);
2055
+ await hookObj.onTurnEnd?.({
2056
+ turn: turns,
2057
+ toolCalls,
2058
+ promptTokens: cumulativeUsage.prompt,
2059
+ completionTokens: cumulativeUsage.completion,
2060
+ });
2061
+ return { text: miss, turns, toolCalls };
2062
+ }
2063
+ const persistReviewArtifact = async (finalText) => {
2064
+ if (!vault || !shouldPersistReviewArtifact)
2065
+ return;
2066
+ const clean = finalText.trim();
2067
+ if (!clean)
2068
+ return;
2069
+ const createdAt = new Date().toISOString();
2070
+ const id = `review-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
2071
+ const artifact = {
2072
+ id,
2073
+ kind: 'code_review',
2074
+ createdAt,
2075
+ model,
2076
+ projectId: reviewKeys.projectId,
2077
+ projectDir,
2078
+ prompt: rawInstructionText.slice(0, 2000),
2079
+ content: clean,
2080
+ gitHead: gitHead(projectDir),
2081
+ gitDirty: isGitDirty(projectDir),
2082
+ };
2083
+ try {
2084
+ const raw = JSON.stringify(artifact);
2085
+ await vault.upsertNote(reviewKeys.latestKey, raw, 'system');
2086
+ await vault.upsertNote(`${reviewKeys.byIdPrefix}${artifact.id}`, raw, 'system');
2087
+ }
2088
+ catch {
2089
+ // best effort only
2090
+ }
2091
+ };
1827
2092
  // Read-only tool call budgets (§ anti-scan guardrails)
1828
2093
  const READ_ONLY_PER_TURN_CAP = 6;
1829
2094
  const READ_BUDGET_WARN = 15;
@@ -1854,6 +2119,10 @@ export async function createSession(opts) {
1854
2119
  let repromptUsed = false;
1855
2120
  // Track blocked command loops by exact reason+command signature.
1856
2121
  const blockedExecAttemptsBySig = new Map();
2122
+ // Cache successful read-only exec observations by exact signature.
2123
+ const execObservationCacheBySig = new Map();
2124
+ // Prevent repeating the same "stop rerunning" reminder every turn.
2125
+ const readOnlyExecHintedSigs = new Set();
1857
2126
  // Keep a lightweight breadcrumb for diagnostics on partial failures.
1858
2127
  let lastSuccessfulTestRun = null;
1859
2128
  // One-time nudge to prevent post-success churn after green test runs.
@@ -1969,10 +2238,11 @@ export async function createSession(opts) {
1969
2238
  const callerSignal = hookObj.signal;
1970
2239
  const onCallerAbort = () => ac.abort();
1971
2240
  callerSignal?.addEventListener('abort', onCallerAbort, { once: true });
1972
- // Per-request timeout: the lesser of 120s or the remaining session wall time.
2241
+ // Per-request timeout: the lesser of response_timeout (default 300s) or the remaining session wall time.
1973
2242
  // This prevents a single slow request from consuming the entire session budget.
2243
+ const perReqCap = cfg.response_timeout && cfg.response_timeout > 0 ? cfg.response_timeout : 300;
1974
2244
  const wallRemaining = Math.max(0, cfg.timeout - (Date.now() - wallStart) / 1000);
1975
- const reqTimeout = Math.min(120, Math.max(10, wallRemaining));
2245
+ const reqTimeout = Math.min(perReqCap, Math.max(10, wallRemaining));
1976
2246
  const timer = setTimeout(() => ac.abort(), reqTimeout * 1000);
1977
2247
  reqCounter++;
1978
2248
  const turnStartMs = Date.now();
@@ -2234,6 +2504,9 @@ export async function createSession(opts) {
2234
2504
  const sig = `${tc.function.name}:${tc.function.arguments ?? '{}'}`;
2235
2505
  turnSigs.add(sig);
2236
2506
  }
2507
+ // Repeated read-only exec calls can be served from cache instead of hard-breaking.
2508
+ const repeatedReadOnlyExecSigs = new Set();
2509
+ const readOnlyExecTurnHints = [];
2237
2510
  // Track whether a mutation happened since a given signature was last seen.
2238
2511
  // (Tool-loop is single-threaded across turns; this is safe to keep in-memory.)
2239
2512
  for (const sig of turnSigs) {
@@ -2255,6 +2528,17 @@ export async function createSession(opts) {
2255
2528
  await injectVaultContext().catch(() => { });
2256
2529
  }
2257
2530
  if (count >= loopThreshold) {
2531
+ const command = execCommandFromSig(sig);
2532
+ const canReuseReadOnlyObservation = looksLikeReadOnlyExecCommand(command) &&
2533
+ execObservationCacheBySig.has(sig);
2534
+ if (canReuseReadOnlyObservation) {
2535
+ repeatedReadOnlyExecSigs.add(sig);
2536
+ if (!readOnlyExecHintedSigs.has(sig)) {
2537
+ readOnlyExecHintedSigs.add(sig);
2538
+ readOnlyExecTurnHints.push(command || 'exec command');
2539
+ }
2540
+ continue;
2541
+ }
2258
2542
  const args = sig.slice(toolName.length + 1);
2259
2543
  const argsPreview = args.length > 220 ? args.slice(0, 220) + '…' : args;
2260
2544
  throw new Error(`tool ${toolName}: identical call repeated ${loopThreshold}x across turns; breaking loop. ` +
@@ -2423,72 +2707,88 @@ export async function createSession(opts) {
2423
2707
  return { id: callId, content: '[skipped by user: step mode]' };
2424
2708
  }
2425
2709
  }
2710
+ const sig = `${name}:${rawArgs || '{}'}`;
2426
2711
  let content = '';
2427
- if (isSpawnTask) {
2428
- content = await runSpawnTask(args);
2712
+ let reusedCachedReadOnlyExec = false;
2713
+ if (name === 'exec' && repeatedReadOnlyExecSigs.has(sig)) {
2714
+ const cached = execObservationCacheBySig.get(sig);
2715
+ if (cached) {
2716
+ content = withCachedExecObservationHint(cached);
2717
+ reusedCachedReadOnlyExec = true;
2718
+ }
2429
2719
  }
2430
- else if (builtInFn) {
2431
- const value = await builtInFn(ctx, args);
2432
- content = typeof value === 'string' ? value : JSON.stringify(value);
2433
- if (name === 'exec') {
2434
- // Successful exec clears blocked-loop counters.
2435
- blockedExecAttemptsBySig.clear();
2436
- // Capture successful test runs for better partial-failure diagnostics.
2437
- try {
2438
- const parsed = JSON.parse(content);
2720
+ if (!reusedCachedReadOnlyExec) {
2721
+ if (isSpawnTask) {
2722
+ content = await runSpawnTask(args);
2723
+ }
2724
+ else if (builtInFn) {
2725
+ const value = await builtInFn(ctx, args);
2726
+ content = typeof value === 'string' ? value : JSON.stringify(value);
2727
+ if (name === 'exec') {
2728
+ // Successful exec clears blocked-loop counters.
2729
+ blockedExecAttemptsBySig.clear();
2439
2730
  const cmd = String(args?.command ?? '');
2440
- const out = String(parsed?.out ?? '');
2441
- const rc = Number(parsed?.rc ?? NaN);
2442
- const looksLikeTest = /(^|\s)(node\s+--test|npm\s+test|pnpm\s+test|yarn\s+test|pytest|go\s+test|cargo\s+test|ctest)(\s|$)/i.test(cmd);
2443
- if (looksLikeTest && Number.isFinite(rc) && rc === 0) {
2444
- lastSuccessfulTestRun = {
2445
- command: cmd,
2446
- outputPreview: out.slice(0, 400),
2447
- };
2731
+ if (looksLikeReadOnlyExecCommand(cmd)) {
2732
+ execObservationCacheBySig.set(sig, content);
2733
+ }
2734
+ // Capture successful test runs for better partial-failure diagnostics.
2735
+ try {
2736
+ const parsed = JSON.parse(content);
2737
+ const out = String(parsed?.out ?? '');
2738
+ const rc = Number(parsed?.rc ?? NaN);
2739
+ const looksLikeTest = /(^|\s)(node\s+--test|npm\s+test|pnpm\s+test|yarn\s+test|pytest|go\s+test|cargo\s+test|ctest)(\s|$)/i.test(cmd);
2740
+ if (looksLikeTest && Number.isFinite(rc) && rc === 0) {
2741
+ lastSuccessfulTestRun = {
2742
+ command: cmd,
2743
+ outputPreview: out.slice(0, 400),
2744
+ };
2745
+ }
2746
+ }
2747
+ catch {
2748
+ // Ignore parse issues; non-JSON exec output is tolerated.
2448
2749
  }
2449
2750
  }
2450
- catch {
2451
- // Ignore parse issues; non-JSON exec output is tolerated.
2452
- }
2453
- }
2454
- }
2455
- else if (isLspTool && lspManager) {
2456
- // LSP tool dispatch
2457
- if (name === 'lsp_diagnostics') {
2458
- content = await lspManager.getDiagnostics(typeof args.path === 'string' ? args.path : undefined, typeof args.severity === 'number' ? args.severity : undefined);
2459
- }
2460
- else if (name === 'lsp_symbols') {
2461
- content = await buildLspLensSymbolOutput(String(args.path ?? ''));
2462
2751
  }
2463
- else if (name === 'lsp_hover') {
2464
- content = await lspManager.getHover(String(args.path ?? ''), Number(args.line ?? 0), Number(args.character ?? 0));
2465
- }
2466
- else if (name === 'lsp_definition') {
2467
- content = await lspManager.getDefinition(String(args.path ?? ''), Number(args.line ?? 0), Number(args.character ?? 0));
2468
- }
2469
- else if (name === 'lsp_references') {
2470
- content = await lspManager.getReferences(String(args.path ?? ''), Number(args.line ?? 0), Number(args.character ?? 0), typeof args.max_results === 'number' ? args.max_results : 50);
2471
- }
2472
- }
2473
- else {
2474
- if (mcpManager == null) {
2475
- throw new Error(`unknown tool: ${name}`);
2752
+ else if (isLspTool && lspManager) {
2753
+ // LSP tool dispatch
2754
+ if (name === 'lsp_diagnostics') {
2755
+ content = await lspManager.getDiagnostics(typeof args.path === 'string' ? args.path : undefined, typeof args.severity === 'number' ? args.severity : undefined);
2756
+ }
2757
+ else if (name === 'lsp_symbols') {
2758
+ content = await buildLspLensSymbolOutput(String(args.path ?? ''));
2759
+ }
2760
+ else if (name === 'lsp_hover') {
2761
+ content = await lspManager.getHover(String(args.path ?? ''), Number(args.line ?? 0), Number(args.character ?? 0));
2762
+ }
2763
+ else if (name === 'lsp_definition') {
2764
+ content = await lspManager.getDefinition(String(args.path ?? ''), Number(args.line ?? 0), Number(args.character ?? 0));
2765
+ }
2766
+ else if (name === 'lsp_references') {
2767
+ content = await lspManager.getReferences(String(args.path ?? ''), Number(args.line ?? 0), Number(args.character ?? 0), typeof args.max_results === 'number' ? args.max_results : 50);
2768
+ }
2476
2769
  }
2477
- const mcpReadOnly = isReadOnlyToolDynamic(name);
2478
- if (!cfg.step_mode && !ctx.noConfirm && !mcpReadOnly) {
2479
- const prompt = `Execute MCP tool '${name}'? [Y/n]`;
2480
- const ok = confirmBridge ? await confirmBridge(prompt, { tool: name, args }) : true;
2481
- if (!ok) {
2482
- return { id: callId, content: '[skipped by user: approval]' };
2770
+ else {
2771
+ if (mcpManager == null) {
2772
+ throw new Error(`unknown tool: ${name}`);
2773
+ }
2774
+ const mcpReadOnly = isReadOnlyToolDynamic(name);
2775
+ if (!cfg.step_mode && !ctx.noConfirm && !mcpReadOnly) {
2776
+ const prompt = `Execute MCP tool '${name}'? [Y/n]`;
2777
+ const ok = confirmBridge ? await confirmBridge(prompt, { tool: name, args }) : true;
2778
+ if (!ok) {
2779
+ return { id: callId, content: '[skipped by user: approval]' };
2780
+ }
2483
2781
  }
2782
+ const callArgs = args && typeof args === 'object' && !Array.isArray(args)
2783
+ ? args
2784
+ : {};
2785
+ content = await mcpManager.callTool(name, callArgs);
2484
2786
  }
2485
- const callArgs = args && typeof args === 'object' && !Array.isArray(args)
2486
- ? args
2487
- : {};
2488
- content = await mcpManager.callTool(name, callArgs);
2489
2787
  }
2490
2788
  // Hook: onToolResult (Phase 8.5 + Phase 7 rich display)
2491
- const summary = toolResultSummary(name, args, content, true);
2789
+ const summary = reusedCachedReadOnlyExec
2790
+ ? 'cached read-only exec observation (unchanged)'
2791
+ : toolResultSummary(name, args, content, true);
2492
2792
  const resultEvent = { id: callId, name, success: true, summary, result: content };
2493
2793
  // Phase 7: populate rich display fields
2494
2794
  if (name === 'exec') {
@@ -2645,6 +2945,18 @@ export async function createSession(opts) {
2645
2945
  for (const r of results) {
2646
2946
  messages.push({ role: 'tool', tool_call_id: r.id, content: r.content });
2647
2947
  }
2948
+ if (readOnlyExecTurnHints.length) {
2949
+ const previews = readOnlyExecTurnHints
2950
+ .slice(0, 2)
2951
+ .map((cmd) => cmd.length > 140 ? `${cmd.slice(0, 140)}…` : cmd)
2952
+ .join(' | ');
2953
+ messages.push({
2954
+ role: 'user',
2955
+ content: '[system] You repeated an identical read-only exec command with unchanged arguments. ' +
2956
+ `Idle Hands reused cached observation output instead of rerunning it (${previews}). ` +
2957
+ 'Do not call the same read-only command again unless files/history changed; proceed with analysis or final answer.',
2958
+ });
2959
+ }
2648
2960
  // If tests are green and we've already made edits, nudge for final summary
2649
2961
  // once to avoid extra non-essential demo/cleanup turns.
2650
2962
  if (!finalizeAfterTestsNudgeUsed && lastSuccessfulTestRun && mutationVersion > 0) {
@@ -2745,6 +3057,7 @@ export async function createSession(opts) {
2745
3057
  noToolTurns = 0;
2746
3058
  // final assistant message
2747
3059
  messages.push({ role: 'assistant', content: assistantText });
3060
+ await persistReviewArtifact(assistantText).catch(() => { });
2748
3061
  await hookObj.onTurnEnd?.({
2749
3062
  turn: turns,
2750
3063
  toolCalls,