@link-assistant/hive-mind 1.54.2 → 1.54.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.54.4
4
+
5
+ ### Patch Changes
6
+
7
+ - 2ac0a14: Notify the source issue when solve exits with a known issue but no pull request, including failure logs when `--attach-logs` is enabled.
8
+
9
+ ## 1.54.3
10
+
11
+ ### Patch Changes
12
+
13
+ - 5030b04: Fix Codex pricing display by calculating OpenAI public estimates from models.dev token rates, passing Codex totals into shared budget stats, and avoiding duplicate raw token usage lines when a Total line is already shown.
14
+
3
15
  ## 1.54.2
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.54.2",
3
+ "version": "1.54.4",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -13,7 +13,7 @@
13
13
  "hive-telegram-bot": "./src/telegram-bot.mjs"
14
14
  },
15
15
  "scripts": {
16
- "test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-telegram-bot-launcher.mjs",
16
+ "test": "node tests/solve-queue.test.mjs && node tests/limits-display.test.mjs && node tests/test-usage-limit.mjs && node tests/test-codex-support.mjs && node tests/test-build-cost-info-string.mjs && node tests/test-claude-code-install-method.mjs && node tests/test-issue-1616-pr-issue-link-preservation.mjs && node tests/test-pre-pr-failure-notifier-1640.mjs && node tests/test-telegram-message-filters.mjs && node tests/test-telegram-bot-command-aliases.mjs && node tests/test-solve-queue-command.mjs && node tests/test-queue-display-1267.mjs && node tests/test-telegram-bot-launcher.mjs",
17
17
  "test:queue": "node tests/solve-queue.test.mjs",
18
18
  "test:limits-display": "node tests/limits-display.test.mjs",
19
19
  "test:usage-limit": "node tests/test-usage-limit.mjs",
@@ -21,7 +21,9 @@ import { handleClaudeRuntimeSwitch } from './claude.runtime-switch.lib.mjs'; //
21
21
  import { CLAUDE_MODELS as availableModels } from './models/index.mjs'; // Issue #1221
22
22
  import { buildMcpConfigWithoutPlaywright } from './playwright-mcp.lib.mjs';
23
23
  import { resolveClaudeSessionToolFlags } from './useless-tools.lib.mjs';
24
+ import { fetchModelInfo } from './model-info.lib.mjs';
24
25
  export { availableModels }; // Re-export for backward compatibility
26
+ export { fetchModelInfo };
25
27
  const showResumeCommand = async (sessionId, tempDir, claudePath, model, log) => {
26
28
  if (!sessionId || !tempDir) return;
27
29
  const cmd = buildClaudeResumeCommand({ tempDir, sessionId, claudePath, model });
@@ -370,58 +372,6 @@ export const executeClaude = async params => {
370
372
  prNumber,
371
373
  });
372
374
  };
373
- /**
374
- * Fetches model information from pricing API
375
- * @param {string} modelId - The model ID (e.g., "claude-sonnet-4-5-20250929")
376
- * @returns {Promise<Object|null>} Model information or null if not found
377
- */
378
- export const fetchModelInfo = async modelId => {
379
- try {
380
- const https = (await use('https')).default;
381
- return new Promise((resolve, reject) => {
382
- https
383
- .get('https://models.dev/api.json', res => {
384
- let data = '';
385
- res.on('data', chunk => {
386
- data += chunk;
387
- });
388
- res.on('end', () => {
389
- try {
390
- const apiData = JSON.parse(data);
391
- // For public pricing calculation, prefer Anthropic provider for Claude models
392
- // Check Anthropic provider first
393
- if (apiData.anthropic?.models?.[modelId]) {
394
- const modelInfo = apiData.anthropic.models[modelId];
395
- modelInfo.provider = apiData.anthropic.name || 'Anthropic';
396
- resolve(modelInfo);
397
- return;
398
- }
399
- // Search for the model across all other providers
400
- for (const provider of Object.values(apiData)) {
401
- if (provider.models && provider.models[modelId]) {
402
- const modelInfo = provider.models[modelId];
403
- // Add provider info
404
- modelInfo.provider = provider.name || provider.id;
405
- resolve(modelInfo);
406
- return;
407
- }
408
- }
409
- // Model not found
410
- resolve(null);
411
- } catch (parseError) {
412
- reject(parseError);
413
- }
414
- });
415
- })
416
- .on('error', err => {
417
- reject(err);
418
- });
419
- });
420
- } catch {
421
- // If we can't fetch model info, return null and continue without it
422
- return null;
423
- }
424
- };
425
375
  /** Check if a model supports vision (image input) using models.dev API @returns {Promise<boolean>} */
426
376
  export const checkModelVisionCapability = async modelId => {
427
377
  try {
package/src/codex.lib.mjs CHANGED
@@ -22,8 +22,11 @@ import { mapModelToId, resolveCodexReasoningEffort } from './codex.options.lib.m
22
22
  import { createInteractiveHandler } from './interactive-mode.lib.mjs';
23
23
  import { initProgressMonitoring } from './solve.progress-monitoring.lib.mjs';
24
24
  import { getCodexPlaywrightMcpDisableConfigArgs } from './playwright-mcp.lib.mjs';
25
+ import { fetchModelInfo } from './model-info.lib.mjs';
26
+ import Decimal from 'decimal.js-light';
25
27
 
26
28
  const CODEX_USAGE_FIELD_NAMES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_creation_input_tokens', 'reasoning_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens', 'output_tokens_details.reasoning_tokens'];
29
+ const CODEX_LONG_CONTEXT_PRICE_THRESHOLD = 272000;
27
30
  const getCodexExecEnv = (verbose = false) => (verbose ? { ...process.env, RUST_LOG: 'debug' } : { ...process.env });
28
31
  const CODEX_MODEL_DIAGNOSTIC_PATHS = [
29
32
  ['model', data => data?.model],
@@ -77,6 +80,9 @@ export const createCodexTokenUsage = requestedModelId => ({
77
80
  stepCount: 0,
78
81
  requestedModelId: requestedModelId || null,
79
82
  respondedModelId: requestedModelId || null,
83
+ contextLimit: null,
84
+ outputLimit: null,
85
+ peakContextUsage: 0,
80
86
  tokenFieldAvailability: createCodexTokenFieldAvailability(),
81
87
  });
82
88
 
@@ -262,6 +268,10 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
262
268
  nextState.tokenUsage.reasoningTokens += reasoningTokens;
263
269
  nextState.tokenUsage.totalTokens = nextState.tokenUsage.inputTokens + nextState.tokenUsage.cacheReadTokens + nextState.tokenUsage.outputTokens + nextState.tokenUsage.cacheWriteTokens;
264
270
  nextState.tokenUsage.stepCount += 1;
271
+ const turnContextUsage = inputTokens + cacheWriteTokens;
272
+ if (turnContextUsage > (nextState.tokenUsage.peakContextUsage || 0)) {
273
+ nextState.tokenUsage.peakContextUsage = turnContextUsage;
274
+ }
265
275
 
266
276
  const usageFieldSet = CODEX_USAGE_FIELD_NAMES.filter(fieldName => hasOwnPath(data.usage, fieldName));
267
277
  if (usageFieldSet.length > 0) nextState.observedUsageFieldSets.push(usageFieldSet);
@@ -323,12 +333,80 @@ export const buildCodexResultModelUsage = (modelId, tokenUsage, pricingInfo = nu
323
333
  outputTokens: tokenUsage.outputTokens || 0,
324
334
  modelName: pricingInfo?.modelName || modelId,
325
335
  modelInfo: pricingInfo?.modelInfo || null,
326
- peakContextUsage: 0,
336
+ peakContextUsage: tokenUsage.peakContextUsage || 0,
327
337
  costUSD: pricingInfo?.totalCostUSD ?? null,
328
338
  },
329
339
  };
330
340
  };
331
341
 
342
+ const toCost = (tokens, pricePerMillion) => {
343
+ if (!Number.isFinite(tokens) || !Number.isFinite(pricePerMillion)) return 0;
344
+ return new Decimal(tokens).mul(pricePerMillion).div(1_000_000).toNumber();
345
+ };
346
+
347
+ const buildCodexPricingFallback = (modelId, tokenUsage, error = null) => ({
348
+ modelId,
349
+ modelName: modelId,
350
+ provider: 'OpenAI',
351
+ tokenUsage,
352
+ modelInfo: null,
353
+ totalCostUSD: null,
354
+ error,
355
+ });
356
+
357
+ export const calculateCodexPricingFromModelInfo = (modelId, tokenUsage, modelInfo) => {
358
+ if (!modelId) return null;
359
+ if (!tokenUsage) return buildCodexPricingFallback(modelId, null);
360
+ if (!modelInfo?.cost) return buildCodexPricingFallback(modelId, tokenUsage, 'Model pricing not found in models.dev API');
361
+
362
+ const standardCost = modelInfo.cost;
363
+ const usesLongContextPricing = !!standardCost.context_over_200k && (tokenUsage.peakContextUsage || 0) > CODEX_LONG_CONTEXT_PRICE_THRESHOLD;
364
+ const cost = usesLongContextPricing ? { ...standardCost, ...standardCost.context_over_200k } : standardCost;
365
+
366
+ const pricing = {
367
+ inputPerMillion: cost.input || 0,
368
+ outputPerMillion: cost.output || 0,
369
+ cacheReadPerMillion: cost.cache_read || 0,
370
+ cacheWritePerMillion: cost.cache_write ?? cost.input ?? 0,
371
+ reasoningPerMillion: cost.reasoning || 0,
372
+ };
373
+
374
+ const breakdown = {
375
+ input: toCost(tokenUsage.inputTokens || 0, pricing.inputPerMillion),
376
+ output: toCost(tokenUsage.outputTokens || 0, pricing.outputPerMillion),
377
+ cacheRead: toCost(tokenUsage.cacheReadTokens || 0, pricing.cacheReadPerMillion),
378
+ cacheWrite: toCost(tokenUsage.cacheWriteTokens || 0, pricing.cacheWritePerMillion),
379
+ reasoning: toCost(tokenUsage.reasoningTokens || 0, pricing.reasoningPerMillion),
380
+ };
381
+ const totalCostUSD = Object.values(breakdown).reduce((sum, value) => new Decimal(sum).plus(value).toNumber(), 0);
382
+
383
+ tokenUsage.contextLimit = tokenUsage.contextLimit || modelInfo.limit?.context || null;
384
+ tokenUsage.outputLimit = tokenUsage.outputLimit || modelInfo.limit?.output || null;
385
+
386
+ return {
387
+ modelId,
388
+ modelName: modelInfo.name || modelId,
389
+ provider: modelInfo.provider || 'OpenAI',
390
+ tokenUsage,
391
+ modelInfo,
392
+ pricing,
393
+ breakdown,
394
+ totalCostUSD,
395
+ usesLongContextPricing,
396
+ longContextThreshold: usesLongContextPricing ? CODEX_LONG_CONTEXT_PRICE_THRESHOLD : null,
397
+ };
398
+ };
399
+
400
+ export const calculateCodexPricing = async (modelId, tokenUsage) => {
401
+ if (!modelId) return null;
402
+ try {
403
+ const modelInfo = await fetchModelInfo(modelId, { preferredProviderIds: ['openai'] });
404
+ return calculateCodexPricingFromModelInfo(modelId, tokenUsage, modelInfo);
405
+ } catch (error) {
406
+ return buildCodexPricingFallback(modelId, tokenUsage, error.message);
407
+ }
408
+ };
409
+
332
410
  // Function to validate Codex CLI connection
333
411
  export const validateCodexConnection = async (model = 'gpt-5.4', verbose = false) => {
334
412
  // Map model alias to full ID
@@ -773,14 +851,15 @@ export const executeCodexCommand = async params => {
773
851
  }
774
852
 
775
853
  const firstActualModelId = mappedModel;
776
- const pricingInfo = firstActualModelId
777
- ? {
778
- modelId: firstActualModelId,
779
- modelName: firstActualModelId,
780
- provider: 'OpenAI',
781
- tokenUsage: codexJsonState.tokenUsage.stepCount > 0 ? codexJsonState.tokenUsage : null,
782
- }
783
- : null;
854
+ const pricingInfo = firstActualModelId ? await calculateCodexPricing(firstActualModelId, codexJsonState.tokenUsage.stepCount > 0 ? codexJsonState.tokenUsage : null) : null;
855
+ if (pricingInfo?.totalCostUSD !== null && pricingInfo?.totalCostUSD !== undefined) {
856
+ await log(`šŸ’° Codex public pricing estimate: $${new Decimal(pricingInfo.totalCostUSD).toFixed(6)}`, { verbose: true });
857
+ if (pricingInfo.usesLongContextPricing) {
858
+ await log(` Long-context pricing applied because peak prompt exceeded ${pricingInfo.longContextThreshold.toLocaleString()} input tokens`, { verbose: true });
859
+ }
860
+ } else if (pricingInfo?.error) {
861
+ await log(`āš ļø Codex public pricing estimate unavailable: ${pricingInfo.error}`, { level: 'warning', verbose: true });
862
+ }
784
863
  const resultModelUsage = pricingInfo?.tokenUsage ? buildCodexResultModelUsage(firstActualModelId, pricingInfo.tokenUsage, pricingInfo) : null;
785
864
 
786
865
  // Check for authentication errors first - these should never be retried
@@ -831,6 +910,7 @@ export const executeCodexCommand = async params => {
831
910
  limitReached,
832
911
  limitResetTime,
833
912
  pricingInfo,
913
+ publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
834
914
  resultModelUsage,
835
915
  subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
836
916
  codexJsonDetails: codexJsonState,
@@ -853,6 +933,7 @@ export const executeCodexCommand = async params => {
853
933
  limitReached,
854
934
  limitResetTime,
855
935
  pricingInfo,
936
+ publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
856
937
  resultModelUsage,
857
938
  subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
858
939
  codexJsonDetails: codexJsonState,
@@ -882,6 +963,7 @@ export const executeCodexCommand = async params => {
882
963
  limitReached: false,
883
964
  limitResetTime: null,
884
965
  pricingInfo: null,
966
+ publicPricingEstimate: null,
885
967
  resultSummary: null, // Issue #1263: No result summary available on error
886
968
  };
887
969
  } finally {
@@ -27,6 +27,8 @@ let logFunction = null;
27
27
  let cleanupFunction = null;
28
28
  let interruptFunction = null;
29
29
  let interruptHandlerRan = false;
30
+ let preExitFunction = null;
31
+ let preExitHandlerRan = false;
30
32
 
31
33
  /**
32
34
  * Initialize the exit handler with required dependencies
@@ -36,11 +38,16 @@ let interruptHandlerRan = false;
36
38
  * @param {Function} interrupt - Optional interrupt function to call on SIGINT/SIGTERM before cleanup
37
39
  * (e.g., auto-commit uncommitted changes, upload logs)
38
40
  */
39
- export const initializeExitHandler = (getLogPath, log, cleanup = null, interrupt = null) => {
41
+ export const initializeExitHandler = (getLogPath, log, cleanup = null, interrupt = null, preExit = null) => {
40
42
  getLogPathFunction = getLogPath;
41
43
  logFunction = log;
42
44
  cleanupFunction = cleanup;
43
45
  interruptFunction = interrupt;
46
+ preExitFunction = preExit;
47
+ };
48
+
49
+ export const setPreExitHandler = preExit => {
50
+ preExitFunction = preExit;
44
51
  };
45
52
 
46
53
  /**
@@ -200,6 +207,20 @@ export const logActiveHandles = async (log = null) => {
200
207
  export const safeExit = async (code = 0, reason = 'Process completed') => {
201
208
  await showExitMessage(reason, code);
202
209
 
210
+ if (code !== 0 && preExitFunction && !preExitHandlerRan) {
211
+ preExitHandlerRan = true;
212
+ try {
213
+ await preExitFunction({ code, reason });
214
+ } catch (error) {
215
+ const message = error && error.message ? error.message : String(error);
216
+ if (logFunction) {
217
+ await logFunction(`āš ļø Pre-exit handler failed: ${message}`, { level: 'warning' });
218
+ } else {
219
+ console.warn(`āš ļø Pre-exit handler failed: ${message}`);
220
+ }
221
+ }
222
+ }
223
+
203
224
  // Issue #1431: Drain/unref active handles so the event loop exits naturally.
204
225
  // This resolves the root causes of dangling ReadStream (stdin), Socket (undici),
205
226
  // ChildProcess (command-stream), and WriteStream (stdout/stderr) handles.
@@ -21,7 +21,8 @@ const buildTokenUsageString = tokenUsage => {
21
21
  };
22
22
 
23
23
  /** Build cost estimation string for log comments (Issue #1250, Issue #1557, Issue #1600: Decimal precision) */
24
- export const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricingInfo) => {
24
+ export const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricingInfo, options = {}) => {
25
+ const includeTokenUsage = options.includeTokenUsage !== false;
25
26
  const hasPublic = totalCostUSD !== null && totalCostUSD !== undefined;
26
27
  const hasAnthropic = anthropicTotalCostUSD !== null && anthropicTotalCostUSD !== undefined;
27
28
  const hasPricing = pricingInfo && (pricingInfo.modelName || pricingInfo.tokenUsage || pricingInfo.isFreeModel || pricingInfo.isOpencodeFreeModel);
@@ -57,7 +58,7 @@ export const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricing
57
58
  costInfo += `\n- Calculated by OpenCode Zen: $${new Decimal(pricingInfo.opencodeCost).toFixed(6)}`;
58
59
  }
59
60
  }
60
- if (pricingInfo?.tokenUsage) costInfo += buildTokenUsageString(pricingInfo.tokenUsage);
61
+ if (includeTokenUsage && pricingInfo?.tokenUsage) costInfo += buildTokenUsageString(pricingInfo.tokenUsage);
61
62
  if (hasAnthropic) {
62
63
  costInfo += `\n- Calculated by Anthropic: $${anthropicDec.toFixed(6)}`;
63
64
  if (hasPublic) {
@@ -501,7 +501,7 @@ ${logContent}
501
501
  *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
502
502
  } else if (errorDuringExecution) {
503
503
  // Issue #1088: "Finished with errors" format - work may have been completed but errors occurred
504
- const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo);
504
+ const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
505
505
  logComment = `## āš ļø ${SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER}
506
506
  This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}
507
507
 
@@ -519,7 +519,7 @@ ${logContent}
519
519
  ---
520
520
  *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
521
521
  } else {
522
- const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo);
522
+ const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
523
523
  // Determine title based on session type (Issue #1152)
524
524
  // Issue #1625: Every title variant embeds SOLUTION_DRAFT_LOG_MARKER so
525
525
  // the filter in checkForAiCreatedComments matches every variant with a
@@ -684,7 +684,7 @@ ${errorMessage}
684
684
  *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
685
685
  } else if (errorDuringExecution) {
686
686
  // Issue #1088: "Finished with errors" format - work may have been completed but errors occurred
687
- const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo);
687
+ const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
688
688
  logUploadComment = `## āš ļø ${SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER}
689
689
  This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}
690
690
 
@@ -697,7 +697,7 @@ This log file contains the complete execution trace of the AI ${targetType === '
697
697
  *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
698
698
  } else {
699
699
  // Success log format - use helper function for cost info
700
- const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo);
700
+ const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
701
701
  // Determine title based on session type
702
702
  // See: https://github.com/link-assistant/hive-mind/issues/1152
703
703
  // Issue #1625: titles embed SOLUTION_DRAFT_LOG_MARKER (single source).
@@ -0,0 +1,66 @@
1
+ import https from 'node:https';
2
+
3
+ const buildLookupIds = modelId => (modelId?.includes('/') ? [modelId.split('/').pop(), modelId] : [modelId]);
4
+
5
+ const buildProviderPriority = (modelId, preferredProviderIds = []) => {
6
+ const inferredProviderIds = [];
7
+ if (modelId?.startsWith('claude-')) inferredProviderIds.push('anthropic');
8
+ if (modelId?.startsWith('gpt-') || modelId?.startsWith('chatgpt-')) inferredProviderIds.push('openai');
9
+ return [...new Set([...preferredProviderIds, ...inferredProviderIds])];
10
+ };
11
+
12
+ const findProviderModel = (apiData, providerIds, lookupIds) => {
13
+ for (const providerId of providerIds) {
14
+ const provider = apiData[providerId];
15
+ if (!provider?.models) continue;
16
+ for (const lookupId of lookupIds) {
17
+ if (provider.models[lookupId]) {
18
+ return {
19
+ ...provider.models[lookupId],
20
+ provider: provider.name || providerId,
21
+ };
22
+ }
23
+ }
24
+ }
25
+ return null;
26
+ };
27
+
28
+ const fetchModelsDevApi = () =>
29
+ new Promise((resolve, reject) => {
30
+ https
31
+ .get('https://models.dev/api.json', res => {
32
+ let data = '';
33
+ res.on('data', chunk => {
34
+ data += chunk;
35
+ });
36
+ res.on('end', () => {
37
+ try {
38
+ resolve(JSON.parse(data));
39
+ } catch (error) {
40
+ reject(error);
41
+ }
42
+ });
43
+ })
44
+ .on('error', reject);
45
+ });
46
+
47
+ /**
48
+ * Fetches model information from models.dev.
49
+ * @param {string} modelId - The model ID (e.g., "claude-sonnet-4-5-20250929")
50
+ * @param {Object} [options]
51
+ * @param {string[]} [options.preferredProviderIds] Provider IDs to check before the default search order.
52
+ * @returns {Promise<Object|null>} Model information or null if not found
53
+ */
54
+ export const fetchModelInfo = async (modelId, options = {}) => {
55
+ if (!modelId) return null;
56
+ try {
57
+ const apiData = await fetchModelsDevApi();
58
+ const lookupIds = buildLookupIds(modelId);
59
+ const preferredProviderIds = Array.isArray(options.preferredProviderIds) ? options.preferredProviderIds : [];
60
+ const providerPriority = buildProviderPriority(modelId, preferredProviderIds);
61
+ return findProviderModel(apiData, providerPriority, lookupIds) || findProviderModel(apiData, Object.keys(apiData), lookupIds);
62
+ } catch {
63
+ // If we can't fetch model info, return null and continue without it.
64
+ return null;
65
+ }
66
+ };
@@ -70,6 +70,7 @@ export const handleFailure = async options => {
70
70
  });
71
71
  if (logUploadSuccess) {
72
72
  await log(`šŸ“Ž Failure log attached to ${targetLabel}`);
73
+ if (!hasPR && hasIssue) global.prePullRequestFailureNotificationPosted = true;
73
74
  }
74
75
  } catch (attachError) {
75
76
  reportError(attachError, {
package/src/solve.mjs CHANGED
@@ -41,6 +41,7 @@ const { formatResetTimeWithRelative } = usageLimitLib;
41
41
 
42
42
  const errorHandlers = await import('./solve.error-handlers.lib.mjs');
43
43
  const { createUncaughtExceptionHandler, createUnhandledRejectionHandler, handleMainExecutionError, handleNoPrAvailableError } = errorHandlers;
44
+ const { notifyIssueAboutPrePullRequestFailure } = await import('./solve.pre-pr-failure-notifier.lib.mjs');
44
45
 
45
46
  const watchLib = await import('./solve.watch.lib.mjs');
46
47
  const { startWatchMode } = watchLib;
@@ -132,7 +133,7 @@ const cleanupWrapper = async () => {
132
133
  }
133
134
  };
134
135
  const interruptWrapper = createInterruptWrapper({ cleanupContext, checkForUncommittedChanges, shouldAttachLogs, attachLogToGitHub, getLogFile, sanitizeLogContent, $, log });
135
- initializeExitHandler(getAbsoluteLogPath, log, cleanupWrapper, interruptWrapper);
136
+ initializeExitHandler(getAbsoluteLogPath, log, cleanupWrapper, interruptWrapper, ({ code, reason }) => notifyIssueAboutPrePullRequestFailure({ code, reason, argv, globalState: global, $, log, getLogFile, shouldAttachLogs, attachLogToGitHub, sanitizeLogContent, rawCommand }));
136
137
  installGlobalExitHandlers();
137
138
 
138
139
  // Now handle argument validation that was moved from early checks
@@ -0,0 +1,107 @@
1
+ import { getTrackedToolCommentIds, postTrackedComment, SOLUTION_DRAFT_FAILED_MARKER } from './tool-comments.lib.mjs';
2
+
3
+ const truncate = (value, maxLength = 2000) => {
4
+ const text = value === null || value === undefined ? '' : String(value);
5
+ if (text.length <= maxLength) return text;
6
+ return `${text.slice(0, maxLength - 22)}\n... truncated ...`;
7
+ };
8
+
9
+ const fence = value => truncate(value || 'Unknown error').replaceAll('```', '` ` `');
10
+
11
+ export function shouldNotifyIssueAboutPrePullRequestFailure({ code, globalState }) {
12
+ if (code === 0) return false;
13
+ if (!globalState?.issueNumber || !globalState?.owner || !globalState?.repo) return false;
14
+ if (globalState?.createdPR?.number) return false;
15
+ if (globalState.prePullRequestFailureNotificationPosted || globalState.prePullRequestFailureNotificationInProgress) return false;
16
+ return getTrackedToolCommentIds().size === 0;
17
+ }
18
+
19
+ export function buildPrePullRequestFailureComment({ reason, owner, repo, issueNumber, argv = {}, rawCommand = null, logAttachmentAttempted = false }) {
20
+ const tool = argv.tool || 'claude';
21
+ const modelLine = argv.model ? `\n- **Requested model**: \`${argv.model}\`` : '';
22
+ const commandBlock = rawCommand
23
+ ? `
24
+
25
+ ### Command
26
+ \`\`\`bash
27
+ ${fence(rawCommand)}
28
+ \`\`\``
29
+ : '';
30
+ const logLine = logAttachmentAttempted ? 'Log attachment was attempted but failed. Check the solver terminal log for the complete failure output.' : 'Logs were not attached because `--attach-logs` was not enabled.';
31
+
32
+ return `## 🚨 ${SOLUTION_DRAFT_FAILED_MARKER}
33
+
34
+ The automated solver stopped before creating a pull request, so no PR was opened for this issue.
35
+
36
+ ### Failure
37
+ - **Repository**: \`${owner}/${repo}\`
38
+ - **Issue**: #${issueNumber}
39
+ - **Tool**: \`${tool}\`${modelLine}
40
+
41
+ **Reason**
42
+ \`\`\`text
43
+ ${fence(reason)}
44
+ \`\`\`${commandBlock}
45
+
46
+ ${logLine}
47
+
48
+ Please resolve the reported problem and rerun the solve command.`;
49
+ }
50
+
51
+ export async function notifyIssueAboutPrePullRequestFailure(options) {
52
+ const { code, reason, argv = {}, globalState = globalThis, $, log = async () => {}, getLogFile, shouldAttachLogs = false, attachLogToGitHub, sanitizeLogContent, rawCommand = null, postComment = postTrackedComment } = options;
53
+
54
+ if (!shouldNotifyIssueAboutPrePullRequestFailure({ code, globalState })) {
55
+ return { notified: false, skipped: true };
56
+ }
57
+
58
+ const owner = globalState.owner;
59
+ const repo = globalState.repo;
60
+ const issueNumber = globalState.issueNumber;
61
+ globalState.prePullRequestFailureNotificationInProgress = true;
62
+
63
+ try {
64
+ if (shouldAttachLogs && getLogFile && attachLogToGitHub && sanitizeLogContent) {
65
+ await log(`\nšŸ“„ Notifying issue #${issueNumber} about pre-PR failure with logs...`);
66
+ const uploaded = await attachLogToGitHub({
67
+ logFile: getLogFile(),
68
+ targetType: 'issue',
69
+ targetNumber: issueNumber,
70
+ owner,
71
+ repo,
72
+ $,
73
+ log,
74
+ sanitizeLogContent,
75
+ verbose: argv.verbose,
76
+ errorMessage: `The solver stopped before creating a pull request.\n\nReason: ${reason || 'Unknown error'}`,
77
+ requestedModel: argv.model,
78
+ tool: argv.tool || 'claude',
79
+ });
80
+ if (uploaded) {
81
+ globalState.prePullRequestFailureNotificationPosted = true;
82
+ return { notified: true, method: 'log-upload' };
83
+ }
84
+ }
85
+
86
+ await log(`\nšŸ’¬ Notifying issue #${issueNumber} about pre-PR failure...`);
87
+ const body = buildPrePullRequestFailureComment({
88
+ reason,
89
+ owner,
90
+ repo,
91
+ issueNumber,
92
+ argv,
93
+ rawCommand,
94
+ logAttachmentAttempted: shouldAttachLogs,
95
+ });
96
+ const posted = await postComment({ $, owner, repo, targetNumber: issueNumber, body });
97
+ if (posted.ok) {
98
+ globalState.prePullRequestFailureNotificationPosted = true;
99
+ await log(` āœ… Pre-PR failure comment posted to issue #${issueNumber}${posted.commentId ? ` (id=${posted.commentId})` : ''}`);
100
+ return { notified: true, method: 'comment', commentId: posted.commentId || null };
101
+ }
102
+ await log(` āš ļø Could not post pre-PR failure comment: ${posted.stderr || 'unknown error'}`, { level: 'warning' });
103
+ return { notified: false, error: posted.stderr || 'unknown error' };
104
+ } finally {
105
+ globalState.prePullRequestFailureNotificationInProgress = false;
106
+ }
107
+ }