@link-assistant/hive-mind 2.11.4 → 2.11.6

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.
@@ -0,0 +1,439 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Formal AI direct-endpoint runtime (issue #2130).
5
+ *
6
+ * Hive Mind used to dispatch every Formal AI run through the `formal-ai with
7
+ * <tool> <args...>` argv wrapper. That wrapper owns the wrapped CLI's argument
8
+ * list, which is incompatible with the way Hive Mind drives agentic CLIs:
9
+ *
10
+ * - `formal-ai with` parses `--model`, `--verbose`, `--silent`, `--base-url`,
11
+ * `--port`, `--protocol`, `--interactive` and `--non-interactive` as its own
12
+ * options, so those Hive Mind flags never reach the tool. A run of
13
+ * `formal-ai with agent --model formal-ai --verbose` launches
14
+ * `agent … --model formalai/formal-ai --interactive` — an interactive TUI
15
+ * session instead of the requested headless stream-json run.
16
+ * - Remaining arguments are appended *after* the wrapper's own argument list,
17
+ * i.e. after `--print` / `-p` / `exec`, so they are interpreted as prompt
18
+ * words or as a second, conflicting flag set.
19
+ * - When any passthrough argument contains workspace-effect vocabulary
20
+ * (`create`, `write`, `implement`, …) the wrapper switches into its own
21
+ * orchestration/recovery mode: it consumes the caller's stdin without
22
+ * forwarding it and replaces the prompt with a recovery prompt of its own.
23
+ * Hive Mind's Claude invocation always carries
24
+ * `--disallowedTools … CronCreate …`, so the real prompt was always dropped
25
+ * and Claude Code aborted with "Input must be provided either through stdin
26
+ * or as a prompt argument when using --print".
27
+ *
28
+ * The wrapper's supported shape is `formal-ai with <tool> "<prompt>"`, which
29
+ * gives Hive Mind no control over streaming format, session resume, MCP config
30
+ * or system prompts. This module therefore uses the other half of the same
31
+ * upstream feature set — the parts that are explicitly machine-readable:
32
+ *
33
+ * 1. `formal-ai serve --agent-mode` provides the model server (agent mode is
34
+ * what allows tool calls; a plain `formal-ai serve` declines them).
35
+ * The server is started with `cwd` set to the repository clone because
36
+ * Formal AI absolutizes tool-call paths against the *server's* working
37
+ * directory.
38
+ * 2. `formal-ai with --global --no-start-server --base-url <url> <tool>`
39
+ * writes the tool's own provider configuration into an isolated HOME.
40
+ * Upstream owns the config format, so Hive Mind never duplicates provider
41
+ * metadata (endpoints, wire API, model catalogs).
42
+ * 3. Hive Mind keeps full ownership of the CLI argument list and simply runs
43
+ * the native binary with the environment that points it at the server.
44
+ *
45
+ * @module formal-ai-runtime
46
+ */
47
+
48
+ import { execFile, spawn } from 'node:child_process';
49
+ import { createServer } from 'node:net';
50
+ import { rmSync } from 'node:fs';
51
+ import { cp, mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
52
+ import { homedir } from 'node:os';
53
+ import { dirname, join } from 'node:path';
54
+ import { promisify } from 'node:util';
55
+
56
+ const execFileAsync = promisify(execFile);
57
+
58
+ export const FORMAL_AI_DEFAULT_API_KEY = 'formal-ai';
59
+ // Mirrors `FORMAL_AI_MODEL_ALIAS` from `src/models/index.mjs`. Kept as a literal
60
+ // so this module stays on node builtins only — `models/index.mjs` fetches `use-m`
61
+ // from the network at import time.
62
+ export const FORMAL_AI_MODEL_NAME = 'formal-ai';
63
+ export const FORMAL_AI_DEFAULT_HOST = '127.0.0.1';
64
+ export const FORMAL_AI_SERVER_READY_TIMEOUT_MS = 90_000;
65
+
66
+ export const resolveFormalAiApiKey = (env = process.env) => env.FORMAL_AI_API_KEY?.trim() || FORMAL_AI_DEFAULT_API_KEY;
67
+
68
+ /**
69
+ * Parse the `shell_env` config format Formal AI writes for Claude, Gemini and
70
+ * Qwen (`export NAME="value"` lines, `${FORMAL_AI_API_KEY:-formal-ai}`
71
+ * placeholders included).
72
+ */
73
+ export const parseShellEnvExports = (text, { apiKey = FORMAL_AI_DEFAULT_API_KEY } = {}) => {
74
+ const parsed = {};
75
+ for (const rawLine of String(text || '').split('\n')) {
76
+ const line = rawLine.trim();
77
+ if (!line || line.startsWith('#')) continue;
78
+ const match = /^export\s+([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
79
+ if (!match) continue;
80
+ const [, name, rawValue] = match;
81
+ let value = rawValue.trim();
82
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
83
+ value = value.slice(1, -1);
84
+ }
85
+ // Formal AI writes `${FORMAL_AI_API_KEY:-formal-ai}` so the key stays overridable.
86
+ value = value.replace(/\$\{FORMAL_AI_API_KEY:-([^}]*)\}/g, (_, fallback) => apiKey || fallback);
87
+ value = value.replace(/\$\{FORMAL_AI_API_KEY\}/g, apiKey);
88
+ parsed[name] = value;
89
+ }
90
+ return parsed;
91
+ };
92
+
93
+ /**
94
+ * Gemini CLI only honours `GEMINI_DEFAULT_AUTH_TYPE` on its interactive code
95
+ * path. Headless runs (`-p`) resolve `security.auth.selectedType` from the
96
+ * settings hierarchy and abort with "Invalid auth method selected." when it is
97
+ * unset, so Formal AI's shell-env-only configuration (`GEMINI_API_KEY` plus
98
+ * `GOOGLE_GEMINI_BASE_URL` in `.profile`) is not enough.
99
+ *
100
+ * Hive Mind supplies the missing piece through `GEMINI_CLI_SYSTEM_SETTINGS_PATH`
101
+ * (gemini-cli 0.53.1, `packages/cli/src/config/settings.ts`), which points the
102
+ * *system* settings scope at a file we own. Writing `~/.gemini/settings.json`
103
+ * would mean overriding HOME for the whole run, which would also hide the
104
+ * operator's `git`/`gh`/ssh configuration from the tool's own shell commands.
105
+ */
106
+ export const buildGeminiAuthSettings = () => ({ security: { auth: { selectedType: 'gemini-api-key' } } });
107
+
108
+ /**
109
+ * Qwen Code has the same headless-auth gap as Gemini CLI, but resolves it from
110
+ * the environment rather than from settings. `getAuthTypeFromEnv`
111
+ * (qwen-code 0.21.2, `packages/core/src/config/models.ts`) only returns the
112
+ * OpenAI auth type when **all three** of `OPENAI_API_KEY`, `OPENAI_BASE_URL` and
113
+ * one of `OPENAI_MODEL` / `QWEN_MODEL` are set. Formal AI's `.profile` block
114
+ * writes the first two, so `validateNonInteractiveAuth` aborted every run with
115
+ * "No auth type is selected. Please configure an auth type (e.g. via settings or
116
+ * `--auth-type`) before running in non-interactive mode."
117
+ *
118
+ * The model name is only read for auth detection here — `--model` on the command
119
+ * line still wins in `resolveCliGenerationConfig` — so echoing back the model
120
+ * Formal AI itself serves is enough to complete the triple.
121
+ */
122
+ export const buildQwenAuthEnv = (env = {}) => (env.OPENAI_API_KEY && env.OPENAI_BASE_URL && !env.OPENAI_MODEL && !env.QWEN_MODEL ? { OPENAI_MODEL: FORMAL_AI_MODEL_NAME } : {});
123
+
124
+ /**
125
+ * Root for the throwaway HOME each Formal AI run gets.
126
+ *
127
+ * Not `os.tmpdir()`: Codex refuses to install its PATH helper binaries when
128
+ * `CODEX_HOME` resolves under the system temporary directory and prints
129
+ * `WARNING: proceeding, even though we could not create PATH aliases: Refusing
130
+ * to create helper binaries under temporary dir "/tmp"` on every run
131
+ * (codex-cli 0.146.0). A cache directory under the operator's HOME is outside
132
+ * that check and is still disposable — `stop()` and the exit hook remove it.
133
+ */
134
+ export const resolveFormalAiHomeRoot = (env = process.env, realHome = homedir()) => env.HIVE_MIND_FORMAL_AI_HOME_ROOT?.trim() || join(realHome, '.cache', 'hive-mind', 'formal-ai');
135
+
136
+ const findFreePort = async (host = FORMAL_AI_DEFAULT_HOST) =>
137
+ new Promise((resolve, reject) => {
138
+ const server = createServer();
139
+ server.on('error', reject);
140
+ server.listen(0, host, () => {
141
+ const { port } = server.address();
142
+ server.close(closeError => (closeError ? reject(closeError) : resolve(port)));
143
+ });
144
+ });
145
+
146
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
147
+
148
+ export const waitForFormalAiServerReady = async ({ baseUrl, probePath = '/api/openai/v1/models', timeoutMs = FORMAL_AI_SERVER_READY_TIMEOUT_MS, fetchImpl = globalThis.fetch, intervalMs = 500, isAlive = () => true } = {}) => {
149
+ const deadline = Date.now() + timeoutMs;
150
+ let lastError = null;
151
+ while (Date.now() < deadline) {
152
+ if (!isAlive()) return { ready: false, error: 'formal-ai serve exited before it became ready' };
153
+ try {
154
+ const response = await fetchImpl(`${baseUrl}${probePath}`);
155
+ if (response.ok) return { ready: true, error: null };
156
+ lastError = `HTTP ${response.status}`;
157
+ } catch (error) {
158
+ lastError = error?.message || String(error);
159
+ }
160
+ await delay(intervalMs);
161
+ }
162
+ return { ready: false, error: lastError || 'timed out' };
163
+ };
164
+
165
+ /** Read the machine-readable client registry (`formal-ai clients --format json`). */
166
+ export const loadFormalAiClientRegistry = async ({ formalAiPath = 'formal-ai', run = execFileAsync, env = process.env, timeoutMs = 30_000 } = {}) => {
167
+ const result = await run(formalAiPath, ['clients', '--format', 'json'], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024 });
168
+ const parsed = JSON.parse(result?.stdout ?? result ?? '[]');
169
+ const clients = Array.isArray(parsed) ? parsed : parsed?.clients || [];
170
+ return clients;
171
+ };
172
+
173
+ export const findFormalAiClient = (clients, tool) => (clients || []).find(client => client?.id === tool || (client?.aliases || []).includes(tool)) || null;
174
+
175
+ /**
176
+ * Turn one Formal AI `global_configs` entry, materialised inside `home`, into
177
+ * the environment a natively-invoked CLI needs.
178
+ */
179
+ export const buildFormalAiClientEnv = async ({ client, home, apiKey = FORMAL_AI_DEFAULT_API_KEY, readFileImpl = readFile }) => {
180
+ const env = { FORMAL_AI_API_KEY: apiKey };
181
+ const notes = [];
182
+ for (const config of client?.global_configs || []) {
183
+ const absolutePath = join(home, config.path);
184
+ if (config.format === 'shell_env') {
185
+ let text;
186
+ try {
187
+ text = await readFileImpl(absolutePath, 'utf8');
188
+ } catch {
189
+ continue;
190
+ }
191
+ Object.assign(env, parseShellEnvExports(text, { apiKey }));
192
+ notes.push(`${config.format}:${config.path}`);
193
+ continue;
194
+ }
195
+ if (config.format === 'toml' && client.id === 'codex') {
196
+ // Codex reads its whole configuration (provider, model catalog) from CODEX_HOME.
197
+ env.CODEX_HOME = dirname(absolutePath);
198
+ notes.push(`CODEX_HOME=${env.CODEX_HOME}`);
199
+ continue;
200
+ }
201
+ if (config.format === 'json') {
202
+ // agent/opencode read `<XDG_CONFIG_HOME>/<app>/opencode.json`.
203
+ env.XDG_CONFIG_HOME = join(home, '.config');
204
+ notes.push(`XDG_CONFIG_HOME=${env.XDG_CONFIG_HOME}`);
205
+ continue;
206
+ }
207
+ notes.push(`unsupported:${config.format}:${config.path}`);
208
+ }
209
+ if (client?.api_key_env && !env[client.api_key_env]) env[client.api_key_env] = apiKey;
210
+ return { env, notes };
211
+ };
212
+
213
+ /**
214
+ * Copy the operator's existing directory-based tool configuration into the
215
+ * isolated HOME before Formal AI patches it, so authentication, plugin state
216
+ * and MCP settings survive a Formal AI run. Upstream then merges its provider
217
+ * block into a copy of the real config instead of a blank one.
218
+ *
219
+ * `shell_env` configs are deliberately skipped: `.profile` is a shell startup
220
+ * file, and Hive Mind reads the exports back out of it — importing the
221
+ * operator's own exports would leak unrelated environment into the CLI.
222
+ */
223
+ export const seedFormalAiClientHome = async ({ client, home, env = process.env, realHome = homedir(), cpImpl = cp }) => {
224
+ const seeded = [];
225
+ for (const config of client?.global_configs || []) {
226
+ if (config.format === 'shell_env') continue;
227
+ const relativeDir = dirname(config.path);
228
+ if (!relativeDir || relativeDir === '.' || relativeDir.startsWith('..')) continue;
229
+ // Codex reads CODEX_HOME, which Hive Mind may already have repointed at a repository-scoped home (issue #2074).
230
+ const source = client.id === 'codex' && env.CODEX_HOME ? env.CODEX_HOME : join(realHome, relativeDir);
231
+ try {
232
+ await cpImpl(source, join(home, relativeDir), { recursive: true, verbatimSymlinks: true, force: true });
233
+ seeded.push(`${source} → ${relativeDir}`);
234
+ } catch {
235
+ // Nothing configured yet for this tool — Formal AI writes a fresh config.
236
+ }
237
+ }
238
+ return seeded;
239
+ };
240
+
241
+ /** Materialise the tool's provider configuration inside an isolated HOME. */
242
+ export const configureFormalAiClientHome = async ({ tool, baseUrl, home, formalAiPath = 'formal-ai', run = execFileAsync, env = process.env, timeoutMs = 120_000 }) => {
243
+ const args = ['with', '--global', '--no-start-server', '--base-url', baseUrl, tool];
244
+ await run(formalAiPath, args, {
245
+ encoding: 'utf8',
246
+ env: { ...process.env, ...env, HOME: home },
247
+ timeout: timeoutMs,
248
+ maxBuffer: 32 * 1024 * 1024,
249
+ });
250
+ return { args, home };
251
+ };
252
+
253
+ /** Start `formal-ai serve --agent-mode` in `cwd` and wait until it answers. */
254
+ export const startFormalAiServer = async ({ cwd, host = FORMAL_AI_DEFAULT_HOST, port, formalAiPath = 'formal-ai', env = process.env, logFile = null, spawnImpl = spawn, readyTimeoutMs = FORMAL_AI_SERVER_READY_TIMEOUT_MS, fetchImpl = globalThis.fetch } = {}) => {
255
+ const resolvedPort = port || (await findFreePort(host));
256
+ const args = ['serve', '--agent-mode', '--host', host, '--port', String(resolvedPort)];
257
+ const child = spawnImpl(formalAiPath, args, {
258
+ cwd,
259
+ env: { ...process.env, ...env, FORMAL_AI_API_KEY: resolveFormalAiApiKey(env) },
260
+ stdio: ['ignore', 'pipe', 'pipe'],
261
+ });
262
+
263
+ let exited = false;
264
+ let exitInfo = null;
265
+ child.once('exit', (code, signal) => {
266
+ exited = true;
267
+ exitInfo = { code, signal };
268
+ });
269
+
270
+ const chunks = [];
271
+ const collect = chunk => {
272
+ chunks.push(chunk.toString());
273
+ if (chunks.length > 500) chunks.splice(0, chunks.length - 500);
274
+ };
275
+ child.stdout?.on('data', collect);
276
+ child.stderr?.on('data', collect);
277
+
278
+ const baseUrl = `http://${host}:${resolvedPort}`;
279
+ const ready = await waitForFormalAiServerReady({ baseUrl, timeoutMs: readyTimeoutMs, fetchImpl, isAlive: () => !exited });
280
+
281
+ const output = () => chunks.join('');
282
+ if (!ready.ready) {
283
+ try {
284
+ child.kill('SIGTERM');
285
+ } catch {
286
+ /* already gone */
287
+ }
288
+ const detail = exitInfo ? ` (exit code ${exitInfo.code}, signal ${exitInfo.signal})` : '';
289
+ throw new Error(`formal-ai serve did not become ready at ${baseUrl}${detail}: ${ready.error}\n${output().slice(-2000)}`);
290
+ }
291
+
292
+ if (logFile) {
293
+ child.stdout?.on('data', chunk => void writeFile(logFile, chunk, { flag: 'a' }).catch(() => {}));
294
+ child.stderr?.on('data', chunk => void writeFile(logFile, chunk, { flag: 'a' }).catch(() => {}));
295
+ }
296
+
297
+ return {
298
+ baseUrl,
299
+ port: resolvedPort,
300
+ pid: child.pid,
301
+ args,
302
+ output,
303
+ stop: async () => {
304
+ if (exited) return;
305
+ try {
306
+ child.kill('SIGTERM');
307
+ } catch {
308
+ /* already gone */
309
+ }
310
+ for (let attempt = 0; attempt < 20 && !exited; attempt += 1) await delay(100);
311
+ if (!exited) {
312
+ try {
313
+ child.kill('SIGKILL');
314
+ } catch {
315
+ /* already gone */
316
+ }
317
+ }
318
+ },
319
+ };
320
+ };
321
+
322
+ const runtimeCache = new Map();
323
+ let exitHookInstalled = false;
324
+
325
+ const installExitHook = () => {
326
+ if (exitHookInstalled) return;
327
+ exitHookInstalled = true;
328
+ // `exit` handlers must be synchronous, so the server is signalled and the
329
+ // isolated HOME removed with the sync APIs.
330
+ const stopAll = () => {
331
+ for (const [key, entry] of runtimeCache) {
332
+ runtimeCache.delete(key);
333
+ try {
334
+ if (entry.server?.pid) process.kill(entry.server.pid, 'SIGTERM');
335
+ } catch {
336
+ /* already gone */
337
+ }
338
+ try {
339
+ if (entry.runtime?.home) rmSync(entry.runtime.home, { recursive: true, force: true });
340
+ } catch {
341
+ /* best effort */
342
+ }
343
+ }
344
+ };
345
+ process.once('exit', stopAll);
346
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.once(signal, stopAll);
347
+ };
348
+
349
+ /**
350
+ * Prepare everything one Formal AI tool run needs and return the environment to
351
+ * merge into the native CLI invocation. Repeated calls for the same workspace
352
+ * and tool reuse the same server and configuration.
353
+ */
354
+ export const prepareFormalAiRuntime = async ({ tool, workdir, log = async () => {}, verbose = false, env = process.env, formalAiPath = null, deps = {} } = {}) => {
355
+ const resolvedFormalAiPath = formalAiPath || env.HIVE_MIND_FORMAL_AI_PATH?.trim() || 'formal-ai';
356
+ const cacheKey = `${tool}::${workdir}::${env.HIVE_MIND_FORMAL_AI_BASE_URL || ''}`;
357
+ const cached = runtimeCache.get(cacheKey);
358
+ if (cached) return cached.runtime;
359
+
360
+ installExitHook();
361
+
362
+ const apiKey = resolveFormalAiApiKey(env);
363
+ const externalBaseUrl = env.HIVE_MIND_FORMAL_AI_BASE_URL?.trim() || null;
364
+ const homeRoot = resolveFormalAiHomeRoot(env);
365
+ await mkdir(homeRoot, { recursive: true }).catch(() => {});
366
+ const home = await (deps.mkdtempImpl || mkdtemp)(join(homeRoot, `${tool}-`));
367
+
368
+ let server = null;
369
+ let baseUrl = externalBaseUrl;
370
+ try {
371
+ if (!baseUrl) {
372
+ await log(`🧠 Formal AI: starting a local server in ${workdir}`, { verbose: true });
373
+ server = await (deps.startServerImpl || startFormalAiServer)({ cwd: workdir, formalAiPath: resolvedFormalAiPath, env, logFile: join(home, 'serve.log') });
374
+ baseUrl = server.baseUrl;
375
+ await log(`🧠 Formal AI: server ready on ${baseUrl} (pid ${server.pid})`, { verbose: true });
376
+ } else {
377
+ await log(`🧠 Formal AI: using the configured server ${baseUrl}`, { verbose: true });
378
+ }
379
+
380
+ const clients = await (deps.loadRegistryImpl || loadFormalAiClientRegistry)({ formalAiPath: resolvedFormalAiPath, env });
381
+ const client = findFormalAiClient(clients, tool);
382
+ if (!client) throw new Error(`Formal AI does not list a client configuration for "${tool}"`);
383
+
384
+ const seeded = await (deps.seedImpl || seedFormalAiClientHome)({ client, home, env });
385
+ await (deps.configureImpl || configureFormalAiClientHome)({ tool, baseUrl, home, formalAiPath: resolvedFormalAiPath, env });
386
+
387
+ const { env: clientEnv, notes } = await buildFormalAiClientEnv({ client, home, apiKey });
388
+ for (const entry of seeded) notes.push(`seeded ${entry}`);
389
+
390
+ if (tool === 'gemini') {
391
+ // Upstream gap: headless Gemini needs an explicit auth type in settings.
392
+ // Injected through the system-settings scope so HOME stays untouched.
393
+ const settingsPath = join(home, '.gemini', 'settings.json');
394
+ await mkdir(dirname(settingsPath), { recursive: true });
395
+ await writeFile(settingsPath, `${JSON.stringify(buildGeminiAuthSettings(), null, 2)}\n`);
396
+ clientEnv.GEMINI_CLI_SYSTEM_SETTINGS_PATH = settingsPath;
397
+ notes.push(`GEMINI_CLI_SYSTEM_SETTINGS_PATH=${settingsPath}`);
398
+ }
399
+
400
+ if (tool === 'qwen') {
401
+ // Upstream gap: Qwen Code detects the auth type from a three-variable
402
+ // combination and Formal AI's `.profile` block only writes two of them.
403
+ const qwenAuthEnv = buildQwenAuthEnv(clientEnv);
404
+ Object.assign(clientEnv, qwenAuthEnv);
405
+ for (const name of Object.keys(qwenAuthEnv)) notes.push(`${name}=${qwenAuthEnv[name]}`);
406
+ }
407
+
408
+ if (verbose) {
409
+ await log(`🧠 Formal AI: protocol ${client.default_protocol}, endpoint ${baseUrl}${client.endpoints?.[client.default_protocol] || ''}`, { verbose: true });
410
+ await log(`🧠 Formal AI: config ${notes.join(', ') || 'none'}`, { verbose: true });
411
+ await log(`🧠 Formal AI: environment ${Object.keys(clientEnv).sort().join(', ')}`, { verbose: true });
412
+ }
413
+
414
+ const runtime = {
415
+ enabled: true,
416
+ tool,
417
+ baseUrl,
418
+ home,
419
+ env: clientEnv,
420
+ client,
421
+ notes,
422
+ serverStarted: !!server,
423
+ stop: async () => {
424
+ runtimeCache.delete(cacheKey);
425
+ await server?.stop?.();
426
+ await rm(home, { recursive: true, force: true }).catch(() => {});
427
+ },
428
+ };
429
+ runtimeCache.set(cacheKey, { runtime, server });
430
+ return runtime;
431
+ } catch (error) {
432
+ await server?.stop?.();
433
+ await rm(home, { recursive: true, force: true }).catch(() => {});
434
+ throw error;
435
+ }
436
+ };
437
+
438
+ /** Test seam: forget cached runtimes without stopping their servers. */
439
+ export const resetFormalAiRuntimeCache = () => runtimeCache.clear();
@@ -3,7 +3,8 @@
3
3
  import { execFile } from 'node:child_process';
4
4
  import { promisify } from 'node:util';
5
5
 
6
- import { FORMAL_AI_MODEL_ALIAS, isFormalAiModel } from './models/index.mjs';
6
+ import { findFormalAiClient, loadFormalAiClientRegistry, prepareFormalAiRuntime } from './formal-ai-runtime.lib.mjs';
7
+ import { isFormalAiModel } from './models/index.mjs';
7
8
 
8
9
  const execFileAsync = promisify(execFile);
9
10
 
@@ -18,7 +19,7 @@ const shellQuote = value => {
18
19
  return `'${stringValue.replaceAll("'", "'\\''")}'`;
19
20
  };
20
21
 
21
- const normalizeExternalBaseUrl = value => {
22
+ export const normalizeFormalAiBaseUrl = value => {
22
23
  if (!value) return null;
23
24
 
24
25
  let parsed;
@@ -35,80 +36,181 @@ const normalizeExternalBaseUrl = value => {
35
36
  return parsed.origin;
36
37
  };
37
38
 
39
+ export const resolveFormalAiPath = (env = process.env) => env.HIVE_MIND_FORMAL_AI_PATH?.trim() || DEFAULT_FORMAL_AI_PATH;
40
+
41
+ const nativeInvocation = toolPath => ({
42
+ command: toolPath,
43
+ args: [],
44
+ displayCommand: shellQuote(toolPath),
45
+ formalAi: false,
46
+ baseUrl: null,
47
+ env: {},
48
+ stop: null,
49
+ });
50
+
38
51
  /**
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.
52
+ * Resolve how one Hive tool invocation reaches Formal AI (issue #2130).
53
+ *
54
+ * Hive Mind runs the *native* CLI and only injects the environment that points
55
+ * it at a Formal AI server. The `formal-ai with <tool> <args…>` argv wrapper is
56
+ * deliberately not used any more: it claims `--model`/`--verbose` for itself,
57
+ * appends Hive Mind's flags after its own `--print`/`-p`/`exec`, and drops the
58
+ * piped prompt whenever an argument contains workspace-effect vocabulary (Hive
59
+ * Mind's `--disallowedTools … CronCreate …` always matched). See
60
+ * ./formal-ai-runtime.lib.mjs for the full analysis and the replacement.
42
61
  */
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
- }
62
+ export const resolveFormalAiToolExecution = async ({ tool, model, toolPath, workdir, log = async () => {}, verbose = false, prepareOnly = false, env = process.env, deps = {} } = {}) => {
63
+ if (!isFormalAiModel(model)) return nativeInvocation(toolPath);
53
64
 
54
65
  if (!FORMAL_AI_SUPPORTED_TOOLS.includes(tool)) {
55
66
  throw new Error(`Formal AI dispatch does not support Hive tool "${tool}"`);
56
67
  }
57
68
 
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'];
69
+ // Validated here so a malformed override fails fast with a clear message.
70
+ const configuredBaseUrl = normalizeFormalAiBaseUrl(env.HIVE_MIND_FORMAL_AI_BASE_URL);
61
71
 
62
- if (baseUrl) {
63
- args.push('--no-start-server', '--base-url', baseUrl);
72
+ // `--dry-run` / `--only-prepare-command` must not start a server or write config.
73
+ if (prepareOnly) {
74
+ return { ...nativeInvocation(toolPath), formalAi: true, baseUrl: configuredBaseUrl, prepared: true };
64
75
  }
65
- args.push(tool);
76
+
77
+ const runtime = await (deps.prepareRuntimeImpl || prepareFormalAiRuntime)({
78
+ tool,
79
+ workdir,
80
+ log,
81
+ verbose,
82
+ env,
83
+ });
66
84
 
67
85
  return {
68
- command,
69
- args,
70
- displayCommand: [command, ...args].map(shellQuote).join(' '),
86
+ command: toolPath,
87
+ args: [],
88
+ displayCommand: shellQuote(toolPath),
71
89
  formalAi: true,
72
- baseUrl,
90
+ baseUrl: runtime.baseUrl,
91
+ env: runtime.env,
92
+ home: runtime.home,
93
+ client: runtime.client,
94
+ stop: runtime.stop,
73
95
  };
74
96
  };
75
97
 
76
98
  /**
77
- * Check both the wrapper and the selected native CLI without starting a model
78
- * server or spending a model request.
99
+ * Render `toolInvocation.env` as `export NAME=value; ` shell prefixes (issue #2130).
100
+ *
101
+ * Tools launched through `sh -lc` (codex, qwen) get a login shell that sources the
102
+ * operator's `~/.profile`, which may still hold stale exports written by an earlier
103
+ * `formal-ai with --global` run. Re-exporting inside the script makes this run's
104
+ * values win regardless of what the profile sets.
105
+ */
106
+ export const buildFormalAiEnvExports = env =>
107
+ Object.entries(env || {})
108
+ .map(([name, value]) => `export ${name}=${shellQuote(value)}; `)
109
+ .join('');
110
+
111
+ /**
112
+ * `formal-ai --version` prints a line such as "formal-ai 0.317.0"; keep only the
113
+ * version so the log records a value that can be compared against a release.
114
+ */
115
+ export const parseFormalAiVersion = stdout => {
116
+ const line = String(stdout || '')
117
+ .split('\n')
118
+ .map(entry => entry.trim())
119
+ .find(Boolean);
120
+ if (!line) return null;
121
+ return line.replace(/^formal-ai\s+/i, '').trim() || null;
122
+ };
123
+
124
+ /**
125
+ * The wrapper's behaviour changes between releases — issue #2130's round-2
126
+ * failures could not be pinned to a mechanism because no log recorded which
127
+ * wrapper produced them. Ask the wrapper for its version, but never let that
128
+ * question decide whether the run may proceed.
129
+ */
130
+ export const readFormalAiVersion = async ({ env = process.env, run = execFileAsync, timeoutMs = 30_000 } = {}) => {
131
+ try {
132
+ const result = await run(resolveFormalAiPath(env), ['--version'], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: timeoutMs });
133
+ return parseFormalAiVersion(result?.stdout);
134
+ } catch {
135
+ return null;
136
+ }
137
+ };
138
+
139
+ /**
140
+ * Check that the Formal AI wrapper and the selected native CLI are both usable
141
+ * without starting a model server or spending a model request.
79
142
  */
80
143
  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'];
144
+ const command = resolveFormalAiPath(env);
145
+ const args = ['clients', '--format', 'json'];
146
+ const formalAiVersion = await readFormalAiVersion({ env, run, timeoutMs });
88
147
 
148
+ let clients;
89
149
  try {
90
- const result = await run(invocation.command, args, {
91
- encoding: 'utf8',
92
- env: { ...process.env, ...env },
93
- timeout: timeoutMs,
94
- });
150
+ clients = await loadFormalAiClientRegistry({ formalAiPath: command, run, env, timeoutMs });
151
+ } catch (error) {
152
+ return {
153
+ valid: false,
154
+ command,
155
+ args,
156
+ formalAiVersion,
157
+ error: error?.stderr?.trim() || error?.message || String(error),
158
+ code: error?.code,
159
+ };
160
+ }
161
+
162
+ const client = findFormalAiClient(clients, tool);
163
+ if (!client) {
164
+ return {
165
+ valid: false,
166
+ command,
167
+ args,
168
+ formalAiVersion,
169
+ error: `Formal AI does not list a client configuration for "${tool}" (available: ${(clients || []).map(entry => entry.id).join(', ')})`,
170
+ };
171
+ }
172
+
173
+ try {
174
+ const result = await run(tool, ['--version'], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: timeoutMs });
95
175
  return {
96
176
  valid: true,
97
- command: invocation.command,
177
+ command,
98
178
  args,
179
+ client: client.id,
180
+ protocol: client.default_protocol,
181
+ formalAiVersion,
99
182
  version: result?.stdout?.trim() || null,
100
183
  };
101
184
  } catch (error) {
102
185
  return {
103
186
  valid: false,
104
- command: invocation.command,
187
+ command,
105
188
  args,
189
+ formalAiVersion,
106
190
  error: error?.stderr?.trim() || error?.message || String(error),
107
191
  code: error?.code,
108
192
  };
109
193
  }
110
194
  };
111
195
 
196
+ /**
197
+ * Vendor login remedies (`codex login`, `claude /login`, …) are wrong advice
198
+ * when the model is served by Formal AI: the CLI never talks to the vendor, so
199
+ * logging in changes nothing. Issue #2130's codex run printed
200
+ * "❌ Codex authentication failed - 401 Unauthorized … 💡 Please run: codex
201
+ * login" while the real cause was that the request went to `api.openai.com`
202
+ * instead of the Formal AI endpoint.
203
+ *
204
+ * @param {object} params
205
+ * @param {string} params.model - the model the run was launched with.
206
+ * @param {string} params.vendorRemedy - the advice to keep for vendor models.
207
+ * @returns {string[]} remedy lines, already indented for the error log.
208
+ */
209
+ export const buildAuthRemedyLines = ({ model, vendorRemedy }) => {
210
+ if (!isFormalAiModel(model)) return [` 💡 ${vendorRemedy}`];
211
+ return [' 💡 This model is served by Formal AI, so a vendor login will not help.', ' 💡 Check that `formal-ai serve --agent-mode` is reachable and that the generated client config is in effect (rerun with --verbose to see the endpoint and environment).'];
212
+ };
213
+
112
214
  export const isPrepareOnly = argv => !!(argv?.dryRun || argv?.onlyPrepareCommand);
113
215
 
114
216
  export const createPreparedToolResult = preparedCommand => ({