@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.
@@ -15,6 +15,7 @@ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
15
15
 
16
16
  import crypto from 'crypto';
17
17
  import { spawn } from 'node:child_process';
18
+ import { lookup as lookupHost } from 'node:dns/promises';
18
19
  import fs from 'node:fs';
19
20
  import os from 'node:os';
20
21
  import path from 'node:path';
@@ -53,6 +54,7 @@ const HIVE_MIND_IMAGE_REPO = 'konard/hive-mind';
53
54
  const HIVE_MIND_DIND_IMAGE_REPO = 'konard/hive-mind-dind';
54
55
  const DEFAULT_HIVE_MIND_IMAGE_TAG = 'latest';
55
56
  const DOCKER_CONTAINER_HOME = '/home/box';
57
+ const FORMAL_AI_COMPOSE_HOSTNAME = 'link-assistant-formal-ai';
56
58
  // Default path where the host Docker socket is bind-mounted inside a DinD
57
59
  // container so box's host-image passthrough can copy host images into the
58
60
  // nested daemon. Matches box's own DIND_HOST_DOCKER_SOCK default. The deploy
@@ -227,6 +229,44 @@ function resolveImageVariant(image, env = process.env) {
227
229
  return image.includes('hive-mind-dind') ? 'dind' : env.HIVE_MIND_IMAGE_VARIANT || 'regular';
228
230
  }
229
231
 
232
+ /**
233
+ * Resolve an outer Compose HTTP service before handing its origin to a nested
234
+ * Docker daemon. The nested daemon has its own DNS namespace, but it can route
235
+ * to the outer service's address through the parent container.
236
+ *
237
+ * HTTPS names are deliberately preserved for certificate verification.
238
+ */
239
+ export async function resolveFormalAiIsolationEnv(env = process.env, { lookup = lookupHost } = {}) {
240
+ const baseUrl = env.HIVE_MIND_FORMAL_AI_BASE_URL;
241
+ if (!baseUrl) return env;
242
+
243
+ let parsed;
244
+ try {
245
+ parsed = new URL(baseUrl);
246
+ } catch {
247
+ return env;
248
+ }
249
+
250
+ if (parsed.protocol !== 'http:' || parsed.hostname !== FORMAL_AI_COMPOSE_HOSTNAME) {
251
+ return env;
252
+ }
253
+
254
+ try {
255
+ const addresses = await lookup(parsed.hostname, { all: true, verbatim: true });
256
+ const selected = addresses.find(candidate => candidate.family === 4) || addresses[0];
257
+ if (!selected?.address) return env;
258
+
259
+ const host = selected.family === 6 ? `[${selected.address}]` : selected.address;
260
+ return {
261
+ ...env,
262
+ HIVE_MIND_FORMAL_AI_BASE_URL: `${parsed.protocol}//${host}${parsed.port ? `:${parsed.port}` : ''}`,
263
+ };
264
+ } catch {
265
+ // Keep the hostname for deployments whose nested DNS can resolve it.
266
+ return env;
267
+ }
268
+ }
269
+
230
270
  /**
231
271
  * Build the `$` (start-command) arguments that launch a Docker-isolated task
232
272
  * using start-command's NATIVE Docker backend (`$ --isolated docker`).
@@ -264,6 +304,13 @@ export function buildDockerIsolationStartArgs(command, args = [], options = {})
264
304
  // working directory comes from the image's WORKDIR.
265
305
  startArgs.push('-e', `HOME=${DOCKER_CONTAINER_HOME}`, '-e', `HIVE_MIND_PARENT_SESSION_ID=${sessionId || ''}`, '-e', `HIVE_MIND_IMAGE_VARIANT=${resolveImageVariant(image, env)}`);
266
306
 
307
+ // A persistent Formal AI server normally runs beside the Telegram/root
308
+ // container. Docker-isolated `/solve` jobs must receive the same endpoint;
309
+ // otherwise the wrapper starts a per-job server and loses shared memory.
310
+ if (env.HIVE_MIND_FORMAL_AI_BASE_URL) {
311
+ startArgs.push('-e', `HIVE_MIND_FORMAL_AI_BASE_URL=${env.HIVE_MIND_FORMAL_AI_BASE_URL}`);
312
+ }
313
+
267
314
  for (const mount of getDockerIsolationAuthMounts({ tool, env, homeDir, existsSync })) {
268
315
  startArgs.push('--volume', `${mount.source}:${mount.target}`);
269
316
  }
@@ -620,14 +667,21 @@ export async function executeWithIsolation(command, args, options = {}) {
620
667
  console.log(`[VERBOSE] isolation-runner: Backend: ${backend}, Session ID: ${sessionId}`);
621
668
  }
622
669
 
623
- const startCommandArgs = buildStartCommandArgs(command, args, { ...options, sessionId });
670
+ const effectiveOptions =
671
+ backend === 'docker'
672
+ ? {
673
+ ...options,
674
+ env: await resolveFormalAiIsolationEnv(options.env || process.env),
675
+ }
676
+ : options;
677
+ const startCommandArgs = buildStartCommandArgs(command, args, { ...effectiveOptions, sessionId });
624
678
 
625
679
  if (verbose) {
626
680
  console.log(`[VERBOSE] isolation-runner: ${[binPath, ...startCommandArgs].map(shellQuote).join(' ')}`);
627
681
  if (backend === 'docker') {
628
- const env = options.env || process.env;
682
+ const env = effectiveOptions.env || process.env;
629
683
  const image = getDockerIsolationImage({ env });
630
- const mounts = getDockerIsolationAuthMounts({ tool: options.tool, env, homeDir: options.homeDir || os.homedir(), existsSync: options.existsSync || fs.existsSync });
684
+ const mounts = getDockerIsolationAuthMounts({ tool: effectiveOptions.tool, env, homeDir: effectiveOptions.homeDir || os.homedir(), existsSync: effectiveOptions.existsSync || fs.existsSync });
631
685
  console.log('[VERBOSE] isolation-runner: Docker isolation backend: native ($ --isolated docker)');
632
686
  console.log(`[VERBOSE] isolation-runner: Docker isolation image: ${image}`);
633
687
  console.log(`[VERBOSE] isolation-runner: Docker isolation privileged: ${shouldRunPrivilegedDockerIsolation(image, env)}`);
@@ -28,10 +28,26 @@ const execFileAsync = promisify(execFile);
28
28
 
29
29
  // ─── MODEL DATA ──────────────────────────────────────────────────────────────
30
30
 
31
+ export const FORMAL_AI_MODEL_ALIAS = 'formal-ai';
32
+ export const FORMAL_AI_PROVIDER_MODEL_ID = 'formalai/formal-ai';
33
+
34
+ const formalAiNativeModelAliases = {
35
+ [FORMAL_AI_MODEL_ALIAS]: FORMAL_AI_MODEL_ALIAS,
36
+ [FORMAL_AI_PROVIDER_MODEL_ID]: FORMAL_AI_MODEL_ALIAS,
37
+ };
38
+
39
+ const formalAiProviderModelAliases = {
40
+ [FORMAL_AI_MODEL_ALIAS]: FORMAL_AI_PROVIDER_MODEL_ID,
41
+ [FORMAL_AI_PROVIDER_MODEL_ID]: FORMAL_AI_PROVIDER_MODEL_ID,
42
+ };
43
+
44
+ export const isFormalAiModel = model => model === FORMAL_AI_MODEL_ALIAS || model === FORMAL_AI_PROVIDER_MODEL_ID;
45
+
31
46
  // Claude models (Anthropic API)
32
47
  // Updated for Opus 4.5/4.6/4.7/4.8/5, Sonnet 4.6/5, and Fable 5 / Mythos 5 support
33
48
  // (Issue #1221, Issue #1238, Issue #1329, Issue #1433, Issue #1620, Issue #1832, Issue #1875, Issue #2003, Issue #2096)
34
49
  export const claudeModels = {
50
+ ...formalAiNativeModelAliases,
35
51
  sonnet: 'claude-sonnet-5', // Sonnet 5 (Issue #2003)
36
52
  opus: 'claude-opus-5', // Opus 5 (default, Issue #2096)
37
53
  haiku: 'claude-haiku-4-5-20251001', // Haiku 4.5
@@ -72,6 +88,7 @@ export const claudeModels = {
72
88
  // Issue #1543: Added qwen3.6-plus-free (former default) and nemotron-3-super-free per agent PR #234
73
89
  // Issue #1563: qwen3.6-plus-free free promotion ended (April 2026), nemotron-3-super-free is now default per agent PR #243
74
90
  export const agentModels = {
91
+ ...formalAiProviderModelAliases,
75
92
  // OpenCode Zen free models (current)
76
93
  grok: 'opencode/grok-code',
77
94
  'grok-code': 'opencode/grok-code',
@@ -111,6 +128,7 @@ export const agentModels = {
111
128
 
112
129
  // OpenCode models (OpenCode API)
113
130
  export const opencodeModels = {
131
+ ...formalAiProviderModelAliases,
114
132
  gpt4: 'openai/gpt-4',
115
133
  gpt4o: 'openai/gpt-4o',
116
134
  claude: 'anthropic/claude-3-5-sonnet',
@@ -124,6 +142,7 @@ export const opencodeModels = {
124
142
 
125
143
  // Codex models (OpenAI API)
126
144
  export const codexModels = {
145
+ ...formalAiNativeModelAliases,
127
146
  gpt5: 'gpt-5',
128
147
  'gpt-5': 'gpt-5',
129
148
  'gpt-5.5': 'gpt-5.5',
@@ -196,6 +215,7 @@ export const CODEX_MODEL_VARIANTS = getCodexModelVariants();
196
215
 
197
216
  // Qwen Code models
198
217
  export const qwenModels = {
218
+ ...formalAiNativeModelAliases,
199
219
  qwen: 'qwen3-coder-plus',
200
220
  'qwen-coder': 'qwen3-coder-plus',
201
221
  qwen3: 'qwen3-coder-plus',
@@ -210,6 +230,7 @@ export const qwenModels = {
210
230
  // Keep aliases aligned with the Gemini CLI model aliases documented in
211
231
  // docs/cli/cli-reference.md: auto, pro, flash, and flash-lite.
212
232
  export const geminiModels = {
233
+ ...formalAiNativeModelAliases,
213
234
  auto: 'auto',
214
235
  gemini: 'gemini-2.5-flash',
215
236
  flash: 'gemini-2.5-flash',
@@ -492,6 +513,10 @@ export const mapModelForTool = (tool, model) => {
492
513
  * @returns {boolean} True if the model is compatible with the tool
493
514
  */
494
515
  export const isModelCompatibleWithTool = (tool, model) => {
516
+ if (isFormalAiModel(model)) {
517
+ return ['claude', 'agent', 'opencode', 'codex', 'qwen', 'gemini'].includes(tool);
518
+ }
519
+
495
520
  const mappedModel = mapModelForTool(tool, model);
496
521
 
497
522
  switch (tool) {
@@ -539,12 +564,12 @@ export const getValidModelsForTool = tool => {
539
564
  // Primary (non-alias, non-deprecated) short names shown in CLI help descriptions
540
565
  // These are the recommended model names users should see in --model help text
541
566
  export const primaryModelNames = {
542
- claude: ['opus', 'sonnet', 'haiku', 'opusplan', 'fable'],
543
- opencode: ['grok', 'gpt4o'],
544
- codex: ['gpt-5.6-sol', 'gpt-5.5', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex-spark'],
545
- agent: ['nemotron-3-super-free', 'minimax-m2.5-free', 'big-pickle', 'gpt-5-nano', 'glm-5-free', 'deepseek-r1-free'],
546
- qwen: ['qwen3-coder-plus', 'qwen3-coder', 'qwen3-coder-flash'],
547
- gemini: ['flash', 'pro', 'flash-lite', 'auto'],
567
+ claude: ['opus', 'sonnet', 'haiku', 'opusplan', 'fable', FORMAL_AI_MODEL_ALIAS],
568
+ opencode: ['grok', 'gpt4o', FORMAL_AI_MODEL_ALIAS],
569
+ codex: ['gpt-5.6-sol', 'gpt-5.5', 'gpt-5.6-terra', 'gpt-5.6-luna', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-codex-spark', FORMAL_AI_MODEL_ALIAS],
570
+ agent: ['nemotron-3-super-free', 'minimax-m2.5-free', 'big-pickle', 'gpt-5-nano', 'glm-5-free', 'deepseek-r1-free', FORMAL_AI_MODEL_ALIAS],
571
+ qwen: ['qwen3-coder-plus', 'qwen3-coder', 'qwen3-coder-flash', FORMAL_AI_MODEL_ALIAS],
572
+ gemini: ['flash', 'pro', 'flash-lite', 'auto', FORMAL_AI_MODEL_ALIAS],
548
573
  };
549
574
 
550
575
  /**
@@ -20,6 +20,7 @@ import { timeouts, retryLimits } from './config.lib.mjs';
20
20
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
21
21
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
22
22
  import { opencodeModels, defaultModels } from './models/index.mjs';
23
+ import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
23
24
  import { checkPlaywrightMcpPackageAvailability, getOpenCodePlaywrightMcpDisableEnv } from './playwright-mcp.lib.mjs';
24
25
  import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage as parseOpenCodeTokenUsage } from './agent-token-usage.lib.mjs';
25
26
  import { calculateAgentPricing } from './agent.lib.mjs';
@@ -212,34 +213,6 @@ export const executeOpenCodeCommand = async params => {
212
213
  }
213
214
  }
214
215
 
215
- // Create OpenCode configuration file with unrestricted permissions
216
- // This allows OpenCode to access files outside the working directory without prompting
217
- // Fixes issue #755: "Permission required to run: Access file outside working directory"
218
- // Reference: https://opencode.ai/docs/config - see permissions documentation
219
- const opencodeConfigPath = path.join(tempDir, 'opencode.json');
220
- const opencodeConfig = {
221
- $schema: 'https://opencode.ai/config.json',
222
- permission: {
223
- // All tool permissions set to 'allow' for unrestricted autonomous execution
224
- // See: https://github.com/sst/opencode - permission options: "allow", "ask", "deny"
225
- edit: 'allow', // File modification operations
226
- bash: 'allow', // Command execution
227
- webfetch: 'allow', // Web page retrieval
228
- skill: 'allow', // Custom skills execution
229
- doom_loop: 'allow', // Allow repeated identical tool calls (default: ask)
230
- external_directory: 'allow', // File operations outside working directory (default: ask)
231
- },
232
- };
233
- try {
234
- await fs.writeFile(opencodeConfigPath, JSON.stringify(opencodeConfig, null, 2));
235
- if (argv.verbose) {
236
- await log(` Created OpenCode config: ${opencodeConfigPath}`, { verbose: true });
237
- await log(' Permissions set: edit=allow, bash=allow, webfetch=allow, skill=allow, doom_loop=allow, external_directory=allow', { verbose: true });
238
- }
239
- } catch (configError) {
240
- await log(`⚠️ Warning: Could not create OpenCode config file: ${configError.message}`, { level: 'warning' });
241
- }
242
-
243
216
  // Take resource snapshot before execution
244
217
  const resourcesBefore = await getResourceSnapshot();
245
218
  await log('📈 System resources before execution:', { verbose: true });
@@ -259,6 +232,11 @@ export const executeOpenCodeCommand = async params => {
259
232
 
260
233
  // Map model alias to full ID
261
234
  const mappedModel = mapModelToId(argv.model);
235
+ const toolInvocation = resolveFormalAiToolInvocation({
236
+ tool: 'opencode',
237
+ model: argv.model,
238
+ toolPath: opencodePath,
239
+ });
262
240
  const streamingTokenUsage = createAgentTokenUsage();
263
241
 
264
242
  // Build opencode command arguments
@@ -280,11 +258,38 @@ export const executeOpenCodeCommand = async params => {
280
258
  await fs.writeFile(promptFile, combinedPrompt);
281
259
 
282
260
  // Build the full command - pipe the prompt file to opencode
283
- const fullCommand = `(cd "${tempDir}" && cat "${promptFile}" | ${opencodePath} ${opencodeArgs})`;
261
+ const fullCommand = `(cd "${tempDir}" && cat "${promptFile}" | ${toolInvocation.displayCommand} ${opencodeArgs})`;
284
262
 
285
- await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
286
- await log(`${fullCommand}`);
287
- await log('');
263
+ const preparedResult = await logPreparedToolCommand({ argv, fullCommand, log, formatAligned });
264
+ if (preparedResult) return preparedResult;
265
+
266
+ // Create OpenCode configuration file with unrestricted permissions
267
+ // This allows OpenCode to access files outside the working directory without prompting
268
+ // Fixes issue #755: "Permission required to run: Access file outside working directory"
269
+ // Reference: https://opencode.ai/docs/config - see permissions documentation
270
+ const opencodeConfigPath = path.join(tempDir, 'opencode.json');
271
+ const opencodeConfig = {
272
+ $schema: 'https://opencode.ai/config.json',
273
+ permission: {
274
+ // All tool permissions set to 'allow' for unrestricted autonomous execution
275
+ // See: https://github.com/sst/opencode - permission options: "allow", "ask", "deny"
276
+ edit: 'allow', // File modification operations
277
+ bash: 'allow', // Command execution
278
+ webfetch: 'allow', // Web page retrieval
279
+ skill: 'allow', // Custom skills execution
280
+ doom_loop: 'allow', // Allow repeated identical tool calls (default: ask)
281
+ external_directory: 'allow', // File operations outside working directory (default: ask)
282
+ },
283
+ };
284
+ try {
285
+ await fs.writeFile(opencodeConfigPath, JSON.stringify(opencodeConfig, null, 2));
286
+ if (argv.verbose) {
287
+ await log(` Created OpenCode config: ${opencodeConfigPath}`, { verbose: true });
288
+ await log(' Permissions set: edit=allow, bash=allow, webfetch=allow, skill=allow, doom_loop=allow, external_directory=allow', { verbose: true });
289
+ }
290
+ } catch (configError) {
291
+ await log(`⚠️ Warning: Could not create OpenCode config file: ${configError.message}`, { level: 'warning' });
292
+ }
288
293
 
289
294
  const buildPricingInfo = async () => {
290
295
  const tokenUsage = streamingTokenUsage;
@@ -298,17 +303,19 @@ export const executeOpenCodeCommand = async params => {
298
303
  try {
299
304
  // Pipe the prompt file to opencode via stdin
300
305
  if (argv.resume) {
301
- execCommand = $({
306
+ const commandRunner = $({
302
307
  cwd: tempDir,
303
308
  mirror: false,
304
309
  env: opencodeEnv,
305
- })`cat ${promptFile} | ${opencodePath} run --format json --session ${argv.resume} --model ${mappedModel}`;
310
+ });
311
+ execCommand = toolInvocation.formalAi ? commandRunner`cat ${promptFile} | ${toolInvocation.command} ${toolInvocation.args} run --format json --session ${argv.resume} --model ${mappedModel}` : commandRunner`cat ${promptFile} | ${toolInvocation.command} run --format json --session ${argv.resume} --model ${mappedModel}`;
306
312
  } else {
307
- execCommand = $({
313
+ const commandRunner = $({
308
314
  cwd: tempDir,
309
315
  mirror: false,
310
316
  env: opencodeEnv,
311
- })`cat ${promptFile} | ${opencodePath} run --format json --model ${mappedModel}`;
317
+ });
318
+ execCommand = toolInvocation.formalAi ? commandRunner`cat ${promptFile} | ${toolInvocation.command} ${toolInvocation.args} run --format json --model ${mappedModel}` : commandRunner`cat ${promptFile} | ${toolInvocation.command} run --format json --model ${mappedModel}`;
312
319
  }
313
320
 
314
321
  await log(`${formatAligned('📋', 'Command details:', '')}`);
@@ -247,6 +247,7 @@ const KNOWN_OPTION_NAMES = [
247
247
  'keep-going-until-all-requirements-are-fully-done',
248
248
  'keep-working',
249
249
  'keep-going',
250
+ 'require-codex-plugin',
250
251
  ];
251
252
 
252
253
  /**
package/src/qwen.lib.mjs CHANGED
@@ -19,6 +19,7 @@ import { timeouts, retryLimits } from './config.lib.mjs';
19
19
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
20
20
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
21
21
  import { qwenModels, defaultModels } from './models/index.mjs';
22
+ import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
22
23
  import { checkPlaywrightMcpPackageAvailability } from './playwright-mcp.lib.mjs';
23
24
  import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
24
25
  import { getCumulativeContextInputTokens, getRestoredContextInputTokens, toTokenCount } from './context-fill.lib.mjs';
@@ -504,15 +505,19 @@ export const executeQwenCommand = async params => {
504
505
  await log(` Load: ${resourcesBefore.load}`, { verbose: true });
505
506
 
506
507
  const mappedModel = mapModelToId(argv.model || defaultModels.qwen);
508
+ const toolInvocation = resolveFormalAiToolInvocation({
509
+ tool: 'qwen',
510
+ model: argv.model || defaultModels.qwen,
511
+ toolPath: qwenPath,
512
+ });
507
513
  const resumeSession = argv.resume || null;
508
514
  const resumeArgs = resumeSession ? ` --resume ${shellQuote(resumeSession)}` : '';
509
515
  const appendSystemPromptArg = systemPrompt ? ` --append-system-prompt "$(cat ${shellQuote(systemPromptFile)})"` : '';
510
- const commandScript = `cd ${shellQuote(tempDir)} && ${shellQuote(qwenPath)} --model ${shellQuote(mappedModel)} --output-format stream-json --yolo${resumeArgs}${appendSystemPromptArg} --prompt "$(cat ${shellQuote(promptFile)})"`;
511
- const fullCommand = `(cd "${tempDir}" && ${qwenPath} --model "${mappedModel}" --output-format stream-json --yolo${resumeSession ? ` --resume "${resumeSession}"` : ''}${systemPrompt ? ` --append-system-prompt "$(cat "${systemPromptFile}")"` : ''} --prompt "$(cat "${promptFile}")")`;
516
+ const commandScript = `cd ${shellQuote(tempDir)} && ${toolInvocation.displayCommand} --model ${shellQuote(mappedModel)} --output-format stream-json --yolo${resumeArgs}${appendSystemPromptArg} --prompt "$(cat ${shellQuote(promptFile)})"`;
517
+ const fullCommand = `(cd "${tempDir}" && ${toolInvocation.displayCommand} --model "${mappedModel}" --output-format stream-json --yolo${resumeSession ? ` --resume "${resumeSession}"` : ''}${systemPrompt ? ` --append-system-prompt "$(cat "${systemPromptFile}")"` : ''} --prompt "$(cat "${promptFile}")")`;
512
518
 
513
- await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
514
- await log(fullCommand);
515
- await log('');
519
+ const preparedResult = await logPreparedToolCommand({ argv, fullCommand, log, formatAligned });
520
+ if (preparedResult) return preparedResult;
516
521
 
517
522
  try {
518
523
  const execCommand = dollar({
@@ -715,6 +715,15 @@ export const SOLVE_OPTION_DEFINITIONS = {
715
715
  type: 'string',
716
716
  description: 'Comma-separated list of MCP server names that gemini-cli is allowed to call (passes --allowed-mcp-server-names to gemini-cli). Only used when --tool gemini.',
717
717
  },
718
+ // Issue #2102: the Codex capability preflight discovers requirements from the
719
+ // issue text and the target repository's agent instruction files (AGENTS.md,
720
+ // CLAUDE.md, .codex/). This is the escape hatch for a requirement no document
721
+ // states — codex's own `request_plugin_install` can never install anything
722
+ // under `codex exec`, so declaring it here is the only way in.
723
+ 'require-codex-plugin': {
724
+ type: 'string',
725
+ description: 'Comma-separated list of Codex plugins (plugin@marketplace) that must be installed into the scoped CODEX_HOME before codex exec starts, in addition to the ones discovered from the issue text and the repository AGENTS.md/CLAUDE.md files. Fails the run when a listed plugin is unavailable. Equivalent to HIVE_MIND_CODEX_REQUIRED_PLUGINS. Only used when --tool codex.',
726
+ },
718
727
  };
719
728
 
720
729
  function hasRawOption(rawArgs, optionName) {
package/src/solve.mjs CHANGED
@@ -253,7 +253,8 @@ if (argv.planModel) {
253
253
  if (argv.subAgentModel) await validateAndExitOnInvalidClaudeSubAgentModel(argv.subAgentModel, tool, safeExit);
254
254
 
255
255
  // Perform all system checks (skip tool connection check in dry-run or when --skip-tool-connection-check; model validation always runs)
256
- const skipToolConnectionCheck = argv.dryRun || argv.skipToolConnectionCheck || argv.toolConnectionCheck === false;
256
+ const prepareOnly = argv.dryRun || argv.onlyPrepareCommand;
257
+ const skipToolConnectionCheck = prepareOnly || argv.skipToolConnectionCheck || argv.toolConnectionCheck === false;
257
258
  const { cascadePlaywrightMcpDisable, ensureSolvePlaywrightMcpReady } = await import('./playwright-mcp.lib.mjs');
258
259
  await cascadePlaywrightMcpDisable(argv, log);
259
260
  if (!(await performSystemChecks(argv.minDiskSpace || 10240, skipToolConnectionCheck, argv.model, argv))) {
@@ -864,6 +865,7 @@ try {
864
865
  await safeExit(shutdownExitCode, 'Graceful shutdown after AI working session', { skipPreExit: true });
865
866
  }
866
867
 
868
+ if (toolResult.preparedOnly) await safeExit(0, 'Command prepared', { skipPreExit: true });
867
869
  const { success } = toolResult;
868
870
  sessionId = toolResult.sessionId;
869
871
  let anthropicTotalCostUSD = toolResult.anthropicTotalCostUSD;
@@ -303,7 +303,22 @@ export const performSystemChecks = async (minDiskSpace = 10240, skipToolConnecti
303
303
  // Skip tool connection validation if in dry-run mode or explicitly requested
304
304
  if (!skipToolConnection) {
305
305
  let isToolConnected;
306
- if (argv.useAgentCommander) {
306
+ const { isFormalAiModel } = await import('./models/index.mjs');
307
+ if (isFormalAiModel(model)) {
308
+ const { validateFormalAiToolConnection } = await import('./formal-ai.lib.mjs');
309
+ const formalAiValidation = await validateFormalAiToolConnection(argv.tool || 'claude');
310
+ isToolConnected = formalAiValidation.valid;
311
+ if (isToolConnected) {
312
+ await log(`✅ Formal AI wrapper and ${argv.tool || 'claude'} CLI are available`);
313
+ if (formalAiValidation.version) {
314
+ await log(`📦 ${argv.tool || 'claude'} CLI version: ${formalAiValidation.version}`);
315
+ }
316
+ } else {
317
+ await log(`❌ Formal AI dispatch validation failed: ${formalAiValidation.error}`, { level: 'error' });
318
+ await log(' Install or update the wrapper with: cargo install formal-ai', { level: 'error' });
319
+ return false;
320
+ }
321
+ } else if (argv.useAgentCommander) {
307
322
  const agentCommanderLib = await import('./agent-commander.lib.mjs');
308
323
  isToolConnected = await agentCommanderLib.validateAgentCommanderConnection({
309
324
  tool: argv.tool || 'claude',