@link-assistant/hive-mind 2.9.1 → 2.10.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.
@@ -11,6 +11,7 @@
11
11
  // Both are re-exported from codex.lib.mjs for backward compatibility, so existing
12
12
  // importers (and tests) can keep importing them from either module.
13
13
 
14
+ import { normalizePluginSelector } from './codex-capability-preflight.lib.mjs';
14
15
  import { isENOSPC } from './lib.mjs';
15
16
 
16
17
  const unwrapCodexErrorMessage = value => {
@@ -115,6 +116,84 @@ export const getCodexErrorEventSummary = codexJsonState => {
115
116
  };
116
117
  };
117
118
 
119
+ // Issue #2102: `request_plugin_install` can never succeed under `codex exec`.
120
+ // The tool validates its `plugin_id` against the server-driven
121
+ // `<recommended_plugins>` list with an exact string comparison, and `codex exec`
122
+ // auto-cancels the elicitation it would raise, so a model that reaches for it is
123
+ // stuck in a loop it cannot exit. In the captured GCS-TS#5 runs that produced no
124
+ // work at all, the only trace was in codex's OTEL text stream (the tool is a
125
+ // builtin, so there is no NDJSON `mcp_tool_call` item to inspect):
126
+ //
127
+ // INFO codex_otel.log_only: event.name="codex.tool_result"
128
+ // tool_name=request_plugin_install call_id=… arguments={"plugin_id":"…"}
129
+ // … success=false output=plugin_id must match one of the entries in the
130
+ // <recommended_plugins> list
131
+ // ERROR codex_core::tools::router: error=plugin_id must match one of the
132
+ // entries in the <recommended_plugins> list
133
+ //
134
+ // Both patterns are anchored at the beginning of the line rather than matched
135
+ // anywhere in it: codex echoes the stdout of every command it runs back into its
136
+ // own stream (issue #1955), and this repository's own case-study logs contain
137
+ // these very lines. An echoed copy is always preceded by the emitting tool's own
138
+ // prefix (`tool_name=shell … output=…`), so requiring `request_plugin_install` to
139
+ // be the tool of the line's *own* event, and the router error to open the line,
140
+ // keeps replayed text from being read as a live rejection.
141
+ const PLUGIN_INSTALL_MESSAGE_TEXT = 'plugin_id must match one of the entries in the <recommended_plugins> list';
142
+ const PLUGIN_INSTALL_MESSAGE_PATTERN = /plugin_id must match one of the entries in the <recommended_plugins> list/;
143
+ const PLUGIN_INSTALL_TOOL_RESULT = /^(?:\S+\s+)?(?:TRACE|DEBUG|INFO|WARN|ERROR)\s+codex_otel\.log_only:\s+event\.name="codex\.tool_result"\s+tool_name=request_plugin_install\b/;
144
+ const PLUGIN_INSTALL_ROUTER_ERROR = /^(?:\S+\s+)?ERROR\s+codex_core::tools::router:\s+error=plugin_id must match one of the entries in the <recommended_plugins> list/;
145
+ const PLUGIN_INSTALL_CALL_ID = /\bcall_id=(\S+)/;
146
+ const PLUGIN_INSTALL_PLUGIN_ID = /"plugin_id"\s*:\s*"([^"]+)"/;
147
+ const PLUGIN_INSTALL_SUCCESS = /\bsuccess=(true|false)\b/;
148
+
149
+ export const matchCodexPluginInstallRejection = line => {
150
+ const text = String(line || '');
151
+ if (PLUGIN_INSTALL_ROUTER_ERROR.test(text)) return { source: 'router', callId: null, pluginId: null, message: PLUGIN_INSTALL_MESSAGE_TEXT };
152
+ if (!PLUGIN_INSTALL_TOOL_RESULT.test(text)) return null;
153
+ if (!PLUGIN_INSTALL_MESSAGE_PATTERN.test(text)) return null;
154
+ if (PLUGIN_INSTALL_SUCCESS.exec(text)?.[1] === 'true') return null;
155
+ return { source: 'tool_result', callId: PLUGIN_INSTALL_CALL_ID.exec(text)?.[1] || null, pluginId: PLUGIN_INSTALL_PLUGIN_ID.exec(text)?.[1] || null, message: PLUGIN_INSTALL_MESSAGE_TEXT };
156
+ };
157
+
158
+ // Issue #2102: a rejected runtime install means the capability preflight did not
159
+ // provision what the task needs, so the run cannot do the work it was asked to
160
+ // do. It is reported as a failure only when nothing was produced: a model may
161
+ // probe `request_plugin_install` and then complete the task without the plugin,
162
+ // and failing that run retroactively would discard real output.
163
+ export const getCodexPluginProvisioningHealth = (codexJsonState, { capabilityPreflight = null } = {}) => {
164
+ const rejections = codexJsonState?.pluginInstallRejections || [];
165
+ const requestedPlugins = [...new Set(rejections.map(entry => entry.pluginId).filter(Boolean))].sort();
166
+ const fileChanges = codexJsonState?.fileChanges || [];
167
+ const producedWork = fileChanges.length > 0;
168
+ const detected = rejections.length > 0;
169
+
170
+ const reasons = [];
171
+ const guidance = [];
172
+ if (detected) {
173
+ reasons.push(`Codex called request_plugin_install${requestedPlugins.length > 0 ? ` for ${requestedPlugins.join(', ')}` : ''} and codex rejected it: ${PLUGIN_INSTALL_MESSAGE_TEXT}. Under codex exec this tool can never install a plugin, so the model cannot recover on its own.`);
174
+ reasons.push(capabilityPreflight?.required ? `The Hive Mind Codex capability preflight ran for ${(capabilityPreflight.plugins || []).join(', ') || 'no plugins'}, so the plugin the model asked for was not among the requirements it discovered.` : 'The Hive Mind Codex capability preflight detected no requirements for this task, so nothing was provisioned before codex exec.');
175
+ for (const plugin of requestedPlugins.length > 0 ? requestedPlugins : ['<plugin>@<marketplace>']) {
176
+ // Issue #2102: the model asks for `@openai-curated-remote`, which is a
177
+ // synthesized namespace that `codex plugin add` cannot install; the
178
+ // preflight's normalization maps it onto the installable `@openai-curated`
179
+ // marketplace, so the guidance must quote the selector that actually works.
180
+ guidance.push(`Declare the requirement so the preflight provisions it: --require-codex-plugin ${normalizePluginSelector(plugin)} (or HIVE_MIND_CODEX_REQUIRED_PLUGINS).`);
181
+ }
182
+ guidance.push('Requirements declared in the target repository AGENTS.md / CLAUDE.md are discovered automatically; run with --verbose to see the sources the preflight scanned.');
183
+ }
184
+
185
+ return {
186
+ healthy: !detected || producedWork,
187
+ detected,
188
+ producedWork,
189
+ requestedPlugins,
190
+ rejections,
191
+ message: detected ? reasons[0] : null,
192
+ reasons,
193
+ guidance,
194
+ };
195
+ };
196
+
118
197
  // Issue #1990: A Codex run can exit 0 with no fatal `turn.failed`/error event yet
119
198
  // still be fundamentally broken. Under docker isolation two long-running
120
199
  // `solve --tool codex` tasks reported SUCCESS (Exit Code: 0) while their
@@ -189,3 +268,47 @@ export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } =
189
268
  reasons,
190
269
  };
191
270
  };
271
+
272
+ // Reporting helpers for the run gates in codex.lib.mjs. They live here with the
273
+ // analysis they narrate so codex.lib.mjs stays inside the 1500-line budget
274
+ // (issues #1730 / #1990).
275
+ export const logCodexResourceSnapshot = async ({ getResourceSnapshot, log }) => {
276
+ const resourcesAfter = await getResourceSnapshot();
277
+ await log('\n📈 System resources after execution:', { verbose: true });
278
+ await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
279
+ await log(` Load: ${resourcesAfter.load}`, { verbose: true });
280
+ };
281
+
282
+ export const reportCodexCompletionFailure = async ({ completionHealth, log, getResourceSnapshot }) => {
283
+ await log('\n\n❌ Codex exited 0 but the run did not complete — treating as failure', { level: 'error' });
284
+ for (const reason of completionHealth.reasons) {
285
+ await log(` • ${reason}`, { level: 'error' });
286
+ }
287
+ await log(` 📊 turn.started=${completionHealth.turnStarted}, turn.completed=${completionHealth.turnCompleted}, turn.failed=${completionHealth.turnFailed}`, { verbose: true });
288
+ if (completionHealth.diskPressureDetected) {
289
+ await log(' 💽 Disk-exhaustion evidence (diagnostic):', { level: 'error' });
290
+ for (const evidence of completionHealth.diskEvidence.slice(0, 5)) {
291
+ await log(` ↳ [${evidence.source}] ${evidence.text}`, { level: 'error' });
292
+ }
293
+ await log(' 💡 Free disk space before retrying. Under docker isolation the container is preserved on failure for inspection.', { level: 'error' });
294
+ }
295
+ await logCodexResourceSnapshot({ getResourceSnapshot, log });
296
+ };
297
+
298
+ export const reportCodexPluginProvisioning = async ({ pluginProvisioning, log }) => {
299
+ if (!pluginProvisioning.detected) return;
300
+ if (!pluginProvisioning.healthy) {
301
+ await log('\n\n❌ Codex could not obtain a required plugin at runtime — treating as failure', { level: 'error' });
302
+ for (const reason of pluginProvisioning.reasons) {
303
+ await log(` • ${reason}`, { level: 'error' });
304
+ }
305
+ for (const hint of pluginProvisioning.guidance) {
306
+ await log(` 💡 ${hint}`, { level: 'error' });
307
+ }
308
+ return;
309
+ }
310
+ await log(`\n⚠️ Codex asked to install ${pluginProvisioning.requestedPlugins.join(', ') || 'a plugin'} at runtime and was rejected, but the run still produced changes`, { level: 'warning' });
311
+ for (const hint of pluginProvisioning.guidance) {
312
+ await log(` 💡 ${hint}`, { level: 'warning', verbose: true });
313
+ }
314
+ };
package/src/codex.lib.mjs CHANGED
@@ -17,8 +17,8 @@ const os = (await use('os')).default;
17
17
  import { log } from './lib.mjs';
18
18
  // Issues #1955 / #1990: run-health analysis lives in its own module to keep this
19
19
  // file under the max-lines budget. Re-exported below for backward compatibility.
20
- import { getCodexErrorEventSummary, getCodexCompletionHealth } from './codex-health.lib.mjs';
21
- export { getCodexErrorEventSummary, getCodexCompletionHealth };
20
+ import { getCodexErrorEventSummary, getCodexCompletionHealth, getCodexPluginProvisioningHealth, logCodexResourceSnapshot, matchCodexPluginInstallRejection, reportCodexCompletionFailure, reportCodexPluginProvisioning } from './codex-health.lib.mjs';
21
+ export { getCodexErrorEventSummary, getCodexCompletionHealth, getCodexPluginProvisioningHealth, matchCodexPluginInstallRejection };
22
22
  import { reportError } from './sentry.lib.mjs';
23
23
  import { timeouts, retryLimits } from './config.lib.mjs';
24
24
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
@@ -31,6 +31,7 @@ import { initProgressMonitoring } from './solve.progress-monitoring.lib.mjs';
31
31
  import { ensureCodexPlaywrightMcpServer, getCodexPlaywrightMcpDisableConfigArgs } from './playwright-mcp.lib.mjs';
32
32
  import { fetchModelInfo } from './model-info.lib.mjs';
33
33
  import { defaultModels } from './models/index.mjs';
34
+ import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
34
35
  import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
35
36
  import { parseSubSessionSize, buildCodexSubSessionSizeConfigArgs, buildCodexDisable1mContextConfigArgs } from './sub-session-size.lib.mjs'; // Issue #1706
36
37
  import { getCumulativeContextInputTokens } from './context-fill.lib.mjs';
@@ -351,6 +352,7 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
351
352
  itemErrors: state.itemErrors || [],
352
353
  turnFailures: state.turnFailures || [],
353
354
  streamErrors: state.streamErrors || [],
355
+ pluginInstallRejections: state.pluginInstallRejections || [],
354
356
  observedUsageFieldSets: state.observedUsageFieldSets || [],
355
357
  observedModelDiagnosticPaths: state.observedModelDiagnosticPaths || [],
356
358
  };
@@ -371,6 +373,11 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
371
373
  try {
372
374
  data = sanitizeObjectStrings(JSON.parse(line));
373
375
  } catch {
376
+ // Issue #2102: `request_plugin_install` is a codex builtin, so its rejection
377
+ // never arrives as an NDJSON item — it only exists in the interleaved OTEL
378
+ // text stream, which is exactly the set of lines that fail to parse here.
379
+ const rejection = matchCodexPluginInstallRejection(line);
380
+ if (rejection) nextState.pluginInstallRejections.push(rejection);
374
381
  continue;
375
382
  }
376
383
 
@@ -745,7 +752,10 @@ export const executeCodex = async params => {
745
752
  // it natively from .agents/skills/handoff/SKILL.md (no-op unless --use-handoff).
746
753
  await deployHandoffSkill({ tempDir, argv, log, $ });
747
754
  const codexBaseEnv = getCodexExecEnv(argv.verbose);
748
- const capabilityPreflight = await runCodexCapabilityPreflight({ owner, repo, issueNumber, projectDir: tempDir, codexPath, log, env: codexBaseEnv });
755
+ // Issue #2102: the target repository's own agent instruction files are part of
756
+ // the requirement corpus, so the preflight needs the checkout; `--require-codex-plugin`
757
+ // is the explicit escape hatch for requirements no document states.
758
+ const capabilityPreflight = await runCodexCapabilityPreflight({ owner, repo, issueNumber, projectDir: tempDir, codexPath, log, env: codexBaseEnv, requiredPlugins: argv.requireCodexPlugin });
749
759
  // Execute the Codex command
750
760
  return await executeCodexCommand({
751
761
  tempDir,
@@ -807,6 +817,11 @@ export const executeCodexCommand = async params => {
807
817
 
808
818
  let execCommand;
809
819
  const mappedModel = mapModelToId(argv.model);
820
+ const toolInvocation = resolveFormalAiToolInvocation({
821
+ tool: 'codex',
822
+ model: argv.model,
823
+ toolPath: codexPath,
824
+ });
810
825
  const { reasoningEffort, source: reasoningEffortSource, rolloutTokenBudget } = resolveCodexReasoningEffort(argv);
811
826
  const isResumeMode = !!argv.resume;
812
827
  const codexEnv = applyCodexCapabilityEnv(capabilityPreflight?.codexBaseEnv || getCodexExecEnv(argv.verbose), {
@@ -880,11 +895,10 @@ export const executeCodexCommand = async params => {
880
895
  if (subSessionSizeArgs.length) await log(`📊 Codex --sub-session-size: ${subSessionSizeArgs.join(' ')}`, { verbose: true });
881
896
  }
882
897
 
883
- const fullCommand = `(cd ${shellQuote(tempDir)} && cat ${shellQuote(promptFile)} | ${codexPath} ${codexArgs})`;
898
+ const fullCommand = `(cd ${shellQuote(tempDir)} && cat ${shellQuote(promptFile)} | ${toolInvocation.displayCommand} ${codexArgs})`;
884
899
 
885
- await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
886
- await log(`${fullCommand}`);
887
- await log('');
900
+ const preparedResult = await logPreparedToolCommand({ argv, fullCommand, log, formatAligned });
901
+ if (preparedResult) return preparedResult;
888
902
 
889
903
  try {
890
904
  let interactiveHandler = null;
@@ -961,6 +975,7 @@ export const executeCodexCommand = async params => {
961
975
  itemErrors: [],
962
976
  turnFailures: [],
963
977
  streamErrors: [],
978
+ pluginInstallRejections: [],
964
979
  observedUsageFieldSets: [],
965
980
  observedModelDiagnosticPaths: [],
966
981
  };
@@ -1127,13 +1142,25 @@ export const executeCodexCommand = async params => {
1127
1142
  await log(`⚠️ Codex public pricing estimate unavailable: ${pricingInfo.error}`, { level: 'warning', verbose: true });
1128
1143
  }
1129
1144
  const resultModelUsage = pricingInfo?.tokenUsage ? buildCodexResultModelUsage(firstActualModelId, pricingInfo.tokenUsage, pricingInfo) : null;
1145
+ // Every exit from this run reports the same accounting fields; only the
1146
+ // outcome-specific ones differ. `lastTextContent` is the result summary
1147
+ // captured from the JSON output stream (issue #1263).
1148
+ const buildRunResult = outcome => ({
1149
+ sessionId,
1150
+ limitReached,
1151
+ limitResetTime,
1152
+ pricingInfo,
1153
+ publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
1154
+ resultModelUsage,
1155
+ subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
1156
+ codexJsonDetails: codexJsonState,
1157
+ resultSummary: lastTextContent || null,
1158
+ ...outcome,
1159
+ });
1130
1160
 
1131
1161
  // Check for authentication errors first - these should never be retried
1132
1162
  if (authError) {
1133
- const resourcesAfter = await getResourceSnapshot();
1134
- await log('\n📈 System resources after execution:', { verbose: true });
1135
- await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
1136
- await log(` Load: ${resourcesAfter.load}`, { verbose: true });
1163
+ await logCodexResourceSnapshot({ getResourceSnapshot, log });
1137
1164
 
1138
1165
  // Throw an error to stop retries and propagate the auth failure
1139
1166
  const error = new Error('Codex authentication failed - 401 Unauthorized. Please run: codex login');
@@ -1199,25 +1226,9 @@ export const executeCodexCommand = async params => {
1199
1226
  await log(` Error events: item=${codexErrorSummary.counts.item}, turn=${codexErrorSummary.counts.turn}, stream=${codexErrorSummary.counts.stream}`, { level: 'error' });
1200
1227
  }
1201
1228
 
1202
- const resourcesAfter = await getResourceSnapshot();
1203
- await log('\n📈 System resources after execution:', { verbose: true });
1204
- await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
1205
- await log(` Load: ${resourcesAfter.load}`, { verbose: true });
1229
+ await logCodexResourceSnapshot({ getResourceSnapshot, log });
1206
1230
 
1207
- return {
1208
- success: false,
1209
- sessionId,
1210
- limitReached,
1211
- limitResetTime,
1212
- pricingInfo,
1213
- publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
1214
- resultModelUsage,
1215
- subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
1216
- codexJsonDetails: codexJsonState,
1217
- errorInfo: codexErrorSummary,
1218
- result: codexErrorSummary.message,
1219
- resultSummary: lastTextContent || null, // Issue #1263: Use last text content from JSON output stream
1220
- };
1231
+ return buildRunResult({ success: false, errorInfo: codexErrorSummary, result: codexErrorSummary.message });
1221
1232
  }
1222
1233
 
1223
1234
  if (exitCode !== 0) {
@@ -1269,24 +1280,22 @@ export const executeCodexCommand = async params => {
1269
1280
  await log(`\n\n❌ Codex command failed with exit code ${exitCode}`, { level: 'error' });
1270
1281
  }
1271
1282
 
1272
- const resourcesAfter = await getResourceSnapshot();
1273
- await log('\n📈 System resources after execution:', { verbose: true });
1274
- await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
1275
- await log(` Load: ${resourcesAfter.load}`, { verbose: true });
1283
+ await logCodexResourceSnapshot({ getResourceSnapshot, log });
1276
1284
 
1277
- return {
1278
- success: false,
1279
- sessionId,
1280
- limitReached,
1281
- limitResetTime,
1282
- pricingInfo,
1283
- publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
1284
- resultModelUsage,
1285
- subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
1286
- codexJsonDetails: codexJsonState,
1287
- errorInfo: getCodexErrorEventSummary(codexJsonState),
1288
- resultSummary: lastTextContent || null, // Issue #1263: Use last text content from JSON output stream
1289
- };
1285
+ return buildRunResult({ success: false, errorInfo: getCodexErrorEventSummary(codexJsonState) });
1286
+ }
1287
+
1288
+ // Issue #2102: a rejected `request_plugin_install` means codex asked for a
1289
+ // capability the preflight did not provision, and under `codex exec` that
1290
+ // request can never succeed — so a run that produced nothing is blocked,
1291
+ // not finished. Reporting it as a named failure (instead of an empty
1292
+ // success) is what makes the missing requirement visible.
1293
+ const pluginProvisioning = getCodexPluginProvisioningHealth(codexJsonState, { capabilityPreflight });
1294
+ await reportCodexPluginProvisioning({ pluginProvisioning, log });
1295
+ if (!pluginProvisioning.healthy) {
1296
+ if (sessionId && !argv.resume) argv.resume = sessionId;
1297
+
1298
+ return buildRunResult({ success: false, errorInfo: getCodexErrorEventSummary(codexJsonState), pluginProvisioning, result: [pluginProvisioning.message, ...pluginProvisioning.guidance].join(' ') });
1290
1299
  }
1291
1300
 
1292
1301
  // Issue #1990: exit code 0 and the absence of a fatal codex error event are
@@ -1297,23 +1306,7 @@ export const executeCodexCommand = async params => {
1297
1306
  // container filesystem needed to inspect and retry the failure (#1990).
1298
1307
  const completionHealth = getCodexCompletionHealth(codexJsonState, { lastMessage });
1299
1308
  if (!completionHealth.healthy) {
1300
- await log('\n\n❌ Codex exited 0 but the run did not complete — treating as failure', { level: 'error' });
1301
- for (const reason of completionHealth.reasons) {
1302
- await log(` • ${reason}`, { level: 'error' });
1303
- }
1304
- await log(` 📊 turn.started=${completionHealth.turnStarted}, turn.completed=${completionHealth.turnCompleted}, turn.failed=${completionHealth.turnFailed}`, { verbose: true });
1305
- if (completionHealth.diskPressureDetected) {
1306
- await log(' 💽 Disk-exhaustion evidence (diagnostic):', { level: 'error' });
1307
- for (const evidence of completionHealth.diskEvidence.slice(0, 5)) {
1308
- await log(` ↳ [${evidence.source}] ${evidence.text}`, { level: 'error' });
1309
- }
1310
- await log(' 💡 Free disk space before retrying. Under docker isolation the container is preserved on failure for inspection.', { level: 'error' });
1311
- }
1312
-
1313
- const resourcesAfter = await getResourceSnapshot();
1314
- await log('\n📈 System resources after execution:', { verbose: true });
1315
- await log(` Memory: ${resourcesAfter.memory.split('\n')[1]}`, { verbose: true });
1316
- await log(` Load: ${resourcesAfter.load}`, { verbose: true });
1309
+ await reportCodexCompletionFailure({ completionHealth, log, getResourceSnapshot });
1317
1310
 
1318
1311
  // Issue #1990: preserve the codex session so an outer full restart can
1319
1312
  // resume with context (mirrors the transient-error retry above and the
@@ -1323,23 +1316,7 @@ export const executeCodexCommand = async params => {
1323
1316
  // restart at the orchestration level.
1324
1317
  if (sessionId && !argv.resume) argv.resume = sessionId;
1325
1318
 
1326
- return {
1327
- success: false,
1328
- sessionId,
1329
- limitReached,
1330
- limitResetTime,
1331
- pricingInfo,
1332
- publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
1333
- resultModelUsage,
1334
- subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
1335
- codexJsonDetails: codexJsonState,
1336
- errorInfo: getCodexErrorEventSummary(codexJsonState),
1337
- completionHealth,
1338
- incompleteSession: completionHealth.incompleteSession,
1339
- diskPressureDetected: completionHealth.diskPressureDetected,
1340
- result: completionHealth.reasons.join(' '),
1341
- resultSummary: lastTextContent || null,
1342
- };
1319
+ return buildRunResult({ success: false, errorInfo: getCodexErrorEventSummary(codexJsonState), completionHealth, pluginProvisioning, incompleteSession: completionHealth.incompleteSession, diskPressureDetected: completionHealth.diskPressureDetected, result: completionHealth.reasons.join(' ') });
1343
1320
  }
1344
1321
 
1345
1322
  await log('\n\n✅ Codex command completed');
@@ -1351,18 +1328,7 @@ export const executeCodexCommand = async params => {
1351
1328
  await log('⚠️ No result summary captured from Codex output or last-message file', { level: 'warning', verbose: true });
1352
1329
  }
1353
1330
 
1354
- return {
1355
- success: true,
1356
- sessionId,
1357
- limitReached,
1358
- limitResetTime,
1359
- pricingInfo,
1360
- publicPricingEstimate: pricingInfo?.totalCostUSD ?? null,
1361
- resultModelUsage,
1362
- subAgentCalls: codexJsonState.subAgentCalls.length > 0 ? codexJsonState.subAgentCalls : null,
1363
- codexJsonDetails: codexJsonState,
1364
- resultSummary: lastTextContent || null, // Issue #1263: Use last text content from JSON output stream
1365
- };
1331
+ return buildRunResult({ success: true, pluginProvisioning });
1366
1332
  } catch (error) {
1367
1333
  // Don't report auth errors to Sentry as they are user configuration issues
1368
1334
  if (!error.isAuthError) {
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+
6
+ import { FORMAL_AI_MODEL_ALIAS, isFormalAiModel } from './models/index.mjs';
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ export const FORMAL_AI_SUPPORTED_TOOLS = Object.freeze(['claude', 'agent', 'opencode', 'codex', 'qwen', 'gemini']);
11
+ export const DEFAULT_FORMAL_AI_PATH = 'formal-ai';
12
+
13
+ const SAFE_SHELL_WORD = /^[a-zA-Z0-9_\-./=,+@:]+$/;
14
+
15
+ const shellQuote = value => {
16
+ const stringValue = String(value);
17
+ if (SAFE_SHELL_WORD.test(stringValue)) return stringValue;
18
+ return `'${stringValue.replaceAll("'", "'\\''")}'`;
19
+ };
20
+
21
+ const normalizeExternalBaseUrl = value => {
22
+ if (!value) return null;
23
+
24
+ let parsed;
25
+ try {
26
+ parsed = new URL(value);
27
+ } catch {
28
+ throw new Error(`HIVE_MIND_FORMAL_AI_BASE_URL must be a valid HTTP(S) URL, received "${value}"`);
29
+ }
30
+
31
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) {
32
+ throw new Error('HIVE_MIND_FORMAL_AI_BASE_URL must be an HTTP(S) origin without credentials, a path, a query, or a fragment');
33
+ }
34
+
35
+ return parsed.origin;
36
+ };
37
+
38
+ /**
39
+ * Resolve the executable and leading arguments for one Hive tool invocation.
40
+ * Formal AI owns the temporary client configuration and forwards all remaining
41
+ * arguments to the selected agentic CLI unchanged.
42
+ */
43
+ export const resolveFormalAiToolInvocation = ({ tool, model, toolPath, env = process.env }) => {
44
+ if (!isFormalAiModel(model)) {
45
+ return {
46
+ command: toolPath,
47
+ args: [],
48
+ displayCommand: shellQuote(toolPath),
49
+ formalAi: false,
50
+ baseUrl: null,
51
+ };
52
+ }
53
+
54
+ if (!FORMAL_AI_SUPPORTED_TOOLS.includes(tool)) {
55
+ throw new Error(`Formal AI dispatch does not support Hive tool "${tool}"`);
56
+ }
57
+
58
+ const command = env.HIVE_MIND_FORMAL_AI_PATH?.trim() || DEFAULT_FORMAL_AI_PATH;
59
+ const baseUrl = normalizeExternalBaseUrl(env.HIVE_MIND_FORMAL_AI_BASE_URL);
60
+ const args = ['with'];
61
+
62
+ if (baseUrl) {
63
+ args.push('--no-start-server', '--base-url', baseUrl);
64
+ }
65
+ args.push(tool);
66
+
67
+ return {
68
+ command,
69
+ args,
70
+ displayCommand: [command, ...args].map(shellQuote).join(' '),
71
+ formalAi: true,
72
+ baseUrl,
73
+ };
74
+ };
75
+
76
+ /**
77
+ * Check both the wrapper and the selected native CLI without starting a model
78
+ * server or spending a model request.
79
+ */
80
+ export const validateFormalAiToolConnection = async (tool, { env = process.env, run = execFileAsync, timeoutMs = 30_000 } = {}) => {
81
+ const invocation = resolveFormalAiToolInvocation({
82
+ tool,
83
+ model: FORMAL_AI_MODEL_ALIAS,
84
+ toolPath: tool,
85
+ env,
86
+ });
87
+ const args = ['with', '--no-start-server', tool, '--version'];
88
+
89
+ try {
90
+ const result = await run(invocation.command, args, {
91
+ encoding: 'utf8',
92
+ env: { ...process.env, ...env },
93
+ timeout: timeoutMs,
94
+ });
95
+ return {
96
+ valid: true,
97
+ command: invocation.command,
98
+ args,
99
+ version: result?.stdout?.trim() || null,
100
+ };
101
+ } catch (error) {
102
+ return {
103
+ valid: false,
104
+ command: invocation.command,
105
+ args,
106
+ error: error?.stderr?.trim() || error?.message || String(error),
107
+ code: error?.code,
108
+ };
109
+ }
110
+ };
111
+
112
+ export const isPrepareOnly = argv => !!(argv?.dryRun || argv?.onlyPrepareCommand);
113
+
114
+ export const createPreparedToolResult = preparedCommand => ({
115
+ success: true,
116
+ preparedOnly: true,
117
+ preparedCommand,
118
+ sessionId: null,
119
+ limitReached: false,
120
+ errorDuringExecution: false,
121
+ });
122
+
123
+ export const logPreparedToolCommand = async ({ argv, fullCommand, log, formatAligned }) => {
124
+ await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
125
+ await log(fullCommand);
126
+ await log('');
127
+
128
+ if (!isPrepareOnly(argv)) return null;
129
+
130
+ await log('🧪 Command prepared; AI execution skipped.');
131
+ return createPreparedToolResult(fullCommand);
132
+ };
@@ -18,6 +18,7 @@ import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Iss
18
18
  const __geminiBuildSolveResumeCmd = (argv, sessionId, tempDir) => (sessionId && argv?.url ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: 'gemini', model: argv.model, fallbackModel: argv.fallbackModel, tempDir }) : null);
19
19
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
20
20
  import { defaultModels, geminiModels } from './models/index.mjs';
21
+ import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
21
22
  import { checkPlaywrightMcpPackageAvailability } from './playwright-mcp.lib.mjs';
22
23
  import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
23
24
  import { getCumulativeContextInputTokens, toTokenCount } from './context-fill.lib.mjs';
@@ -423,6 +424,11 @@ export const executeGeminiCommand = async params => {
423
424
  await log(` Load: ${resourcesBefore.load}`, { verbose: true });
424
425
 
425
426
  const mappedModel = mapModelToId(argv.model || defaultModels.gemini);
427
+ const toolInvocation = resolveFormalAiToolInvocation({
428
+ tool: 'gemini',
429
+ model: argv.model || defaultModels.gemini,
430
+ toolPath: geminiPath,
431
+ });
426
432
  const combinedPrompt = systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt;
427
433
 
428
434
  if (argv.resume) {
@@ -432,11 +438,11 @@ export const executeGeminiCommand = async params => {
432
438
  // Issue #1809: build args via shared helper so verbose/sandbox/include-dirs
433
439
  // toggles stay consistent between the logged command and the real invocation.
434
440
  const geminiArgList = buildGeminiArgs(argv, mappedModel, { tempDir, workspaceTmpDir });
435
- const fullCommand = `(cd ${shellQuote(tempDir)} && ${geminiPath} ${geminiArgList.map(shellQuote).join(' ')} <<< <prompt>)`;
441
+ const fullGeminiArgList = [...toolInvocation.args, ...geminiArgList];
442
+ const fullCommand = `(cd ${shellQuote(tempDir)} && ${toolInvocation.displayCommand} ${geminiArgList.map(shellQuote).join(' ')} <<< <prompt>)`;
436
443
 
437
- await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
438
- await log(fullCommand);
439
- await log('');
444
+ const preparedResult = await logPreparedToolCommand({ argv, fullCommand, log, formatAligned });
445
+ if (preparedResult) return preparedResult;
440
446
 
441
447
  let geminiJsonState = {};
442
448
  let allOutput = '';
@@ -455,7 +461,7 @@ export const executeGeminiCommand = async params => {
455
461
  cwd: tempDir,
456
462
  stdin: combinedPrompt,
457
463
  mirror: false,
458
- })`${geminiPath} ${geminiArgList}`;
464
+ })`${toolInvocation.command} ${fullGeminiArgList}`;
459
465
 
460
466
  await log(`${formatAligned('📋', 'Command details:', '')}`);
461
467
  await log(formatAligned('📂', 'Working directory:', tempDir, 2));