@link-assistant/hive-mind 2.9.2 → 2.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.10.1
4
+
5
+ ### Patch Changes
6
+
7
+ - a9b0967: Fix killed Telegram task resume instructions by recovering the last tool session from the complete task log, rejecting unrelated shared-directory session logs, and preserving the original slash-command alias through queued and restarted sessions.
8
+
9
+ ## 2.10.0
10
+
11
+ ### Minor Changes
12
+
13
+ - a1a1f63: Allow `--model formal-ai` with every supported agentic CLI, route execution through `formal-ai with`, and provide a persistent Formal AI Docker service.
14
+
3
15
  ## 2.9.2
4
16
 
5
17
  ### Patch Changes
package/README.hi.md CHANGED
@@ -502,6 +502,11 @@ Free Models via Kilo Gateway (with --tool agent):
502
502
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-5-free
503
503
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-4.5-air-free
504
504
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/deepseek-r1-free
505
+
506
+ # किसी भी समर्थित agentic CLI को Formal AI के माध्यम से route करें
507
+ /solve https://github.com/owner/repo/issues/123 --tool agent --model formal-ai
508
+ /solve https://github.com/owner/repo/issues/123 --tool codex --model formal-ai
509
+ /solve https://github.com/owner/repo/issues/123 --tool claude --model formal-ai
505
510
  ```
506
511
 
507
512
  > **📖 मुफ्त मॉडल गाइड**: OpenCode Zen और Kilo Gateway प्रदाताओं सहित सभी मुफ्त मॉडलों के बारे में व्यापक जानकारी के लिए [docs/FREE_MODELS.hi.md](./docs/FREE_MODELS.hi.md) देखें।
package/README.md CHANGED
@@ -506,6 +506,11 @@ Free Models via Kilo Gateway (with --tool agent):
506
506
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-5-free
507
507
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-4.5-air-free
508
508
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/deepseek-r1-free
509
+
510
+ # Route any supported agentic CLI through Formal AI
511
+ /solve https://github.com/owner/repo/issues/123 --tool agent --model formal-ai
512
+ /solve https://github.com/owner/repo/issues/123 --tool codex --model formal-ai
513
+ /solve https://github.com/owner/repo/issues/123 --tool claude --model formal-ai
509
514
  ```
510
515
 
511
516
  Current tool defaults in Hive Mind:
package/README.ru.md CHANGED
@@ -503,6 +503,11 @@ Free Models via Kilo Gateway (with --tool agent):
503
503
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-5-free
504
504
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-4.5-air-free
505
505
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/deepseek-r1-free
506
+
507
+ # Маршрутизация любого поддерживаемого агентного CLI через Formal AI
508
+ /solve https://github.com/owner/repo/issues/123 --tool agent --model formal-ai
509
+ /solve https://github.com/owner/repo/issues/123 --tool codex --model formal-ai
510
+ /solve https://github.com/owner/repo/issues/123 --tool claude --model formal-ai
506
511
  ```
507
512
 
508
513
  > **📖 Руководство по бесплатным моделям**: см. [docs/FREE_MODELS.ru.md](./docs/FREE_MODELS.ru.md) для получения полной информации обо всех бесплатных моделях, включая провайдеры OpenCode Zen и Kilo Gateway.
package/README.zh.md CHANGED
@@ -499,6 +499,11 @@ Free Models via Kilo Gateway (with --tool agent):
499
499
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-5-free
500
500
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/glm-4.5-air-free
501
501
  /solve https://github.com/owner/repo/issues/123 --tool agent --model kilo/deepseek-r1-free
502
+
503
+ # 通过 Formal AI 路由任意受支持的 Agent CLI
504
+ /solve https://github.com/owner/repo/issues/123 --tool agent --model formal-ai
505
+ /solve https://github.com/owner/repo/issues/123 --tool codex --model formal-ai
506
+ /solve https://github.com/owner/repo/issues/123 --tool claude --model formal-ai
502
507
  ```
503
508
 
504
509
  > **📖 免费模型指南**:有关所有免费模型(包括 OpenCode Zen 和 Kilo Gateway 提供商)的全面信息,请参见 [docs/FREE_MODELS.zh.md](./docs/FREE_MODELS.zh.md)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.9.2",
3
+ "version": "2.10.1",
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,6 +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 } from './models/index.mjs';
25
+ import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
25
26
  import { checkPlaywrightMcpPackageAvailability, getAgentPlaywrightMcpDisableEnv } from './playwright-mcp.lib.mjs';
26
27
  import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage } from './agent-token-usage.lib.mjs';
27
28
  import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
@@ -502,6 +503,11 @@ export const executeAgentCommand = async params => {
502
503
 
503
504
  // Map model alias to full ID
504
505
  const mappedModel = mapModelToId(argv.model);
506
+ const toolInvocation = resolveFormalAiToolInvocation({
507
+ tool: 'agent',
508
+ model: argv.model,
509
+ toolPath: agentPath,
510
+ });
505
511
 
506
512
  // Build agent command arguments
507
513
  let agentArgs = `--model ${mappedModel}`;
@@ -546,30 +552,31 @@ export const executeAgentCommand = async params => {
546
552
  await fs.writeFile(promptFile, combinedPrompt);
547
553
  }
548
554
 
549
- const fullCommand = streamingInput ? `(cd "${tempDir}" && ${agentPath} ${agentArgs})` : `(cd "${tempDir}" && cat "${promptFile}" | ${agentPath} ${agentArgs})`;
555
+ const fullCommand = streamingInput ? `(cd "${tempDir}" && ${toolInvocation.displayCommand} ${agentArgs})` : `(cd "${tempDir}" && cat "${promptFile}" | ${toolInvocation.displayCommand} ${agentArgs})`;
550
556
 
551
- await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
552
- await log(`${fullCommand}`);
553
- await log('');
557
+ const preparedResult = await logPreparedToolCommand({ argv, fullCommand, log, formatAligned });
558
+ if (preparedResult) return preparedResult;
554
559
 
555
560
  if (streamingInput) {
556
- execCommand = $({
561
+ const commandRunner = $({
557
562
  cwd: tempDir,
558
563
  stdin: 'pipe',
559
564
  mirror: false,
560
565
  env: agentEnv,
561
- })`${agentPath} ${agentArgs}`;
566
+ });
567
+ execCommand = toolInvocation.formalAi ? commandRunner`${toolInvocation.command} ${toolInvocation.args} ${agentArgs}` : commandRunner`${toolInvocation.command} ${agentArgs}`;
562
568
  const attached = await attachStreamingInput(bidirectionalHandler, execCommand, combinedPrompt, log, !!argv.verbose, { toolLabel: 'Agent' });
563
569
  if (!attached) {
564
570
  throw new Error('Agent live stream-json input requested, but stdin attachment failed');
565
571
  }
566
572
  } else {
567
573
  // Pipe the prompt file to agent via stdin for the legacy one-shot path.
568
- execCommand = $({
574
+ const commandRunner = $({
569
575
  cwd: tempDir,
570
576
  mirror: false,
571
577
  env: agentEnv,
572
- })`cat ${promptFile} | ${agentPath} ${agentArgs}`;
578
+ });
579
+ execCommand = toolInvocation.formalAi ? commandRunner`cat ${promptFile} | ${toolInvocation.command} ${toolInvocation.args} ${agentArgs}` : commandRunner`cat ${promptFile} | ${toolInvocation.command} ${agentArgs}`;
573
580
  }
574
581
 
575
582
  await log(`${formatAligned('📋', 'Command details:', '')}`);
@@ -22,6 +22,7 @@ import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Iss
22
22
  import { SESSION_FORCE_KILLED_MARKER, postTrackedComment } from './tool-comments.lib.mjs'; // Issue #1625
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
+ import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
25
26
  import { buildMcpConfigWithoutPlaywright, ensureClaudePlaywrightMcpServer } from './playwright-mcp.lib.mjs';
26
27
  import { resolveClaudeSessionToolFlags } from './useless-tools.lib.mjs';
27
28
  import { ensureClaudeQuietConfig } from './claude-quiet-config.lib.mjs';
@@ -614,13 +615,13 @@ export const executeClaudeCommand = async params => {
614
615
  const progressMonitor = await initProgressMonitoring(argv, { owner, repo, prNumber, $, log }); // works with or without --interactive-mode
615
616
  let execCommand;
616
617
  const mappedModel = mapModelToId(argv.model);
618
+ const toolInvocation = resolveFormalAiToolInvocation({ tool: 'claude', model: argv.model, toolPath: claudePath });
617
619
  const resolvedPlanModel = argv.planModel ? mapModelToId(argv.planModel) : undefined; // Issue #1223
618
620
  const resolvedSubAgentModel = argv.subAgentModel ? mapClaudeSubAgentModelToEnvValue(argv.subAgentModel) : undefined; // Issue #1978
619
621
  const effectiveModel = resolvedPlanModel ? 'opusplan' : mappedModel;
620
622
  const resolvedExecutionModel = resolvedPlanModel ? mappedModel : undefined;
621
- // Issue #1949: Let Claude Code handle transient overload (529) fallback via its own
622
- // `--fallback-model` flag (it re-tries the primary each turn) instead of us swapping
623
- // `--model`. Only for plain `--model` runs (not opusplan) with a distinct fallback.
623
+ // Issue #1949: Let Claude Code's `--fallback-model` handle transient overloads and retry
624
+ // the primary each turn. Only for plain `--model` runs with a distinct fallback.
624
625
  const mappedFallbackModel = argv.fallbackModel ? mapModelToId(argv.fallbackModel) : undefined;
625
626
  const useClaudeFallbackModel = !resolvedPlanModel && mappedFallbackModel && mappedFallbackModel !== effectiveModel;
626
627
  let claudeArgs = `--output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel}`;
@@ -650,10 +651,9 @@ export const executeClaudeCommand = async params => {
650
651
  } else {
651
652
  claudeArgs += ` -p "${escapedPromptForAttempt}" --append-system-prompt "${escapedSystemPrompt}"`;
652
653
  }
653
- const fullCommand = `(cd "${tempDir}" && ${claudePath} ${claudeArgs} | jq -c .)`;
654
- await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
655
- await log(`${fullCommand}`);
656
- await log('');
654
+ const fullCommand = `(cd "${tempDir}" && ${toolInvocation.displayCommand} ${claudeArgs} | jq -c .)`;
655
+ const preparedResult = await logPreparedToolCommand({ argv, fullCommand, log, formatAligned });
656
+ if (preparedResult) return preparedResult;
657
657
  if (argv.verbose) {
658
658
  await log(`📋 User prompt:\n---BEGIN USER PROMPT---\n${promptForAttempt}\n---END USER PROMPT---`, { verbose: true });
659
659
  await log(`📋 System prompt:\n---BEGIN SYSTEM PROMPT---\n${systemPrompt}\n---END SYSTEM PROMPT---`, { verbose: true });
@@ -684,15 +684,15 @@ export const executeClaudeCommand = async params => {
684
684
  if (useClaudeFallbackModel && argv.verbose) await log(`📊 Claude --fallback-model: ${mappedFallbackModel} (Issue #1949 — primary --model ${effectiveModel} stays stable across overload retries)`, { verbose: true });
685
685
  if (argv.resume) {
686
686
  const simpleEscapedPrompt = promptForAttempt.replace(/"/g, '\\"');
687
- execCommand = $({ cwd: tempDir, mirror: false, env: claudeEnv })`${claudePath} --resume ${argv.resume} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} -p "${simpleEscapedPrompt}" --append-system-prompt "${simpleEscapedSystem}"`;
687
+ 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}"`;
688
688
  } else if (streamingInput) {
689
689
  // Issue #817: Drive Claude via --input-format stream-json on a pipe
690
690
  // stdin. Initial prompt + later PR comments are written as NDJSON
691
691
  // frames by attachStreamingInput (see bidirectional-interactive.lib.mjs).
692
692
  const streamingInputArgs = ['-p', '--input-format', 'stream-json'];
693
- execCommand = $({ cwd: tempDir, stdin: 'pipe', mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} ${streamingInputArgs} --append-system-prompt "${simpleEscapedSystem}"`;
693
+ 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}"`;
694
694
  } else {
695
- execCommand = $({ cwd: tempDir, stdin: promptForAttempt, mirror: false, env: claudeEnv })`${claudePath} --output-format stream-json --verbose --dangerously-skip-permissions --model ${effectiveModel} ${fallbackModelArgs} ${mcpDisableArgs} ${disallowedToolsArgs} --append-system-prompt "${simpleEscapedSystem}"`;
695
+ 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}"`;
696
696
  }
697
697
  if (streamingInput) {
698
698
  await attachStreamingInput(bidirectionalHandler, execCommand, promptForAttempt, log, !!argv.verbose);
package/src/codex.lib.mjs CHANGED
@@ -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';
@@ -816,6 +817,11 @@ export const executeCodexCommand = async params => {
816
817
 
817
818
  let execCommand;
818
819
  const mappedModel = mapModelToId(argv.model);
820
+ const toolInvocation = resolveFormalAiToolInvocation({
821
+ tool: 'codex',
822
+ model: argv.model,
823
+ toolPath: codexPath,
824
+ });
819
825
  const { reasoningEffort, source: reasoningEffortSource, rolloutTokenBudget } = resolveCodexReasoningEffort(argv);
820
826
  const isResumeMode = !!argv.resume;
821
827
  const codexEnv = applyCodexCapabilityEnv(capabilityPreflight?.codexBaseEnv || getCodexExecEnv(argv.verbose), {
@@ -889,11 +895,10 @@ export const executeCodexCommand = async params => {
889
895
  if (subSessionSizeArgs.length) await log(`📊 Codex --sub-session-size: ${subSessionSizeArgs.join(' ')}`, { verbose: true });
890
896
  }
891
897
 
892
- const fullCommand = `(cd ${shellQuote(tempDir)} && cat ${shellQuote(promptFile)} | ${codexPath} ${codexArgs})`;
898
+ const fullCommand = `(cd ${shellQuote(tempDir)} && cat ${shellQuote(promptFile)} | ${toolInvocation.displayCommand} ${codexArgs})`;
893
899
 
894
- await log(`\n${formatAligned('📝', 'Raw command:', '')}`);
895
- await log(`${fullCommand}`);
896
- await log('');
900
+ const preparedResult = await logPreparedToolCommand({ argv, fullCommand, log, formatAligned });
901
+ if (preparedResult) return preparedResult;
897
902
 
898
903
  try {
899
904
  let interactiveHandler = null;
@@ -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));
@@ -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:', '')}`);
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({
@@ -25,8 +25,7 @@ import { promisify } from 'util';
25
25
  import { formatSessionCompletionMessage, getSessionCompletionExitCode, classifySessionOutcome } from './work-session-formatting.lib.mjs';
26
26
  import { notifySubscribers, getSubscriberCount } from './telegram-subscribers.lib.mjs';
27
27
  import { classifyExitStatus, normalizeExitCode } from './session-status.lib.mjs';
28
- import path from 'node:path';
29
- import { readLastSessionIdFromLog, findLatestSessionLogId, buildResumeCommand, formatResumeSection } from './session-resume.lib.mjs';
28
+ import { readLastSessionIdFromLog, buildResumeCommand, formatResumeSection } from './session-resume.lib.mjs';
30
29
 
31
30
  export { formatSessionCompletionMessage, getSessionCompletionExitCode } from './work-session-formatting.lib.mjs';
32
31
 
@@ -913,14 +912,11 @@ export async function monitorSessions(bot, verbose = false, options = {}) {
913
912
  const logPath = statusResult?.logPath || sessionInfo?.logPath || null;
914
913
  // The id must be the AI TOOL's session id, not the isolation session
915
914
  // id (sessionInfo.sessionId — wrong namespace for `solve --resume`).
916
- // Prefer the last `Session ID:` marker in the captured log; fall
917
- // back to the newest `<sessionId>.log` start-command wrote in the
918
- // same directory. If neither exists, offer no command (a bogus
919
- // resume id would be worse than none).
920
- let lastSessionId = readLastSessionIdFromLog(logPath, { verbose });
921
- if (!lastSessionId && logPath) {
922
- lastSessionId = findLatestSessionLogId({ dir: path.dirname(logPath), verbose });
923
- }
915
+ // Scan backwards through this task's captured log for its last
916
+ // `Session ID:` marker. Do not guess from neighboring UUID-named
917
+ // logs: start-command stores unrelated tasks in the same backend
918
+ // directory, which caused issue #2109's invalid resume id.
919
+ const lastSessionId = readLastSessionIdFromLog(logPath, { verbose });
924
920
  const resumeCommand = buildResumeCommand({ sessionInfo, lastSessionId });
925
921
  const resumeSection = formatResumeSection({ lastSessionId, command: resumeCommand });
926
922
  if (resumeSection) {
@@ -11,11 +11,10 @@
11
11
  * 1. **Use the LAST session id.** A single `/solve` run can spin up *many*
12
12
  * tool sessions — auto-continue across usage-limit resets, uncommitted-
13
13
  * changes restarts (`solve.watch`), and manual `--resume` chains. Every one
14
- * prints a `Session ID:` marker to the captured log in chronological order,
15
- * and start-command also renames the per-session log to `<sessionId>.log`.
14
+ * prints a `Session ID:` marker to the captured log in chronological order.
16
15
  * The most advanced context lives in the *last* of these, so resuming must
17
- * pick the last id — never the first. {@link selectLastSessionId} /
18
- * {@link findLatestSessionLogId} enforce that rule.
16
+ * pick the last id — never the first. {@link selectLastSessionId} and
17
+ * {@link readLastSessionIdFromLog} enforce that rule.
19
18
  *
20
19
  * 2. **Never storm.** Auto-resuming a killed session must be bounded so a job
21
20
  * that reliably OOMs cannot spawn an infinite relaunch loop (which would be
@@ -41,6 +40,8 @@ const SESSION_ID_MARKER_RE = /Session ID:\s*`?([^\s`]+)`?/gi;
41
40
  // `<sessionId>.log` files start-command writes. Used to validate directory
42
41
  // scans so unrelated `*.log` files are never mistaken for a session.
43
42
  const SESSION_LOG_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
43
+ const DEFAULT_LOG_SCAN_CHUNK_BYTES = 262144;
44
+ const LOG_SCAN_OVERLAP_BYTES = 1024;
44
45
 
45
46
  /**
46
47
  * Extract every tool session id printed to a log, in the order they appear.
@@ -79,34 +80,52 @@ export function selectLastSessionId(text) {
79
80
  }
80
81
 
81
82
  /**
82
- * Read the LAST tool session id from a `/solve` execution log. Only the tail of
83
- * the file is scanned (the most recent session marker lives near the end), so
84
- * this stays cheap on multi-megabyte logs. Never throws a missing/unreadable
85
- * log yields `null`.
83
+ * Read the LAST tool session id from a `/solve` execution log. The file is
84
+ * scanned backwards in bounded chunks: this normally stops after the tail
85
+ * chunk, while still finding a valid marker that precedes a long tool trace.
86
+ * Adjacent chunks overlap so a marker split at a chunk boundary is not lost.
87
+ * Never throws — a missing/unreadable log yields `null`.
86
88
  *
87
89
  * @param {string} logPath
88
90
  * @param {Object} [options]
89
91
  * @param {Object} [options.fsImpl=fs] - Injectable fs (for tests)
90
- * @param {number} [options.tailBytes=262144] - Trailing bytes to scan (256 KiB)
92
+ * @param {number} [options.tailBytes=262144] - Bytes per backwards scan chunk
91
93
  * @param {boolean} [options.verbose]
92
94
  * @returns {string|null}
93
95
  */
94
96
  export function readLastSessionIdFromLog(logPath, options = {}) {
95
- const { fsImpl = fs, tailBytes = 262144, verbose = false } = options;
97
+ const { fsImpl = fs, tailBytes = DEFAULT_LOG_SCAN_CHUNK_BYTES, verbose = false } = options;
96
98
  if (!logPath) return null;
97
99
  try {
98
100
  const stat = fsImpl.statSync(logPath);
99
- const start = Math.max(0, stat.size - tailBytes);
101
+ const chunkBytes = Number.isFinite(tailBytes) && tailBytes > 0 ? Math.floor(tailBytes) : DEFAULT_LOG_SCAN_CHUNK_BYTES;
100
102
  const fd = fsImpl.openSync(logPath, 'r');
101
103
  try {
102
- const length = stat.size - start;
103
- const buffer = Buffer.alloc(length);
104
- fsImpl.readSync(fd, buffer, 0, length, start);
105
- const id = selectLastSessionId(buffer.toString('utf8'));
106
- if (verbose && id) {
107
- console.log(`[VERBOSE] session-resume: last tool session id in ${logPath} is ${id}`);
104
+ let end = stat.size;
105
+ let laterPrefix = Buffer.alloc(0);
106
+ let chunksScanned = 0;
107
+ while (end > 0) {
108
+ const start = Math.max(0, end - chunkBytes);
109
+ const length = end - start;
110
+ const buffer = Buffer.alloc(length);
111
+ const bytesRead = fsImpl.readSync(fd, buffer, 0, length, start);
112
+ const current = bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
113
+ const scanBuffer = laterPrefix.length > 0 ? Buffer.concat([current, laterPrefix]) : current;
114
+ const id = selectLastSessionId(scanBuffer.toString('utf8'));
115
+ chunksScanned += 1;
116
+ if (id) {
117
+ if (verbose) {
118
+ console.log(`[VERBOSE] session-resume: last tool session id in ${logPath} is ${id} (scanned ${chunksScanned} chunk${chunksScanned === 1 ? '' : 's'})`);
119
+ }
120
+ return id;
121
+ }
122
+ laterPrefix = current.subarray(0, Math.min(current.length, LOG_SCAN_OVERLAP_BYTES));
123
+ end = start;
108
124
  }
109
- return id;
125
+ if (verbose) {
126
+ console.log(`[VERBOSE] session-resume: no tool session id found in ${logPath} after scanning ${chunksScanned} chunk${chunksScanned === 1 ? '' : 's'}`);
127
+ }
128
+ return null;
110
129
  } finally {
111
130
  fsImpl.closeSync(fd);
112
131
  }
@@ -121,10 +140,10 @@ export function readLastSessionIdFromLog(logPath, options = {}) {
121
140
  /**
122
141
  * Find the id of the most-recently-modified `<sessionId>.log` in a directory.
123
142
  *
124
- * start-command renames each tool session's log to `<sessionId>.log`, so the
125
- * newest such file is the last session of the run — a second, filesystem-based
126
- * source for the "use the last session" rule that works even when the captured
127
- * stdout log has been rotated away. Never throws.
143
+ * This helper is safe only when `dir` is already known to contain logs for one
144
+ * task. A shared start-command directory cannot attribute its newest UUID log
145
+ * to a particular `/solve` run, so completion notifications must not use this
146
+ * as a fallback. Never throws.
128
147
  *
129
148
  * @param {Object} options
130
149
  * @param {string} options.dir - Directory holding `<sessionId>.log` files
@@ -215,7 +234,8 @@ export function buildResumeCommand({ sessionInfo = {}, lastSessionId = null, bin
215
234
  const url = sessionInfo.url || (Array.isArray(sessionInfo.args) ? sessionInfo.args[0] : null);
216
235
  if (!url) return null;
217
236
 
218
- const bin = binary || command;
237
+ const commandAlias = typeof sessionInfo.commandAlias === 'string' && /^[a-z0-9_-]+$/i.test(sessionInfo.commandAlias) ? sessionInfo.commandAlias : null;
238
+ const bin = binary || (commandAlias ? `/${commandAlias}` : command);
219
239
  let args;
220
240
  if (Array.isArray(sessionInfo.args) && sessionInfo.args.length > 0) {
221
241
  args = stripResumeFlag(sessionInfo.args);
@@ -33,7 +33,9 @@ import path from 'node:path';
33
33
  // excluded so the snapshot stays small and safe to reload.
34
34
  // `args` (#1927 review follow-up) is persisted so a killed /solve can be resumed
35
35
  // with its exact original invocation plus `--resume <lastSessionId>`.
36
- const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'isolationBackend', 'sessionId', 'containerFilesystemStartBytes', 'containerFilesystemLastBytes', 'containerFilesystemLastObservedAt', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
36
+ // `commandAlias` (#2109) preserves the Telegram spelling (`solve`, `codex`,
37
+ // `claude`, etc.) so a bot notification never suggests a terminal-only command.
38
+ const PERSISTABLE_FIELDS = ['chatId', 'messageId', 'startTime', 'url', 'command', 'commandAlias', 'isolationBackend', 'sessionId', 'containerFilesystemStartBytes', 'containerFilesystemLastBytes', 'containerFilesystemLastObservedAt', 'tool', 'infoBlock', 'urlContext', 'requesterUserId', 'showLimits', 'locale', 'logPath', 'args'];
37
39
 
38
40
  /**
39
41
  * Resolve the directory durable bot state is written to. Honors
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',
@@ -820,13 +820,13 @@ async function handleSolveCommand(ctx) {
820
820
 
821
821
  if (check.canStart && check.startReserved) {
822
822
  const startingMessage = await safeReply(ctx, formatStartingWorkSessionMessage({ infoBlock, locale: solveLocale }), { reply_to_message_id: ctx.message.message_id });
823
- await executeAndUpdateMessage(ctx, startingMessage, 'solve', argsWithLocale, infoBlock, effectiveSolveIsolation, solveTool, solveUrlContext, { showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart, locale: solveLocale });
823
+ await executeAndUpdateMessage(ctx, startingMessage, 'solve', argsWithLocale, infoBlock, effectiveSolveIsolation, solveTool, solveUrlContext, { showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart, locale: solveLocale, commandAlias: solveCommandName });
824
824
  } else {
825
825
  if (!solveQueue.executeCallback) {
826
826
  const _t = (s, i) => trackSession(s, i, VERBOSE);
827
827
  solveQueue.executeCallback = createIsolationAwareQueueCallback(ISOLATION_BACKEND, isolationRunner, _t, createQueueExecuteCallback(executeStartScreen, _t), VERBOSE);
828
828
  }
829
- const queueItem = solveQueue.enqueue({ url: normalizedUrl, args: argsWithLocale, ctx, requester, infoBlock, tool: solveTool, perCommandIsolation: effectiveSolveIsolation, urlContext: solveUrlContext, showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart, locale: solveLocale });
829
+ const queueItem = solveQueue.enqueue({ url: normalizedUrl, args: argsWithLocale, ctx, requester, infoBlock, commandAlias: solveCommandName, tool: solveTool, perCommandIsolation: effectiveSolveIsolation, urlContext: solveUrlContext, showLimits: solveShowLimits, limitsAtStart: solveLimitsAtStart, locale: solveLocale });
830
830
  const queueMessage = buildSolveQueuedMessage({ locale: solveLocale, tool: solveTool, position: toolQueuedCount + 1, infoBlock, reason: check.reason ? escapeMarkdown(check.reason) : '' }); // tool-specific position (#1551)
831
831
  const queuedMessage = await safeReply(ctx, queueMessage, { reply_to_message_id: ctx.message.message_id });
832
832
  queueItem.messageInfo = { chatId: queuedMessage.chat.id, messageId: queuedMessage.message_id };
@@ -99,7 +99,7 @@ function executeWithCommand(startScreenCmd, command, args, verbose = false) {
99
99
  */
100
100
  export function buildExecuteAndUpdateMessage(deps) {
101
101
  const { resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, untrackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage } = deps;
102
- return async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude', urlContext = null, { showLimits = false, limitsAtStart = null, locale = null } = {}) {
102
+ return async function executeAndUpdateMessage(ctx, startingMessage, commandName, args, infoBlock, perCommandIsolation = null, tool = 'claude', urlContext = null, { showLimits = false, limitsAtStart = null, locale = null, commandAlias = null } = {}) {
103
103
  const { chat, message_id: msgId } = startingMessage;
104
104
  const safeEdit = async text => {
105
105
  try {
@@ -111,7 +111,7 @@ export function buildExecuteAndUpdateMessage(deps) {
111
111
  const requesterUserId = ctx.from?.id ?? null; // Issue #1688: suppress duplicate /subscribe DM
112
112
  // #1927 review follow-up: persist the full args so a killed /solve can be
113
113
  // resumed with its exact original invocation + `--resume <lastSessionId>`.
114
- const baseSessionInfo = { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, tool, infoBlock, urlContext, requesterUserId, showLimits, limitsAtStart, locale, args: Array.isArray(args) ? [...args] : undefined }; // #594: showLimits/limitsAtStart
114
+ const baseSessionInfo = { chatId: ctx.chat.id, messageId: msgId, startTime: new Date(), url: args[0], command: commandName, commandAlias, tool, infoBlock, urlContext, requesterUserId, showLimits, limitsAtStart, locale, args: Array.isArray(args) ? [...args] : undefined }; // #594: showLimits/limitsAtStart
115
115
  const iso = await resolveIsolation(perCommandIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE);
116
116
  let result, session, sessionInfo;
117
117
  if (iso) {
@@ -90,11 +90,13 @@ export function createIsolationAwareQueueCallback(botIsolationBackend, botIsolat
90
90
  startTime: new Date(),
91
91
  url: item.url,
92
92
  command: item.command || 'solve',
93
+ commandAlias: item.commandAlias || null,
93
94
  isolationBackend: iso.backend,
94
95
  sessionId: sid,
95
96
  containerFilesystemStartBytes: Number.isFinite(r.containerFilesystemStartBytes) ? r.containerFilesystemStartBytes : null,
96
97
  tool,
97
98
  infoBlock: item.infoBlock,
99
+ args: Array.isArray(item.args) ? [...item.args] : undefined,
98
100
  // Issue #1688: propagate URL context + requester through the queue so the
99
101
  // completion notification can append a 'Pull request:' line and skip
100
102
  // notifying the requester twice via /subscribe.
@@ -53,6 +53,7 @@ class SolveQueueItem {
53
53
  this.ctx = options.ctx;
54
54
  this.requester = options.requester;
55
55
  this.infoBlock = options.infoBlock;
56
+ this.commandAlias = options.commandAlias || null; // #2109: retain Telegram spelling for resume guidance
56
57
  this.tool = options.tool || 'claude';
57
58
  // Issue #1983: preserve per-command isolation through queued execution.
58
59
  this.perCommandIsolation = options.perCommandIsolation || null;
@@ -1442,16 +1443,14 @@ export function createQueueExecuteCallback(executeStartScreen, trackSessionFn) {
1442
1443
  startTime: new Date(),
1443
1444
  url: item.url,
1444
1445
  command: 'solve',
1446
+ commandAlias: item.commandAlias || null,
1445
1447
  tool: item.tool || 'claude',
1446
1448
  infoBlock: item.infoBlock,
1447
- // Issue #1688: propagate URL context + requester so the completion
1448
- // notification can append a 'Pull request:' line and skip
1449
- // notifying the requester twice via /subscribe.
1449
+ args: Array.isArray(item.args) ? [...item.args] : undefined,
1450
+ // #1688: propagate URL context and requester to completion.
1450
1451
  urlContext: item.urlContext || null,
1451
1452
  requesterUserId: item.requesterUserId ?? null,
1452
- // Issue #594: --show-limits virtual option carries the start-of-task
1453
- // snapshot from the queueing point through to the completion
1454
- // handler so it can render an end-of-task delta.
1453
+ // #594: carry the start-of-task limits snapshot to completion.
1455
1454
  showLimits: item.showLimits === true,
1456
1455
  limitsAtStart: item.limitsAtStart || null,
1457
1456
  locale: item.locale || null,