@visorcraft/idlehands 1.1.6 → 1.1.7

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
@@ -3,6 +3,7 @@ import { enforceContextBudget, stripThinking, estimateTokensFromMessages, estima
3
3
  import * as tools from './tools.js';
4
4
  import { selectHarness } from './harnesses.js';
5
5
  import { BASE_MAX_TOKENS, deriveContextWindow, deriveGenerationParams, supportsVisionModel } from './model-customization.js';
6
+ import { HookManager, loadHookPlugins } from './hooks/index.js';
6
7
  import { checkExecSafety, checkPathSafety } from './safety.js';
7
8
  import { loadProjectContext } from './context.js';
8
9
  import { loadGitContext, isGitDirty, stashWorkingTree } from './git.js';
@@ -989,6 +990,15 @@ export async function createSession(opts) {
989
990
  if (typeof cfg.response_timeout === 'number' && cfg.response_timeout > 0) {
990
991
  client.setResponseTimeout(cfg.response_timeout);
991
992
  }
993
+ if (typeof client.setConnectionTimeout === 'function' && typeof cfg.connection_timeout === 'number' && cfg.connection_timeout > 0) {
994
+ client.setConnectionTimeout(cfg.connection_timeout);
995
+ }
996
+ if (typeof client.setInitialConnectionCheck === 'function' && typeof cfg.initial_connection_check === 'boolean') {
997
+ client.setInitialConnectionCheck(cfg.initial_connection_check);
998
+ }
999
+ if (typeof client.setInitialConnectionProbeTimeout === 'function' && typeof cfg.initial_connection_timeout === 'number' && cfg.initial_connection_timeout > 0) {
1000
+ client.setInitialConnectionProbeTimeout(cfg.initial_connection_timeout);
1001
+ }
992
1002
  // Health check + model list (cheap, avoids wasting GPU on chat warmups if unreachable)
993
1003
  let modelsList = normalizeModelsResponse(await client.models().catch(() => null));
994
1004
  let model = cfg.model && cfg.model.trim().length
@@ -1004,6 +1014,44 @@ export async function createSession(opts) {
1004
1014
  modelMeta,
1005
1015
  });
1006
1016
  let supportsVision = supportsVisionModel(model, modelMeta, harness);
1017
+ const sessionId = `session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1018
+ const hookCfg = cfg.hooks ?? {};
1019
+ const hookManager = opts.runtime?.hookManager ?? new HookManager({
1020
+ enabled: hookCfg.enabled !== false,
1021
+ strict: hookCfg.strict === true,
1022
+ warnMs: hookCfg.warn_ms,
1023
+ allowedCapabilities: Array.isArray(hookCfg.allow_capabilities) ? hookCfg.allow_capabilities : undefined,
1024
+ context: () => ({
1025
+ sessionId,
1026
+ cwd: cfg.dir ?? process.cwd(),
1027
+ model,
1028
+ harness: harness.id,
1029
+ endpoint: cfg.endpoint,
1030
+ }),
1031
+ });
1032
+ const emitDetached = (promise, eventName) => {
1033
+ void promise.catch((error) => {
1034
+ if (!process.env.IDLEHANDS_QUIET_WARNINGS) {
1035
+ console.warn(`[hooks] async ${eventName} dispatch failed: ${error?.message ?? String(error)}`);
1036
+ }
1037
+ });
1038
+ };
1039
+ if (!opts.runtime?.hookManager && hookManager.isEnabled()) {
1040
+ const loadedPlugins = await loadHookPlugins({
1041
+ pluginPaths: Array.isArray(hookCfg.plugin_paths) ? hookCfg.plugin_paths : [],
1042
+ cwd: cfg.dir ?? process.cwd(),
1043
+ strict: hookCfg.strict === true,
1044
+ });
1045
+ for (const loaded of loadedPlugins) {
1046
+ await hookManager.registerPlugin(loaded.plugin, loaded.path);
1047
+ }
1048
+ }
1049
+ await hookManager.emit('session_start', {
1050
+ model,
1051
+ harness: harness.id,
1052
+ endpoint: cfg.endpoint,
1053
+ cwd: cfg.dir ?? process.cwd(),
1054
+ });
1007
1055
  if (!cfg.i_know_what_im_doing && contextWindow > 131072) {
1008
1056
  console.warn('[warn] context_window is above 131072; this can increase memory usage and hurt throughput. Use --i-know-what-im-doing to proceed.');
1009
1057
  }
@@ -1276,6 +1324,7 @@ export async function createSession(opts) {
1276
1324
  ];
1277
1325
  sessionMetaPending = sessionMeta;
1278
1326
  lastEditedPath = undefined;
1327
+ initialConnectionProbeDone = false;
1279
1328
  mcpToolsLoaded = !mcpLazySchemaMode;
1280
1329
  };
1281
1330
  const restore = (next) => {
@@ -1298,6 +1347,7 @@ export async function createSession(opts) {
1298
1347
  };
1299
1348
  let reqCounter = 0;
1300
1349
  let inFlight = null;
1350
+ let initialConnectionProbeDone = false;
1301
1351
  let lastEditedPath;
1302
1352
  // Plan mode state (Phase 8)
1303
1353
  let planSteps = [];
@@ -1798,6 +1848,7 @@ export async function createSession(opts) {
1798
1848
  return fresh.data.map((m) => m.id).filter(Boolean);
1799
1849
  };
1800
1850
  const setModel = (name) => {
1851
+ const previousModel = model;
1801
1852
  model = name;
1802
1853
  harness = selectHarness(model, cfg.harness && cfg.harness.trim() ? cfg.harness.trim() : undefined);
1803
1854
  const nextMeta = modelsList?.data?.find((m) => m.id === model);
@@ -1815,6 +1866,11 @@ export async function createSession(opts) {
1815
1866
  configuredTopP: cfg.top_p,
1816
1867
  baseMaxTokens: BASE_MAX_TOKENS,
1817
1868
  }));
1869
+ emitDetached(hookManager.emit('model_changed', {
1870
+ previousModel,
1871
+ nextModel: model,
1872
+ harness: harness.id,
1873
+ }), 'model_changed');
1818
1874
  };
1819
1875
  const setEndpoint = async (endpoint, modelName) => {
1820
1876
  const normalized = endpoint.replace(/\/+$/, '');
@@ -2002,11 +2058,35 @@ export async function createSession(opts) {
2002
2058
  const hookObj = typeof hooks === 'function' ? { onToken: hooks } : hooks ?? {};
2003
2059
  let turns = 0;
2004
2060
  let toolCalls = 0;
2061
+ const askId = `ask-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
2062
+ const emitToolCall = async (call) => {
2063
+ hookObj.onToolCall?.(call);
2064
+ await hookManager.emit('tool_call', { askId, turn: turns, call });
2065
+ };
2066
+ const emitToolResult = async (result) => {
2067
+ await hookObj.onToolResult?.(result);
2068
+ await hookManager.emit('tool_result', { askId, turn: turns, result });
2069
+ };
2070
+ const emitTurnEnd = async (stats) => {
2071
+ await hookObj.onTurnEnd?.(stats);
2072
+ await hookManager.emit('turn_end', { askId, stats });
2073
+ };
2074
+ const finalizeAsk = async (text) => {
2075
+ await hookManager.emit('ask_end', { askId, text, turns, toolCalls });
2076
+ return { text, turns, toolCalls };
2077
+ };
2005
2078
  const rawInstructionText = userContentToText(instruction).trim();
2079
+ await hookManager.emit('ask_start', { askId, instruction: rawInstructionText });
2006
2080
  const projectDir = cfg.dir ?? process.cwd();
2007
2081
  const reviewKeys = reviewArtifactKeys(projectDir);
2008
2082
  const retrievalRequested = looksLikeReviewRetrievalRequest(rawInstructionText);
2009
2083
  const shouldPersistReviewArtifact = looksLikeCodeReviewRequest(rawInstructionText) && !retrievalRequested;
2084
+ if (!retrievalRequested && cfg.initial_connection_check !== false && !initialConnectionProbeDone) {
2085
+ if (typeof client.probeConnection === 'function') {
2086
+ await client.probeConnection();
2087
+ initialConnectionProbeDone = true;
2088
+ }
2089
+ }
2010
2090
  if (retrievalRequested) {
2011
2091
  const latest = vault
2012
2092
  ? await vault.getLatestByKey(reviewKeys.latestKey, 'system').catch(() => null)
@@ -2023,37 +2103,37 @@ export async function createSession(opts) {
2023
2103
  'Reply with "print stale review anyway" to override, or request a fresh review.';
2024
2104
  messages.push({ role: 'assistant', content: blocked });
2025
2105
  hookObj.onToken?.(blocked);
2026
- await hookObj.onTurnEnd?.({
2106
+ await emitTurnEnd({
2027
2107
  turn: turns,
2028
2108
  toolCalls,
2029
2109
  promptTokens: cumulativeUsage.prompt,
2030
2110
  completionTokens: cumulativeUsage.completion,
2031
2111
  });
2032
- return { text: blocked, turns, toolCalls };
2112
+ return await finalizeAsk(blocked);
2033
2113
  }
2034
2114
  const text = stale
2035
2115
  ? `${artifact.content}\n\n[artifact note] ${stale}`
2036
2116
  : artifact.content;
2037
2117
  messages.push({ role: 'assistant', content: text });
2038
2118
  hookObj.onToken?.(text);
2039
- await hookObj.onTurnEnd?.({
2119
+ await emitTurnEnd({
2040
2120
  turn: turns,
2041
2121
  toolCalls,
2042
2122
  promptTokens: cumulativeUsage.prompt,
2043
2123
  completionTokens: cumulativeUsage.completion,
2044
2124
  });
2045
- return { text, turns, toolCalls };
2125
+ return await finalizeAsk(text);
2046
2126
  }
2047
2127
  const miss = 'No stored full code review found yet. Ask me to run a code review first, then I can replay it verbatim.';
2048
2128
  messages.push({ role: 'assistant', content: miss });
2049
2129
  hookObj.onToken?.(miss);
2050
- await hookObj.onTurnEnd?.({
2130
+ await emitTurnEnd({
2051
2131
  turn: turns,
2052
2132
  toolCalls,
2053
2133
  promptTokens: cumulativeUsage.prompt,
2054
2134
  completionTokens: cumulativeUsage.completion,
2055
2135
  });
2056
- return { text: miss, turns, toolCalls };
2136
+ return await finalizeAsk(miss);
2057
2137
  }
2058
2138
  const persistReviewArtifact = async (finalText) => {
2059
2139
  if (!vault || !shouldPersistReviewArtifact)
@@ -2193,6 +2273,7 @@ export async function createSession(opts) {
2193
2273
  if (inFlight?.signal?.aborted)
2194
2274
  break;
2195
2275
  turns++;
2276
+ await hookManager.emit('turn_start', { askId, turn: turns });
2196
2277
  const wallElapsed = (Date.now() - wallStart) / 1000;
2197
2278
  if (wallElapsed > cfg.timeout) {
2198
2279
  throw new Error(`session timeout exceeded (${cfg.timeout}s) after ${wallElapsed.toFixed(1)}s`);
@@ -2233,9 +2314,9 @@ export async function createSession(opts) {
2233
2314
  const callerSignal = hookObj.signal;
2234
2315
  const onCallerAbort = () => ac.abort();
2235
2316
  callerSignal?.addEventListener('abort', onCallerAbort, { once: true });
2236
- // Per-request timeout: the lesser of response_timeout (default 300s) or the remaining session wall time.
2317
+ // Per-request timeout: the lesser of response_timeout (default 600s) or the remaining session wall time.
2237
2318
  // This prevents a single slow request from consuming the entire session budget.
2238
- const perReqCap = cfg.response_timeout && cfg.response_timeout > 0 ? cfg.response_timeout : 300;
2319
+ const perReqCap = cfg.response_timeout && cfg.response_timeout > 0 ? cfg.response_timeout : 600;
2239
2320
  const wallRemaining = Math.max(0, cfg.timeout - (Date.now() - wallStart) / 1000);
2240
2321
  const reqTimeout = Math.min(perReqCap, Math.max(10, wallRemaining));
2241
2322
  const timer = setTimeout(() => ac.abort(), reqTimeout * 1000);
@@ -2389,7 +2470,7 @@ export async function createSession(opts) {
2389
2470
  role: 'user',
2390
2471
  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.',
2391
2472
  });
2392
- await hookObj.onTurnEnd?.({
2473
+ await emitTurnEnd({
2393
2474
  turn: turns,
2394
2475
  toolCalls,
2395
2476
  promptTokens: cumulativeUsage.prompt,
@@ -2638,8 +2719,8 @@ export async function createSession(opts) {
2638
2719
  const searchTerm = typeof args.search === 'string' ? args.search : '';
2639
2720
  // Fix 1: Hard cumulative budget — refuse reads past hard cap
2640
2721
  if (cumulativeReadOnlyCalls > READ_BUDGET_HARD) {
2641
- hookObj.onToolCall?.({ id: callId, name, args });
2642
- hookObj.onToolResult?.({ id: callId, name, success: false, summary: 'read budget exhausted', result: '' });
2722
+ await emitToolCall({ id: callId, name, args });
2723
+ await emitToolResult({ id: callId, name, success: false, summary: 'read budget exhausted', result: '' });
2643
2724
  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.` };
2644
2725
  }
2645
2726
  // Fix 2: Directory scan detection — counts unique files per dir (re-reads are OK)
@@ -2654,8 +2735,8 @@ export async function createSession(opts) {
2654
2735
  blockedDirs.add(parentDir);
2655
2736
  }
2656
2737
  if (blockedDirs.has(parentDir) && uniqueCount > 8) {
2657
- hookObj.onToolCall?.({ id: callId, name, args });
2658
- hookObj.onToolResult?.({ id: callId, name, success: false, summary: 'dir scan blocked', result: '' });
2738
+ await emitToolCall({ id: callId, name, args });
2739
+ await emitToolResult({ id: callId, name, success: false, summary: 'dir scan blocked', result: '' });
2659
2740
  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.` };
2660
2741
  }
2661
2742
  }
@@ -2666,8 +2747,8 @@ export async function createSession(opts) {
2666
2747
  searchTermFiles.set(key, new Set());
2667
2748
  searchTermFiles.get(key).add(filePath);
2668
2749
  if (searchTermFiles.get(key).size >= 3) {
2669
- hookObj.onToolCall?.({ id: callId, name, args });
2670
- hookObj.onToolResult?.({ id: callId, name, success: false, summary: 'use search_files', result: '' });
2750
+ await emitToolCall({ id: callId, name, args });
2751
+ await emitToolResult({ id: callId, name, success: false, summary: 'use search_files', result: '' });
2671
2752
  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}" .` };
2672
2753
  }
2673
2754
  }
@@ -2689,12 +2770,12 @@ export async function createSession(opts) {
2689
2770
  // Notify via confirmProvider.showBlocked if available
2690
2771
  opts.confirmProvider?.showBlocked?.({ tool: name, args, reason: `plan mode: ${summary}` });
2691
2772
  // Hook: onToolCall + onToolResult for plan-blocked actions
2692
- hookObj.onToolCall?.({ id: callId, name, args });
2693
- hookObj.onToolResult?.({ id: callId, name, success: true, summary: `⏸ ${summary} (blocked)`, result: blockedMsg });
2773
+ await emitToolCall({ id: callId, name, args });
2774
+ await emitToolResult({ id: callId, name, success: true, summary: `⏸ ${summary} (blocked)`, result: blockedMsg });
2694
2775
  return { id: callId, content: blockedMsg };
2695
2776
  }
2696
2777
  // Hook: onToolCall (Phase 8.5)
2697
- hookObj.onToolCall?.({ id: callId, name, args });
2778
+ await emitToolCall({ id: callId, name, args });
2698
2779
  if (cfg.step_mode) {
2699
2780
  const stepPrompt = `Step mode: execute ${name}(${JSON.stringify(args).slice(0, 200)}) ? [Y/n]`;
2700
2781
  const ok = confirmBridge ? await confirmBridge(stepPrompt, { tool: name, args }) : true;
@@ -2815,7 +2896,7 @@ export async function createSession(opts) {
2815
2896
  }
2816
2897
  catch { }
2817
2898
  }
2818
- hookObj.onToolResult?.(resultEvent);
2899
+ await emitToolResult(resultEvent);
2819
2900
  // Proactive LSP diagnostics after file mutations
2820
2901
  if (lspManager?.hasServers() && lspCfg?.proactive_diagnostics !== false) {
2821
2902
  if (FILE_MUTATION_TOOL_SET.has(name)) {
@@ -2843,7 +2924,7 @@ export async function createSession(opts) {
2843
2924
  };
2844
2925
  const results = [];
2845
2926
  // Helper: catch tool errors but re-throw AgentLoopBreak (those must break the outer loop)
2846
- const catchToolError = (e, tc) => {
2927
+ const catchToolError = async (e, tc) => {
2847
2928
  if (e instanceof AgentLoopBreak)
2848
2929
  throw e;
2849
2930
  const msg = e?.message ?? String(e);
@@ -2877,7 +2958,7 @@ export async function createSession(opts) {
2877
2958
  }
2878
2959
  // Hook: onToolResult for errors (Phase 8.5)
2879
2960
  const callId = resolveCallId(tc);
2880
- hookObj.onToolResult?.({ id: callId, name: tc.function.name, success: false, summary: msg || 'unknown error', result: `ERROR: ${msg || 'unknown error'}` });
2961
+ await emitToolResult({ id: callId, name: tc.function.name, success: false, summary: msg || 'unknown error', result: `ERROR: ${msg || 'unknown error'}` });
2881
2962
  // Never return undefined error text; it makes bench failures impossible to debug.
2882
2963
  return { id: callId, content: `ERROR: ${msg || 'unknown tool error'}` };
2883
2964
  };
@@ -2916,7 +2997,7 @@ export async function createSession(opts) {
2916
2997
  results.push(await runOne(tc));
2917
2998
  }
2918
2999
  catch (e) {
2919
- results.push(catchToolError(e, tc));
3000
+ results.push(await catchToolError(e, tc));
2920
3001
  }
2921
3002
  }
2922
3003
  }
@@ -2930,7 +3011,7 @@ export async function createSession(opts) {
2930
3011
  results.push(await runOne(tc));
2931
3012
  }
2932
3013
  catch (e) {
2933
- results.push(catchToolError(e, tc));
3014
+ results.push(await catchToolError(e, tc));
2934
3015
  }
2935
3016
  }
2936
3017
  }
@@ -2972,7 +3053,7 @@ export async function createSession(opts) {
2972
3053
  });
2973
3054
  }
2974
3055
  // Hook: onTurnEnd (Phase 8.5)
2975
- await hookObj.onTurnEnd?.({
3056
+ await emitTurnEnd({
2976
3057
  turn: turns,
2977
3058
  toolCalls,
2978
3059
  promptTokens: cumulativeUsage.prompt,
@@ -3015,7 +3096,7 @@ export async function createSession(opts) {
3015
3096
  `Original task:\n${clippedReminder}\n\n` +
3016
3097
  `Call the needed tools directly. If everything is truly complete, provide the final answer.`
3017
3098
  });
3018
- await hookObj.onTurnEnd?.({
3099
+ await emitTurnEnd({
3019
3100
  turn: turns,
3020
3101
  toolCalls,
3021
3102
  promptTokens: cumulativeUsage.prompt,
@@ -3035,7 +3116,7 @@ export async function createSession(opts) {
3035
3116
  role: 'user',
3036
3117
  content: '[system] Continue executing the task. Use tools now (do not just narrate plans). If complete, give the final answer.'
3037
3118
  });
3038
- await hookObj.onTurnEnd?.({
3119
+ await emitTurnEnd({
3039
3120
  turn: turns,
3040
3121
  toolCalls,
3041
3122
  promptTokens: cumulativeUsage.prompt,
@@ -3053,7 +3134,7 @@ export async function createSession(opts) {
3053
3134
  // final assistant message
3054
3135
  messages.push({ role: 'assistant', content: assistantText });
3055
3136
  await persistReviewArtifact(assistantText).catch(() => { });
3056
- await hookObj.onTurnEnd?.({
3137
+ await emitTurnEnd({
3057
3138
  turn: turns,
3058
3139
  toolCalls,
3059
3140
  promptTokens: cumulativeUsage.prompt,
@@ -3065,7 +3146,7 @@ export async function createSession(opts) {
3065
3146
  ppTps,
3066
3147
  tgTps,
3067
3148
  });
3068
- return { text: assistantText, turns, toolCalls };
3149
+ return await finalizeAsk(assistantText);
3069
3150
  }
3070
3151
  const reason = `max iterations exceeded (${maxIters})`;
3071
3152
  const diag = lastSuccessfulTestRun
@@ -3091,6 +3172,12 @@ export async function createSession(opts) {
3091
3172
  })();
3092
3173
  const err = new Error(`BUG: threw undefined in agent.ask() (turn=${turns}). lastMsg=${lastMsg?.role ?? 'unknown'}:${lastMsgPreview}`);
3093
3174
  await persistFailure(err, `ask turn ${turns}`);
3175
+ await hookManager.emit('ask_error', {
3176
+ askId,
3177
+ error: err.message,
3178
+ turns,
3179
+ toolCalls,
3180
+ });
3094
3181
  throw err;
3095
3182
  }
3096
3183
  await persistFailure(e, `ask turn ${turns}`);
@@ -3100,8 +3187,21 @@ export async function createSession(opts) {
3100
3187
  }
3101
3188
  // Never rethrow undefined; normalize to Error for debuggability.
3102
3189
  if (e === undefined) {
3103
- throw new Error('BUG: threw undefined (normalized at ask() boundary)');
3190
+ const normalized = new Error('BUG: threw undefined (normalized at ask() boundary)');
3191
+ await hookManager.emit('ask_error', {
3192
+ askId,
3193
+ error: normalized.message,
3194
+ turns,
3195
+ toolCalls,
3196
+ });
3197
+ throw normalized;
3104
3198
  }
3199
+ await hookManager.emit('ask_error', {
3200
+ askId,
3201
+ error: e instanceof Error ? e.message : String(e),
3202
+ turns,
3203
+ toolCalls,
3204
+ });
3105
3205
  throw e;
3106
3206
  }
3107
3207
  };
@@ -3148,6 +3248,7 @@ export async function createSession(opts) {
3148
3248
  replay,
3149
3249
  vault,
3150
3250
  lens,
3251
+ hookManager,
3151
3252
  get lastEditedPath() {
3152
3253
  return lastEditedPath;
3153
3254
  },