@probelabs/probe 0.6.0-rc278 → 0.6.0-rc280

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@probelabs/probe",
3
- "version": "0.6.0-rc278",
3
+ "version": "0.6.0-rc280",
4
4
  "description": "Node.js wrapper for the probe code search tool",
5
5
  "main": "src/index.js",
6
6
  "module": "src/index.js",
@@ -125,6 +125,27 @@ const MAX_HISTORY_MESSAGES = 100;
125
125
  // Maximum image file size (20MB) to prevent OOM attacks
126
126
  const MAX_IMAGE_FILE_SIZE = 20 * 1024 * 1024;
127
127
 
128
+ /**
129
+ * Truncate a string for debug logging, showing first and last portion.
130
+ */
131
+ export function debugTruncate(s, limit = 200) {
132
+ if (s.length <= limit) return s;
133
+ const half = Math.floor(limit / 2);
134
+ return s.substring(0, half) + ` ... [${s.length} chars] ... ` + s.substring(s.length - half);
135
+ }
136
+
137
+ /**
138
+ * Log tool results details for debug output.
139
+ */
140
+ export function debugLogToolResults(toolResults) {
141
+ if (!toolResults || toolResults.length === 0) return;
142
+ for (const tr of toolResults) {
143
+ const argsStr = JSON.stringify(tr.args || {});
144
+ const resultStr = typeof tr.result === 'string' ? tr.result : JSON.stringify(tr.result || '');
145
+ console.log(`[DEBUG] tool: ${tr.toolName} | args: ${debugTruncate(argsStr)} | result: ${debugTruncate(resultStr)}`);
146
+ }
147
+ }
148
+
128
149
  /**
129
150
  * ProbeAgent class to handle AI interactions with code search capabilities
130
151
  */
@@ -193,6 +214,7 @@ export class ProbeAgent {
193
214
  this.enableExecutePlan = !!options.enableExecutePlan;
194
215
  this.debug = options.debug || process.env.DEBUG === '1';
195
216
  this.cancelled = false;
217
+ this._abortController = new AbortController();
196
218
  this.tracer = options.tracer || null;
197
219
  this.outline = !!options.outline;
198
220
  this.searchDelegate = options.searchDelegate !== undefined ? !!options.searchDelegate : true;
@@ -232,6 +254,7 @@ export class ProbeAgent {
232
254
  // Supports exclusion with '!' prefix: ['*', '!bash'] = all tools except bash
233
255
  // disableTools is a convenience flag that overrides allowedTools to []
234
256
  const effectiveAllowedTools = options.disableTools ? [] : options.allowedTools;
257
+ this._rawAllowedTools = options.allowedTools; // Keep raw value for explicit tool checks
235
258
  this.allowedTools = this._parseAllowedTools(effectiveAllowedTools);
236
259
 
237
260
  // Storage adapter (defaults to in-memory)
@@ -459,6 +482,17 @@ export class ProbeAgent {
459
482
  return mcpToolNames.filter(toolName => this._isMcpToolAllowed(toolName));
460
483
  }
461
484
 
485
+ /**
486
+ * Check if query tool was explicitly listed in allowedTools (not via wildcard).
487
+ * Query (ast-grep) is excluded by default because models struggle with AST pattern syntax.
488
+ * @returns {boolean}
489
+ * @private
490
+ */
491
+ _isQueryExplicitlyAllowed() {
492
+ if (!this._rawAllowedTools) return false;
493
+ return Array.isArray(this._rawAllowedTools) && this._rawAllowedTools.includes('query');
494
+ }
495
+
462
496
  /**
463
497
  * Check if tracer is AppTracer (expects sessionId as first param) vs SimpleAppTracer
464
498
  * @returns {boolean} - True if tracer is AppTracer style (requires sessionId)
@@ -792,6 +826,7 @@ export class ProbeAgent {
792
826
  searchDelegateProvider: this.searchDelegateProvider,
793
827
  searchDelegateModel: this.searchDelegateModel,
794
828
  delegationManager: this.delegationManager, // Per-instance delegation limits
829
+ parentAbortSignal: this._abortController.signal, // Propagate cancellation to delegations
795
830
  outputBuffer: this._outputBuffer,
796
831
  concurrencyLimiter: this.concurrencyLimiter, // Global AI concurrency limiter
797
832
  isToolAllowed,
@@ -814,7 +849,9 @@ export class ProbeAgent {
814
849
  if (wrappedTools.searchToolInstance && isToolAllowed('search')) {
815
850
  this.toolImplementations.search = wrappedTools.searchToolInstance;
816
851
  }
817
- if (wrappedTools.queryToolInstance && isToolAllowed('query')) {
852
+ // query tool (ast-grep) is not exposed to AI by default — models struggle with AST pattern syntax.
853
+ // Only register it when explicitly listed in allowedTools (not via wildcard '*').
854
+ if (wrappedTools.queryToolInstance && isToolAllowed('query') && this._isQueryExplicitlyAllowed()) {
818
855
  this.toolImplementations.query = wrappedTools.queryToolInstance;
819
856
  }
820
857
  if (wrappedTools.extractToolInstance && isToolAllowed('extract')) {
@@ -1362,6 +1399,19 @@ export class ProbeAgent {
1362
1399
  const controller = new AbortController();
1363
1400
  const timeoutState = { timeoutId: null };
1364
1401
 
1402
+ // Link agent-level abort to this operation's controller
1403
+ // so that cancel() / cleanup() stops the current streamText call
1404
+ if (this._abortController.signal.aborted) {
1405
+ controller.abort();
1406
+ } else {
1407
+ const onAgentAbort = () => controller.abort();
1408
+ this._abortController.signal.addEventListener('abort', onAgentAbort, { once: true });
1409
+ // Clean up listener when this controller aborts (from any source)
1410
+ controller.signal.addEventListener('abort', () => {
1411
+ this._abortController.signal.removeEventListener('abort', onAgentAbort);
1412
+ }, { once: true });
1413
+ }
1414
+
1365
1415
  // Set up overall operation timeout (default 5 minutes)
1366
1416
  if (this.maxOperationTimeout && this.maxOperationTimeout > 0) {
1367
1417
  timeoutState.timeoutId = setTimeout(() => {
@@ -1729,7 +1779,8 @@ export class ProbeAgent {
1729
1779
  allowEdit: this.allowEdit,
1730
1780
  allowedTools: allowedToolsForDelegate,
1731
1781
  debug: this.debug,
1732
- tracer: this.tracer
1782
+ tracer: this.tracer,
1783
+ parentAbortSignal: this._abortController.signal
1733
1784
  };
1734
1785
 
1735
1786
  if (this.debug) {
@@ -1971,12 +2022,15 @@ export class ProbeAgent {
1971
2022
  const toolMap = {
1972
2023
  search: {
1973
2024
  schema: searchSchema,
1974
- description: 'Search code in the repository using keyword queries with Elasticsearch syntax.'
1975
- },
1976
- query: {
1977
- schema: querySchema,
1978
- description: 'Search code using ast-grep structural pattern matching.'
2025
+ description: this.searchDelegate
2026
+ ? 'Search code in the repository by asking a question. Accepts natural language questions — a subagent breaks them into targeted keyword searches and returns extracted code blocks. Do NOT formulate keyword queries yourself.'
2027
+ : 'Search code in the repository using keyword queries with Elasticsearch syntax. Handles stemming, case-insensitive matching, and camelCase/snake_case splitting automatically — do NOT try keyword variations manually.'
1979
2028
  },
2029
+ // query tool (ast-grep) removed from AI-facing tools — models struggle with pattern syntax
2030
+ // query: {
2031
+ // schema: querySchema,
2032
+ // description: 'Search code using ast-grep structural pattern matching.'
2033
+ // },
1980
2034
  extract: {
1981
2035
  schema: extractSchema,
1982
2036
  description: 'Extract code blocks from files based on file paths and optional line numbers.'
@@ -2812,10 +2866,12 @@ export class ProbeAgent {
2812
2866
  }
2813
2867
 
2814
2868
  // Add high-level instructions about when to use tools
2869
+ const searchToolDesc1 = this.searchDelegate
2870
+ ? '- search: Ask natural language questions to find code (e.g., "How does authentication work?"). A subagent handles keyword searches and returns extracted code blocks. Do NOT formulate keyword queries — just ask questions.'
2871
+ : '- search: Find code patterns using keyword queries with Elasticsearch syntax. Handles stemming and case variations automatically — do NOT try manual keyword variations.';
2815
2872
  systemPrompt += `You have access to powerful code search and analysis tools through MCP:
2816
- - search: Find code patterns using semantic search
2873
+ ${searchToolDesc1}
2817
2874
  - extract: Extract specific code sections with context
2818
- - query: Use AST patterns for structural code matching
2819
2875
  - listFiles: Browse directory contents
2820
2876
  - searchFiles: Find files by name patterns`;
2821
2877
 
@@ -2823,19 +2879,21 @@ export class ProbeAgent {
2823
2879
  systemPrompt += `\n- bash: Execute bash commands for system operations`;
2824
2880
  }
2825
2881
 
2826
- const searchGuidance = this.searchDelegate
2827
- ? '1. Start with search to retrieve extracted code blocks'
2828
- : '1. Start with search to find relevant code patterns';
2829
- const extractGuidance = this.searchDelegate
2882
+ const searchGuidance1 = this.searchDelegate
2883
+ ? '1. Start with search — ask a question about what you want to understand. It returns extracted code blocks directly.'
2884
+ : '1. Start with search to find relevant code patterns. One search per concept is usually enough — probe handles stemming and case variations.';
2885
+ const extractGuidance1 = this.searchDelegate
2830
2886
  ? '2. Use extract only if you need more context or a full file'
2831
2887
  : '2. Use extract to get detailed context when needed';
2832
2888
 
2833
2889
  systemPrompt += `\n
2834
2890
  When exploring code:
2835
- ${searchGuidance}
2836
- ${extractGuidance}
2891
+ ${searchGuidance1}
2892
+ ${extractGuidance1}
2837
2893
  3. Prefer focused, specific searches over broad queries
2838
- 4. Combine multiple tools to build complete understanding`;
2894
+ 4. Do NOT repeat the same search or try trivial keyword variations — probe handles stemming and case variations automatically
2895
+ 5. If 2-3 consecutive searches return no results for a concept, stop searching for it — the term likely does not exist in that codebase
2896
+ 6. Combine multiple tools to build complete understanding`;
2839
2897
 
2840
2898
  // Add workspace context
2841
2899
  if (this.allowedFolders && this.allowedFolders.length > 0) {
@@ -2874,10 +2932,12 @@ ${extractGuidance}
2874
2932
  }
2875
2933
 
2876
2934
  // Add high-level instructions about when to use tools
2935
+ const searchToolDesc2 = this.searchDelegate
2936
+ ? '- search: Ask natural language questions to find code (e.g., "How does authentication work?"). A subagent handles keyword searches and returns extracted code blocks. Do NOT formulate keyword queries — just ask questions.'
2937
+ : '- search: Find code patterns using keyword queries with Elasticsearch syntax. Handles stemming and case variations automatically — do NOT try manual keyword variations.';
2877
2938
  systemPrompt += `You have access to powerful code search and analysis tools through MCP:
2878
- - search: Find code patterns using semantic search
2939
+ ${searchToolDesc2}
2879
2940
  - extract: Extract specific code sections with context
2880
- - query: Use AST patterns for structural code matching
2881
2941
  - listFiles: Browse directory contents
2882
2942
  - searchFiles: Find files by name patterns`;
2883
2943
 
@@ -2885,19 +2945,21 @@ ${extractGuidance}
2885
2945
  systemPrompt += `\n- bash: Execute bash commands for system operations`;
2886
2946
  }
2887
2947
 
2888
- const searchGuidance = this.searchDelegate
2889
- ? '1. Start with search to retrieve extracted code blocks'
2890
- : '1. Start with search to find relevant code patterns';
2891
- const extractGuidance = this.searchDelegate
2948
+ const searchGuidance2 = this.searchDelegate
2949
+ ? '1. Start with search — ask a question about what you want to understand. It returns extracted code blocks directly.'
2950
+ : '1. Start with search to find relevant code patterns. One search per concept is usually enough — probe handles stemming and case variations.';
2951
+ const extractGuidance2 = this.searchDelegate
2892
2952
  ? '2. Use extract only if you need more context or a full file'
2893
2953
  : '2. Use extract to get detailed context when needed';
2894
2954
 
2895
2955
  systemPrompt += `\n
2896
2956
  When exploring code:
2897
- ${searchGuidance}
2898
- ${extractGuidance}
2957
+ ${searchGuidance2}
2958
+ ${extractGuidance2}
2899
2959
  3. Prefer focused, specific searches over broad queries
2900
- 4. Combine multiple tools to build complete understanding`;
2960
+ 4. Do NOT repeat the same search or try trivial keyword variations — probe handles stemming and case variations automatically
2961
+ 5. If 2-3 consecutive searches return no results for a concept, stop searching for it — the term likely does not exist in that codebase
2962
+ 6. Combine multiple tools to build complete understanding`;
2901
2963
 
2902
2964
  // Add workspace context
2903
2965
  if (this.allowedFolders && this.allowedFolders.length > 0) {
@@ -2953,10 +3015,10 @@ ${extractGuidance}
2953
3015
  Follow these instructions carefully:
2954
3016
  1. Analyze the user's request.
2955
3017
  2. Use the available tools step-by-step to fulfill the request.
2956
- 3. You should always prefer the search tool for code-related questions.${this.searchDelegate ? ' It already returns extracted code blocks; use extract only to expand context or read full files.' : ' Read full files only if really necessary.'}
3018
+ 3. You should always prefer the search tool for code-related questions.${this.searchDelegate ? ' Ask natural language questions — the search subagent handles keyword formulation and returns extracted code blocks. Use extract only to expand context or read full files.' : ' Search handles stemming and case variations automatically — do NOT try keyword variations manually. Read full files only if really necessary.'}
2957
3019
  4. Ensure to get really deep and understand the full picture before answering.
2958
3020
  5. Once the task is fully completed, use the attempt_completion tool to provide the final result.
2959
- 6. Prefer concise and focused search queries. Use specific keywords and phrases to narrow down results.${this.allowEdit ? `
3021
+ 6. ${this.searchDelegate ? 'Ask clear, specific questions when searching. Each search should target a distinct concept or question.' : 'Prefer concise and focused search queries. Use specific keywords and phrases to narrow down results.'}${this.allowEdit ? `
2960
3022
  7. When modifying files, choose the appropriate tool:
2961
3023
  - Use 'edit' for all code modifications:
2962
3024
  * PREFERRED: Use start_line (and optionally end_line) for line-targeted editing — this is the safest and most precise approach.${this.hashLines ? ' Use the line:hash references from extract/search output (e.g. "42:ab") for integrity verification.' : ''} Always use extract first to see line numbers${this.hashLines ? ' and hashes' : ''}, then edit by line reference.
@@ -3369,6 +3431,11 @@ Follow these instructions carefully:
3369
3431
  completionAttempted = true;
3370
3432
  }, toolContext);
3371
3433
 
3434
+ if (this.debug) {
3435
+ const toolNames = Object.keys(tools);
3436
+ console.log(`[DEBUG] Agent tools registered (${toolNames.length}): ${toolNames.join(', ')}`);
3437
+ }
3438
+
3372
3439
  let maxResponseTokens = this.maxResponseTokens;
3373
3440
  if (!maxResponseTokens) {
3374
3441
  maxResponseTokens = 4000;
@@ -3418,6 +3485,7 @@ Follow these instructions carefully:
3418
3485
 
3419
3486
  if (this.debug) {
3420
3487
  console.log(`[DEBUG] Step ${currentIteration}/${maxIterations} finished (reason: ${finishReason}, tools: ${toolResults?.length || 0})`);
3488
+ debugLogToolResults(toolResults);
3421
3489
  }
3422
3490
  }
3423
3491
  };
@@ -3629,6 +3697,7 @@ Double-check your response based on the criteria above. If everything looks good
3629
3697
  }
3630
3698
  if (this.debug) {
3631
3699
  console.log(`[DEBUG] Completion prompt step finished (reason: ${finishReason}, tools: ${toolResults?.length || 0})`);
3700
+ debugLogToolResults(toolResults);
3632
3701
  }
3633
3702
  }
3634
3703
  };
@@ -4545,6 +4614,11 @@ Convert your previous response content into actual JSON data that follows this s
4545
4614
  * Clean up resources (including MCP connections)
4546
4615
  */
4547
4616
  async cleanup() {
4617
+ // Abort any in-flight operations (delegations, streaming, etc.)
4618
+ if (!this._abortController.signal.aborted) {
4619
+ this._abortController.abort();
4620
+ }
4621
+
4548
4622
  // Clean up MCP bridge
4549
4623
  if (this.mcpBridge) {
4550
4624
  try {
@@ -4574,12 +4648,24 @@ Convert your previous response content into actual JSON data that follows this s
4574
4648
  }
4575
4649
 
4576
4650
  /**
4577
- * Cancel the current request
4651
+ * Cancel the current request and all in-flight delegations.
4652
+ * Aborts the internal AbortController so streamText, subagents,
4653
+ * and any code checking the signal will stop.
4578
4654
  */
4579
4655
  cancel() {
4580
4656
  this.cancelled = true;
4657
+ this._abortController.abort();
4581
4658
  if (this.debug) {
4582
4659
  console.log(`[DEBUG] Agent cancelled for session ${this.sessionId}`);
4583
4660
  }
4584
4661
  }
4662
+
4663
+ /**
4664
+ * Get the abort signal for this agent.
4665
+ * Delegations and subagents should check this signal.
4666
+ * @returns {AbortSignal}
4667
+ */
4668
+ get abortSignal() {
4669
+ return this._abortController.signal;
4670
+ }
4585
4671
  }
@@ -284,6 +284,7 @@ export function generateSandboxGlobals(options) {
284
284
  results.push(p);
285
285
 
286
286
  if (executing.size >= mapConcurrency) {
287
+ console.error(`[map] Concurrency limit reached (${executing.size}/${mapConcurrency}), waiting for a slot...`);
287
288
  await Promise.race(executing);
288
289
  }
289
290
  }
package/src/delegate.js CHANGED
@@ -122,20 +122,20 @@ class DelegationManager {
122
122
  }
123
123
 
124
124
  // Need to wait in queue
125
- if (debug) {
126
- console.error(`[DelegationManager] Slot unavailable (${this.globalActive}/${this.maxConcurrent}), queuing... (queue size: ${this.waitQueue.length}, timeout: ${effectiveTimeout}ms)`);
127
- }
125
+ console.error(`[DelegationManager] Slot unavailable (${this.globalActive}/${this.maxConcurrent}), queuing... (queue size: ${this.waitQueue.length + 1}, timeout: ${effectiveTimeout}ms)`);
128
126
 
129
127
  // Create a promise that will be resolved when a slot becomes available
130
128
  // or rejected if session limit is exceeded or queue timeout expires
131
129
  return new Promise((resolve, reject) => {
130
+ const queuedAt = Date.now();
132
131
  const entry = {
133
132
  resolve: null, // Will be wrapped below
134
133
  reject: null, // Will be wrapped below
135
134
  parentSessionId,
136
135
  debug,
137
- queuedAt: Date.now(),
138
- timeoutId: null
136
+ queuedAt,
137
+ timeoutId: null,
138
+ reminderId: null
139
139
  };
140
140
 
141
141
  // Wrap resolve/reject to clear timeout and prevent double-settling
@@ -144,12 +144,14 @@ class DelegationManager {
144
144
  if (settled) return;
145
145
  settled = true;
146
146
  if (entry.timeoutId) clearTimeout(entry.timeoutId);
147
+ if (entry.reminderId) clearInterval(entry.reminderId);
147
148
  resolve(value);
148
149
  };
149
150
  entry.reject = (error) => {
150
151
  if (settled) return;
151
152
  settled = true;
152
153
  if (entry.timeoutId) clearTimeout(entry.timeoutId);
154
+ if (entry.reminderId) clearInterval(entry.reminderId);
153
155
  reject(error);
154
156
  };
155
157
 
@@ -165,6 +167,15 @@ class DelegationManager {
165
167
  }, effectiveTimeout);
166
168
  }
167
169
 
170
+ // Always emit periodic wait visibility while queued.
171
+ entry.reminderId = setInterval(() => {
172
+ const waitedSeconds = Math.round((Date.now() - queuedAt) / 1000);
173
+ console.error(`[DelegationManager] Still waiting for slot (${waitedSeconds}s). ${this.globalActive}/${this.maxConcurrent} active, ${this.waitQueue.length} queued.`);
174
+ }, 15000);
175
+ if (entry.reminderId.unref) {
176
+ entry.reminderId.unref();
177
+ }
178
+
168
179
  this.waitQueue.push(entry);
169
180
  });
170
181
  }
@@ -221,9 +232,7 @@ class DelegationManager {
221
232
  if (sessionCount >= this.maxPerSession) {
222
233
  // Session limit reached - reject with error (consistent with tryAcquire behavior)
223
234
  // This is a hard limit, not something that will resolve by waiting longer
224
- if (debug) {
225
- console.error(`[DelegationManager] Session limit (${this.maxPerSession}) reached for queued item, rejecting`);
226
- }
235
+ console.error(`[DelegationManager] Session limit (${this.maxPerSession}) reached for queued item, rejecting`);
227
236
  toReject.push({ reject, error: new Error(`Maximum delegations per session (${this.maxPerSession}) reached for session ${parentSessionId}`) });
228
237
  // Continue to process next item in queue
229
238
  continue;
@@ -233,10 +242,8 @@ class DelegationManager {
233
242
  // Grant the slot
234
243
  this._incrementCounters(parentSessionId);
235
244
 
236
- if (debug) {
237
- const waitTime = Date.now() - queuedAt;
238
- console.error(`[DelegationManager] Granted slot from queue (waited ${waitTime}ms). Active: ${this.globalActive}/${this.maxConcurrent}`);
239
- }
245
+ const waitTime = Date.now() - queuedAt;
246
+ console.error(`[DelegationManager] Granted slot from queue (waited ${waitTime}ms). Active: ${this.globalActive}/${this.maxConcurrent}`);
240
247
 
241
248
  toResolve.push(resolve);
242
249
  }
@@ -296,6 +303,9 @@ class DelegationManager {
296
303
  if (entry.timeoutId) {
297
304
  clearTimeout(entry.timeoutId);
298
305
  }
306
+ if (entry.reminderId) {
307
+ clearInterval(entry.reminderId);
308
+ }
299
309
  // Reject pending entries so they don't hang
300
310
  if (entry.reject) {
301
311
  entry.reject(new Error('DelegationManager was cleaned up'));
@@ -386,12 +396,18 @@ export async function delegate({
386
396
  mcpConfig = null,
387
397
  mcpConfigPath = null,
388
398
  delegationManager = null, // Optional per-instance manager, falls back to default singleton
389
- concurrencyLimiter = null // Optional global AI concurrency limiter
399
+ concurrencyLimiter = null, // Optional global AI concurrency limiter
400
+ parentAbortSignal = null // Optional AbortSignal from parent to cancel this delegation
390
401
  }) {
391
402
  if (!task || typeof task !== 'string') {
392
403
  throw new Error('Task parameter is required and must be a string');
393
404
  }
394
405
 
406
+ // Check if parent has already been cancelled
407
+ if (parentAbortSignal?.aborted) {
408
+ throw new Error('Delegation cancelled: parent operation was aborted');
409
+ }
410
+
395
411
  // Support runtime timeout override via environment variables when timeout not explicitly passed
396
412
  // This allows operators to configure delegation timeouts without code changes
397
413
  // Priority: DELEGATION_TIMEOUT_MS (milliseconds) > DELEGATION_TIMEOUT_SECONDS > DELEGATION_TIMEOUT (seconds)
@@ -481,24 +497,47 @@ export async function delegate({
481
497
  console.error(`[DELEGATE] Subagent config: promptType=${promptType}, enableDelegate=false, maxIterations=${remainingIterations}`);
482
498
  }
483
499
 
484
- // Set up timeout with proper cleanup
485
- // TODO: Implement AbortController support in ProbeAgent.answer() for proper cancellation
486
- // Current limitation: When timeout occurs, subagent.answer() continues running in background
487
- // This is acceptable since:
488
- // 1. The promise will eventually resolve/reject and be garbage collected
489
- // 2. The delegation slot is properly released on timeout
490
- // 3. The parent receives timeout error and can handle it
491
- // Future improvement: Add signal parameter to ProbeAgent.answer(task, [], { signal })
500
+ // Set up timeout and parent abort handling.
501
+ // When timeout fires or parent aborts, we cancel the subagent so it
502
+ // stops making API calls and releases resources promptly.
492
503
  const timeoutPromise = new Promise((_, reject) => {
493
504
  timeoutId = setTimeout(() => {
505
+ subagent.cancel();
494
506
  reject(new Error(`Delegation timed out after ${timeout} seconds`));
495
507
  }, timeout * 1000);
496
508
  });
497
509
 
498
- // Execute the task with timeout
510
+ // Listen for parent abort signal
511
+ let parentAbortHandler;
512
+ const parentAbortPromise = new Promise((_, reject) => {
513
+ if (parentAbortSignal) {
514
+ if (parentAbortSignal.aborted) {
515
+ subagent.cancel();
516
+ reject(new Error('Delegation cancelled: parent operation was aborted'));
517
+ return;
518
+ }
519
+ parentAbortHandler = () => {
520
+ subagent.cancel();
521
+ reject(new Error('Delegation cancelled: parent operation was aborted'));
522
+ };
523
+ parentAbortSignal.addEventListener('abort', parentAbortHandler, { once: true });
524
+ }
525
+ });
526
+
527
+ // Execute the task with timeout and parent abort
499
528
  const answerOptions = schema ? { schema } : undefined;
500
529
  const answerPromise = answerOptions ? subagent.answer(task, [], answerOptions) : subagent.answer(task);
501
- const response = await Promise.race([answerPromise, timeoutPromise]);
530
+ const racers = [answerPromise, timeoutPromise];
531
+ if (parentAbortSignal) racers.push(parentAbortPromise);
532
+ let response;
533
+ try {
534
+ response = await Promise.race(racers);
535
+ } finally {
536
+ // Clean up parent abort listener to prevent memory leaks
537
+ if (parentAbortHandler && parentAbortSignal) {
538
+ parentAbortSignal.removeEventListener('abort', parentAbortHandler);
539
+ }
540
+ }
502
541
 
503
542
  // Clear timeout immediately after race completes to prevent memory leak
504
543
  // Note: timeoutId is always set by this point (synchronous in Promise constructor)
package/src/downloader.js CHANGED
@@ -95,9 +95,7 @@ async function acquireFileLock(lockPath, version) {
95
95
  try {
96
96
  // Try to create lock file atomically (fails if already exists)
97
97
  await fs.writeFile(lockPath, JSON.stringify(lockData), { flag: 'wx' });
98
- if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') {
99
- console.log(`Acquired file lock: ${lockPath}`);
100
- }
98
+ console.log(`Acquired file lock: ${lockPath}`);
101
99
  return true;
102
100
  } catch (error) {
103
101
  if (error.code === 'EEXIST') {
@@ -108,17 +106,13 @@ async function acquireFileLock(lockPath, version) {
108
106
 
109
107
  if (lockAge > LOCK_TIMEOUT_MS) {
110
108
  // Lock is stale, remove it
111
- if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') {
112
- console.log(`Removing stale lock file (age: ${Math.round(lockAge / 1000)}s, pid: ${existingLock.pid})`);
113
- }
109
+ console.log(`Removing stale lock file (age: ${Math.round(lockAge / 1000)}s, pid: ${existingLock.pid})`);
114
110
  await fs.remove(lockPath);
115
111
  return false; // Caller should retry
116
112
  }
117
113
 
118
114
  // Lock is fresh, another process is downloading
119
- if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') {
120
- console.log(`Download in progress by process ${existingLock.pid}, waiting...`);
121
- }
115
+ console.log(`Download in progress by process ${existingLock.pid}, waiting...`);
122
116
  return false;
123
117
  } catch (readError) {
124
118
  // Can't read lock file, might be corrupted - remove it
@@ -180,23 +174,23 @@ async function releaseFileLock(lockPath) {
180
174
  */
181
175
  async function waitForFileLock(lockPath, binaryPath) {
182
176
  const startTime = Date.now();
177
+ let lastStatusTime = startTime;
178
+
179
+ console.log(`Waiting for file lock to clear: ${lockPath}`);
183
180
 
184
181
  // Poll in a loop until binary appears, lock expires, or we timeout
185
182
  while (Date.now() - startTime < MAX_LOCK_WAIT_MS) {
186
183
  // Check #1: Is the binary now available?
187
184
  if (await fs.pathExists(binaryPath)) {
188
- if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') {
189
- console.log(`Binary now available at ${binaryPath}, download completed by another process`);
190
- }
185
+ const waitedSeconds = Math.round((Date.now() - startTime) / 1000);
186
+ console.log(`Binary now available at ${binaryPath}, download completed by another process (waited ${waitedSeconds}s)`);
191
187
  return true;
192
188
  }
193
189
 
194
190
  // Check #2: Is the lock file gone? (download finished or failed)
195
191
  const lockExists = await fs.pathExists(lockPath);
196
192
  if (!lockExists) {
197
- if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') {
198
- console.log(`Lock file removed but binary not found - download may have failed`);
199
- }
193
+ console.log(`Lock file removed but binary not found - download may have failed`);
200
194
  return false;
201
195
  }
202
196
 
@@ -205,22 +199,24 @@ async function waitForFileLock(lockPath, binaryPath) {
205
199
  const lockData = JSON.parse(await fs.readFile(lockPath, 'utf-8'));
206
200
  const lockAge = Date.now() - lockData.timestamp;
207
201
  if (lockAge > LOCK_TIMEOUT_MS) {
208
- if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') {
209
- console.log(`Lock expired (age: ${Math.round(lockAge / 1000)}s), will retry download`);
210
- }
202
+ console.log(`Lock expired (age: ${Math.round(lockAge / 1000)}s), will retry download`);
211
203
  return false;
212
204
  }
213
205
  } catch {
214
206
  // Ignore errors reading lock file - will retry on next poll
215
207
  }
216
208
 
209
+ if (Date.now() - lastStatusTime >= 15000) {
210
+ const elapsedSeconds = Math.round((Date.now() - startTime) / 1000);
211
+ console.log(`Still waiting for file lock (${elapsedSeconds}s/${MAX_LOCK_WAIT_MS / 1000}s max)`);
212
+ lastStatusTime = Date.now();
213
+ }
214
+
217
215
  // Wait 1 second before checking again
218
216
  await new Promise(resolve => setTimeout(resolve, LOCK_POLL_INTERVAL_MS));
219
217
  }
220
218
 
221
- if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') {
222
- console.log(`Timeout waiting for file lock`);
223
- }
219
+ console.log(`Timeout waiting for file lock after ${MAX_LOCK_WAIT_MS / 1000}s`);
224
220
  return false;
225
221
  }
226
222
 
@@ -247,9 +243,7 @@ async function withDownloadLock(version, downloadFn) {
247
243
  }
248
244
  downloadLocks.delete(lockKey);
249
245
  } else {
250
- if (process.env.DEBUG === '1' || process.env.VERBOSE === '1') {
251
- console.log(`Download already in progress in this process for version ${lockKey}, waiting...`);
252
- }
246
+ console.log(`Download already in progress in this process for version ${lockKey}, waiting...`);
253
247
  try {
254
248
  return await lock.promise;
255
249
  } catch (error) {
@@ -262,10 +256,16 @@ async function withDownloadLock(version, downloadFn) {
262
256
  }
263
257
 
264
258
  // Create new download promise with timeout protection
259
+ let timeoutId = null;
265
260
  const downloadPromise = Promise.race([
266
261
  downloadFn(),
267
262
  new Promise((_, reject) =>
268
- setTimeout(() => reject(new Error(`Download timeout after ${LOCK_TIMEOUT_MS / 1000}s`)), LOCK_TIMEOUT_MS)
263
+ {
264
+ timeoutId = setTimeout(() => reject(new Error(`Download timeout after ${LOCK_TIMEOUT_MS / 1000}s`)), LOCK_TIMEOUT_MS);
265
+ if (timeoutId.unref) {
266
+ timeoutId.unref();
267
+ }
268
+ }
269
269
  )
270
270
  ]);
271
271
 
@@ -278,6 +278,9 @@ async function withDownloadLock(version, downloadFn) {
278
278
  const result = await downloadPromise;
279
279
  return result;
280
280
  } finally {
281
+ if (timeoutId) {
282
+ clearTimeout(timeoutId);
283
+ }
281
284
  // Clean up lock after download completes (success or failure)
282
285
  downloadLocks.delete(lockKey);
283
286
  }