@link-assistant/hive-mind 2.12.5 → 2.13.1

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.
@@ -389,6 +389,35 @@ export async function buildDiskDiagnosticsExtraSection(logPath, { verbose = fals
389
389
  return '';
390
390
  }
391
391
  }
392
+ /**
393
+ * Issue #2161: Replay the `🚫 SUBSCRIPTION/ACCESS BLOCKED` report from the
394
+ * captured solve log into the Telegram completion message, so the operator is
395
+ * told that the account itself lost access (and not that "claude failed") on the
396
+ * surface they actually watch. Returns '' when the session hit no such block.
397
+ */
398
+ export async function buildSubscriptionBlockedExtraSection(logPath, { verbose = false, readFile = fs.readFile, locale = null } = {}) {
399
+ if (!logPath) return '';
400
+ try {
401
+ let logText = '';
402
+ try {
403
+ logText = await readFile(logPath, 'utf8');
404
+ } catch (readError) {
405
+ if (verbose) {
406
+ console.log(`[VERBOSE] Could not read session log ${logPath} for subscription block: ${readError?.message || readError}`);
407
+ }
408
+ return '';
409
+ }
410
+ const telegramLib = await import('./subscription-block-telegram.lib.mjs');
411
+ const parsed = telegramLib.parseSubscriptionBlockFromLog(logText);
412
+ if (!parsed) return '';
413
+ return telegramLib.formatSubscriptionBlockedSection(parsed, { locale });
414
+ } catch (error) {
415
+ if (verbose) {
416
+ console.log(`[VERBOSE] Could not build subscription block section for ${logPath}: ${error?.message || error}`);
417
+ }
418
+ return '';
419
+ }
420
+ }
392
421
  async function getDockerContainerFilesystemSizeForSession(sessionName, sessionInfo, { verbose = false, sizeProvider = null } = {}) {
393
422
  if (sessionInfo?.isolationBackend !== 'docker') return null;
394
423
  const containerName = sessionInfo.sessionId || sessionName;
@@ -826,6 +855,21 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
826
855
  }
827
856
  }
828
857
  const dockerTaskContainerExtraSections = dockerTaskContainerAction?.extraSection ? [dockerTaskContainerAction.extraSection] : [];
858
+ // Issue #2161: a blocked subscription/account explains every other
859
+ // symptom of the run, so it goes first in the completion message.
860
+ const subscriptionBlockedExtraSections = [];
861
+ try {
862
+ const blockedSection = await buildSubscriptionBlockedExtraSection(statusResult?.logPath || sessionInfo?.logPath || null, {
863
+ verbose,
864
+ readFile: options.readFile,
865
+ locale: sessionInfo?.locale || null,
866
+ });
867
+ if (blockedSection) subscriptionBlockedExtraSections.push(blockedSection);
868
+ } catch (blockedError) {
869
+ if (verbose) {
870
+ console.log(`[VERBOSE] Could not build subscription block section for ${sessionName}: ${blockedError?.message || blockedError}`);
871
+ }
872
+ }
829
873
  // Issue #2134: say exactly WHY a session was killed, and warn when a
830
874
  // session merely survived a kill event instead of reporting a plain
831
875
  // success. The pull request gets the very same report below.
@@ -869,7 +913,7 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
869
913
  infoBlock: sessionInfo?.infoBlock || '',
870
914
  pullRequestUrl,
871
915
  pullRequestState,
872
- extraSections: [...limitsExtraSections, ...killReport.sections, ...resumeExtraSections, ...diskExtraSections, ...dockerTaskContainerExtraSections],
916
+ extraSections: [...subscriptionBlockedExtraSections, ...limitsExtraSections, ...killReport.sections, ...resumeExtraSections, ...diskExtraSections, ...dockerTaskContainerExtraSections],
873
917
  });
874
918
  if (killReport.killed || killReport.recovered) {
875
919
  const notice = await announceKillOnPullRequest({
package/src/solve.mjs CHANGED
@@ -19,7 +19,9 @@ const fs = (await use('fs')).promises;
19
19
  const crypto = (await use('crypto')).default;
20
20
  const memoryCheck = await import('./memory-check.mjs');
21
21
  const lib = await import('./lib.mjs');
22
- const { log, setLogFile, getLogFile, getAbsoluteLogPath, cleanErrorMessage, formatAligned, formatToolExecutionFailure, getVersionInfo, logSolveStartup, setupVerboseLogInterceptor, setupStdioLogInterceptor } = lib;
22
+ const { log, setLogFile, getLogFile, getAbsoluteLogPath, cleanErrorMessage, formatAligned, formatToolExecutionFailure, extractToolErrorCore, getVersionInfo, logSolveStartup, setupVerboseLogInterceptor, setupStdioLogInterceptor } = lib;
23
+ // Issue #2161: terminal subscription/account-access blocks.
24
+ const { detectSubscriptionError, formatSubscriptionErrorReport, formatSubscriptionErrorSummary, SUBSCRIPTION_BLOCKED_MARKER } = await import('./subscription-error.lib.mjs');
23
25
  const githubLib = await import('./github.lib.mjs');
24
26
  const { sanitizeLogContent, attachLogToGitHub, getToolDisplayName } = githubLib;
25
27
  const validation = await import('./solve.validation.lib.mjs');
@@ -221,6 +223,15 @@ const skipToolConnectionCheck = prepareOnly || argv.skipToolConnectionCheck || a
221
223
  const { cascadePlaywrightMcpDisable, ensureSolvePlaywrightMcpReady } = await import('./playwright-mcp.lib.mjs');
222
224
  await cascadePlaywrightMcpDisable(argv, log);
223
225
  if (!(await performSystemChecks(argv.minDiskSpace || 10240, skipToolConnectionCheck, argv.model, argv))) {
226
+ // Issue #2160: an exhausted host disk is an environment condition, not a defect in the issue.
227
+ // Exit with EX_TEMPFAIL (75) so an orchestrator can requeue the task, and skip the pre-exit
228
+ // notifier: posting "🚨 Solution Draft Failed — Reason: System checks failed" on the target
229
+ // repository's issue told its maintainers nothing they could act on.
230
+ if (argv.systemCheckFailure?.check === 'disk-space') {
231
+ const { EXIT_CODE_INSUFFICIENT_DISK_SPACE } = await import('./disk-guard.lib.mjs');
232
+ const { availableMB, requiredMB } = argv.systemCheckFailure;
233
+ await safeExit(EXIT_CODE_INSUFFICIENT_DISK_SPACE, `Insufficient disk space on this host (${availableMB}MB available, ${requiredMB}MB required) — the issue itself was not attempted`, { skipPreExit: true });
234
+ }
224
235
  await safeExit(1, 'System checks failed');
225
236
  }
226
237
  // Playwright MCP preflight is local/free and stays independent from paid tool connection checks.
@@ -269,6 +280,9 @@ const { isPublic: isRepoPublic } = await detectRepositoryVisibility(owner, repo)
269
280
  if (argv.autoCleanup === undefined) {
270
281
  // For public repos: keep temp directories (default false) For private repos: clean up temp directories (default true)
271
282
  argv.autoCleanup = !isRepoPublic;
283
+ // Issue #2160: remember that this was a default, not a flag, so the "keeping directory"
284
+ // message at the end of the session can say why the workspace is being kept.
285
+ argv.autoCleanupSource = 'repository-visibility-default';
272
286
  if (argv.verbose) {
273
287
  await log(` Auto-cleanup default: ${argv.autoCleanup} (repository is ${isRepoPublic ? 'public' : 'private'})`, {
274
288
  verbose: true,
@@ -1000,6 +1014,15 @@ try {
1000
1014
  const toolForFailure = argv.tool || 'claude';
1001
1015
  // Issue #1845: surface the core error instead of just "<TOOL> execution failed" (terminal + comment).
1002
1016
  const toolFailureMessage = formatToolExecutionFailure({ tool: toolForFailure, toolResult });
1017
+ // Issue #2161: an account/subscription block ("Your organization has disabled
1018
+ // Claude subscription access for Claude Code", a revoked OAuth token, an
1019
+ // expired plan) is terminal — the run must stop, say precisely what happened
1020
+ // and preserve the work, instead of ending on a bare "<TOOL> execution failed
1021
+ // with <provider sentence>". Adapters that parse structured provider codes
1022
+ // (claude.lib.mjs) hand the classification over directly; for every other tool
1023
+ // the rendered message is re-classified here, so the whole failure surface is
1024
+ // covered by one chokepoint.
1025
+ const subscriptionInfo = toolResult?.subscriptionError || detectSubscriptionError({ message: extractToolErrorCore({ toolResult }) || toolFailureMessage, tool: toolForFailure });
1003
1026
  if (sessionId) {
1004
1027
  await log('');
1005
1028
  await log('💡 To continue this session:');
@@ -1014,15 +1037,32 @@ try {
1014
1037
  await log('');
1015
1038
  }
1016
1039
  // Preserve work before remote diagnostics; issue #2101 ended during log upload.
1040
+ let preservedWork = null;
1017
1041
  try {
1018
1042
  const { criticalErrorRecovery } = await import('./config.lib.mjs');
1019
1043
  if (criticalErrorRecovery.autoCommitUncommittedChanges) {
1020
1044
  const { commitUncommittedChangesOnCriticalError } = await import('./critical-error-commit.lib.mjs');
1021
- await commitUncommittedChangesOnCriticalError({ tempDir, branchName, $, log, reason: toolFailureMessage });
1045
+ // Issue #2161: when the subscription is gone it is unknown whether/when it
1046
+ // will be restored, so the emergency commit is the only thing standing
1047
+ // between the operator and hours of lost work — name it as such.
1048
+ preservedWork = await commitUncommittedChangesOnCriticalError({ tempDir, branchName, $, log, reason: subscriptionInfo ? formatSubscriptionErrorSummary(subscriptionInfo, { tool: toolForFailure }) : toolFailureMessage });
1022
1049
  }
1023
1050
  } catch (preserveError) {
1024
1051
  await log(` ⚠️ Could not auto-commit before failure exit: ${preserveError.message}`, { verbose: true });
1025
1052
  }
1053
+ // Issue #2161: printed after the emergency commit so the block can state
1054
+ // whether the work was preserved. This is the message the operator reads.
1055
+ if (subscriptionInfo) {
1056
+ const reportLines = formatSubscriptionErrorReport(subscriptionInfo, {
1057
+ tool: toolForFailure,
1058
+ sessionId,
1059
+ tempDir,
1060
+ branchName,
1061
+ committed: preservedWork ? preservedWork.committed : null,
1062
+ resumeCommand: sessionId && argv.url ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: toolForFailure, model: argv.model, fallbackModel: argv.fallbackModel, tempDir }) : null,
1063
+ });
1064
+ for (const line of reportLines) await log(line, { level: 'error' });
1065
+ }
1026
1066
  // Attach failure logs before exiting (Issues #1212, #1462: fall back to issue if no PR)
1027
1067
  const hasPR = global.createdPR && global.createdPR.number;
1028
1068
  const hasIssue = global.issueNumber;
@@ -1052,7 +1092,9 @@ try {
1052
1092
  // Include sessionId so the PR comment can present it
1053
1093
  sessionId,
1054
1094
  // If not a usage limit case, fall back to generic failure format
1055
- errorMessage: limitReached ? undefined : toolFailureMessage,
1095
+ // Issue #2161: the PR/issue comment gets the diagnosis + the remediation
1096
+ // steps too — whoever finds the run in the morning reads that, not the log.
1097
+ errorMessage: limitReached ? undefined : subscriptionInfo ? [formatSubscriptionErrorSummary(subscriptionInfo, { tool: toolForFailure }), '', ...(subscriptionInfo.guidance || []).map(step => `- ${step}`)].join('\n') : toolFailureMessage,
1056
1098
  argv,
1057
1099
  requestedModel: argv.originalModel || argv.model,
1058
1100
  tool: argv.tool || 'claude',
@@ -1070,7 +1112,9 @@ try {
1070
1112
  await log(` ⚠️ Error uploading failure logs: ${uploadError.message}`);
1071
1113
  }
1072
1114
  }
1073
- await safeExit(1, toolFailureMessage);
1115
+ // Issue #2161: the exit message is what /hive and the session monitor see, so
1116
+ // it carries the marker rather than the generic tool-failure sentence.
1117
+ await safeExit(1, subscriptionInfo ? `${SUBSCRIPTION_BLOCKED_MARKER} — ${formatSubscriptionErrorSummary(subscriptionInfo, { tool: toolForFailure })}` : toolFailureMessage);
1074
1118
  }
1075
1119
  // Clean up .playwright-mcp/ to prevent browser artifacts from triggering auto-restart (Issue #1124)
1076
1120
  if (argv.playwrightMcpAutoCleanup !== false) {
@@ -1338,6 +1338,10 @@ export const cleanupTempDirectory = async (tempDir, argv, limitReached) => {
1338
1338
  } else if (limitReached) {
1339
1339
  await log(`\n📁 Keeping directory for future resume: ${tempDir}`);
1340
1340
  } else if (!argv.autoCleanup) {
1341
- await log(`\n📁 Keeping directory (--no-auto-cleanup): ${tempDir}`);
1341
+ // Issue #2160: `--no-auto-cleanup` is only one of the two ways to get here. On a public
1342
+ // repository auto-cleanup defaults to off, and reporting a flag that was never passed made
1343
+ // the run log misleading — the disk kept filling with no hint of why.
1344
+ const reason = argv.autoCleanupSource === 'repository-visibility-default' ? 'auto-cleanup is off by default for public repositories' : '--no-auto-cleanup';
1345
+ await log(`\n📁 Keeping directory (${reason}): ${tempDir}`);
1342
1346
  }
1343
1347
  };
@@ -30,7 +30,10 @@ const fs = (await use('fs')).promises;
30
30
 
31
31
  // Import shared library functions
32
32
  const lib = await import('./lib.mjs');
33
- const { log, formatAligned, extractToolErrorCore } = lib;
33
+ // Issue #2160: the real log-file accessors must be forwarded to every tool executor. Passing
34
+ // no-op stubs (or omitting them entirely) broke session-log renaming in restart/watch iterations
35
+ // ("⚠️ Could not rename log file: getLogFile is not a function").
36
+ const { log, formatAligned, extractToolErrorCore, getLogFile, setLogFile } = lib;
34
37
  const { ensurePullRequestBaseBranch } = await import('./solve.pr-base-guard.lib.mjs');
35
38
  const { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } = await import('./ai-tool-scratch.lib.mjs');
36
39
  const { RESOURCE_PHASE_RESTART_AFTER, RESOURCE_PHASE_RESTART_BEFORE, recordResourceSnapshot } = await import('./solve.resource-diagnostics.lib.mjs');
@@ -240,8 +243,8 @@ export const executeToolIteration = async params => {
240
243
  log,
241
244
  formatAligned,
242
245
  getResourceSnapshot,
243
- setLogFile: () => {},
244
- getLogFile: () => '',
246
+ setLogFile,
247
+ getLogFile,
245
248
  $,
246
249
  });
247
250
  } else if (argv.tool === 'opencode') {
@@ -280,6 +283,8 @@ export const executeToolIteration = async params => {
280
283
  log,
281
284
  formatAligned,
282
285
  getResourceSnapshot,
286
+ setLogFile,
287
+ getLogFile,
283
288
  opencodePath,
284
289
  $,
285
290
  });
@@ -318,8 +323,8 @@ export const executeToolIteration = async params => {
318
323
  repo,
319
324
  argv,
320
325
  log,
321
- setLogFile: () => {},
322
- getLogFile: () => '',
326
+ setLogFile,
327
+ getLogFile,
323
328
  formatAligned,
324
329
  getResourceSnapshot,
325
330
  codexPath,
@@ -362,6 +367,8 @@ export const executeToolIteration = async params => {
362
367
  log,
363
368
  formatAligned,
364
369
  getResourceSnapshot,
370
+ setLogFile,
371
+ getLogFile,
365
372
  agentPath,
366
373
  $,
367
374
  });
@@ -400,8 +407,8 @@ export const executeToolIteration = async params => {
400
407
  repo,
401
408
  argv,
402
409
  log,
403
- setLogFile: () => {},
404
- getLogFile: () => '',
410
+ setLogFile,
411
+ getLogFile,
405
412
  formatAligned,
406
413
  getResourceSnapshot,
407
414
  geminiPath,
@@ -442,8 +449,8 @@ export const executeToolIteration = async params => {
442
449
  repo,
443
450
  argv,
444
451
  log,
445
- setLogFile: () => {},
446
- getLogFile: () => '',
452
+ setLogFile,
453
+ getLogFile,
447
454
  formatAligned,
448
455
  getResourceSnapshot,
449
456
  qwenPath,
@@ -488,6 +495,8 @@ export const executeToolIteration = async params => {
488
495
  log,
489
496
  formatAligned,
490
497
  getResourceSnapshot,
498
+ setLogFile,
499
+ getLogFile,
491
500
  claudePath,
492
501
  $,
493
502
  });
@@ -485,12 +485,15 @@ export const cleanupClaudeFile = async (tempDir, branchName, claudeCommitHash =
485
485
  const verifyResult = await $({ cwd: tempDir })`git ls-files ${fileName} 2>&1`;
486
486
  const fileStillExists = verifyResult.code === 0 && verifyResult.stdout && verifyResult.stdout.trim();
487
487
  if (fileStillExists) {
488
- await log(` ⚠️ WARNING: ${fileName} still exists after cleanup attempting direct removal...`);
489
- // Check if the file existed before the initial commit (parent)
488
+ // Issue #2160: the pre-existence check must come FIRST. A file that legitimately predates
489
+ // the session is not a cleanup failure, and warning about it produced a false positive
490
+ // ("⚠️ WARNING: .gitkeep still exists after cleanup" immediately followed by
491
+ // "ℹ️ .gitkeep existed before this session — keeping pre-existing file").
490
492
  const parentCommit = `${claudeCommitHash}~1`;
491
493
  const parentFileExists = await $({ cwd: tempDir })`git cat-file -e ${parentCommit}:${fileName} 2>&1`;
492
494
  if (parentFileExists.code !== 0) {
493
- // File didn't exist before the session — force remove it
495
+ // File didn't exist before the session — this is a real leftover, force remove it
496
+ await log(` ⚠️ WARNING: ${fileName} still exists after cleanup — attempting direct removal...`);
494
497
  await $({ cwd: tempDir })`git rm -f ${fileName} 2>&1`;
495
498
  const fallbackCommit = await $({ cwd: tempDir })`git commit -m "Remove leftover ${fileName} (post-cleanup fallback, Issue #1436)" 2>&1`;
496
499
  if (fallbackCommit.code === 0) {
@@ -53,12 +53,6 @@ const { parseResetTime: parseResetTimeToDate } = usageLimitLib;
53
53
 
54
54
  const { validateClaudeConnection } = claudeLib;
55
55
 
56
- // Wrapper function for disk space check using imported module
57
- const checkDiskSpace = async (minSpaceMB = 10240) => {
58
- const result = await memoryCheck.checkDiskSpace(minSpaceMB, { log });
59
- return result.success;
60
- };
61
-
62
56
  // Wrapper function for memory check using imported module
63
57
  const checkMemory = async (minMemoryMB = 256) => {
64
58
  const result = await memoryCheck.checkMemory(minMemoryMB, { log });
@@ -217,9 +211,13 @@ export const validateContinueOnlyOnFeedback = async (argv, isPrUrl, isIssueUrl)
217
211
  // Note: skipToolConnection only skips the connection check, not model validation
218
212
  // Model validation should be done separately before calling this function
219
213
  export const performSystemChecks = async (minDiskSpace = 10240, skipToolConnection = false, model = 'sonnet', argv = {}) => {
220
- // Check disk space before proceeding
221
- const hasEnoughSpace = await checkDiskSpace(minDiskSpace);
222
- if (!hasEnoughSpace) {
214
+ // Check disk space before proceeding.
215
+ // Issue #2160: record *which* check failed on argv. A full disk says nothing about the issue
216
+ // being solved, so the caller exits with a retry-later code and skips the "Solution Draft
217
+ // Failed" comment instead of blaming the task (hive counted 4 such exits as task failures).
218
+ const diskSpace = await memoryCheck.checkDiskSpace(minDiskSpace, { log });
219
+ if (!diskSpace.success) {
220
+ argv.systemCheckFailure = { check: 'disk-space', availableMB: diskSpace.availableMB, requiredMB: minDiskSpace };
223
221
  return false;
224
222
  }
225
223
 
@@ -0,0 +1,115 @@
1
+ // Issue #2161: Telegram surface for subscription/account-access blocks.
2
+ //
3
+ // `/solve` prints a SUBSCRIPTION_BLOCKED_MARKER report into the session log when
4
+ // the account can no longer use the agent tool (expired/cancelled Claude MAX
5
+ // subscription, org policy, revoked ChatGPT/Codex entitlement, ...). The session
6
+ // monitor captures that log, so the same block can be replayed into the Telegram
7
+ // completion message without any extra plumbing between processes.
8
+
9
+ import { SUBSCRIPTION_BLOCKED_MARKER } from './subscription-error.lib.mjs';
10
+ import { lt } from './limits-i18n.lib.mjs';
11
+
12
+ const MAX_MESSAGE_LENGTH = 400;
13
+ const MAX_GUIDANCE_STEPS = 4;
14
+
15
+ const truncate = (value, limit = MAX_MESSAGE_LENGTH) => {
16
+ const text = String(value || '').trim();
17
+ if (text.length <= limit) return text;
18
+ return `${text.slice(0, limit - 1)}…`;
19
+ };
20
+
21
+ const stripPrefix = (line, prefix) => line.slice(line.indexOf(prefix) + prefix.length).trim();
22
+
23
+ /**
24
+ * Parse the last SUBSCRIPTION_BLOCKED_MARKER report out of a captured session log.
25
+ *
26
+ * The report is emitted by formatSubscriptionErrorReport(); every line after the
27
+ * marker line is indented, so the block ends at the first non-indented line.
28
+ *
29
+ * @param {string} logText
30
+ * @returns {null|{tool: string|null, label: string|null, message: string|null, code: string|null, reason: string|null, guidance: string[], committed: boolean|null, resumeCommand: string|null}}
31
+ */
32
+ export function parseSubscriptionBlockFromLog(logText) {
33
+ if (!logText || typeof logText !== 'string') return null;
34
+ if (!logText.includes(SUBSCRIPTION_BLOCKED_MARKER)) return null;
35
+
36
+ const lines = logText.split('\n');
37
+ // Walk backwards: the richest report (from /solve) is the last one printed.
38
+ let markerIndex = -1;
39
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
40
+ if (lines[i].includes(SUBSCRIPTION_BLOCKED_MARKER)) {
41
+ markerIndex = i;
42
+ break;
43
+ }
44
+ }
45
+ if (markerIndex === -1) return null;
46
+
47
+ const headline = stripPrefix(lines[markerIndex], SUBSCRIPTION_BLOCKED_MARKER).replace(/^—\s*/, '');
48
+ const separator = headline.indexOf(':');
49
+ const parsed = {
50
+ tool: separator > 0 ? headline.slice(0, separator).trim() : null,
51
+ label: separator > 0 ? headline.slice(separator + 1).trim() : headline || null,
52
+ message: null,
53
+ code: null,
54
+ reason: null,
55
+ guidance: [],
56
+ committed: null,
57
+ resumeCommand: null,
58
+ };
59
+
60
+ for (let i = markerIndex + 1; i < lines.length; i += 1) {
61
+ const raw = lines[i];
62
+ if (!raw.trim()) continue;
63
+ if (!/^\s{3}/.test(raw)) break; // end of the indented report block
64
+ const line = raw.trim();
65
+ if (line.startsWith('Provider said:')) parsed.message = stripPrefix(line, 'Provider said:');
66
+ else if (line.startsWith('Error code:')) parsed.code = stripPrefix(line, 'Error code:');
67
+ else if (line.startsWith('HTTP status:')) parsed.code = `HTTP ${stripPrefix(line, 'HTTP status:')}`;
68
+ else if (line.startsWith('Why this stops the run:')) parsed.reason = stripPrefix(line, 'Why this stops the run:');
69
+ else if (line.startsWith('•')) parsed.guidance.push(line.slice(1).trim());
70
+ else if (line.startsWith('💾')) parsed.committed = true;
71
+ else if (line.startsWith('⚠️')) parsed.committed = false;
72
+ else if (line.startsWith('▶️')) parsed.resumeCommand = stripPrefix(line, ':');
73
+ }
74
+
75
+ return parsed;
76
+ }
77
+
78
+ /**
79
+ * Render the parsed block as a Telegram extraSection (title + fenced body), the
80
+ * same shape formatDiskDiagnosticsBlock() uses.
81
+ *
82
+ * @returns {string} empty string when there is nothing to show
83
+ */
84
+ export function formatSubscriptionBlockedSection(parsed, { locale = null } = {}) {
85
+ if (!parsed) return '';
86
+ const options = locale ? { locale } : {};
87
+ const body = [];
88
+
89
+ const label = parsed.label || lt('subscription_blocked_title', {}, options);
90
+ body.push(parsed.tool ? `${parsed.tool}: ${label}` : label);
91
+ if (parsed.message) body.push(`${lt('subscription_blocked_provider', {}, options)}: ${truncate(parsed.message)}`);
92
+ if (parsed.code) body.push(`${lt('subscription_blocked_code', {}, options)}: ${parsed.code}`);
93
+ if (parsed.reason) body.push(`${lt('subscription_blocked_reason', {}, options)}: ${parsed.reason}`);
94
+ body.push(lt('subscription_blocked_note', {}, options));
95
+ if (parsed.guidance.length) {
96
+ body.push('');
97
+ body.push(`${lt('subscription_blocked_steps', {}, options)}:`);
98
+ for (const step of parsed.guidance.slice(0, MAX_GUIDANCE_STEPS)) body.push(` • ${step}`);
99
+ }
100
+ if (parsed.committed === true) {
101
+ body.push('');
102
+ body.push(lt('subscription_blocked_preserved', {}, options));
103
+ }
104
+ if (parsed.resumeCommand) {
105
+ body.push('');
106
+ body.push(`${lt('subscription_blocked_resume', {}, options)}: ${parsed.resumeCommand}`);
107
+ }
108
+
109
+ return `🚫 ${lt('subscription_blocked_title', {}, options)}\n\`\`\`\n${body.join('\n')}\n\`\`\``;
110
+ }
111
+
112
+ export default {
113
+ parseSubscriptionBlockFromLog,
114
+ formatSubscriptionBlockedSection,
115
+ };