@link-assistant/hive-mind 2.15.2 → 2.17.0

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 (49) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.hi.md +12 -0
  3. package/README.md +15 -0
  4. package/README.ru.md +15 -0
  5. package/README.zh.md +24 -12
  6. package/package.json +24 -17
  7. package/src/agent-snapshot-store.lib.mjs +252 -0
  8. package/src/agent.lib.mjs +25 -25
  9. package/src/agent.version-gates.lib.mjs +73 -0
  10. package/src/bot-lifecycle.lib.mjs +63 -5
  11. package/src/child-exit.lib.mjs +53 -1
  12. package/src/claude.session-tokens.lib.mjs +10 -8
  13. package/src/claude.session-transcript-repair.lib.mjs +65 -34
  14. package/src/cleanup.mjs +57 -3
  15. package/src/codex.lib.mjs +11 -1
  16. package/src/development-log.lib.mjs +22 -7
  17. package/src/disk-guard.lib.mjs +21 -1
  18. package/src/formal-ai-version.lib.mjs +10 -6
  19. package/src/github-error-reporter.lib.mjs +84 -2
  20. package/src/github.lib.mjs +212 -152
  21. package/src/instrument.mjs +12 -14
  22. package/src/instrument.sanitize.lib.mjs +52 -0
  23. package/src/isolation-runner.lib.mjs +56 -33
  24. package/src/isolation-runner.parsers.lib.mjs +29 -3
  25. package/src/isolation-runner.resume.lib.mjs +263 -0
  26. package/src/log-bounded-read.lib.mjs +411 -0
  27. package/src/log-sanitize-stream.lib.mjs +267 -0
  28. package/src/log-sanitize-worker-entry.mjs +31 -0
  29. package/src/log-sanitize-worker.lib.mjs +186 -0
  30. package/src/log-upload.lib.mjs +16 -4
  31. package/src/pull-request-changes.lib.mjs +1 -1
  32. package/src/session-completion-state.lib.mjs +124 -0
  33. package/src/session-kill-diagnostics.lib.mjs +117 -12
  34. package/src/session-kill-policy.lib.mjs +18 -7
  35. package/src/session-kill-resume.in-place.lib.mjs +136 -0
  36. package/src/session-kill-resume.lib.mjs +48 -18
  37. package/src/session-monitor.kill-sections.lib.mjs +8 -0
  38. package/src/session-monitor.lib.mjs +132 -9
  39. package/src/session-store.lib.mjs +15 -1
  40. package/src/solve.clone-errors.lib.mjs +86 -0
  41. package/src/solve.config.lib.mjs +5 -2
  42. package/src/solve.repository.lib.mjs +36 -63
  43. package/src/solve.resource-diagnostics.lib.mjs +105 -5
  44. package/src/start-command-cli.lib.mjs +60 -0
  45. package/src/telegram-bot.mjs +31 -91
  46. package/src/telegram-log-command.lib.mjs +7 -3
  47. package/src/telegram-overrides-validation.lib.mjs +73 -0
  48. package/src/telegram-terminal-watch-command.lib.mjs +9 -1
  49. package/src/working-session-summary.lib.mjs +1 -1
@@ -10,6 +10,10 @@ import { batchCheckPullRequestsForIssues as batchCheckPRs, batchCheckArchivedRep
10
10
  import { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeOutput, sanitizeLogContent, sanitizeForPublication, writeSanitizedPublicationFile } from './token-sanitization.lib.mjs';
11
11
  export { isSafeToken, isHexInSafeContext, getGitHubTokensFromFiles, getGitHubTokensFromCommand, sanitizeOutput, sanitizeLogContent, sanitizeForPublication, writeSanitizedPublicationFile }; // Re-export for backward compatibility
12
12
  import { uploadLogWithGhUploadLog } from './log-upload.lib.mjs';
13
+ // Issue #2189: bracket the log-upload phase with resource samples. The incident
14
+ // log's last sample was `after_agent`, ten minutes before the heap OOM, so the
15
+ // phase that actually died left no telemetry at all.
16
+ import { recordResourceSnapshot, RESOURCE_PHASE_LOG_UPLOAD_START, RESOURCE_PHASE_LOG_UPLOAD_END } from './solve.resource-diagnostics.lib.mjs';
13
17
  import { formatResetTimeWithRelative } from './usage-limit.lib.mjs'; // See: https://github.com/link-assistant/hive-mind/issues/1236
14
18
  // Import model info helpers (Issue #1225)
15
19
  import { getToolDisplayName, getModelInfoForComment } from './models/index.mjs';
@@ -26,6 +30,13 @@ export { buildGitHubPullRequestUrl, buildGitHubPullRequestUrlOrNull, isGitHubUrl
26
30
  import { SOLUTION_DRAFT_LOG_MARKER, SOLUTION_DRAFT_FAILED_MARKER, SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER, USAGE_LIMIT_REACHED_MARKER, NOW_WORKING_SESSION_IS_ENDED_MARKER, postTrackedComment, postTrackedCommentFromFile } from './tool-comments.lib.mjs';
27
31
  export const maskGitHubToken = maskToken; // Alias for backward compatibility
28
32
  export const escapeCodeBlocksInLog = logContent => logContent.replace(/```/g, '\\`\\`\\`'); // Escape ``` in logs
33
+ // Issue #2189: the old "too large" message always rendered megabytes, so a 200 KB
34
+ // log that still could not be inlined was reported as "0MB". Report the unit that
35
+ // actually carries information.
36
+ export const formatLogSizeForHumans = bytes => {
37
+ const size = Number(bytes) || 0;
38
+ return size >= 1024 * 1024 ? `${Math.round(size / 1024 / 1024)}MB` : `${Math.round(size / 1024)}KB`;
39
+ };
29
40
  const buildIssueFailureActionSection = targetType => {
30
41
  if (targetType !== 'issue') return '';
31
42
  return `
@@ -317,6 +328,150 @@ const getLogUploadTerminalStatus = ({ errorMessage, errorDuringExecution, isUsag
317
328
  if (isUsageLimit) return { emoji: '📎', label: 'Usage-limit execution log' };
318
329
  return { emoji: '✅', label: 'Solution draft log' };
319
330
  };
331
+ /**
332
+ * Build the inline `--attach-logs` comment body — the variant that embeds the
333
+ * whole transcript inside a `<details>` block.
334
+ *
335
+ * Issue #2189: extracted from attachLogToGitHub so that it is only ever called
336
+ * for a log small enough to inline. Previously the body was assembled
337
+ * unconditionally — including reading, sanitizing and escaping the log — and
338
+ * then discarded when the size check downstream routed the log to gh-upload-log
339
+ * anyway. On a 134 MB transcript that discarded pre-work alone exhausted V8's
340
+ * old space and killed the run.
341
+ *
342
+ * @param {object} params - Everything the four comment shapes interpolate
343
+ * @param {string} params.logContent - Already sanitized and markdown-escaped log
344
+ * @param {number} params.logSizeBytes - Size of the log on disk, for the summary line
345
+ * @returns {string} Markdown comment body
346
+ */
347
+ function buildInlineLogComment({ logContent, logSizeBytes, targetType, customTitle, sessionType, sessionId, errorMessage, errorDuringExecution, isUsageLimit, limitResetTime, toolName, resumeCommand, isAutoResumeEnabled, autoResumeMode, modelInfoString, budgetStats, totalCostUSD, anthropicTotalCostUSD, pricingInfo, failureAction }) {
348
+ let logComment;
349
+ // Usage limit comments should be shown whenever isUsageLimit is true, regardless of whether a generic errorMessage is provided.
350
+ if (isUsageLimit) {
351
+ // Usage limit error format - separate from general failures
352
+ logComment = `## ⏳ ${USAGE_LIMIT_REACHED_MARKER}
353
+
354
+ The automated solution draft was interrupted because the ${toolName} usage limit was reached.
355
+
356
+ ### 📊 Limit Information
357
+ - **Tool**: ${toolName}
358
+ - **Limit Type**: Usage limit exceeded`;
359
+ if (limitResetTime) {
360
+ // Format reset time with relative time and UTC for better user understanding Shows "in 14m (Feb 6, 3:00 PM UTC)" instead of just "4:00 PM" See: https://github.com/link-assistant/hive-mind/issues/1236
361
+ const formattedResetTime = formatResetTimeWithRelative(limitResetTime, global.limitTimezone || null) || limitResetTime;
362
+ logComment += `\n- **Reset Time**: ${formattedResetTime}`;
363
+ }
364
+ if (sessionId) {
365
+ logComment += `\n- **Session ID**: ${sessionId}`;
366
+ }
367
+ logComment += '\n\n### 🔄 How to Continue\n';
368
+ // If auto-resume/auto-restart is enabled, show automatic continuation message instead of CLI commands See: https://github.com/link-assistant/hive-mind/issues/1152
369
+ if (isAutoResumeEnabled) {
370
+ const modeName = autoResumeMode === 'restart' ? 'restart' : 'resume';
371
+ const modeDescription = autoResumeMode === 'restart' ? 'The session will automatically restart (fresh start) when the limit resets.' : 'The session will automatically resume (with context preserved) when the limit resets.';
372
+ logComment += `**Auto-${modeName} is enabled.** ${modeDescription}`;
373
+ } else {
374
+ // Manual resume mode - show CLI commands
375
+ if (limitResetTime) {
376
+ logComment += `Once the limit resets at **${limitResetTime}**, `;
377
+ } else {
378
+ logComment += 'Once the limit resets, ';
379
+ }
380
+ if (resumeCommand) {
381
+ logComment += `you can resume this session by running:
382
+ \`\`\`bash
383
+ ${resumeCommand}
384
+ \`\`\``;
385
+ } else if (sessionId) {
386
+ logComment += `you can resume this session using session ID: \`${sessionId}\``;
387
+ } else {
388
+ logComment += 'you can retry the operation.';
389
+ }
390
+ }
391
+ const footerNote = isAutoResumeEnabled ? (autoResumeMode === 'restart' ? '*This session was interrupted due to usage limits. The session will automatically restart when the limit resets.*' : '*This session was interrupted due to usage limits. The session will automatically resume when the limit resets.*') : '*This session was interrupted due to usage limits. You can resume once the limit resets.*';
392
+ logComment += `${modelInfoString}
393
+
394
+ <details>
395
+ <summary>Click to expand execution log (${Math.round(logSizeBytes / 1024)}KB)</summary>
396
+
397
+ \`\`\`
398
+ ${logContent}
399
+ \`\`\`
400
+
401
+ </details>
402
+
403
+ ---
404
+ ${footerNote}`;
405
+ } else if (errorMessage) {
406
+ // Failure log format (non-usage-limit errors)
407
+ logComment = `## 🚨 ${SOLUTION_DRAFT_FAILED_MARKER}
408
+ The automated solution draft encountered an error:
409
+ \`\`\`
410
+ ${errorMessage}
411
+ \`\`\`${failureAction}${modelInfoString}
412
+
413
+ <details>
414
+ <summary>Click to expand failure log (${Math.round(logSizeBytes / 1024)}KB)</summary>
415
+
416
+ \`\`\`
417
+ ${logContent}
418
+ \`\`\`
419
+
420
+ </details>
421
+
422
+ ---
423
+ *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
424
+ } else if (errorDuringExecution) {
425
+ // Issue #1088: "Finished with errors" format - work may have been completed but errors occurred
426
+ const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
427
+ logComment = `## ⚠️ ${SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER}
428
+ This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}
429
+
430
+ > **Note**: The session encountered errors during execution, but some work may have been completed. Please review the changes carefully.
431
+
432
+ <details>
433
+ <summary>Click to expand solution draft log (${Math.round(logSizeBytes / 1024)}KB)</summary>
434
+
435
+ \`\`\`
436
+ ${logContent}
437
+ \`\`\`
438
+
439
+ </details>
440
+
441
+ ---
442
+ *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
443
+ } else {
444
+ const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
445
+ // Determine title based on session type (Issue #1152) Issue #1625: Every title variant embeds SOLUTION_DRAFT_LOG_MARKER so the filter in checkForAiCreatedComments matches every variant with a single substring check against the centralized marker constant.
446
+ let title = customTitle;
447
+ let sessionNote = '';
448
+ if (sessionType === 'auto-resume') {
449
+ title = `🔄 ${SOLUTION_DRAFT_LOG_MARKER} (auto resume on limit reset)`;
450
+ sessionNote = '\n\n**Note**: This session was automatically resumed after a usage limit reset, with the previous context preserved.';
451
+ } else if (sessionType === 'auto-restart') {
452
+ title = `🔄 ${SOLUTION_DRAFT_LOG_MARKER} (auto restart on limit reset)`;
453
+ sessionNote = '\n\n**Note**: This session was automatically restarted after a usage limit reset (fresh start).';
454
+ } else if (sessionType === 'resume') {
455
+ title = `🔄 ${SOLUTION_DRAFT_LOG_MARKER} (Resumed)`;
456
+ sessionNote = '\n\n**Note**: This session was manually resumed using the --resume flag.';
457
+ }
458
+ logComment = `## ${title}
459
+ This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}${sessionNote}
460
+
461
+ <details>
462
+ <summary>Click to expand solution draft log (${Math.round(logSizeBytes / 1024)}KB)</summary>
463
+
464
+ \`\`\`
465
+ ${logContent}
466
+ \`\`\`
467
+
468
+ </details>
469
+
470
+ ---
471
+ *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
472
+ }
473
+ return logComment;
474
+ }
320
475
  /** Attaches a log file to a GitHub PR or issue as a comment. Returns true if upload succeeded. */
321
476
  export async function attachLogToGitHub(options) {
322
477
  const fs = (await use('fs')).promises;
@@ -351,9 +506,11 @@ export async function attachLogToGitHub(options) {
351
506
  resultModelUsage = null, // Issue #1454
352
507
  budgetStatsData = null, // Issue #1491: budget stats for comment
353
508
  failureActionSection = null,
509
+ recordResources = recordResourceSnapshot, // Issue #2189: injectable for tests
354
510
  } = options;
355
511
  const budgetStats = budgetStatsData ? buildBudgetStatsString(budgetStatsData.tokenUsage, budgetStatsData.subAgentCalls) : '';
356
512
  const targetName = targetType === 'pr' ? 'Pull Request' : 'Issue';
513
+ let uploadPhaseEntered = false;
357
514
  try {
358
515
  // Issue #1212: Check disk space before attempting log upload (100MB minimum)
359
516
  try {
@@ -372,6 +529,12 @@ export async function attachLogToGitHub(options) {
372
529
  await log(' ⚠️ Log file is empty, skipping upload');
373
530
  return false;
374
531
  }
532
+ // Issue #2189: sample resources on the way in and on the way out of the
533
+ // upload, tagged with the log size, so a future post-mortem can see the V8
534
+ // heap climbing against its own limit instead of guessing from a machine
535
+ // that still had 10 GB of RAM free.
536
+ uploadPhaseEntered = true;
537
+ await recordResources({ phase: RESOURCE_PHASE_LOG_UPLOAD_START, log, label: `log upload start, ${formatLogSizeForHumans(logStats.size)} log` });
375
538
  // Issue #1173: gh-upload-log handles large files; skip inline comment for files > fileMaxSize
376
539
  const useLargeFileMode = logStats.size > githubLimits.fileMaxSize;
377
540
  if (useLargeFileMode && verbose) {
@@ -436,148 +599,37 @@ export async function attachLogToGitHub(options) {
436
599
  }
437
600
  }
438
601
  }
439
- // Read and sanitize log content
440
- const rawLogContent = await fs.readFile(logFile, 'utf8');
441
- if (verbose) {
442
- await log(' 🔍 Sanitizing log content to mask GitHub tokens...', { verbose: true });
443
- }
444
- let logContent = await sanitizeForPublication(rawLogContent);
445
- // Escape code blocks in the log content to prevent them from breaking markdown formatting
446
- if (verbose) {
447
- await log(' 🔧 Escaping code blocks in log content for safe embedding...', { verbose: true });
448
- }
449
- logContent = escapeCodeBlocksInLog(logContent);
450
602
  const failureAction = normalizeFailureActionSection(failureActionSection ?? buildIssueFailureActionSection(targetType));
451
- // Create formatted comment
452
- let logComment;
453
- // Usage limit comments should be shown whenever isUsageLimit is true, regardless of whether a generic errorMessage is provided.
454
- if (isUsageLimit) {
455
- // Usage limit error format - separate from general failures
456
- logComment = `## ${USAGE_LIMIT_REACHED_MARKER}
457
-
458
- The automated solution draft was interrupted because the ${toolName} usage limit was reached.
459
-
460
- ### 📊 Limit Information
461
- - **Tool**: ${toolName}
462
- - **Limit Type**: Usage limit exceeded`;
463
- if (limitResetTime) {
464
- // Format reset time with relative time and UTC for better user understanding Shows "in 14m (Feb 6, 3:00 PM UTC)" instead of just "4:00 PM" See: https://github.com/link-assistant/hive-mind/issues/1236
465
- const formattedResetTime = formatResetTimeWithRelative(limitResetTime, global.limitTimezone || null) || limitResetTime;
466
- logComment += `\n- **Reset Time**: ${formattedResetTime}`;
467
- }
468
- if (sessionId) {
469
- logComment += `\n- **Session ID**: ${sessionId}`;
603
+ // Issue #2189: choose the publication route from the file size BEFORE touching
604
+ // the bytes. The old order read the log, sanitized it and escaped it — three
605
+ // full-size strings and only then discovered the comment was far over
606
+ // GitHub's limit and had to be uploaded instead. On a 134 MB transcript that
607
+ // wasted pre-work alone exhausted V8's ~2 GB old space and killed the run
608
+ // ("FATAL ERROR: Reached heap limit"). A log that cannot possibly fit in a
609
+ // comment is now never read into memory at all: it streams straight to
610
+ // gh-upload-log, which sanitizes it block by block.
611
+ const canBuildInlineComment = !useLargeFileMode && logStats.size <= githubLimits.commentMaxSize;
612
+ let logComment = '';
613
+ if (canBuildInlineComment) {
614
+ // Read and sanitize log content
615
+ const rawLogContent = await fs.readFile(logFile, 'utf8');
616
+ if (verbose) {
617
+ await log(' 🔍 Sanitizing log content to mask GitHub tokens...', { verbose: true });
470
618
  }
471
- logComment += '\n\n### 🔄 How to Continue\n';
472
- // If auto-resume/auto-restart is enabled, show automatic continuation message instead of CLI commands See: https://github.com/link-assistant/hive-mind/issues/1152
473
- if (isAutoResumeEnabled) {
474
- const modeName = autoResumeMode === 'restart' ? 'restart' : 'resume';
475
- const modeDescription = autoResumeMode === 'restart' ? 'The session will automatically restart (fresh start) when the limit resets.' : 'The session will automatically resume (with context preserved) when the limit resets.';
476
- logComment += `**Auto-${modeName} is enabled.** ${modeDescription}`;
477
- } else {
478
- // Manual resume mode - show CLI commands
479
- if (limitResetTime) {
480
- logComment += `Once the limit resets at **${limitResetTime}**, `;
481
- } else {
482
- logComment += 'Once the limit resets, ';
483
- }
484
- if (resumeCommand) {
485
- logComment += `you can resume this session by running:
486
- \`\`\`bash
487
- ${resumeCommand}
488
- \`\`\``;
489
- } else if (sessionId) {
490
- logComment += `you can resume this session using session ID: \`${sessionId}\``;
491
- } else {
492
- logComment += 'you can retry the operation.';
493
- }
619
+ let logContent = await sanitizeForPublication(rawLogContent);
620
+ // Escape code blocks in the log content to prevent them from breaking markdown formatting
621
+ if (verbose) {
622
+ await log(' 🔧 Escaping code blocks in log content for safe embedding...', { verbose: true });
494
623
  }
495
- const footerNote = isAutoResumeEnabled ? (autoResumeMode === 'restart' ? '*This session was interrupted due to usage limits. The session will automatically restart when the limit resets.*' : '*This session was interrupted due to usage limits. The session will automatically resume when the limit resets.*') : '*This session was interrupted due to usage limits. You can resume once the limit resets.*';
496
- logComment += `${modelInfoString}
497
-
498
- <details>
499
- <summary>Click to expand execution log (${Math.round(logStats.size / 1024)}KB)</summary>
500
-
501
- \`\`\`
502
- ${logContent}
503
- \`\`\`
504
-
505
- </details>
506
-
507
- ---
508
- ${footerNote}`;
509
- } else if (errorMessage) {
510
- // Failure log format (non-usage-limit errors)
511
- logComment = `## 🚨 ${SOLUTION_DRAFT_FAILED_MARKER}
512
- The automated solution draft encountered an error:
513
- \`\`\`
514
- ${errorMessage}
515
- \`\`\`${failureAction}${modelInfoString}
516
-
517
- <details>
518
- <summary>Click to expand failure log (${Math.round(logStats.size / 1024)}KB)</summary>
519
-
520
- \`\`\`
521
- ${logContent}
522
- \`\`\`
523
-
524
- </details>
525
-
526
- ---
527
- *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
528
- } else if (errorDuringExecution) {
529
- // Issue #1088: "Finished with errors" format - work may have been completed but errors occurred
530
- const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
531
- logComment = `## ⚠️ ${SOLUTION_DRAFT_FINISHED_WITH_ERRORS_MARKER}
532
- This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}
533
-
534
- > **Note**: The session encountered errors during execution, but some work may have been completed. Please review the changes carefully.
535
-
536
- <details>
537
- <summary>Click to expand solution draft log (${Math.round(logStats.size / 1024)}KB)</summary>
538
-
539
- \`\`\`
540
- ${logContent}
541
- \`\`\`
542
-
543
- </details>
544
-
545
- ---
546
- *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
547
- } else {
548
- const costInfo = buildCostInfoString(totalCostUSD, anthropicTotalCostUSD, pricingInfo, { includeTokenUsage: !budgetStats });
549
- // Determine title based on session type (Issue #1152) Issue #1625: Every title variant embeds SOLUTION_DRAFT_LOG_MARKER so the filter in checkForAiCreatedComments matches every variant with a single substring check against the centralized marker constant.
550
- let title = customTitle;
551
- let sessionNote = '';
552
- if (sessionType === 'auto-resume') {
553
- title = `🔄 ${SOLUTION_DRAFT_LOG_MARKER} (auto resume on limit reset)`;
554
- sessionNote = '\n\n**Note**: This session was automatically resumed after a usage limit reset, with the previous context preserved.';
555
- } else if (sessionType === 'auto-restart') {
556
- title = `🔄 ${SOLUTION_DRAFT_LOG_MARKER} (auto restart on limit reset)`;
557
- sessionNote = '\n\n**Note**: This session was automatically restarted after a usage limit reset (fresh start).';
558
- } else if (sessionType === 'resume') {
559
- title = `🔄 ${SOLUTION_DRAFT_LOG_MARKER} (Resumed)`;
560
- sessionNote = '\n\n**Note**: This session was manually resumed using the --resume flag.';
561
- }
562
- logComment = `## ${title}
563
- This log file contains the complete execution trace of the AI ${targetType === 'pr' ? 'solution draft' : 'analysis'} process.${costInfo}${budgetStats}${modelInfoString}${sessionNote}
564
-
565
- <details>
566
- <summary>Click to expand solution draft log (${Math.round(logStats.size / 1024)}KB)</summary>
567
-
568
- \`\`\`
569
- ${logContent}
570
- \`\`\`
571
-
572
- </details>
573
-
574
- ---
575
- *${NOW_WORKING_SESSION_IS_ENDED_MARKER}, feel free to review and add any feedback on the solution draft.*`;
624
+ logContent = escapeCodeBlocksInLog(logContent);
625
+ logComment = buildInlineLogComment({ logContent, logSizeBytes: logStats.size, targetType, customTitle, sessionType, sessionId, errorMessage, errorDuringExecution, isUsageLimit, limitResetTime, toolName, resumeCommand, isAutoResumeEnabled, autoResumeMode, modelInfoString, budgetStats, totalCostUSD, anthropicTotalCostUSD, pricingInfo, failureAction });
626
+ } else if (verbose) {
627
+ await log(` ⏭️ Log is ${formatLogSizeForHumans(logStats.size)} — skipping inline comment construction, the log goes straight to gh-upload-log`, { verbose: true });
576
628
  }
577
629
  // Check GitHub comment size limit or large file mode Issue #1173: Also use gh-upload-log for large files, not just long comments
578
- if (useLargeFileMode || logComment.length > githubLimits.commentMaxSize) {
579
- if (useLargeFileMode) {
580
- await log(` 📁 Log file too large for inline comment (${Math.round(logStats.size / 1024 / 1024)}MB), using gh-upload-log`);
630
+ if (!canBuildInlineComment || logComment.length > githubLimits.commentMaxSize) {
631
+ if (!canBuildInlineComment) {
632
+ await log(` 📁 Log file too large for inline comment (${formatLogSizeForHumans(logStats.size)}), using gh-upload-log`);
581
633
  } else {
582
634
  // Issue #2160: this is the expected route for a long log, not a problem — the upload
583
635
  // below handles it. Reporting it as a warning made every normal run look degraded.
@@ -607,23 +659,20 @@ ${logContent}
607
659
  isPublicRepo = false;
608
660
  await log(' ⚠️ Could not determine repository visibility, defaulting to private for safety');
609
661
  }
610
- // Create temp log file with sanitized content (no compression, just gh-upload-log)
611
- const tempLogFile = `/tmp/solution-draft-log-${targetType}-${Date.now()}.txt`;
612
- // Use the original sanitized content for upload since it's a plain text file
613
- await writeSanitizedPublicationFile(tempLogFile, rawLogContent);
662
+ // Issue #2189: hand the raw log path to the uploader and let it stream the
663
+ // sanitize into its own private temp file. This used to read the whole log
664
+ // a second time and write a sanitized copy here, after which
665
+ // uploadLogWithGhUploadLog read and sanitized that copy a *third* time —
666
+ // which is why the incident log shows "🔒 Sanitized 591 secrets" twice.
667
+ // There is now exactly one sanitize pass, and it never buffers the file.
614
668
  // Use gh-upload-log default auto mode and shared repository fallback.
615
669
  const uploadDescription = `Solution draft log for https://github.com/${owner}/${repo}/${targetType === 'pr' ? 'pull' : 'issues'}/${targetNumber}`;
616
- let uploadResult;
617
- try {
618
- uploadResult = await uploadLogWithGhUploadLog({
619
- logFile: tempLogFile,
620
- isPublic: isPublicRepo,
621
- description: uploadDescription,
622
- verbose,
623
- });
624
- } finally {
625
- await fs.unlink(tempLogFile).catch(() => {});
626
- }
670
+ const uploadResult = await uploadLogWithGhUploadLog({
671
+ logFile,
672
+ isPublic: isPublicRepo,
673
+ description: uploadDescription,
674
+ verbose,
675
+ });
627
676
  if (uploadResult.success) {
628
677
  // Use rawUrl for direct file access (single chunk) or url for repository (multiple chunks) Requirements: 1 chunk = direct raw link, >1 chunks = repo link Private repository raw URLs can contain short-lived tokens, so keep private uploads on the stable repository/tree page URL.
629
678
  const logUrl = selectLogUploadUrl({ uploadResult, isPublicRepo });
@@ -789,6 +838,17 @@ ${sessionNote}
789
838
  const msg = isENOSPC(uploadError) ? 'ENOSPC: No space left on device during log upload. Free disk space and retry.' : `Error uploading log file: ${uploadError.message}`;
790
839
  await log(` ❌ ${msg}`);
791
840
  return false;
841
+ } finally {
842
+ // Issue #2189: the closing sample must run on every exit path, including the
843
+ // failure ones — an upload that died half way through is exactly the case
844
+ // whose memory profile we need.
845
+ if (uploadPhaseEntered) {
846
+ try {
847
+ await recordResources({ phase: RESOURCE_PHASE_LOG_UPLOAD_END, log, label: 'log upload end' });
848
+ } catch {
849
+ /* telemetry must never change the outcome of the upload */
850
+ }
851
+ }
792
852
  }
793
853
  }
794
854
  /**
@@ -1,17 +1,6 @@
1
1
  // Lazy-load config only when needed to avoid loading use-m at module initialization
2
2
  // This prevents network fetches that can hang during --help or --version
3
- import { sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
4
-
5
- const sanitizeEventValue = (value, seen = new WeakSet()) => {
6
- if (typeof value === 'string') return sanitizeCredentialText(value);
7
- if (!value || typeof value !== 'object') return value;
8
- if (seen.has(value)) return value;
9
- seen.add(value);
10
- for (const [key, item] of Object.entries(value)) {
11
- value[key] = sanitizeEventValue(item, seen);
12
- }
13
- return value;
14
- };
3
+ import { sanitizeSentryLog, sanitizeSentryValue } from './instrument.sanitize.lib.mjs';
15
4
 
16
5
  // Check if Sentry should be disabled
17
6
  const shouldDisableSentry = () => {
@@ -78,7 +67,9 @@ if (!shouldDisableSentry()) {
78
67
  environment: process.env.NODE_ENV || 'production',
79
68
  release: `hive-mind@${process.env.npm_package_version || version.default}`,
80
69
 
81
- // Send structured logs to Sentry
70
+ // Send structured logs to Sentry. Stated explicitly even though Sentry
71
+ // 10.71 made it the default, so the setting stays a decision rather than
72
+ // whatever the SDK happens to default to next.
82
73
  enableLogs: true,
83
74
 
84
75
  // Tracing
@@ -98,7 +89,7 @@ if (!shouldDisableSentry()) {
98
89
 
99
90
  // Before send hook to filter out sensitive data
100
91
  beforeSend(event) {
101
- sanitizeEventValue(event);
92
+ sanitizeSentryValue(event);
102
93
 
103
94
  // Filter out sensitive environment variables
104
95
  if (event.contexts && event.contexts.runtime && event.contexts.runtime.env) {
@@ -123,6 +114,13 @@ if (!shouldDisableSentry()) {
123
114
  return event;
124
115
  },
125
116
 
117
+ // Structured logs never pass through beforeSend, so they get the same
118
+ // masking here — otherwise a token printed by `Sentry.logger.*` would
119
+ // leave the process verbatim.
120
+ beforeSendLog(log) {
121
+ return sanitizeSentryLog(log);
122
+ },
123
+
126
124
  // Integration specific options
127
125
  ignoreErrors: [
128
126
  // Ignore specific errors that are expected or not relevant
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Credential sanitization for everything Hive Mind hands to Sentry.
3
+ *
4
+ * `src/instrument.mjs` has always masked credentials in *events* (`beforeSend`).
5
+ * Structured logs are a second, separate pipeline: `enableLogs` sends
6
+ * `Sentry.logger.*` records straight to the transport, and `beforeSend` is never
7
+ * called for them. Sentry 10.71 made `enableLogs` the default, so any consumer
8
+ * that has not opted out now ships that pipeline whether it meant to or not —
9
+ * which is why the same masking is applied through `beforeSendLog` here.
10
+ *
11
+ * Both hooks share one walker so a token can never be masked in one surface and
12
+ * printed verbatim in the other.
13
+ *
14
+ * @see https://github.com/link-assistant/hive-mind/issues/2189
15
+ */
16
+
17
+ import { sanitizeCredentialText } from './credential-sanitization-core.lib.mjs';
18
+
19
+ /**
20
+ * Recursively mask credentials in a Sentry payload, in place.
21
+ *
22
+ * Mutates rather than clones on purpose: Sentry hands us the object it is about
23
+ * to serialize, and a copy would leave the original untouched. Cycles are
24
+ * tracked so a self-referencing payload cannot spin forever — the same class of
25
+ * unbounded work this issue is about.
26
+ *
27
+ * @param {*} value - Any part of a Sentry event or log record
28
+ * @param {WeakSet} [seen] - Cycle guard, supplied by the recursion
29
+ * @returns {*} The same value with every string masked
30
+ */
31
+ export const sanitizeSentryValue = (value, seen = new WeakSet()) => {
32
+ if (typeof value === 'string') return sanitizeCredentialText(value);
33
+ if (!value || typeof value !== 'object') return value;
34
+ if (seen.has(value)) return value;
35
+ seen.add(value);
36
+ for (const [key, item] of Object.entries(value)) {
37
+ value[key] = sanitizeSentryValue(item, seen);
38
+ }
39
+ return value;
40
+ };
41
+
42
+ /**
43
+ * `beforeSendLog` hook: mask credentials in a structured log record.
44
+ *
45
+ * Returns the record (never `null`) so sanitization only ever changes the
46
+ * content of a log, never whether it is delivered — dropping logs silently
47
+ * would recreate the blind spot described in Finding F5.
48
+ *
49
+ * @param {Object} log - The log record Sentry is about to send
50
+ * @returns {Object} The same record, masked
51
+ */
52
+ export const sanitizeSentryLog = log => sanitizeSentryValue(log);
@@ -1,4 +1,3 @@
1
- import { ensureUseM } from './use-m-bootstrap.lib.mjs';
2
1
  /**
3
2
  * Isolation Runner for Telegram bot
4
3
  *
@@ -30,30 +29,22 @@ import { buildRouterGitConfigEntries, buildRouterTaskEnv, getRouterSuppressedCre
30
29
  import { acquireRouterForTask, attachRouterTaskContainer, registerFormalAiWithRouter, releaseRouterForTask } from './router-task-isolation.lib.mjs';
31
30
  import { buildGitConfigEnv, GIT_PUSH_GUARD_CONTAINER_DIR, GIT_PUSH_GUARD_ESCAPE_ENV, hasForcePushOptIn, installGitPushGuard } from './git-push-guard.lib.mjs';
32
31
  export { getDockerIsolationImage, resolveDockerIsolationImageTag } from './hive-mind-image.lib.mjs';
33
- let commandStreamDollarPromise = null;
34
- async function getCommandStreamDollar() {
35
- if (!commandStreamDollarPromise) {
36
- commandStreamDollarPromise = (async () => {
37
- if (typeof globalThis.use === 'undefined') {
38
- await ensureUseM();
39
- }
40
- const { $ } = await globalThis.use('command-stream');
41
- return $;
42
- })();
43
- }
44
- try {
45
- return await commandStreamDollarPromise;
46
- } catch (error) {
47
- commandStreamDollarPromise = null;
48
- throw error;
49
- }
50
- }
51
32
  // Re-export the shared status predicates so existing callers that reach them via the isolation-runner module (e.g. session-monitor's `runner.isExecutingSessionStatus`) keep working. The canonical definitions live in session-status.lib.mjs so the killed/terminated/oom vocabulary stays consistent everywhere (issue #1927).
52
33
  export { isExecutingSessionStatus, isTerminalSessionStatus, isKilledSessionStatus } from './session-status.lib.mjs';
53
34
  // Issue #2175: the `$` output parsers live in their own module to keep this file
54
35
  // under the 1350-line warning threshold. Re-exported so importers are unaffected.
55
36
  import { isUnknownDockerExitCode, parseSessionExitFooter, parseSessionListOutput, parseSessionStatusOutput, parseStartCommandExecutionUuid, readSessionExitFromLog, shouldFallbackToScreenStatus } from './isolation-runner.parsers.lib.mjs';
56
37
  export { isUnknownDockerExitCode, parseSessionExitFooter, parseSessionListOutput, parseSessionStatusOutput, parseStartCommandExecutionUuid, readSessionExitFromLog, shouldFallbackToScreenStatus };
38
+ // Issue #2189: the `$` loader and PATH lookup live in their own module so the
39
+ // resume/attach wrappers can use them without importing this runner (a cycle).
40
+ import { findStartCommandBinary, getCommandStreamDollar } from './start-command-cli.lib.mjs';
41
+ export { findStartCommandBinary };
42
+ // Issue #2189: `$ --resume` / `$ --resume-all`, added in start-command 0.33.0
43
+ // (link-foundation/start#162). Re-exported so callers keep reaching every
44
+ // isolation verb through this module.
45
+ import { resumeAllIsolationSessions, resumeIsolatedSession } from './isolation-runner.resume.lib.mjs';
46
+ export { resumeAllIsolationSessions, resumeIsolatedSession };
47
+ export { parseExecutionResumeAllOutput, parseExecutionResumeOutput, RESUME_ALL_ACTIONS, RESUME_MODES } from './isolation-runner.resume.lib.mjs';
57
48
  // Valid isolation backends
58
49
  const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
59
50
  const DOCKER_CONTAINER_HOME = '/home/box';
@@ -311,20 +302,6 @@ async function runStartCommand(binPath, startCommandArgs) {
311
302
  export function generateSessionId() {
312
303
  return crypto.randomUUID();
313
304
  }
314
- /**
315
- * Find the `$` CLI binary path
316
- * @returns {Promise<string|null>} Path to `$` binary or null
317
- */
318
- async function findStartCommandBinary() {
319
- try {
320
- const $ = await getCommandStreamDollar();
321
- const result = await $({ mirror: false })`which $`;
322
- const path = result.stdout?.toString().trim() || '';
323
- return path || null;
324
- } catch {
325
- return null;
326
- }
327
- }
328
305
  /**
329
306
  * Verbose post-launch diagnostics for a native docker-isolated session.
330
307
  *
@@ -611,6 +588,21 @@ export async function stopIsolatedSession(sessionId, verbose = false) {
611
588
  console.log(`[VERBOSE] isolation-runner: $ --stop ${sessionId} stderr: ${stderr.substring(0, 300)}`);
612
589
  }
613
590
  }
591
+ // Issue #2189: `command-stream`'s `$` resolves — it does not throw — when the
592
+ // child exits non-zero, so the catch below never sees a refusal. `$ --stop`
593
+ // answers `Error: No execution found with UUID or session name: …` on stderr
594
+ // with exit code 1; without this check every such refusal was reported to the
595
+ // operator as a successful stop, and a session nobody stopped looked handled.
596
+ const code = Number.isFinite(result.code) ? result.code : 0;
597
+ if (code !== 0) {
598
+ // describeChildExit rather than an interpolated code: issue #2135 made it
599
+ // the single vocabulary for "how a child ended", so a `$` that was
600
+ // signalled reads the same way here as everywhere else. command-stream
601
+ // normalizes a signalled exit to 128+signum before we see it
602
+ // (node_modules/command-stream/src/$.process-runner-stream-kill.mjs:194),
603
+ // so there is no separate signal to pass.
604
+ return { success: false, output: stdout, error: stderr.trim() || describeChildExit({ command: '`$ --stop`', code }) };
605
+ }
614
606
  return { success: true, output: stdout || stderr, error: null };
615
607
  } catch (error) {
616
608
  const stderr = error?.stderr?.toString?.() || '';
@@ -679,6 +671,37 @@ export async function checkDockerContainerRunning(containerName, verbose = false
679
671
  return false;
680
672
  }
681
673
  }
674
+ /**
675
+ * Check whether the Docker container backing a session still exists at all —
676
+ * running or stopped.
677
+ *
678
+ * Issue #2189 requirement R2: a killed session should be re-entered rather than
679
+ * restarted from scratch, and `$ --resume` can only do that while the container
680
+ * is still there. `checkDockerContainerRunning` answers a different question (a
681
+ * stopped container is "not running" but is exactly the one worth resuming), so
682
+ * the state is read instead of the running flag.
683
+ *
684
+ * @param {string} containerName - Container name (the session UUID)
685
+ * @param {boolean} [verbose] - Enable verbose logging
686
+ * @returns {Promise<boolean>} True when `docker inspect` finds the container
687
+ */
688
+ export async function checkDockerContainerExists(containerName, verbose = false) {
689
+ if (!containerName) return false;
690
+ try {
691
+ const $ = await getCommandStreamDollar();
692
+ const result = await $({ mirror: false })`docker inspect -f ${'{{.State.Status}}'} ${containerName}`;
693
+ const code = Number.isFinite(result.code) ? result.code : 0;
694
+ const state = (result.stdout?.toString() || '').trim();
695
+ const exists = code === 0 && state !== '';
696
+ if (verbose) {
697
+ console.log(`[VERBOSE] isolation-runner: docker inspect state for '${containerName}': ${exists ? state : 'no such container'}`);
698
+ }
699
+ return exists;
700
+ } catch {
701
+ // `docker inspect` exits non-zero when no such container exists.
702
+ return false;
703
+ }
704
+ }
682
705
  export function parseDockerContainerWritableLayerSizeOutput(output) {
683
706
  const text = String(output || '').trim();
684
707
  if (!text) return null;