@link-assistant/hive-mind 2.11.5 → 2.11.7

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,26 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.11.7
4
+
5
+ ### Patch Changes
6
+
7
+ - 815a63c: Keep context/cost budget statistics out of the working session summary (issue #2132).
8
+
9
+ Cost estimation and token/context usage are now published only in the working
10
+ session log comment, and only when `--attach-logs` is enabled — previously every
11
+ session posted the identical blocks twice, and the summary could publish them even
12
+ with log attachment disabled. The per-session budget stats derivation used by the
13
+ top-level run, watch iterations and auto-restart-until-mergeable iterations is now
14
+ a single shared implementation.
15
+
16
+ ## 2.11.6
17
+
18
+ ### Patch Changes
19
+
20
+ - 55b30c8: Make `--model formal-ai` work for every tool by talking to Formal AI directly instead of through the `formal-ai with` wrapper. Hive Mind now starts `formal-ai serve --agent-mode` and points each CLI at that endpoint through its own native configuration channel — `CODEX_HOME` for codex, a settings file for gemini, `OPENAI_MODEL` for qwen — so the wrapper can no longer rewrite the argv, drop the caller's prompt, or send codex traffic to `api.openai.com`. The operator's `HOME`, git, gh and ssh configuration is never shadowed.
21
+
22
+ Also removes the log noise and false verdicts the same runs exposed: read-only `gh`/`git` probes no longer mirror their raw payloads into the log that `--attach-logs` uploads (`gh auth status --show-token` was printing a live credential in clear text), codex no longer warns on every run, an expected 404 is no longer printed as an error, `gh auth setup-git` no longer fails on a bind-mounted `~/.gitconfig`, and "No working session summary available" is no longer reported for a session that produced one. The Formal AI wrapper version is recorded in the solve log, and the runtime logs its endpoint, protocol, config root and environment for the next iteration.
23
+
3
24
  ## 2.11.5
4
25
 
5
26
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.11.5",
3
+ "version": "2.11.7",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
package/src/agent.lib.mjs CHANGED
@@ -22,7 +22,7 @@ import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
22
22
  import Decimal from 'decimal.js-light';
23
23
  import semver from 'semver';
24
24
  import { agentModels, defaultModels, freeToBaseModelMap, isFormalAiModel } from './models/index.mjs';
25
- import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
25
+ import { isPrepareOnly, logPreparedToolCommand, resolveFormalAiToolExecution } from './formal-ai.lib.mjs';
26
26
  import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
27
27
  import { checkPlaywrightMcpPackageAvailability, getAgentPlaywrightMcpDisableEnv } from './playwright-mcp.lib.mjs';
28
28
  import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage } from './agent-token-usage.lib.mjs';
@@ -510,11 +510,9 @@ export const executeAgentCommand = async params => {
510
510
 
511
511
  // Map model alias to full ID
512
512
  const mappedModel = mapModelToId(argv.model);
513
- const toolInvocation = resolveFormalAiToolInvocation({
514
- tool: 'agent',
515
- model: argv.model,
516
- toolPath: agentPath,
517
- });
513
+ // Issue #2130: Formal AI runs the native CLI against a local Formal AI server (no argv wrapper).
514
+ const toolInvocation = await resolveFormalAiToolExecution({ tool: 'agent', model: argv.model, toolPath: agentPath, workdir: tempDir, log, verbose: argv.verbose, prepareOnly: isPrepareOnly(argv), env: agentEnv });
515
+ Object.assign(agentEnv, toolInvocation.env);
518
516
 
519
517
  // Build agent command arguments
520
518
  let agentArgs = `--model ${mappedModel}`;
@@ -571,7 +569,7 @@ export const executeAgentCommand = async params => {
571
569
  mirror: false,
572
570
  env: agentEnv,
573
571
  });
574
- execCommand = toolInvocation.formalAi ? commandRunner`${toolInvocation.command} ${toolInvocation.args} ${agentArgs}` : commandRunner`${toolInvocation.command} ${agentArgs}`;
572
+ execCommand = commandRunner`${toolInvocation.command} ${agentArgs}`;
575
573
  const attached = await attachStreamingInput(bidirectionalHandler, execCommand, combinedPrompt, log, !!argv.verbose, { toolLabel: 'Agent' });
576
574
  if (!attached) {
577
575
  throw new Error('Agent live stream-json input requested, but stdin attachment failed');
@@ -583,7 +581,7 @@ export const executeAgentCommand = async params => {
583
581
  mirror: false,
584
582
  env: agentEnv,
585
583
  });
586
- execCommand = toolInvocation.formalAi ? commandRunner`cat ${promptFile} | ${toolInvocation.command} ${toolInvocation.args} ${agentArgs}` : commandRunner`cat ${promptFile} | ${toolInvocation.command} ${agentArgs}`;
584
+ execCommand = commandRunner`cat ${promptFile} | ${toolInvocation.command} ${agentArgs}`;
587
585
  }
588
586
 
589
587
  await log(`${formatAligned('📋', 'Command details:', '')}`);
@@ -675,8 +673,13 @@ export const executeAgentCommand = async params => {
675
673
  }
676
674
  // Issue #1263: Track text content for result summary
677
675
  // Agent outputs text via 'text', 'assistant', or 'message' type events
678
- if (data.type === 'text' && data.text) {
679
- lastTextContent = data.text;
676
+ // Issue #2130: Agent CLI 0.25.x nests the assistant text under `part`
677
+ // (`{"type":"text","part":{"type":"text","text":"…"}}`) and never sets a
678
+ // top-level `data.text`. Reading only `data.text` left `resultSummary`
679
+ // null for every successful run, which surfaced as the false negative
680
+ // "ℹ️ No working session summary available from AI tool output".
681
+ if (data.type === 'text' && (data.text || data.part?.text)) {
682
+ lastTextContent = data.text || data.part.text;
680
683
  } else if (data.type === 'assistant' && data.message?.content) {
681
684
  // Extract text from assistant message content
682
685
  const content = Array.isArray(data.message.content) ? data.message.content : [data.message.content];
@@ -23,6 +23,7 @@
23
23
  */
24
24
 
25
25
  import { wrapDollarWithGhRetry as _wrapDollarWithGhRetry } from './github-rate-limit.lib.mjs'; // rate-limit marker (#1726): gh API calls flow through $ wrapped by caller
26
+ import { quietProbe } from './quiet-probe.lib.mjs'; // issue #2130: keep read-only probe payloads out of the attached log
26
27
  import { getLiveInputCapability, getLiveInputCapabilityRows, getLiveInputMode, isLiveInputSupported, LIVE_INPUT_MODE_FALLBACK, LIVE_INPUT_MODE_STREAM } from './live-input-capabilities.lib.mjs';
27
28
  // Configuration constants
28
29
  const CONFIG = {
@@ -186,7 +187,7 @@ export const createBidirectionalHandler = options => {
186
187
  const resolveOwnUserLogin = async () => {
187
188
  if (ownUserResolved) return ownUserLogin;
188
189
  try {
189
- const result = await $`gh api user --jq .login`;
190
+ const result = await quietProbe($)`gh api user --jq .login`;
190
191
  ownUserLogin = (result.stdout?.toString() || '').trim() || null;
191
192
  } catch (error) {
192
193
  if (verbose) {
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Issue #2132: single source of truth for *where* context/cost budget statistics
5
+ * may be published.
6
+ *
7
+ * Rules encoded here:
8
+ * 1. Budget stats belong to the working session **log** comment only. The
9
+ * "Working session summary" comment reports what the AI did, never how many
10
+ * tokens or dollars it took (see `attachSolutionSummary`).
11
+ * 2. `--attach-logs` disabled ⇒ no log comment ⇒ no published budget stats.
12
+ * `--tokens-budget-stats` alone is not enough to publish them to GitHub; it
13
+ * only controls the local terminal rendering of the same facts.
14
+ *
15
+ * Keeping this in one module means the top-level run, the watch loop and the
16
+ * auto-restart-until-mergeable loop cannot drift apart again.
17
+ *
18
+ * @see https://github.com/link-assistant/hive-mind/issues/2132
19
+ */
20
+
21
+ /** Whether `--attach-logs` is enabled (both camelCase and kebab-case forms). */
22
+ export const isAttachLogsEnabled = argv => !!(argv && (argv.attachLogs || argv['attach-logs']));
23
+
24
+ /** Whether `--tokens-budget-stats` is enabled (both camelCase and kebab-case forms). */
25
+ export const isTokensBudgetStatsEnabled = argv => !!(argv && (argv.tokensBudgetStats || argv['tokens-budget-stats']));
26
+
27
+ /**
28
+ * Whether context/cost budget statistics may be published to GitHub for this run.
29
+ * Requires BOTH `--tokens-budget-stats` and `--attach-logs`.
30
+ */
31
+ export const shouldPublishBudgetStats = argv => isTokensBudgetStatsEnabled(argv) && isAttachLogsEnabled(argv);
@@ -23,7 +23,7 @@ import { SESSION_FORCE_KILLED_MARKER, postTrackedComment } from './tool-comments
23
23
  import { handleClaudeRuntimeSwitch } from './claude.runtime-switch.lib.mjs'; // see issue #1141
24
24
  import { CLAUDE_MODELS as availableModels, mapClaudeSubAgentModelToEnvValue } from './models/index.mjs'; // Issue #1221, #1978
25
25
  import { applyFormalAiPricingOverride } from './formal-ai-pricing.lib.mjs'; // Issue #2119
26
- import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
26
+ import { buildAuthRemedyLines, isPrepareOnly, logPreparedToolCommand, resolveFormalAiToolExecution } from './formal-ai.lib.mjs';
27
27
  import { buildMcpConfigWithoutPlaywright, ensureClaudePlaywrightMcpServer } from './playwright-mcp.lib.mjs';
28
28
  import { resolveClaudeSessionToolFlags } from './useless-tools.lib.mjs';
29
29
  import { ensureClaudeQuietConfig } from './claude-quiet-config.lib.mjs';
@@ -41,6 +41,8 @@ export { availableModels, fetchModelInfo }; // Re-export for backward compatibil
41
41
  export { formatNumber, mapModelToId, checkModelVisionCapability };
42
42
  export const validateClaudeConnection = async (model = 'haiku') => {
43
43
  const mappedModel = mapModelToId(model);
44
+ // Issue #2130: "run claude login" is wrong advice for a Formal-AI-served model.
45
+ const authRemedyLines = buildAuthRemedyLines({ model, vendorRemedy: 'Please run: claude login' });
44
46
  const maxRetries = 3;
45
47
  const baseDelay = timeouts.retryBaseDelay;
46
48
  let retryCount = 0;
@@ -137,7 +139,7 @@ export const validateClaudeConnection = async (model = 'haiku') => {
137
139
  if (stderr) await log(` Error: ${stderr.trim()}`, { level: 'error' });
138
140
  }
139
141
  if (stderr.includes('Please run /login') || (jsonError && jsonError.type === 'forbidden')) {
140
- await log(' 💡 Please run: claude login', { level: 'error' });
142
+ for (const line of authRemedyLines) await log(line, { level: 'error' });
141
143
  }
142
144
  return false;
143
145
  }
@@ -158,7 +160,7 @@ export const validateClaudeConnection = async (model = 'haiku') => {
158
160
  }
159
161
  await log(`❌ Claude CLI returned error: ${jsonError.type} - ${jsonError.message}`, { level: 'error' });
160
162
  if (jsonError.type === 'forbidden') {
161
- await log(' 💡 Please run: claude login', { level: 'error' });
163
+ for (const line of authRemedyLines) await log(line, { level: 'error' });
162
164
  }
163
165
  return false;
164
166
  }
@@ -613,7 +615,8 @@ export const executeClaudeCommand = async params => {
613
615
  const progressMonitor = await initProgressMonitoring(argv, { owner, repo, prNumber, $, log }); // works with or without --interactive-mode
614
616
  let execCommand;
615
617
  const mappedModel = mapModelToId(argv.model);
616
- const toolInvocation = resolveFormalAiToolInvocation({ tool: 'claude', model: argv.model, toolPath: claudePath });
618
+ // Issue #2130: Formal AI runs the native CLI against a local Formal AI server (no argv wrapper).
619
+ const toolInvocation = await resolveFormalAiToolExecution({ tool: 'claude', model: argv.model, toolPath: claudePath, workdir: tempDir, log, verbose: argv.verbose, prepareOnly: isPrepareOnly(argv) });
617
620
  const resolvedPlanModel = argv.planModel ? mapModelToId(argv.planModel) : undefined; // Issue #1223
618
621
  const resolvedSubAgentModel = argv.subAgentModel ? mapClaudeSubAgentModelToEnvValue(argv.subAgentModel) : undefined; // Issue #1978
619
622
  const effectiveModel = resolvedPlanModel ? 'opusplan' : mappedModel;
@@ -633,12 +636,7 @@ export const executeClaudeCommand = async params => {
633
636
  await log(`🔄 Resuming from session: ${argv.resume}`);
634
637
  claudeArgs = `--resume ${argv.resume} ${claudeArgs}`;
635
638
  }
636
- let claudeWorkLanguage = null;
637
- try {
638
- claudeWorkLanguage = (await import('./i18n.lib.mjs')).getWorkLocale?.() ?? null;
639
- } catch {
640
- /* ignore */
641
- }
639
+ const claudeWorkLanguage = await import('./i18n.lib.mjs').then(i18n => i18n.getWorkLocale?.() ?? null).catch(() => null);
642
640
  await ensureClaudeQuietConfig({ log, workLanguage: claudeWorkLanguage });
643
641
  const { mcpConfigPath, disallowedToolsList } = await resolveClaudeSessionToolFlags({ argv, log, fallbackBuildMcpConfigWithoutPlaywright: buildMcpConfigWithoutPlaywright });
644
642
  if (mcpConfigPath) claudeArgs += ` --strict-mcp-config --mcp-config "${mcpConfigPath}"`;
@@ -660,7 +658,8 @@ export const executeClaudeCommand = async params => {
660
658
  const { thinkingBudget: resolvedThinkingBudget, thinkLevel, isNewVersion, maxBudget } = await resolveThinkingSettings(argv, log);
661
659
  const { parsed: parsedSubSessionSize, contextWindowTokens } = await resolveSubSessionSize({ rawValue: argv.subSessionSize, tool: 'claude', modelId: effectiveModel, fetchModelInfo, log });
662
660
  // Issue #817: streaming mode sets exitAfterStopDelayMs=60000 so the headless Claude process stays alive between NDJSON turns.
663
- const claudeEnv = getClaudeEnv({ thinkingBudget: resolvedThinkingBudget, model: effectiveModel, thinkLevel, maxBudget, planModel: resolvedPlanModel, executionModel: resolvedExecutionModel, subAgentModel: resolvedSubAgentModel, showThinkingContent: argv.showThinkingContent, exitAfterStopDelayMs: streamingInput ? 60_000 : undefined, disable1mContext: !!argv.disable1mContext, subSessionSize: parsedSubSessionSize, contextWindowTokens });
661
+ // Issue #2130: `toolInvocation.env` points the native CLI at the local Formal AI server (base URL + API key).
662
+ const claudeEnv = { ...getClaudeEnv({ thinkingBudget: resolvedThinkingBudget, model: effectiveModel, thinkLevel, maxBudget, planModel: resolvedPlanModel, executionModel: resolvedExecutionModel, subAgentModel: resolvedSubAgentModel, showThinkingContent: argv.showThinkingContent, exitAfterStopDelayMs: streamingInput ? 60_000 : undefined, disable1mContext: !!argv.disable1mContext, subSessionSize: parsedSubSessionSize, contextWindowTokens }), ...toolInvocation.env };
664
663
  if (argv.verbose) claudeEnv.ANTHROPIC_LOG = 'debug';
665
664
  const modelMaxOutputTokens = getMaxOutputTokensForModel(effectiveModel);
666
665
  if (argv.verbose) {
@@ -682,15 +681,15 @@ export const executeClaudeCommand = async params => {
682
681
  if (useClaudeFallbackModel && argv.verbose) await log(`📊 Claude --fallback-model: ${mappedFallbackModel} (Issue #1949 — primary --model ${effectiveModel} stays stable across overload retries)`, { verbose: true });
683
682
  if (argv.resume) {
684
683
  const simpleEscapedPrompt = promptForAttempt.replace(/"/g, '\\"');
685
- execCommand = toolInvocation.formalAi ? $({ cwd: tempDir, mirror: false, env: claudeEnv })`${toolInvocation.command} ${toolInvocation.args} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"` : $({ cwd: tempDir, mirror: false, env: claudeEnv })`${toolInvocation.command} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
684
+ execCommand = $({ cwd: tempDir, mirror: false, env: claudeEnv })`${toolInvocation.command} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
686
685
  } else if (streamingInput) {
687
686
  // Issue #817: Drive Claude via --input-format stream-json on a pipe
688
687
  // stdin. Initial prompt + later PR comments are written as NDJSON
689
688
  // frames by attachStreamingInput (see bidirectional-interactive.lib.mjs).
690
689
  const streamingInputArgs = ['-p', '--input-format', 'stream-json'];
691
- execCommand = toolInvocation.formalAi ? $({ cwd: tempDir, stdin: 'pipe', mirror: false, env: claudeEnv })`${toolInvocation.command} ${toolInvocation.args} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} ${streamingInputArgs} --append-system-prompt "${simpleEscapedSystem}"` : $({ cwd: tempDir, stdin: 'pipe', mirror: false, env: claudeEnv })`${toolInvocation.command} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} ${streamingInputArgs} --append-system-prompt "${simpleEscapedSystem}"`;
690
+ execCommand = $({ cwd: tempDir, stdin: 'pipe', mirror: false, env: claudeEnv })`${toolInvocation.command} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} ${streamingInputArgs} --append-system-prompt "${simpleEscapedSystem}"`;
692
691
  } else {
693
- execCommand = toolInvocation.formalAi ? $({ cwd: tempDir, stdin: promptForAttempt, mirror: false, env: claudeEnv })`${toolInvocation.command} ${toolInvocation.args} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} --append-system-prompt "${simpleEscapedSystem}"` : $({ cwd: tempDir, stdin: promptForAttempt, mirror: false, env: claudeEnv })`${toolInvocation.command} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} --append-system-prompt "${simpleEscapedSystem}"`;
692
+ execCommand = $({ cwd: tempDir, stdin: promptForAttempt, mirror: false, env: claudeEnv })`${toolInvocation.command} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} --append-system-prompt "${simpleEscapedSystem}"`;
694
693
  }
695
694
  if (streamingInput) {
696
695
  await attachStreamingInput(bidirectionalHandler, execCommand, promptForAttempt, log, !!argv.verbose);
package/src/codex.lib.mjs CHANGED
@@ -27,12 +27,13 @@ const __codexBuildSolveResumeCmd = (argv, sessionId, tempDir) => (sessionId && a
27
27
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
28
28
  import { createLineBuffer } from './json-stream.lib.mjs'; // Issue #2119
29
29
  import { mapModelToId, resolveCodexReasoningEffort } from './codex.options.lib.mjs';
30
+ import { buildCodexRunDiagnostics, codexRunAlreadyFailed, describeCodexLastMessageOutcome } from './codex.run-diagnostics.lib.mjs'; // Issue #2130
30
31
  import { createInteractiveHandler } from './interactive-mode.lib.mjs';
31
32
  import { initProgressMonitoring } from './solve.progress-monitoring.lib.mjs';
32
33
  import { ensureCodexPlaywrightMcpServer, getCodexPlaywrightMcpDisableConfigArgs } from './playwright-mcp.lib.mjs';
33
34
  import { fetchModelInfo } from './model-info.lib.mjs';
34
35
  import { defaultModels, isFormalAiModel } from './models/index.mjs';
35
- import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
36
+ import { buildAuthRemedyLines, buildFormalAiEnvExports, isPrepareOnly, logPreparedToolCommand, resolveFormalAiToolExecution } from './formal-ai.lib.mjs';
36
37
  import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
37
38
  import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
38
39
  import { parseSubSessionSize, buildCodexSubSessionSizeConfigArgs, buildCodexDisable1mContextConfigArgs } from './sub-session-size.lib.mjs'; // Issue #1706
@@ -641,7 +642,7 @@ export const validateCodexConnection = async (model = defaultModels.codex, verbo
641
642
  const authError = new Error('Codex authentication failed - 401 Unauthorized');
642
643
  authError.isAuthError = true;
643
644
  await log('❌ Codex authentication failed', { level: 'error' });
644
- await log(' 💡 Please run: codex login', { level: 'error' });
645
+ for (const line of buildAuthRemedyLines({ model, vendorRemedy: 'Please run: codex login' })) await log(line, { level: 'error' });
645
646
  throw authError;
646
647
  }
647
648
 
@@ -823,17 +824,17 @@ export const executeCodexCommand = async params => {
823
824
 
824
825
  let execCommand;
825
826
  const mappedModel = mapModelToId(argv.model);
826
- const toolInvocation = resolveFormalAiToolInvocation({
827
- tool: 'codex',
828
- model: argv.model,
829
- toolPath: codexPath,
830
- });
831
827
  const { reasoningEffort, source: reasoningEffortSource, rolloutTokenBudget } = resolveCodexReasoningEffort(argv);
832
828
  const isResumeMode = !!argv.resume;
833
829
  const codexEnv = applyCodexCapabilityEnv(capabilityPreflight?.codexBaseEnv || getCodexExecEnv(argv.verbose), {
834
830
  codexHome: capabilityPreflight?.codexHome,
835
831
  baseCodexHome: capabilityPreflight?.baseCodexHome,
836
832
  });
833
+ // Issue #2130: run the native CLI against a local Formal AI server (no argv wrapper); `codexEnv` seeds the isolated CODEX_HOME.
834
+ const toolInvocation = await resolveFormalAiToolExecution({ tool: 'codex', model: argv.model, toolPath: codexPath, workdir: tempDir, log, verbose: argv.verbose, prepareOnly: isPrepareOnly(argv), env: codexEnv });
835
+ // Issue #2130: "run codex login" is wrong advice for a Formal-AI-served model.
836
+ const codexAuthRemedyLines = buildAuthRemedyLines({ model: argv.model, vendorRemedy: 'Please run: codex login' });
837
+ Object.assign(codexEnv, toolInvocation.env);
837
838
 
838
839
  // For Codex, we combine system and user prompts into a single message
839
840
  // Codex doesn't have separate system prompt support in CLI mode
@@ -901,7 +902,9 @@ export const executeCodexCommand = async params => {
901
902
  if (subSessionSizeArgs.length) await log(`📊 Codex --sub-session-size: ${subSessionSizeArgs.join(' ')}`, { verbose: true });
902
903
  }
903
904
 
904
- const fullCommand = `(cd ${shellQuote(tempDir)} && cat ${shellQuote(promptFile)} | ${toolInvocation.displayCommand} ${codexArgs})`;
905
+ // Issue #2130: re-export the Formal AI environment inside the `sh -lc` script so a
906
+ // stale `formal-ai with --global` block in the operator profile cannot override it.
907
+ const fullCommand = `(${buildFormalAiEnvExports(toolInvocation.env)}cd ${shellQuote(tempDir)} && cat ${shellQuote(promptFile)} | ${toolInvocation.displayCommand} ${codexArgs})`;
905
908
 
906
909
  const preparedResult = await logPreparedToolCommand({ argv, fullCommand, log, formatAligned });
907
910
  if (preparedResult) return preparedResult;
@@ -1031,7 +1034,7 @@ export const executeCodexCommand = async params => {
1031
1034
  authError = true;
1032
1035
  await log('\n❌ Authentication error detected in Codex JSON stream', { level: 'error' });
1033
1036
  await log(' This error cannot be resolved by retrying.', { level: 'error' });
1034
- await log(' 💡 Please run: codex login', { level: 'error' });
1037
+ for (const line of codexAuthRemedyLines) await log(line, { level: 'error' });
1035
1038
  }
1036
1039
  }
1037
1040
 
@@ -1066,76 +1069,32 @@ export const executeCodexCommand = async params => {
1066
1069
  authError = true;
1067
1070
  await log('\n❌ Authentication error detected in Codex JSON stream', { level: 'error' });
1068
1071
  await log(' This error cannot be resolved by retrying.', { level: 'error' });
1069
- await log(' 💡 Please run: codex login', { level: 'error' });
1072
+ for (const line of codexAuthRemedyLines) await log(line, { level: 'error' });
1070
1073
  }
1071
1074
 
1072
1075
  if (interactiveHandler) {
1073
1076
  await interactiveHandler.flush();
1074
1077
  }
1075
1078
 
1079
+ // Issue #2130: a failed run legitimately has no final message and no
1080
+ // turn.completed usage, so those outcomes must not be logged as warnings.
1081
+ const runFailed = codexRunAlreadyFailed({ state: codexJsonState, exitCode });
1082
+ let lastMessageFromFile = null;
1083
+ let lastMessageReadError = null;
1076
1084
  try {
1077
- const lastMessageFromFile = (await fs.readFile(lastMessageFile, 'utf8')).trim();
1078
- if (lastMessageFromFile) {
1079
- await log(`📝 Final Codex message captured in ${lastMessageFile}`, { verbose: true });
1080
- await log(lastMessageFromFile, { verbose: true });
1081
- lastTextContent = lastTextContent || lastMessageFromFile;
1082
- } else {
1083
- await log(`⚠️ Final Codex message file was empty: ${lastMessageFile}`, { level: 'warning', verbose: true });
1084
- }
1085
+ lastMessageFromFile = (await fs.readFile(lastMessageFile, 'utf8')).trim();
1085
1086
  } catch (readError) {
1086
- await log(`⚠️ Could not read Codex final message file: ${readError.message}`, { level: 'warning', verbose: true });
1087
- }
1088
-
1089
- if (Object.keys(codexJsonState.eventCounts).length > 0) {
1090
- const eventSummary = Object.entries(codexJsonState.eventCounts)
1091
- .map(([eventType, count]) => `${eventType}=${count}`)
1092
- .join(', ');
1093
- await log(`📊 Codex JSON events: ${eventSummary}`, { verbose: true });
1094
- }
1095
- if (Object.keys(codexJsonState.itemTypeCounts).length > 0) {
1096
- const itemSummary = Object.entries(codexJsonState.itemTypeCounts)
1097
- .map(([itemType, count]) => `${itemType}=${count}`)
1098
- .join(', ');
1099
- await log(`📦 Codex item types: ${itemSummary}`, { verbose: true });
1100
- }
1101
- if (codexJsonState.tokenUsage.stepCount > 0) {
1102
- await log(`📈 Codex usage from turn.completed: ${codexJsonState.tokenUsage.inputTokens.toLocaleString()} input, ${codexJsonState.tokenUsage.cacheReadTokens.toLocaleString()} cache read, ${codexJsonState.tokenUsage.outputTokens.toLocaleString()} output across ${codexJsonState.tokenUsage.stepCount} turn(s)`, { verbose: true });
1103
- } else {
1104
- await log('📈 No Codex usage found in turn.completed events', { level: 'warning', verbose: true });
1105
- }
1106
- if (codexJsonState.subAgentCalls.length > 0) {
1107
- await log(`🤝 Codex collab/sub-agent calls observed: ${codexJsonState.subAgentCalls.length}`, { verbose: true });
1087
+ lastMessageReadError = readError;
1108
1088
  }
1109
- if (codexJsonState.reasoningSummaries.length > 0) {
1110
- await log(`🧠 Codex reasoning summaries observed: ${codexJsonState.reasoningSummaries.length}`, { verbose: true });
1089
+ const lastMessageOutcome = describeCodexLastMessageOutcome({ lastMessageFile, lastMessage: lastMessageFromFile, readError: lastMessageReadError, runFailed });
1090
+ await log(lastMessageOutcome.message, lastMessageOutcome.options);
1091
+ if (lastMessageFromFile) {
1092
+ await log(lastMessageFromFile, { verbose: true });
1093
+ lastTextContent = lastTextContent || lastMessageFromFile;
1111
1094
  }
1112
- if (codexJsonState.commandExecutions.length > 0) {
1113
- await log(`💻 Codex command executions observed: ${codexJsonState.commandExecutions.length}`, { verbose: true });
1114
- }
1115
- if (codexJsonState.fileChanges.length > 0) {
1116
- await log(`📝 Codex file change items observed: ${codexJsonState.fileChanges.length}`, { verbose: true });
1117
- }
1118
- if (codexJsonState.mcpToolCalls.length > 0) {
1119
- await log(`🔌 Codex MCP tool calls observed: ${codexJsonState.mcpToolCalls.length}`, { verbose: true });
1120
- }
1121
- if (codexJsonState.webSearches.length > 0) {
1122
- await log(`🌐 Codex web searches observed: ${codexJsonState.webSearches.length}`, { verbose: true });
1123
- }
1124
- if (codexJsonState.todoLists.length > 0) {
1125
- const latestTodoCount = codexJsonState.todoLists.at(-1)?.items?.length || 0;
1126
- await log(`📋 Codex todo list updates observed: ${codexJsonState.todoLists.length} (latest: ${latestTodoCount} items)`, { verbose: true });
1127
- }
1128
- if (codexJsonState.itemErrors.length > 0 || codexJsonState.turnFailures.length > 0 || codexJsonState.streamErrors.length > 0) {
1129
- await log(`⚠️ Codex error events observed: item=${codexJsonState.itemErrors.length}, turn=${codexJsonState.turnFailures.length}, stream=${codexJsonState.streamErrors.length}`, { verbose: true });
1130
- }
1131
- if (codexJsonState.observedUsageFieldSets.length > 0) {
1132
- const lastUsageFieldSet = codexJsonState.observedUsageFieldSets.at(-1);
1133
- await log(`📐 Codex usage fields observed: ${lastUsageFieldSet.join(', ')}`, { verbose: true });
1134
- }
1135
- if (codexJsonState.observedModelDiagnosticPaths.length > 0) {
1136
- await log(`🔎 Undocumented model-related JSON fields observed but ignored for accounting: ${codexJsonState.observedModelDiagnosticPaths.join(', ')}`, { verbose: true });
1137
- } else {
1138
- await log(`🤖 Codex exec JSON did not expose model IDs; using requested model for reporting: ${mappedModel}`, { verbose: true });
1095
+
1096
+ for (const line of buildCodexRunDiagnostics({ state: codexJsonState, exitCode, mappedModel })) {
1097
+ await log(line.message, line.options);
1139
1098
  }
1140
1099
 
1141
1100
  const baseBranchIntervention = baseBranchCommandIntervention.getIntervention();
@@ -1195,7 +1154,7 @@ export const executeCodexCommand = async params => {
1195
1154
  await logCodexResourceSnapshot({ getResourceSnapshot, log });
1196
1155
 
1197
1156
  // Throw an error to stop retries and propagate the auth failure
1198
- const error = new Error('Codex authentication failed - 401 Unauthorized. Please run: codex login');
1157
+ const error = new Error(`Codex authentication failed - 401 Unauthorized.${codexAuthRemedyLines.map(line => ` ${line.replace(/^\s*💡\s*/, '')}`).join('')}`);
1199
1158
  error.isAuthError = true;
1200
1159
  throw error;
1201
1160
  }
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Post-run diagnostics for a `codex exec --json` stream (issue #2130).
5
+ *
6
+ * Split out of codex.lib.mjs to keep that file under the max-lines budget, and
7
+ * kept pure so the "is this line a real warning?" decisions are unit-testable.
8
+ *
9
+ * The motivating defect: when a run already failed (`turn.failed`, non-zero
10
+ * exit), Codex never writes the `--output-last-message` file and never emits a
11
+ * `turn.completed` usage block. Hive Mind reported both of those *expected*
12
+ * consequences as WARNINGs, so the log for a single upstream failure carried two
13
+ * extra warnings that pointed at nothing actionable:
14
+ *
15
+ * [WARNING] ⚠️ Could not read Codex final message file: ENOENT: no such file …
16
+ * [WARNING] 📈 No Codex usage found in turn.completed events
17
+ *
18
+ * See docs/case-studies/issue-2130 (data/tool-logs/codex-02-rv2k7W.log.gz:752).
19
+ */
20
+
21
+ /**
22
+ * Did this Codex run already report a failure of its own?
23
+ *
24
+ * @param {object} params
25
+ * @param {object} [params.state] - accumulated parseCodexExecJsonOutput state.
26
+ * @param {number|null} [params.exitCode] - the CLI exit code, if known.
27
+ * @returns {boolean}
28
+ */
29
+ export const codexRunAlreadyFailed = ({ state = {}, exitCode = null } = {}) => {
30
+ if (typeof exitCode === 'number' && exitCode !== 0) return true;
31
+ if (state.authError) return true;
32
+ return !!(state.turnFailures?.length || state.itemErrors?.length || state.streamErrors?.length);
33
+ };
34
+
35
+ const isMissingFileError = error => error?.code === 'ENOENT' || /ENOENT/.test(error?.message || '');
36
+
37
+ /**
38
+ * Describe the `--output-last-message` file outcome.
39
+ *
40
+ * @param {object} params
41
+ * @param {string} params.lastMessageFile - the path Codex was asked to write.
42
+ * @param {string|null} [params.lastMessage] - trimmed contents, when readable.
43
+ * @param {Error|null} [params.readError] - the read failure, when unreadable.
44
+ * @param {boolean} [params.runFailed] - result of codexRunAlreadyFailed.
45
+ * @returns {{message: string, options: object}} a single log line.
46
+ */
47
+ export const describeCodexLastMessageOutcome = ({ lastMessageFile, lastMessage = null, readError = null, runFailed = false }) => {
48
+ if (readError) {
49
+ // A failed run never gets far enough to write the file; saying so at
50
+ // warning level invents a second problem on top of the reported one.
51
+ if (isMissingFileError(readError) && runFailed) {
52
+ return { message: '📝 Codex wrote no final message file (the run ended with an error)', options: { verbose: true } };
53
+ }
54
+ return { message: `⚠️ Could not read Codex final message file: ${readError.message}`, options: { level: 'warning', verbose: true } };
55
+ }
56
+
57
+ if (lastMessage) {
58
+ return { message: `📝 Final Codex message captured in ${lastMessageFile}`, options: { verbose: true } };
59
+ }
60
+
61
+ if (runFailed) {
62
+ return { message: '📝 Codex left the final message file empty (the run ended with an error)', options: { verbose: true } };
63
+ }
64
+ return { message: `⚠️ Final Codex message file was empty: ${lastMessageFile}`, options: { level: 'warning', verbose: true } };
65
+ };
66
+
67
+ /**
68
+ * Build every verbose diagnostic line for a finished Codex run.
69
+ *
70
+ * @param {object} params
71
+ * @param {object} params.state - accumulated parseCodexExecJsonOutput state.
72
+ * @param {number|null} [params.exitCode] - the CLI exit code, if known.
73
+ * @param {string} params.mappedModel - the model id used for reporting.
74
+ * @returns {Array<{message: string, options: object}>} lines in log order.
75
+ */
76
+ export const buildCodexRunDiagnostics = ({ state = {}, exitCode = null, mappedModel = null } = {}) => {
77
+ const runFailed = codexRunAlreadyFailed({ state, exitCode });
78
+ const lines = [];
79
+ const push = (message, options = { verbose: true }) => lines.push({ message, options });
80
+
81
+ const counts = pairs =>
82
+ Object.entries(pairs || {})
83
+ .map(([key, count]) => `${key}=${count}`)
84
+ .join(', ');
85
+
86
+ if (Object.keys(state.eventCounts || {}).length > 0) push(`📊 Codex JSON events: ${counts(state.eventCounts)}`);
87
+ if (Object.keys(state.itemTypeCounts || {}).length > 0) push(`📦 Codex item types: ${counts(state.itemTypeCounts)}`);
88
+
89
+ const usage = state.tokenUsage || {};
90
+ if (usage.stepCount > 0) {
91
+ push(`📈 Codex usage from turn.completed: ${usage.inputTokens.toLocaleString()} input, ${usage.cacheReadTokens.toLocaleString()} cache read, ${usage.outputTokens.toLocaleString()} output across ${usage.stepCount} turn(s)`);
92
+ } else if (runFailed) {
93
+ // No `turn.completed` is the definition of a failed turn, not a second fault.
94
+ push('📈 Codex reported no usage (the turn never completed)');
95
+ } else {
96
+ push('📈 No Codex usage found in turn.completed events', { level: 'warning', verbose: true });
97
+ }
98
+
99
+ if (state.subAgentCalls?.length > 0) push(`🤝 Codex collab/sub-agent calls observed: ${state.subAgentCalls.length}`);
100
+ if (state.reasoningSummaries?.length > 0) push(`🧠 Codex reasoning summaries observed: ${state.reasoningSummaries.length}`);
101
+ if (state.commandExecutions?.length > 0) push(`💻 Codex command executions observed: ${state.commandExecutions.length}`);
102
+ if (state.fileChanges?.length > 0) push(`📝 Codex file change items observed: ${state.fileChanges.length}`);
103
+ if (state.mcpToolCalls?.length > 0) push(`🔌 Codex MCP tool calls observed: ${state.mcpToolCalls.length}`);
104
+ if (state.webSearches?.length > 0) push(`🌐 Codex web searches observed: ${state.webSearches.length}`);
105
+ if (state.todoLists?.length > 0) push(`📋 Codex todo list updates observed: ${state.todoLists.length} (latest: ${state.todoLists.at(-1)?.items?.length || 0} items)`);
106
+ if (state.itemErrors?.length || state.turnFailures?.length || state.streamErrors?.length) {
107
+ push(`⚠️ Codex error events observed: item=${state.itemErrors?.length || 0}, turn=${state.turnFailures?.length || 0}, stream=${state.streamErrors?.length || 0}`);
108
+ }
109
+ if (state.observedUsageFieldSets?.length > 0) push(`📐 Codex usage fields observed: ${state.observedUsageFieldSets.at(-1).join(', ')}`);
110
+ if (state.observedModelDiagnosticPaths?.length > 0) {
111
+ push(`🔎 Undocumented model-related JSON fields observed but ignored for accounting: ${state.observedModelDiagnosticPaths.join(', ')}`);
112
+ } else {
113
+ push(`🤖 Codex exec JSON did not expose model IDs; using requested model for reporting: ${mappedModel}`);
114
+ }
115
+
116
+ return lines;
117
+ };