@visorcraft/idlehands 1.1.6 → 1.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +32 -0
  2. package/dist/agent/formatting.js +251 -0
  3. package/dist/agent/formatting.js.map +1 -0
  4. package/dist/agent/review-artifact.js +147 -0
  5. package/dist/agent/review-artifact.js.map +1 -0
  6. package/dist/agent/tool-calls.js +226 -0
  7. package/dist/agent/tool-calls.js.map +1 -0
  8. package/dist/agent.js +314 -695
  9. package/dist/agent.js.map +1 -1
  10. package/dist/anton/controller.js +1 -1
  11. package/dist/anton/controller.js.map +1 -1
  12. package/dist/anton/lock.js +0 -3
  13. package/dist/anton/lock.js.map +1 -1
  14. package/dist/anton/parser.js +0 -1
  15. package/dist/anton/parser.js.map +1 -1
  16. package/dist/anton/reporter.js +1 -1
  17. package/dist/anton/reporter.js.map +1 -1
  18. package/dist/bot/commands.js +3 -2
  19. package/dist/bot/commands.js.map +1 -1
  20. package/dist/bot/confirm-telegram.js +2 -1
  21. package/dist/bot/confirm-telegram.js.map +1 -1
  22. package/dist/bot/discord-routing.js +179 -0
  23. package/dist/bot/discord-routing.js.map +1 -0
  24. package/dist/bot/discord-streaming.js +171 -0
  25. package/dist/bot/discord-streaming.js.map +1 -0
  26. package/dist/bot/discord.js +25 -221
  27. package/dist/bot/discord.js.map +1 -1
  28. package/dist/bot/format.js +2 -25
  29. package/dist/bot/format.js.map +1 -1
  30. package/dist/bot/telegram.js +56 -12
  31. package/dist/bot/telegram.js.map +1 -1
  32. package/dist/cli/args.js +4 -1
  33. package/dist/cli/args.js.map +1 -1
  34. package/dist/cli/build-repl-context.js.map +1 -1
  35. package/dist/cli/command-registry.js +2 -1
  36. package/dist/cli/command-registry.js.map +1 -1
  37. package/dist/cli/command-utils.js +27 -0
  38. package/dist/cli/command-utils.js.map +1 -0
  39. package/dist/cli/commands/anton.js +3 -2
  40. package/dist/cli/commands/anton.js.map +1 -1
  41. package/dist/cli/commands/model.js +8 -7
  42. package/dist/cli/commands/model.js.map +1 -1
  43. package/dist/cli/commands/project.js +5 -4
  44. package/dist/cli/commands/project.js.map +1 -1
  45. package/dist/cli/commands/session.js +118 -8
  46. package/dist/cli/commands/session.js.map +1 -1
  47. package/dist/cli/commands/tools.js +4 -3
  48. package/dist/cli/commands/tools.js.map +1 -1
  49. package/dist/cli/input.js +2 -1
  50. package/dist/cli/input.js.map +1 -1
  51. package/dist/cli/repl-dispatch.js +85 -0
  52. package/dist/cli/repl-dispatch.js.map +1 -0
  53. package/dist/cli/runtime-cmds.js +7 -7
  54. package/dist/cli/runtime-cmds.js.map +1 -1
  55. package/dist/cli/service.js +0 -14
  56. package/dist/cli/service.js.map +1 -1
  57. package/dist/cli/setup.js +25 -5
  58. package/dist/cli/setup.js.map +1 -1
  59. package/dist/cli/watch.js +2 -1
  60. package/dist/cli/watch.js.map +1 -1
  61. package/dist/client.js +51 -4
  62. package/dist/client.js.map +1 -1
  63. package/dist/config.js +79 -0
  64. package/dist/config.js.map +1 -1
  65. package/dist/context.js +101 -10
  66. package/dist/context.js.map +1 -1
  67. package/dist/harnesses.js +1 -1
  68. package/dist/harnesses.js.map +1 -1
  69. package/dist/hooks/index.js +5 -0
  70. package/dist/hooks/index.js.map +1 -0
  71. package/dist/hooks/loader.js +58 -0
  72. package/dist/hooks/loader.js.map +1 -0
  73. package/dist/hooks/manager.js +180 -0
  74. package/dist/hooks/manager.js.map +1 -0
  75. package/dist/hooks/plugins/example-console.js +24 -0
  76. package/dist/hooks/plugins/example-console.js.map +1 -0
  77. package/dist/hooks/scaffold.js +53 -0
  78. package/dist/hooks/scaffold.js.map +1 -0
  79. package/dist/hooks/types.js +8 -0
  80. package/dist/hooks/types.js.map +1 -0
  81. package/dist/index.js +16 -64
  82. package/dist/index.js.map +1 -1
  83. package/dist/progress/agent-hooks.js +37 -0
  84. package/dist/progress/agent-hooks.js.map +1 -0
  85. package/dist/progress/ir.js +7 -0
  86. package/dist/progress/ir.js.map +1 -0
  87. package/dist/progress/progress-message-renderer.js +63 -0
  88. package/dist/progress/progress-message-renderer.js.map +1 -0
  89. package/dist/progress/serialize-discord.js +60 -0
  90. package/dist/progress/serialize-discord.js.map +1 -0
  91. package/dist/progress/serialize-telegram.js +55 -0
  92. package/dist/progress/serialize-telegram.js.map +1 -0
  93. package/dist/progress/serialize-tui.js +39 -0
  94. package/dist/progress/serialize-tui.js.map +1 -0
  95. package/dist/progress/tool-summary.js +58 -0
  96. package/dist/progress/tool-summary.js.map +1 -0
  97. package/dist/progress/tool-tail.js +48 -0
  98. package/dist/progress/tool-tail.js.map +1 -0
  99. package/dist/progress/turn-progress.js +215 -0
  100. package/dist/progress/turn-progress.js.map +1 -0
  101. package/dist/replay.js +2 -5
  102. package/dist/replay.js.map +1 -1
  103. package/dist/safety.js +0 -1
  104. package/dist/safety.js.map +1 -1
  105. package/dist/spinner.js +8 -0
  106. package/dist/spinner.js.map +1 -1
  107. package/dist/tools.js +422 -29
  108. package/dist/tools.js.map +1 -1
  109. package/dist/tui/branch-picker.js.map +1 -1
  110. package/dist/tui/command-handler.js.map +1 -1
  111. package/dist/tui/controller.js +417 -33
  112. package/dist/tui/controller.js.map +1 -1
  113. package/dist/tui/keymap.js +15 -0
  114. package/dist/tui/keymap.js.map +1 -1
  115. package/dist/tui/render.js +115 -3
  116. package/dist/tui/render.js.map +1 -1
  117. package/dist/tui/state.js +82 -1
  118. package/dist/tui/state.js.map +1 -1
  119. package/dist/upgrade.js.map +1 -1
  120. package/dist/utils.js +17 -0
  121. package/dist/utils.js.map +1 -1
  122. package/package.json +1 -1
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';
@@ -13,123 +14,18 @@ import { LensStore } from './lens.js';
13
14
  import { SYS_CONTEXT_SCHEMA, collectSnapshot } from './sys/context.js';
14
15
  import { MCPManager } from './mcp.js';
15
16
  import { LspManager, detectInstalledLspServers } from './lsp.js';
17
+ import { generateMinimalDiff, toolResultSummary, execCommandFromSig, formatDurationMs, looksLikePlanningNarration, capTextByApproxTokens, isLikelyBinaryBuffer, sanitizePathsInMessage, digestToolResult, } from './agent/formatting.js';
18
+ import { parseToolCallsFromContent, getMissingRequiredParams, stripMarkdownFences } from './agent/tool-calls.js';
19
+ export { parseToolCallsFromContent };
20
+ import { reviewArtifactKeys, looksLikeCodeReviewRequest, looksLikeReviewRetrievalRequest, retrievalAllowsStaleArtifact, parseReviewArtifactStalePolicy, parseReviewArtifact, reviewArtifactStaleReason, gitHead, normalizeModelsResponse, } from './agent/review-artifact.js';
16
21
  import fs from 'node:fs/promises';
17
22
  import path from 'node:path';
18
- import { spawnSync } from 'node:child_process';
19
- import { stateDir, BASH_PATH as BASH } from './utils.js';
23
+ import { stateDir, timestampedId } from './utils.js';
20
24
  function makeAbortController() {
21
25
  // Node 24: AbortController is global.
22
26
  return new AbortController();
23
27
  }
24
- /** Generate a minimal unified diff for Phase 7 rich display (max 20 lines, truncated). */
25
- function generateMinimalDiff(before, after, filePath) {
26
- const bLines = before.split('\n');
27
- const aLines = after.split('\n');
28
- const out = [];
29
- out.push(`--- a/${filePath}`);
30
- out.push(`+++ b/${filePath}`);
31
- // Simple line-by-line diff (find changed region)
32
- let diffStart = 0;
33
- while (diffStart < bLines.length && diffStart < aLines.length && bLines[diffStart] === aLines[diffStart])
34
- diffStart++;
35
- let bEnd = bLines.length - 1;
36
- let aEnd = aLines.length - 1;
37
- while (bEnd > diffStart && aEnd > diffStart && bLines[bEnd] === aLines[aEnd]) {
38
- bEnd--;
39
- aEnd--;
40
- }
41
- const contextBefore = Math.max(0, diffStart - 2);
42
- const contextAfter = Math.min(Math.max(bLines.length, aLines.length) - 1, Math.max(bEnd, aEnd) + 2);
43
- const bEndContext = Math.min(bLines.length - 1, contextAfter);
44
- const aEndContext = Math.min(aLines.length - 1, contextAfter);
45
- out.push(`@@ -${contextBefore + 1},${bEndContext - contextBefore + 1} +${contextBefore + 1},${aEndContext - contextBefore + 1} @@`);
46
- let lineCount = 0;
47
- const MAX_LINES = 20;
48
- // Context before change
49
- for (let i = contextBefore; i < diffStart && lineCount < MAX_LINES; i++) {
50
- out.push(` ${bLines[i]}`);
51
- lineCount++;
52
- }
53
- // Removed lines
54
- for (let i = diffStart; i <= bEnd && i < bLines.length && lineCount < MAX_LINES; i++) {
55
- out.push(`-${bLines[i]}`);
56
- lineCount++;
57
- }
58
- // Added lines
59
- for (let i = diffStart; i <= aEnd && i < aLines.length && lineCount < MAX_LINES; i++) {
60
- out.push(`+${aLines[i]}`);
61
- lineCount++;
62
- }
63
- // Context after change
64
- const afterStart = Math.max(bEnd, aEnd) + 1;
65
- for (let i = afterStart; i <= contextAfter && i < Math.max(bLines.length, aLines.length) && lineCount < MAX_LINES; i++) {
66
- const line = i < aLines.length ? aLines[i] : bLines[i] ?? '';
67
- out.push(` ${line}`);
68
- lineCount++;
69
- }
70
- const totalChanges = (bEnd - diffStart + 1) + (aEnd - diffStart + 1);
71
- if (lineCount >= MAX_LINES && totalChanges > MAX_LINES) {
72
- out.push(`[+${totalChanges - MAX_LINES} more lines]`);
73
- }
74
- return out.join('\n');
75
- }
76
- /** Generate a one-line summary of a tool result for hooks/display. */
77
- function toolResultSummary(name, args, content, success) {
78
- if (!success)
79
- return content.slice(0, 120);
80
- switch (name) {
81
- case 'read_file':
82
- case 'read_files': {
83
- const lines = content.split('\n').length;
84
- return `${lines} lines read`;
85
- }
86
- case 'write_file':
87
- return `wrote ${args.path || 'file'}`;
88
- case 'edit_file':
89
- return content.startsWith('ERROR') ? content.slice(0, 120) : `applied edit`;
90
- case 'insert_file':
91
- return `inserted at line ${args.line ?? '?'}`;
92
- case 'exec': {
93
- try {
94
- const r = JSON.parse(content);
95
- const lines = (r.out || '').split('\n').filter(Boolean).length;
96
- return `rc=${r.rc}, ${lines} lines`;
97
- }
98
- catch {
99
- return content.slice(0, 80);
100
- }
101
- }
102
- case 'list_dir': {
103
- const entries = content.split('\n').filter(Boolean).length;
104
- return `${entries} entries`;
105
- }
106
- case 'search_files': {
107
- const matches = (content.match(/^\d+:/gm) || []).length;
108
- return `${matches} matches`;
109
- }
110
- case 'spawn_task': {
111
- const line = content.split(/\r?\n/).find((l) => l.includes('status='));
112
- return line ? line.trim() : 'sub-agent task finished';
113
- }
114
- case 'vault_search':
115
- return `vault results`;
116
- default:
117
- return content.slice(0, 80);
118
- }
119
- }
120
28
  const CACHED_EXEC_OBSERVATION_HINT = '[idlehands hint] Reused cached output for repeated read-only exec call (unchanged observation).';
121
- function execCommandFromSig(sig) {
122
- if (!sig.startsWith('exec:'))
123
- return '';
124
- const raw = sig.slice('exec:'.length);
125
- try {
126
- const parsed = JSON.parse(raw);
127
- return typeof parsed?.command === 'string' ? parsed.command : '';
128
- }
129
- catch {
130
- return '';
131
- }
132
- }
133
29
  function looksLikeReadOnlyExecCommand(command) {
134
30
  const cmd = String(command || '').trim().toLowerCase();
135
31
  if (!cmd)
@@ -202,7 +98,7 @@ Rules:
202
98
  - 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.
203
99
  - Read the target file before editing. You need the exact text for search/replace.
204
100
  - Use read_file with search=... to jump to relevant code; avoid reading whole files.
205
- - Use edit_file for surgical changes. Never rewrite entire files when a targeted edit works.
101
+ - Prefer apply_patch or edit_range for code edits (token-efficient). Use edit_file only when exact old_text replacement is necessary.
206
102
  - Use insert_file for insertions (prepend/append/line).
207
103
  - Use exec to run commands, tests, builds; check results before reporting success.
208
104
  - 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.
@@ -229,7 +125,7 @@ const DEFAULT_SUB_AGENT_RESULT_TOKEN_CAP = 4000;
229
125
  const APPROVAL_MODE_SET = new Set(['plan', 'reject', 'default', 'auto-edit', 'yolo']);
230
126
  const LSP_TOOL_NAMES = ['lsp_diagnostics', 'lsp_symbols', 'lsp_hover', 'lsp_definition', 'lsp_references'];
231
127
  const LSP_TOOL_NAME_SET = new Set(LSP_TOOL_NAMES);
232
- const FILE_MUTATION_TOOL_SET = new Set(['edit_file', 'write_file', 'insert_file']);
128
+ const FILE_MUTATION_TOOL_SET = new Set(['edit_file', 'edit_range', 'apply_patch', 'write_file', 'insert_file']);
233
129
  function normalizeApprovalMode(value) {
234
130
  if (typeof value !== 'string')
235
131
  return undefined;
@@ -245,66 +141,6 @@ const APPROVAL_MODE_RANK = { plan: 0, reject: 1, default: 2, 'auto-edit': 3, yol
245
141
  function capApprovalMode(requested, parentMode) {
246
142
  return APPROVAL_MODE_RANK[requested] <= APPROVAL_MODE_RANK[parentMode] ? requested : parentMode;
247
143
  }
248
- function formatDurationMs(ms) {
249
- if (!Number.isFinite(ms) || ms <= 0)
250
- return '0.0s';
251
- return `${(ms / 1000).toFixed(1)}s`;
252
- }
253
- function looksLikePlanningNarration(text, finishReason) {
254
- const s = String(text ?? '').trim().toLowerCase();
255
- if (!s)
256
- return false;
257
- // Incomplete streamed answer: likely still needs another turn.
258
- if (finishReason === 'length')
259
- return true;
260
- // Strong completion cues: treat as final answer.
261
- if (/(^|\n)\s*(done|completed|finished|final answer|summary:)\b/.test(s))
262
- return false;
263
- // Typical "thinking out loud"/plan chatter that should continue with tools.
264
- return /\b(let me|i(?:'|’)ll|i will|i'm going to|i am going to|next i(?:'|’)ll|first i(?:'|’)ll|i need to|i should|checking|reviewing|exploring|starting by)\b/.test(s);
265
- }
266
- function approxTokenCharCap(maxTokens) {
267
- const safe = Math.max(64, Math.floor(maxTokens));
268
- return safe * 4;
269
- }
270
- function capTextByApproxTokens(text, maxTokens) {
271
- const raw = String(text ?? '');
272
- const maxChars = approxTokenCharCap(maxTokens);
273
- if (raw.length <= maxChars)
274
- return { text: raw, truncated: false };
275
- const clipped = raw.slice(0, maxChars);
276
- return {
277
- text: `${clipped}\n\n[sub-agent] result truncated to ~${maxTokens} tokens (${raw.length} chars original)`,
278
- truncated: true,
279
- };
280
- }
281
- function isLikelyBinaryBuffer(buf) {
282
- const n = Math.min(buf.length, 512);
283
- for (let i = 0; i < n; i++) {
284
- if (buf[i] === 0)
285
- return true;
286
- }
287
- return false;
288
- }
289
- /**
290
- * Strip absolute paths from a message to prevent cross-project leaks in vault.
291
- * Paths within cwd are replaced with relative equivalents; other absolute paths
292
- * are replaced with just the basename.
293
- */
294
- function sanitizePathsInMessage(message, cwd) {
295
- const normCwd = cwd.replace(/\/+$/, '');
296
- // Match absolute Unix paths (at least 2 segments)
297
- return message.replace(/\/(?:home|tmp|var|usr|opt|etc|root)\/[^\s"',;)\]}>]+/g, (match) => {
298
- const normMatch = match.replace(/\/+$/, '');
299
- if (normMatch.startsWith(normCwd + '/')) {
300
- // Within cwd — make relative
301
- return normMatch.slice(normCwd.length + 1);
302
- }
303
- // Outside cwd — strip to basename
304
- const base = path.basename(normMatch);
305
- return base || match;
306
- });
307
- }
308
144
  async function buildSubAgentContextBlock(cwd, rawFiles) {
309
145
  const values = Array.isArray(rawFiles) ? rawFiles : [];
310
146
  const files = values
@@ -384,155 +220,155 @@ function buildToolsSchema(opts) {
384
220
  properties,
385
221
  required
386
222
  });
223
+ const str = () => ({ type: 'string' });
224
+ const bool = () => ({ type: 'boolean' });
225
+ const int = (min, max) => ({ type: 'integer', ...(min !== undefined && { minimum: min }), ...(max !== undefined && { maximum: max }) });
387
226
  const schemas = [
227
+ // ────────────────────────────────────────────────────────────────────────────
228
+ // Token-safe reads (require limit; allow plain output without per-line numbers)
229
+ // ────────────────────────────────────────────────────────────────────────────
388
230
  {
389
231
  type: 'function',
390
232
  function: {
391
233
  name: 'read_file',
392
- description: 'Read file contents with line numbers. Use search/context to jump to relevant code.',
234
+ description: 'Read a bounded slice of a file.',
393
235
  parameters: obj({
394
- path: { type: 'string' },
395
- offset: { type: 'integer' },
396
- limit: { type: 'integer' },
397
- search: { type: 'string' },
398
- context: { type: 'integer' },
399
- }, ['path'])
400
- }
236
+ path: str(),
237
+ offset: int(1, 1_000_000),
238
+ limit: int(1, 240),
239
+ search: str(),
240
+ context: int(0, 80),
241
+ format: { type: 'string', enum: ['plain', 'numbered', 'sparse'] },
242
+ max_bytes: int(256, 20_000),
243
+ }, ['path', 'limit']),
244
+ },
401
245
  },
402
246
  {
403
247
  type: 'function',
404
248
  function: {
405
249
  name: 'read_files',
406
- description: 'Batch read multiple files.',
250
+ description: 'Batch read bounded file slices.',
407
251
  parameters: obj({
408
252
  requests: {
409
253
  type: 'array',
410
254
  items: obj({
411
- path: { type: 'string' },
412
- offset: { type: 'integer' },
413
- limit: { type: 'integer' },
414
- search: { type: 'string' },
415
- context: { type: 'integer' },
416
- }, ['path'])
417
- }
418
- }, ['requests'])
419
- }
255
+ path: str(),
256
+ offset: int(1, 1_000_000),
257
+ limit: int(1, 240),
258
+ search: str(),
259
+ context: int(0, 80),
260
+ format: { type: 'string', enum: ['plain', 'numbered', 'sparse'] },
261
+ max_bytes: int(256, 20_000),
262
+ }, ['path', 'limit']),
263
+ },
264
+ }, ['requests']),
265
+ },
420
266
  },
267
+ // ────────────────────────────────────────────────────────────────────────────
268
+ // Writes/edits
269
+ // ────────────────────────────────────────────────────────────────────────────
421
270
  {
422
271
  type: 'function',
423
272
  function: {
424
273
  name: 'write_file',
425
- description: 'Write a file (atomic). Creates parents. Makes a backup first.',
426
- parameters: obj({ path: { type: 'string' }, content: { type: 'string' } }, ['path', 'content'])
427
- }
274
+ description: 'Write file (atomic, backup).',
275
+ parameters: obj({ path: str(), content: str() }, ['path', 'content']),
276
+ },
428
277
  },
429
278
  {
430
279
  type: 'function',
431
280
  function: {
432
- name: 'edit_file',
433
- description: 'Search/replace exact text in a file. Fails if old_text not found.',
281
+ name: 'apply_patch',
282
+ description: 'Apply unified diff patch (multi-file).',
434
283
  parameters: obj({
435
- path: { type: 'string' },
436
- old_text: { type: 'string' },
437
- new_text: { type: 'string' },
438
- replace_all: { type: 'boolean' }
439
- }, ['path', 'old_text', 'new_text'])
440
- }
284
+ patch: str(),
285
+ files: { type: 'array', items: str() },
286
+ strip: int(0, 5),
287
+ }, ['patch', 'files']),
288
+ },
441
289
  },
442
290
  {
443
291
  type: 'function',
444
292
  function: {
445
- name: 'insert_file',
446
- description: 'Insert text at a specific line (0=prepend, -1=append).',
293
+ name: 'edit_range',
294
+ description: 'Replace a line range in a file.',
447
295
  parameters: obj({
448
- path: { type: 'string' },
449
- line: { type: 'integer' },
450
- text: { type: 'string' }
451
- }, ['path', 'line', 'text'])
452
- }
296
+ path: str(),
297
+ start_line: int(1),
298
+ end_line: int(1),
299
+ replacement: str(),
300
+ }, ['path', 'start_line', 'end_line', 'replacement']),
301
+ },
453
302
  },
303
+ {
304
+ type: 'function',
305
+ function: {
306
+ name: 'edit_file',
307
+ description: 'Legacy exact replace (requires old_text). Prefer apply_patch/edit_range.',
308
+ parameters: obj({ path: str(), old_text: str(), new_text: str(), replace_all: bool() }, ['path', 'old_text', 'new_text']),
309
+ },
310
+ },
311
+ {
312
+ type: 'function',
313
+ function: {
314
+ name: 'insert_file',
315
+ description: 'Insert text at line (0=prepend, -1=append).',
316
+ parameters: obj({ path: str(), line: int(), text: str() }, ['path', 'line', 'text']),
317
+ },
318
+ },
319
+ // ────────────────────────────────────────────────────────────────────────────
320
+ // Bounded listings/search (expose existing caps)
321
+ // ────────────────────────────────────────────────────────────────────────────
454
322
  {
455
323
  type: 'function',
456
324
  function: {
457
325
  name: 'list_dir',
458
- description: 'List directory contents (optional recursive, max depth 3).',
459
- parameters: obj({
460
- path: { type: 'string' },
461
- recursive: { type: 'boolean' },
462
- }, ['path'])
463
- }
326
+ description: 'List directory entries.',
327
+ parameters: obj({ path: str(), recursive: bool(), max_entries: int(1, 500) }, ['path']),
328
+ },
464
329
  },
465
330
  {
466
331
  type: 'function',
467
332
  function: {
468
333
  name: 'search_files',
469
- description: 'Search for a regex pattern in files under a directory.',
470
- parameters: obj({
471
- pattern: { type: 'string' },
472
- path: { type: 'string' },
473
- include: { type: 'string' },
474
- }, ['pattern', 'path'])
475
- }
334
+ description: 'Search regex in files.',
335
+ parameters: obj({ pattern: str(), path: str(), include: str(), max_results: int(1, 100) }, ['pattern', 'path']),
336
+ },
476
337
  },
338
+ // ────────────────────────────────────────────────────────────────────────────
339
+ // Exec (minified schema)
340
+ // ────────────────────────────────────────────────────────────────────────────
477
341
  {
478
342
  type: 'function',
479
343
  function: {
480
344
  name: 'exec',
481
- description: 'Run a shell command (bash -c) with timeout; returns JSON rc/out/err. Each call is a new shell — cwd does not persist between calls.',
482
- parameters: obj({
483
- command: { type: 'string', description: 'Shell command to run' },
484
- cwd: { type: 'string', description: 'Working directory (default: project root). Use this instead of cd.' },
485
- timeout: { type: 'integer', description: 'Timeout in seconds (default: 30, max: 120). Use 60-120 for npm install, builds, or test suites.' }
486
- }, ['command'])
487
- }
488
- }
345
+ description: 'Run bash -c; returns JSON rc/out/err.',
346
+ parameters: obj({ command: str(), cwd: str(), timeout: int(1, 120) }, ['command']),
347
+ },
348
+ },
489
349
  ];
490
350
  if (opts?.allowSpawnTask !== false) {
491
351
  schemas.push({
492
352
  type: 'function',
493
353
  function: {
494
354
  name: 'spawn_task',
495
- description: 'Delegate a focused task to an isolated sub-agent session (no parent chat history).',
355
+ description: 'Run a sub-agent task (no parent history).',
496
356
  parameters: obj({
497
- task: { type: 'string', description: 'Instruction for the sub-agent' },
498
- context_files: {
499
- type: 'array',
500
- description: 'Optional extra files to inject into sub-agent context',
501
- items: { type: 'string' },
502
- },
503
- model: { type: 'string', description: 'Optional model override for this task' },
504
- endpoint: { type: 'string', description: 'Optional endpoint override for this task' },
505
- max_iterations: { type: 'integer', description: 'Optional max turn cap for the sub-agent' },
506
- max_tokens: { type: 'integer', description: 'Optional max completion tokens for the sub-agent' },
507
- timeout_sec: { type: 'integer', description: 'Optional timeout for this sub-agent run (seconds)' },
508
- system_prompt: { type: 'string', description: 'Optional sub-agent system prompt override for this task' },
357
+ task: str(),
358
+ context_files: { type: 'array', items: str() },
359
+ model: str(),
360
+ endpoint: str(),
361
+ max_iterations: int(),
362
+ max_tokens: int(),
363
+ timeout_sec: int(),
364
+ system_prompt: str(),
509
365
  approval_mode: { type: 'string', enum: ['plan', 'reject', 'default', 'auto-edit', 'yolo'] },
510
- }, ['task'])
511
- }
366
+ }, ['task']),
367
+ },
512
368
  });
513
369
  }
514
370
  if (opts?.activeVaultTools) {
515
- schemas.push({
516
- type: 'function',
517
- function: {
518
- name: 'vault_search',
519
- description: 'Search vault entries (notes and previous tool outputs) to reuse prior high-signal findings.',
520
- parameters: obj({
521
- query: { type: 'string' },
522
- limit: { type: 'integer' }
523
- }, ['query'])
524
- }
525
- }, {
526
- type: 'function',
527
- function: {
528
- name: 'vault_note',
529
- description: 'Persist a concise, high-signal note into the Trifecta vault.',
530
- parameters: obj({
531
- key: { type: 'string' },
532
- value: { type: 'string' }
533
- }, ['key', 'value'])
534
- }
535
- });
371
+ 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']) } });
536
372
  }
537
373
  // Phase 9: sys_context tool is only available in sys mode.
538
374
  if (opts?.sysMode) {
@@ -543,54 +379,36 @@ function buildToolsSchema(opts) {
543
379
  type: 'function',
544
380
  function: {
545
381
  name: 'lsp_diagnostics',
546
- description: 'Get current LSP diagnostics (errors/warnings) for a file or the whole project. Structured — replaces running build commands to check for errors.',
547
- parameters: obj({
548
- path: { type: 'string', description: 'File path (omit for project-wide diagnostics)' },
549
- severity: { type: 'integer', description: '1=Error, 2=Warning, 3=Info, 4=Hint (default: config threshold)' },
550
- }, [])
382
+ description: 'Get LSP diagnostics (errors/warnings) for file or project.',
383
+ parameters: obj({ path: str(), severity: int() }, [])
551
384
  }
552
385
  }, {
553
386
  type: 'function',
554
387
  function: {
555
388
  name: 'lsp_symbols',
556
- description: 'List all symbols (functions, classes, variables) in a file via LSP.',
557
- parameters: obj({
558
- path: { type: 'string' },
559
- }, ['path'])
389
+ description: 'List symbols (functions, classes, vars) in a file.',
390
+ parameters: obj({ path: str() }, ['path'])
560
391
  }
561
392
  }, {
562
393
  type: 'function',
563
394
  function: {
564
395
  name: 'lsp_hover',
565
- description: 'Get type info and documentation for a symbol at a position.',
566
- parameters: obj({
567
- path: { type: 'string' },
568
- line: { type: 'integer' },
569
- character: { type: 'integer' },
570
- }, ['path', 'line', 'character'])
396
+ description: 'Get type/docs for symbol at position.',
397
+ parameters: obj({ path: str(), line: int(), character: int() }, ['path', 'line', 'character'])
571
398
  }
572
399
  }, {
573
400
  type: 'function',
574
401
  function: {
575
402
  name: 'lsp_definition',
576
- description: 'Go to definition of a symbol at a given position.',
577
- parameters: obj({
578
- path: { type: 'string' },
579
- line: { type: 'integer' },
580
- character: { type: 'integer' },
581
- }, ['path', 'line', 'character'])
403
+ description: 'Go to definition of symbol at position.',
404
+ parameters: obj({ path: str(), line: int(), character: int() }, ['path', 'line', 'character'])
582
405
  }
583
406
  }, {
584
407
  type: 'function',
585
408
  function: {
586
409
  name: 'lsp_references',
587
- description: 'Find all references to a symbol at a given position.',
588
- parameters: obj({
589
- path: { type: 'string' },
590
- line: { type: 'integer' },
591
- character: { type: 'integer' },
592
- max_results: { type: 'integer', description: 'Cap results (default 50)' },
593
- }, ['path', 'line', 'character'])
410
+ description: 'Find all references to symbol at position.',
411
+ parameters: obj({ path: str(), line: int(), character: int(), max_results: int() }, ['path', 'line', 'character'])
594
412
  }
595
413
  });
596
414
  }
@@ -599,203 +417,6 @@ function buildToolsSchema(opts) {
599
417
  }
600
418
  return schemas;
601
419
  }
602
- /** @internal Exported for testing. Parses tool calls from model content when tool_calls array is empty. */
603
- export function parseToolCallsFromContent(content) {
604
- // Fallback parser: if model printed JSON tool_calls in content.
605
- const trimmed = content.trim();
606
- const tryParse = (s) => {
607
- try {
608
- return JSON.parse(s);
609
- }
610
- catch {
611
- return null;
612
- }
613
- };
614
- // Case 1: whole content is JSON
615
- const whole = tryParse(trimmed);
616
- if (whole?.tool_calls && Array.isArray(whole.tool_calls))
617
- return whole.tool_calls;
618
- if (whole?.name && whole?.arguments) {
619
- return [
620
- {
621
- id: 'call_0',
622
- type: 'function',
623
- function: { name: String(whole.name), arguments: JSON.stringify(whole.arguments) }
624
- }
625
- ];
626
- }
627
- // Case 2: raw JSON array of tool calls (model writes [{name, arguments}, ...])
628
- const arrStart = trimmed.indexOf('[');
629
- const arrEnd = trimmed.lastIndexOf(']');
630
- if (arrStart !== -1 && arrEnd !== -1 && arrEnd > arrStart) {
631
- const arrSub = tryParse(trimmed.slice(arrStart, arrEnd + 1));
632
- if (Array.isArray(arrSub) && arrSub.length > 0 && arrSub[0]?.name) {
633
- return arrSub.map((item, i) => ({
634
- id: `call_${i}`,
635
- type: 'function',
636
- function: {
637
- name: String(item.name),
638
- arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? {})
639
- }
640
- }));
641
- }
642
- }
643
- // Case 3: find a JSON object substring (handles tool_calls wrapper OR single tool-call)
644
- const start = trimmed.indexOf('{');
645
- const end = trimmed.lastIndexOf('}');
646
- if (start !== -1 && end !== -1 && end > start) {
647
- const sub = tryParse(trimmed.slice(start, end + 1));
648
- if (sub?.tool_calls && Array.isArray(sub.tool_calls))
649
- return sub.tool_calls;
650
- if (sub?.name && sub?.arguments) {
651
- return [
652
- {
653
- id: 'call_0',
654
- type: 'function',
655
- function: { name: String(sub.name), arguments: typeof sub.arguments === 'string' ? sub.arguments : JSON.stringify(sub.arguments) }
656
- }
657
- ];
658
- }
659
- }
660
- // Case 4: XML tool calls — used by Qwen, Hermes, and other models whose chat
661
- // templates emit <tool_call><function=name><parameter=key>value</parameter></function></tool_call>.
662
- // When llama-server's XML→JSON conversion fails (common with large write_file content),
663
- // the raw XML leaks into the content field. This recovers it.
664
- const xmlCalls = parseXmlToolCalls(trimmed);
665
- if (xmlCalls?.length)
666
- return xmlCalls;
667
- // Case 5: Lightweight function-tag calls (seen in some Qwen content-mode outputs):
668
- // <function=tool_name>
669
- // {...json args...}
670
- // </function>
671
- // or single-line <function=tool_name>{...}</function>
672
- const fnTagCalls = parseFunctionTagToolCalls(trimmed);
673
- if (fnTagCalls?.length)
674
- return fnTagCalls;
675
- return null;
676
- }
677
- /**
678
- * Parse XML-style tool calls from content.
679
- * Format: <tool_call><function=name><parameter=key>value</parameter>...</function></tool_call>
680
- * Handles multiple tool call blocks and arbitrary parameter names/values.
681
- */
682
- function parseXmlToolCalls(content) {
683
- // Quick bailout: no point parsing if there's no <tool_call> marker
684
- if (!content.includes('<tool_call>'))
685
- return null;
686
- const calls = [];
687
- // Match each <tool_call>...</tool_call> block.
688
- // Using a manual scan instead of a single greedy regex to handle nested angle brackets
689
- // in parameter values (e.g. TypeScript generics, JSX, comparison operators).
690
- let searchFrom = 0;
691
- while (searchFrom < content.length) {
692
- const blockStart = content.indexOf('<tool_call>', searchFrom);
693
- if (blockStart === -1)
694
- break;
695
- const blockEnd = content.indexOf('</tool_call>', blockStart);
696
- if (blockEnd === -1)
697
- break; // Truncated — can't recover partial tool calls
698
- const block = content.slice(blockStart + '<tool_call>'.length, blockEnd);
699
- searchFrom = blockEnd + '</tool_call>'.length;
700
- // Extract function name: <function=name>...</function>
701
- const fnMatch = block.match(/<function=(\w[\w.-]*)>/);
702
- if (!fnMatch)
703
- continue;
704
- const fnName = fnMatch[1];
705
- const fnStart = block.indexOf(fnMatch[0]) + fnMatch[0].length;
706
- const fnEnd = block.lastIndexOf('</function>');
707
- const fnBody = fnEnd !== -1 ? block.slice(fnStart, fnEnd) : block.slice(fnStart);
708
- // Extract parameters: <parameter=key>value</parameter>
709
- // Uses bracket-matching (depth counting) so that parameter values containing
710
- // literal <parameter=...>...</parameter> (e.g. writing XML files) are handled
711
- // correctly instead of being truncated at the inner close tag.
712
- const args = {};
713
- const openRe = /<parameter=(\w[\w.-]*)>/g;
714
- const closeTag = '</parameter>';
715
- let paramMatch;
716
- while ((paramMatch = openRe.exec(fnBody)) !== null) {
717
- const paramName = paramMatch[1];
718
- const valueStart = paramMatch.index + paramMatch[0].length;
719
- // Bracket-match: find the </parameter> that balances this open tag.
720
- // Depth starts at 1; nested <parameter=...> increments, </parameter> decrements.
721
- let depth = 1;
722
- let scanPos = valueStart;
723
- let closeIdx = -1;
724
- while (scanPos < fnBody.length && depth > 0) {
725
- const nextOpen = fnBody.indexOf('<parameter=', scanPos);
726
- const nextClose = fnBody.indexOf(closeTag, scanPos);
727
- if (nextClose === -1)
728
- break; // No more close tags — truncated
729
- if (nextOpen !== -1 && nextOpen < nextClose) {
730
- // An open tag comes before the next close — increase depth
731
- depth++;
732
- scanPos = nextOpen + 1; // advance past '<' to avoid re-matching
733
- }
734
- else {
735
- // Close tag comes first — decrease depth
736
- depth--;
737
- if (depth === 0) {
738
- closeIdx = nextClose;
739
- }
740
- scanPos = nextClose + closeTag.length;
741
- }
742
- }
743
- if (closeIdx === -1) {
744
- // No matching close tag — take rest of body as value (truncated output)
745
- args[paramName] = fnBody.slice(valueStart).trim();
746
- break;
747
- }
748
- // Trim exactly the template-added leading/trailing newline, preserve internal whitespace
749
- let value = fnBody.slice(valueStart, closeIdx);
750
- if (value.startsWith('\n'))
751
- value = value.slice(1);
752
- if (value.endsWith('\n'))
753
- value = value.slice(0, -1);
754
- args[paramName] = value;
755
- // Advance the regex past the close tag so the next openRe.exec starts after it
756
- openRe.lastIndex = closeIdx + closeTag.length;
757
- }
758
- if (fnName && Object.keys(args).length > 0) {
759
- calls.push({
760
- id: `call_xml_${calls.length}`,
761
- type: 'function',
762
- function: {
763
- name: fnName,
764
- arguments: JSON.stringify(args)
765
- }
766
- });
767
- }
768
- }
769
- return calls.length > 0 ? calls : null;
770
- }
771
- /** Check for missing required params by tool name — universal pre-dispatch validation */
772
- function getMissingRequiredParams(toolName, args) {
773
- const required = {
774
- read_file: ['path'],
775
- read_files: ['requests'],
776
- write_file: ['path', 'content'],
777
- edit_file: ['path', 'old_text', 'new_text'],
778
- insert_file: ['path', 'line', 'text'],
779
- list_dir: ['path'],
780
- search_files: ['pattern', 'path'],
781
- exec: ['command'],
782
- spawn_task: ['task'],
783
- sys_context: [],
784
- vault_search: ['query'],
785
- vault_note: ['key', 'value']
786
- };
787
- const req = required[toolName];
788
- if (!req)
789
- return [];
790
- return req.filter(p => args[p] === undefined || args[p] === null);
791
- }
792
- /** Strip markdown code fences (```json ... ```) from tool argument strings */
793
- function stripMarkdownFences(s) {
794
- const trimmed = s.trim();
795
- // Match ```json\n...\n``` or ```\n...\n```
796
- const m = /^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/.exec(trimmed);
797
- return m ? m[1] : s;
798
- }
799
420
  function isReadOnlyTool(name) {
800
421
  return name === 'read_file' || name === 'read_files' || name === 'list_dir' || name === 'search_files' || name === 'vault_search' || name === 'sys_context';
801
422
  }
@@ -804,6 +425,10 @@ function planModeSummary(name, args) {
804
425
  switch (name) {
805
426
  case 'write_file':
806
427
  return `write ${args.path ?? 'unknown'} (${typeof args.content === 'string' ? args.content.split('\n').length : '?'} lines)`;
428
+ case 'apply_patch':
429
+ return `apply patch to ${Array.isArray(args.files) ? args.files.length : '?'} file(s)`;
430
+ case 'edit_range':
431
+ return `edit ${args.path ?? 'unknown'} lines ${args.start_line ?? '?'}-${args.end_line ?? '?'}`;
807
432
  case 'edit_file':
808
433
  return `edit ${args.path ?? 'unknown'} (replace ${typeof args.old_text === 'string' ? args.old_text.split('\n').length : '?'} lines)`;
809
434
  case 'insert_file':
@@ -838,148 +463,6 @@ function userDisallowsDelegation(content) {
838
463
  /\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);
839
464
  return negationNearDelegation;
840
465
  }
841
- function reviewArtifactKeys(projectDir) {
842
- const { projectId } = projectIndexKeys(projectDir);
843
- return {
844
- projectId,
845
- latestKey: `artifact:review:latest:${projectId}`,
846
- byIdPrefix: `artifact:review:item:${projectId}:`,
847
- };
848
- }
849
- function looksLikeCodeReviewRequest(text) {
850
- const t = text.toLowerCase();
851
- if (!t.trim())
852
- return false;
853
- if (/^\s*\/review\b/.test(t))
854
- return true;
855
- 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))
856
- return true;
857
- return /\breview\b/.test(t) && /\b(?:code|repo|repository|diff|changes|pull\s*request|pr)\b/.test(t);
858
- }
859
- function looksLikeReviewRetrievalRequest(text) {
860
- const t = text.toLowerCase();
861
- if (!t.trim())
862
- return false;
863
- if (/^\s*\/review\s+(?:print|show|replay|latest|last|full)\b/.test(t))
864
- return true;
865
- if (!/\breview\b/.test(t))
866
- return false;
867
- if (/\bprint\s+stale\s+review\s+anyway\b/.test(t))
868
- return true;
869
- 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))
870
- return true;
871
- 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))
872
- return true;
873
- 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))
874
- return true;
875
- 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))
876
- return true;
877
- return false;
878
- }
879
- function retrievalAllowsStaleArtifact(text) {
880
- const t = text.toLowerCase();
881
- if (!t.trim())
882
- return false;
883
- if (/\bprint\s+stale\s+review\s+anyway\b/.test(t))
884
- return true;
885
- if (/\b(?:force|override|ignore)\b[^\n.]{0,80}\b(?:stale|old|previous)\b[^\n.]{0,80}\breview\b/.test(t))
886
- return true;
887
- if (/\b(?:stale|old|previous)\b[^\n.]{0,80}\breview\b[^\n.]{0,80}\b(?:anyway|still|force|override|ignore)\b/.test(t))
888
- return true;
889
- return false;
890
- }
891
- function parseReviewArtifactStalePolicy(raw) {
892
- const v = typeof raw === 'string' ? raw.toLowerCase().trim() : '';
893
- if (v === 'block')
894
- return 'block';
895
- return 'warn';
896
- }
897
- function parseReviewArtifact(raw) {
898
- try {
899
- const parsed = JSON.parse(raw);
900
- if (!parsed || typeof parsed !== 'object')
901
- return null;
902
- if (parsed.kind !== 'code_review')
903
- return null;
904
- if (typeof parsed.id !== 'string' || !parsed.id)
905
- return null;
906
- if (typeof parsed.createdAt !== 'string' || !parsed.createdAt)
907
- return null;
908
- if (typeof parsed.model !== 'string')
909
- return null;
910
- if (typeof parsed.projectId !== 'string' || !parsed.projectId)
911
- return null;
912
- if (typeof parsed.projectDir !== 'string' || !parsed.projectDir)
913
- return null;
914
- if (typeof parsed.prompt !== 'string')
915
- return null;
916
- if (typeof parsed.content !== 'string')
917
- return null;
918
- return parsed;
919
- }
920
- catch {
921
- return null;
922
- }
923
- }
924
- function gitHead(cwd) {
925
- const inside = spawnSync(BASH, ['-lc', 'git rev-parse --is-inside-work-tree'], {
926
- cwd,
927
- encoding: 'utf8',
928
- timeout: 1000,
929
- });
930
- if (inside.status !== 0 || !String(inside.stdout || '').trim().startsWith('true'))
931
- return undefined;
932
- const head = spawnSync(BASH, ['-lc', 'git rev-parse HEAD'], {
933
- cwd,
934
- encoding: 'utf8',
935
- timeout: 1000,
936
- });
937
- if (head.status !== 0)
938
- return undefined;
939
- const sha = String(head.stdout || '').trim();
940
- return sha || undefined;
941
- }
942
- function shortSha(sha) {
943
- if (!sha)
944
- return 'unknown';
945
- return sha.slice(0, 8);
946
- }
947
- function reviewArtifactStaleReason(artifact, cwd) {
948
- const currentHead = gitHead(cwd);
949
- const currentDirty = isGitDirty(cwd);
950
- if (artifact.gitHead && currentHead && artifact.gitHead !== currentHead) {
951
- return `Stored review was generated at commit ${shortSha(artifact.gitHead)}; repository is now at ${shortSha(currentHead)}.`;
952
- }
953
- if (artifact.gitDirty === false && currentDirty) {
954
- return 'Stored review was generated on a clean tree; working tree now has uncommitted changes.';
955
- }
956
- return '';
957
- }
958
- function normalizeModelsResponse(raw) {
959
- if (Array.isArray(raw)) {
960
- return {
961
- data: raw
962
- .map((m) => {
963
- if (!m)
964
- return null;
965
- if (typeof m === 'string')
966
- return { id: m };
967
- if (typeof m.id === 'string' && m.id)
968
- return m;
969
- return null;
970
- })
971
- .filter(Boolean)
972
- };
973
- }
974
- if (raw && Array.isArray(raw.data)) {
975
- return {
976
- data: raw.data
977
- .map((m) => (m && typeof m.id === 'string' && m.id ? m : null))
978
- .filter(Boolean)
979
- };
980
- }
981
- return { data: [] };
982
- }
983
466
  export async function createSession(opts) {
984
467
  const cfg = opts.config;
985
468
  let client = opts.runtime?.client ?? new OpenAIClient(cfg.endpoint, opts.apiKey, cfg.verbose);
@@ -989,6 +472,15 @@ export async function createSession(opts) {
989
472
  if (typeof cfg.response_timeout === 'number' && cfg.response_timeout > 0) {
990
473
  client.setResponseTimeout(cfg.response_timeout);
991
474
  }
475
+ if (typeof client.setConnectionTimeout === 'function' && typeof cfg.connection_timeout === 'number' && cfg.connection_timeout > 0) {
476
+ client.setConnectionTimeout(cfg.connection_timeout);
477
+ }
478
+ if (typeof client.setInitialConnectionCheck === 'function' && typeof cfg.initial_connection_check === 'boolean') {
479
+ client.setInitialConnectionCheck(cfg.initial_connection_check);
480
+ }
481
+ if (typeof client.setInitialConnectionProbeTimeout === 'function' && typeof cfg.initial_connection_timeout === 'number' && cfg.initial_connection_timeout > 0) {
482
+ client.setInitialConnectionProbeTimeout(cfg.initial_connection_timeout);
483
+ }
992
484
  // Health check + model list (cheap, avoids wasting GPU on chat warmups if unreachable)
993
485
  let modelsList = normalizeModelsResponse(await client.models().catch(() => null));
994
486
  let model = cfg.model && cfg.model.trim().length
@@ -1004,6 +496,44 @@ export async function createSession(opts) {
1004
496
  modelMeta,
1005
497
  });
1006
498
  let supportsVision = supportsVisionModel(model, modelMeta, harness);
499
+ const sessionId = `session-${timestampedId()}`;
500
+ const hookCfg = cfg.hooks ?? {};
501
+ const hookManager = opts.runtime?.hookManager ?? new HookManager({
502
+ enabled: hookCfg.enabled !== false,
503
+ strict: hookCfg.strict === true,
504
+ warnMs: hookCfg.warn_ms,
505
+ allowedCapabilities: Array.isArray(hookCfg.allow_capabilities) ? hookCfg.allow_capabilities : undefined,
506
+ context: () => ({
507
+ sessionId,
508
+ cwd: cfg.dir ?? process.cwd(),
509
+ model,
510
+ harness: harness.id,
511
+ endpoint: cfg.endpoint,
512
+ }),
513
+ });
514
+ const emitDetached = (promise, eventName) => {
515
+ void promise.catch((error) => {
516
+ if (!process.env.IDLEHANDS_QUIET_WARNINGS) {
517
+ console.warn(`[hooks] async ${eventName} dispatch failed: ${error?.message ?? String(error)}`);
518
+ }
519
+ });
520
+ };
521
+ if (!opts.runtime?.hookManager && hookManager.isEnabled()) {
522
+ const loadedPlugins = await loadHookPlugins({
523
+ pluginPaths: Array.isArray(hookCfg.plugin_paths) ? hookCfg.plugin_paths : [],
524
+ cwd: cfg.dir ?? process.cwd(),
525
+ strict: hookCfg.strict === true,
526
+ });
527
+ for (const loaded of loadedPlugins) {
528
+ await hookManager.registerPlugin(loaded.plugin, loaded.path);
529
+ }
530
+ }
531
+ await hookManager.emit('session_start', {
532
+ model,
533
+ harness: harness.id,
534
+ endpoint: cfg.endpoint,
535
+ cwd: cfg.dir ?? process.cwd(),
536
+ });
1007
537
  if (!cfg.i_know_what_im_doing && contextWindow > 131072) {
1008
538
  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
539
  }
@@ -1034,7 +564,7 @@ export async function createSession(opts) {
1034
564
  ? Number(cfg.mcp_call_timeout_sec)
1035
565
  : (Number.isFinite(cfg.mcp?.call_timeout_sec) ? Number(cfg.mcp?.call_timeout_sec) : 30);
1036
566
  const builtInToolNames = [
1037
- 'read_file', 'read_files', 'write_file', 'edit_file', 'insert_file',
567
+ 'read_file', 'read_files', 'write_file', 'apply_patch', 'edit_range', 'edit_file', 'insert_file',
1038
568
  'list_dir', 'search_files', 'exec', 'vault_search', 'vault_note', 'sys_context',
1039
569
  ...(spawnTaskEnabled ? ['spawn_task'] : []),
1040
570
  ];
@@ -1276,6 +806,7 @@ export async function createSession(opts) {
1276
806
  ];
1277
807
  sessionMetaPending = sessionMeta;
1278
808
  lastEditedPath = undefined;
809
+ initialConnectionProbeDone = false;
1279
810
  mcpToolsLoaded = !mcpLazySchemaMode;
1280
811
  };
1281
812
  const restore = (next) => {
@@ -1298,6 +829,7 @@ export async function createSession(opts) {
1298
829
  };
1299
830
  let reqCounter = 0;
1300
831
  let inFlight = null;
832
+ let initialConnectionProbeDone = false;
1301
833
  let lastEditedPath;
1302
834
  // Plan mode state (Phase 8)
1303
835
  let planSteps = [];
@@ -1798,6 +1330,7 @@ export async function createSession(opts) {
1798
1330
  return fresh.data.map((m) => m.id).filter(Boolean);
1799
1331
  };
1800
1332
  const setModel = (name) => {
1333
+ const previousModel = model;
1801
1334
  model = name;
1802
1335
  harness = selectHarness(model, cfg.harness && cfg.harness.trim() ? cfg.harness.trim() : undefined);
1803
1336
  const nextMeta = modelsList?.data?.find((m) => m.id === model);
@@ -1815,6 +1348,11 @@ export async function createSession(opts) {
1815
1348
  configuredTopP: cfg.top_p,
1816
1349
  baseMaxTokens: BASE_MAX_TOKENS,
1817
1350
  }));
1351
+ emitDetached(hookManager.emit('model_changed', {
1352
+ previousModel,
1353
+ nextModel: model,
1354
+ harness: harness.id,
1355
+ }), 'model_changed');
1818
1356
  };
1819
1357
  const setEndpoint = async (endpoint, modelName) => {
1820
1358
  const normalized = endpoint.replace(/\/+$/, '');
@@ -2002,11 +1540,49 @@ export async function createSession(opts) {
2002
1540
  const hookObj = typeof hooks === 'function' ? { onToken: hooks } : hooks ?? {};
2003
1541
  let turns = 0;
2004
1542
  let toolCalls = 0;
1543
+ const askId = `ask-${timestampedId()}`;
1544
+ const emitToolCall = async (call) => {
1545
+ hookObj.onToolCall?.(call);
1546
+ await hookManager.emit('tool_call', { askId, turn: turns, call });
1547
+ };
1548
+ const emitToolStream = (stream) => {
1549
+ try {
1550
+ void hookObj.onToolStream?.(stream);
1551
+ }
1552
+ catch {
1553
+ // best effort
1554
+ }
1555
+ try {
1556
+ void hookManager.emit('tool_stream', { askId, turn: turns, stream });
1557
+ }
1558
+ catch {
1559
+ // best effort
1560
+ }
1561
+ };
1562
+ const emitToolResult = async (result) => {
1563
+ await hookObj.onToolResult?.(result);
1564
+ await hookManager.emit('tool_result', { askId, turn: turns, result });
1565
+ };
1566
+ const emitTurnEnd = async (stats) => {
1567
+ await hookObj.onTurnEnd?.(stats);
1568
+ await hookManager.emit('turn_end', { askId, stats });
1569
+ };
1570
+ const finalizeAsk = async (text) => {
1571
+ await hookManager.emit('ask_end', { askId, text, turns, toolCalls });
1572
+ return { text, turns, toolCalls };
1573
+ };
2005
1574
  const rawInstructionText = userContentToText(instruction).trim();
1575
+ await hookManager.emit('ask_start', { askId, instruction: rawInstructionText });
2006
1576
  const projectDir = cfg.dir ?? process.cwd();
2007
1577
  const reviewKeys = reviewArtifactKeys(projectDir);
2008
1578
  const retrievalRequested = looksLikeReviewRetrievalRequest(rawInstructionText);
2009
1579
  const shouldPersistReviewArtifact = looksLikeCodeReviewRequest(rawInstructionText) && !retrievalRequested;
1580
+ if (!retrievalRequested && cfg.initial_connection_check !== false && !initialConnectionProbeDone) {
1581
+ if (typeof client.probeConnection === 'function') {
1582
+ await client.probeConnection();
1583
+ initialConnectionProbeDone = true;
1584
+ }
1585
+ }
2010
1586
  if (retrievalRequested) {
2011
1587
  const latest = vault
2012
1588
  ? await vault.getLatestByKey(reviewKeys.latestKey, 'system').catch(() => null)
@@ -2023,37 +1599,37 @@ export async function createSession(opts) {
2023
1599
  'Reply with "print stale review anyway" to override, or request a fresh review.';
2024
1600
  messages.push({ role: 'assistant', content: blocked });
2025
1601
  hookObj.onToken?.(blocked);
2026
- await hookObj.onTurnEnd?.({
1602
+ await emitTurnEnd({
2027
1603
  turn: turns,
2028
1604
  toolCalls,
2029
1605
  promptTokens: cumulativeUsage.prompt,
2030
1606
  completionTokens: cumulativeUsage.completion,
2031
1607
  });
2032
- return { text: blocked, turns, toolCalls };
1608
+ return await finalizeAsk(blocked);
2033
1609
  }
2034
1610
  const text = stale
2035
1611
  ? `${artifact.content}\n\n[artifact note] ${stale}`
2036
1612
  : artifact.content;
2037
1613
  messages.push({ role: 'assistant', content: text });
2038
1614
  hookObj.onToken?.(text);
2039
- await hookObj.onTurnEnd?.({
1615
+ await emitTurnEnd({
2040
1616
  turn: turns,
2041
1617
  toolCalls,
2042
1618
  promptTokens: cumulativeUsage.prompt,
2043
1619
  completionTokens: cumulativeUsage.completion,
2044
1620
  });
2045
- return { text, turns, toolCalls };
1621
+ return await finalizeAsk(text);
2046
1622
  }
2047
1623
  const miss = 'No stored full code review found yet. Ask me to run a code review first, then I can replay it verbatim.';
2048
1624
  messages.push({ role: 'assistant', content: miss });
2049
1625
  hookObj.onToken?.(miss);
2050
- await hookObj.onTurnEnd?.({
1626
+ await emitTurnEnd({
2051
1627
  turn: turns,
2052
1628
  toolCalls,
2053
1629
  promptTokens: cumulativeUsage.prompt,
2054
1630
  completionTokens: cumulativeUsage.completion,
2055
1631
  });
2056
- return { text: miss, turns, toolCalls };
1632
+ return await finalizeAsk(miss);
2057
1633
  }
2058
1634
  const persistReviewArtifact = async (finalText) => {
2059
1635
  if (!vault || !shouldPersistReviewArtifact)
@@ -2062,7 +1638,7 @@ export async function createSession(opts) {
2062
1638
  if (!clean)
2063
1639
  return;
2064
1640
  const createdAt = new Date().toISOString();
2065
- const id = `review-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1641
+ const id = `review-${timestampedId()}`;
2066
1642
  const artifact = {
2067
1643
  id,
2068
1644
  kind: 'code_review',
@@ -2098,6 +1674,7 @@ export async function createSession(opts) {
2098
1674
  // identical tool call signature counts across this ask() run
2099
1675
  const sigCounts = new Map();
2100
1676
  const toolNameByCallId = new Map();
1677
+ const toolArgsByCallId = new Map();
2101
1678
  // Loop-break helper state: bump mutationVersion whenever a tool mutates files.
2102
1679
  // We also record the mutationVersion at which a given signature was last seen.
2103
1680
  let mutationVersion = 0;
@@ -2139,6 +1716,42 @@ export async function createSession(opts) {
2139
1716
  }
2140
1717
  return msg;
2141
1718
  };
1719
+ const compactToolMessageForHistory = async (toolCallId, rawContent) => {
1720
+ const toolName = toolNameByCallId.get(toolCallId) ?? 'tool';
1721
+ const toolArgs = toolArgsByCallId.get(toolCallId) ?? {};
1722
+ const rawMsg = { role: 'tool', tool_call_id: toolCallId, content: rawContent };
1723
+ // Persist full-fidelity output immediately so live context can stay small.
1724
+ if (vault && typeof vault.archiveToolResult === 'function') {
1725
+ try {
1726
+ await vault.archiveToolResult(rawMsg, toolName);
1727
+ }
1728
+ catch (e) {
1729
+ console.warn(`[warn] vault archive failed: ${e instanceof Error ? e.message : String(e)}`);
1730
+ }
1731
+ }
1732
+ let compact = rawContent;
1733
+ if (lens) {
1734
+ try {
1735
+ const lensCompact = await lens.summarizeToolOutput(rawContent, toolName, typeof toolArgs.path === 'string' ? String(toolArgs.path) : undefined);
1736
+ if (typeof lensCompact === 'string' && lensCompact.length && lensCompact.length < compact.length) {
1737
+ compact = lensCompact;
1738
+ }
1739
+ }
1740
+ catch {
1741
+ // ignore lens failures; fallback to raw
1742
+ }
1743
+ }
1744
+ const success = !String(rawContent).startsWith('ERROR:');
1745
+ const digested = digestToolResult(toolName, toolArgs, compact, success);
1746
+ if (digested !== rawContent) {
1747
+ return {
1748
+ role: 'tool',
1749
+ tool_call_id: toolCallId,
1750
+ content: `${digested}\n[full output archived in vault: tool=${toolName}, call_id=${toolCallId}]`,
1751
+ };
1752
+ }
1753
+ return rawMsg;
1754
+ };
2142
1755
  const persistFailure = async (error, contextLine) => {
2143
1756
  if (!vault)
2144
1757
  return;
@@ -2193,13 +1806,13 @@ export async function createSession(opts) {
2193
1806
  if (inFlight?.signal?.aborted)
2194
1807
  break;
2195
1808
  turns++;
1809
+ await hookManager.emit('turn_start', { askId, turn: turns });
2196
1810
  const wallElapsed = (Date.now() - wallStart) / 1000;
2197
1811
  if (wallElapsed > cfg.timeout) {
2198
1812
  throw new Error(`session timeout exceeded (${cfg.timeout}s) after ${wallElapsed.toFixed(1)}s`);
2199
1813
  }
2200
1814
  await maybeAutoDetectModelChange();
2201
1815
  const beforeMsgs = messages;
2202
- const beforeTokens = estimateTokensFromMessages(beforeMsgs);
2203
1816
  const compacted = enforceContextBudget({
2204
1817
  messages: beforeMsgs,
2205
1818
  contextWindow,
@@ -2208,7 +1821,6 @@ export async function createSession(opts) {
2208
1821
  compactAt: cfg.compact_at ?? 0.8,
2209
1822
  toolSchemaTokens: estimateToolSchemaTokens(getToolsSchema()),
2210
1823
  });
2211
- const compactedDropped = beforeMsgs.length > compacted.length || estimateTokensFromMessages(compacted) < beforeTokens;
2212
1824
  const compactedByRefs = new Set(compacted);
2213
1825
  const dropped = beforeMsgs.filter((m) => !compactedByRefs.has(m));
2214
1826
  if (dropped.length && vault) {
@@ -2233,9 +1845,9 @@ export async function createSession(opts) {
2233
1845
  const callerSignal = hookObj.signal;
2234
1846
  const onCallerAbort = () => ac.abort();
2235
1847
  callerSignal?.addEventListener('abort', onCallerAbort, { once: true });
2236
- // Per-request timeout: the lesser of response_timeout (default 300s) or the remaining session wall time.
1848
+ // Per-request timeout: the lesser of response_timeout (default 600s) or the remaining session wall time.
2237
1849
  // 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;
1850
+ const perReqCap = cfg.response_timeout && cfg.response_timeout > 0 ? cfg.response_timeout : 600;
2239
1851
  const wallRemaining = Math.max(0, cfg.timeout - (Date.now() - wallStart) / 1000);
2240
1852
  const reqTimeout = Math.min(perReqCap, Math.max(10, wallRemaining));
2241
1853
  const timer = setTimeout(() => ac.abort(), reqTimeout * 1000);
@@ -2389,7 +2001,7 @@ export async function createSession(opts) {
2389
2001
  role: 'user',
2390
2002
  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
2003
  });
2392
- await hookObj.onTurnEnd?.({
2004
+ await emitTurnEnd({
2393
2005
  turn: turns,
2394
2006
  toolCalls,
2395
2007
  promptTokens: cumulativeUsage.prompt,
@@ -2451,7 +2063,11 @@ export async function createSession(opts) {
2451
2063
  if (visible && hookObj.onToken)
2452
2064
  hookObj.onToken('\n');
2453
2065
  toolCalls += toolCallsArr.length;
2454
- messages.push({ role: 'assistant', content: visible || '', tool_calls: toolCallsArr });
2066
+ const assistantToolCallText = visible || '';
2067
+ const compactAssistantToolCallText = assistantToolCallText.length > 900
2068
+ ? `${assistantToolCallText.slice(0, 900)}\n[history-compacted: assistant narration truncated before tool execution]`
2069
+ : assistantToolCallText;
2070
+ messages.push({ role: 'assistant', content: compactAssistantToolCallText, tool_calls: toolCallsArr });
2455
2071
  // sigCounts is scoped to the entire ask() run (see above)
2456
2072
  // Bridge ConfirmationProvider → legacy confirm callback for tools.
2457
2073
  // If a ConfirmationProvider is given, wrap it; otherwise fall back to raw callback.
@@ -2574,7 +2190,7 @@ export async function createSession(opts) {
2574
2190
  `Hint: you repeated the same tool call ${loopThreshold} times with identical arguments. ` +
2575
2191
  `If the call succeeded, move on to the next step. ` +
2576
2192
  `If it failed, check that all required parameters are present and correct. ` +
2577
- `For write_file/edit_file, ensure 'content'/'old_text'/'new_text' are included as strings.`);
2193
+ `For write_file/edit_file/apply_patch/edit_range, ensure required args are present (content/old_text/new_text/patch/files/start_line/end_line/replacement).`);
2578
2194
  }
2579
2195
  }
2580
2196
  // Update consecutive tracking: save this turn's signatures for next turn comparison.
@@ -2603,6 +2219,8 @@ export async function createSession(opts) {
2603
2219
  const hasMcpTool = mcpManager?.hasTool(name) === true;
2604
2220
  if (!builtInFn && !isLspTool && !hasMcpTool && !isSpawnTask)
2605
2221
  throw new Error(`unknown tool: ${name}`);
2222
+ // Keep parsed args by call-id so we can digest/archive tool outputs with context.
2223
+ toolArgsByCallId.set(callId, args && typeof args === 'object' && !Array.isArray(args) ? args : {});
2606
2224
  // Pre-dispatch check for missing required params.
2607
2225
  // Universal: catches omitted params early with a clear, instructive error
2608
2226
  // before the tool itself throws a less helpful message.
@@ -2638,8 +2256,8 @@ export async function createSession(opts) {
2638
2256
  const searchTerm = typeof args.search === 'string' ? args.search : '';
2639
2257
  // Fix 1: Hard cumulative budget — refuse reads past hard cap
2640
2258
  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: '' });
2259
+ await emitToolCall({ id: callId, name, args });
2260
+ await emitToolResult({ id: callId, name, success: false, summary: 'read budget exhausted', result: '' });
2643
2261
  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
2262
  }
2645
2263
  // Fix 2: Directory scan detection — counts unique files per dir (re-reads are OK)
@@ -2654,8 +2272,8 @@ export async function createSession(opts) {
2654
2272
  blockedDirs.add(parentDir);
2655
2273
  }
2656
2274
  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: '' });
2275
+ await emitToolCall({ id: callId, name, args });
2276
+ await emitToolResult({ id: callId, name, success: false, summary: 'dir scan blocked', result: '' });
2659
2277
  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
2278
  }
2661
2279
  }
@@ -2666,8 +2284,8 @@ export async function createSession(opts) {
2666
2284
  searchTermFiles.set(key, new Set());
2667
2285
  searchTermFiles.get(key).add(filePath);
2668
2286
  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: '' });
2287
+ await emitToolCall({ id: callId, name, args });
2288
+ await emitToolResult({ id: callId, name, success: false, summary: 'use search_files', result: '' });
2671
2289
  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
2290
  }
2673
2291
  }
@@ -2689,12 +2307,12 @@ export async function createSession(opts) {
2689
2307
  // Notify via confirmProvider.showBlocked if available
2690
2308
  opts.confirmProvider?.showBlocked?.({ tool: name, args, reason: `plan mode: ${summary}` });
2691
2309
  // 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 });
2310
+ await emitToolCall({ id: callId, name, args });
2311
+ await emitToolResult({ id: callId, name, success: true, summary: `⏸ ${summary} (blocked)`, result: blockedMsg });
2694
2312
  return { id: callId, content: blockedMsg };
2695
2313
  }
2696
2314
  // Hook: onToolCall (Phase 8.5)
2697
- hookObj.onToolCall?.({ id: callId, name, args });
2315
+ await emitToolCall({ id: callId, name, args });
2698
2316
  if (cfg.step_mode) {
2699
2317
  const stepPrompt = `Step mode: execute ${name}(${JSON.stringify(args).slice(0, 200)}) ? [Y/n]`;
2700
2318
  const ok = confirmBridge ? await confirmBridge(stepPrompt, { tool: name, args }) : true;
@@ -2717,7 +2335,13 @@ export async function createSession(opts) {
2717
2335
  content = await runSpawnTask(args);
2718
2336
  }
2719
2337
  else if (builtInFn) {
2720
- const value = await builtInFn(ctx, args);
2338
+ const callCtx = {
2339
+ ...ctx,
2340
+ toolCallId: callId,
2341
+ toolName: name,
2342
+ onToolStream: emitToolStream,
2343
+ };
2344
+ const value = await builtInFn(callCtx, args);
2721
2345
  content = typeof value === 'string' ? value : JSON.stringify(value);
2722
2346
  if (name === 'exec') {
2723
2347
  // Successful exec clears blocked-loop counters.
@@ -2815,7 +2439,7 @@ export async function createSession(opts) {
2815
2439
  }
2816
2440
  catch { }
2817
2441
  }
2818
- hookObj.onToolResult?.(resultEvent);
2442
+ await emitToolResult(resultEvent);
2819
2443
  // Proactive LSP diagnostics after file mutations
2820
2444
  if (lspManager?.hasServers() && lspCfg?.proactive_diagnostics !== false) {
2821
2445
  if (FILE_MUTATION_TOOL_SET.has(name)) {
@@ -2843,7 +2467,7 @@ export async function createSession(opts) {
2843
2467
  };
2844
2468
  const results = [];
2845
2469
  // Helper: catch tool errors but re-throw AgentLoopBreak (those must break the outer loop)
2846
- const catchToolError = (e, tc) => {
2470
+ const catchToolError = async (e, tc) => {
2847
2471
  if (e instanceof AgentLoopBreak)
2848
2472
  throw e;
2849
2473
  const msg = e?.message ?? String(e);
@@ -2877,7 +2501,7 @@ export async function createSession(opts) {
2877
2501
  }
2878
2502
  // Hook: onToolResult for errors (Phase 8.5)
2879
2503
  const callId = resolveCallId(tc);
2880
- hookObj.onToolResult?.({ id: callId, name: tc.function.name, success: false, summary: msg || 'unknown error', result: `ERROR: ${msg || 'unknown error'}` });
2504
+ await emitToolResult({ id: callId, name: tc.function.name, success: false, summary: msg || 'unknown error', result: `ERROR: ${msg || 'unknown error'}` });
2881
2505
  // Never return undefined error text; it makes bench failures impossible to debug.
2882
2506
  return { id: callId, content: `ERROR: ${msg || 'unknown tool error'}` };
2883
2507
  };
@@ -2916,7 +2540,7 @@ export async function createSession(opts) {
2916
2540
  results.push(await runOne(tc));
2917
2541
  }
2918
2542
  catch (e) {
2919
- results.push(catchToolError(e, tc));
2543
+ results.push(await catchToolError(e, tc));
2920
2544
  }
2921
2545
  }
2922
2546
  }
@@ -2930,7 +2554,7 @@ export async function createSession(opts) {
2930
2554
  results.push(await runOne(tc));
2931
2555
  }
2932
2556
  catch (e) {
2933
- results.push(catchToolError(e, tc));
2557
+ results.push(await catchToolError(e, tc));
2934
2558
  }
2935
2559
  }
2936
2560
  }
@@ -2938,7 +2562,8 @@ export async function createSession(opts) {
2938
2562
  if (ac.signal.aborted)
2939
2563
  break;
2940
2564
  for (const r of results) {
2941
- messages.push({ role: 'tool', tool_call_id: r.id, content: r.content });
2565
+ const compactToolMsg = await compactToolMessageForHistory(r.id, r.content);
2566
+ messages.push(compactToolMsg);
2942
2567
  }
2943
2568
  if (readOnlyExecTurnHints.length) {
2944
2569
  const previews = readOnlyExecTurnHints
@@ -2972,7 +2597,7 @@ export async function createSession(opts) {
2972
2597
  });
2973
2598
  }
2974
2599
  // Hook: onTurnEnd (Phase 8.5)
2975
- await hookObj.onTurnEnd?.({
2600
+ await emitTurnEnd({
2976
2601
  turn: turns,
2977
2602
  toolCalls,
2978
2603
  promptTokens: cumulativeUsage.prompt,
@@ -3015,7 +2640,7 @@ export async function createSession(opts) {
3015
2640
  `Original task:\n${clippedReminder}\n\n` +
3016
2641
  `Call the needed tools directly. If everything is truly complete, provide the final answer.`
3017
2642
  });
3018
- await hookObj.onTurnEnd?.({
2643
+ await emitTurnEnd({
3019
2644
  turn: turns,
3020
2645
  toolCalls,
3021
2646
  promptTokens: cumulativeUsage.prompt,
@@ -3035,7 +2660,7 @@ export async function createSession(opts) {
3035
2660
  role: 'user',
3036
2661
  content: '[system] Continue executing the task. Use tools now (do not just narrate plans). If complete, give the final answer.'
3037
2662
  });
3038
- await hookObj.onTurnEnd?.({
2663
+ await emitTurnEnd({
3039
2664
  turn: turns,
3040
2665
  toolCalls,
3041
2666
  promptTokens: cumulativeUsage.prompt,
@@ -3053,7 +2678,7 @@ export async function createSession(opts) {
3053
2678
  // final assistant message
3054
2679
  messages.push({ role: 'assistant', content: assistantText });
3055
2680
  await persistReviewArtifact(assistantText).catch(() => { });
3056
- await hookObj.onTurnEnd?.({
2681
+ await emitTurnEnd({
3057
2682
  turn: turns,
3058
2683
  toolCalls,
3059
2684
  promptTokens: cumulativeUsage.prompt,
@@ -3065,7 +2690,7 @@ export async function createSession(opts) {
3065
2690
  ppTps,
3066
2691
  tgTps,
3067
2692
  });
3068
- return { text: assistantText, turns, toolCalls };
2693
+ return await finalizeAsk(assistantText);
3069
2694
  }
3070
2695
  const reason = `max iterations exceeded (${maxIters})`;
3071
2696
  const diag = lastSuccessfulTestRun
@@ -3091,6 +2716,12 @@ export async function createSession(opts) {
3091
2716
  })();
3092
2717
  const err = new Error(`BUG: threw undefined in agent.ask() (turn=${turns}). lastMsg=${lastMsg?.role ?? 'unknown'}:${lastMsgPreview}`);
3093
2718
  await persistFailure(err, `ask turn ${turns}`);
2719
+ await hookManager.emit('ask_error', {
2720
+ askId,
2721
+ error: err.message,
2722
+ turns,
2723
+ toolCalls,
2724
+ });
3094
2725
  throw err;
3095
2726
  }
3096
2727
  await persistFailure(e, `ask turn ${turns}`);
@@ -3100,8 +2731,21 @@ export async function createSession(opts) {
3100
2731
  }
3101
2732
  // Never rethrow undefined; normalize to Error for debuggability.
3102
2733
  if (e === undefined) {
3103
- throw new Error('BUG: threw undefined (normalized at ask() boundary)');
2734
+ const normalized = new Error('BUG: threw undefined (normalized at ask() boundary)');
2735
+ await hookManager.emit('ask_error', {
2736
+ askId,
2737
+ error: normalized.message,
2738
+ turns,
2739
+ toolCalls,
2740
+ });
2741
+ throw normalized;
3104
2742
  }
2743
+ await hookManager.emit('ask_error', {
2744
+ askId,
2745
+ error: e instanceof Error ? e.message : String(e),
2746
+ turns,
2747
+ toolCalls,
2748
+ });
3105
2749
  throw e;
3106
2750
  }
3107
2751
  };
@@ -3148,6 +2792,7 @@ export async function createSession(opts) {
3148
2792
  replay,
3149
2793
  vault,
3150
2794
  lens,
2795
+ hookManager,
3151
2796
  get lastEditedPath() {
3152
2797
  return lastEditedPath;
3153
2798
  },
@@ -3192,30 +2837,4 @@ async function autoPickModel(client, cached) {
3192
2837
  clearTimeout(timer);
3193
2838
  }
3194
2839
  }
3195
- function parseFunctionTagToolCalls(content) {
3196
- const m = content.match(/<function=([\w.-]+)>([\s\S]*?)<\/function>/i);
3197
- if (!m)
3198
- return null;
3199
- const name = m[1];
3200
- const body = (m[2] ?? '').trim();
3201
- // If body contains JSON object, use it as arguments; else empty object.
3202
- let args = '{}';
3203
- const jsonStart = body.indexOf('{');
3204
- const jsonEnd = body.lastIndexOf('}');
3205
- if (jsonStart !== -1 && jsonEnd > jsonStart) {
3206
- const sub = body.slice(jsonStart, jsonEnd + 1);
3207
- try {
3208
- JSON.parse(sub);
3209
- args = sub;
3210
- }
3211
- catch {
3212
- // keep {}
3213
- }
3214
- }
3215
- return [{
3216
- id: 'call_0',
3217
- type: 'function',
3218
- function: { name, arguments: args }
3219
- }];
3220
- }
3221
2840
  //# sourceMappingURL=agent.js.map