@link-assistant/hive-mind 2.18.0 → 2.19.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.
Files changed (38) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.hi.md +2 -0
  3. package/README.md +2 -0
  4. package/README.ru.md +2 -0
  5. package/README.zh.md +2 -0
  6. package/package.json +3 -2
  7. package/src/agentic-cli-freshness.lib.mjs +118 -0
  8. package/src/agentic-cli-updater.lib.mjs +8 -4
  9. package/src/docker-sidecar.lib.mjs +17 -1
  10. package/src/formal-ai-image.lib.mjs +108 -10
  11. package/src/formal-ai-isolation.lib.mjs +15 -1
  12. package/src/formal-ai-runtime.lib.mjs +219 -6
  13. package/src/formal-ai-sidecar.lib.mjs +41 -9
  14. package/src/formal-ai.lib.mjs +5 -0
  15. package/src/hive-models.lib.mjs +181 -0
  16. package/src/hive-models.mjs +20 -0
  17. package/src/isolation-runner.lib.mjs +2 -2
  18. package/src/locales/en.lino +2 -1
  19. package/src/locales/hi.lino +2 -1
  20. package/src/locales/ru.lino +2 -1
  21. package/src/locales/zh.lino +2 -1
  22. package/src/model-catalogue-fetch.lib.mjs +333 -0
  23. package/src/model-catalogue-render.lib.mjs +191 -0
  24. package/src/model-catalogue-sources.lib.mjs +224 -0
  25. package/src/model-catalogue.lib.mjs +385 -0
  26. package/src/models/catalog.mjs +408 -0
  27. package/src/models/index.mjs +23 -362
  28. package/src/router-isolation.lib.mjs +103 -19
  29. package/src/router-routes.lib.mjs +250 -0
  30. package/src/router-sidecar.lib.mjs +33 -13
  31. package/src/solve.config.lib.mjs +5 -0
  32. package/src/solve.escalate.lib.mjs +3 -0
  33. package/src/solve.mjs +12 -0
  34. package/src/task.config.lib.mjs +5 -0
  35. package/src/task.mjs +12 -0
  36. package/src/telegram-bot.mjs +4 -1
  37. package/src/telegram-models-command.lib.mjs +157 -0
  38. package/src/telegram-ui-messages.lib.mjs +1 -1
@@ -53,7 +53,7 @@ import { homedir } from 'node:os';
53
53
  import { dirname, join } from 'node:path';
54
54
  import { promisify } from 'node:util';
55
55
 
56
- import { assertSupportedFormalAiVersion, FORMAL_AI_MINIMUM_VERSION, readFormalAiBinaryVersion } from './formal-ai-version.lib.mjs';
56
+ import { assertSupportedFormalAiVersion, FORMAL_AI_MINIMUM_VERSION, isFormalAiVersionAtLeast, readFormalAiBinaryVersion } from './formal-ai-version.lib.mjs';
57
57
 
58
58
  const execFileAsync = promisify(execFile);
59
59
 
@@ -65,6 +65,26 @@ export const FORMAL_AI_MODEL_NAME = 'formal-ai';
65
65
  export const FORMAL_AI_DEFAULT_HOST = '127.0.0.1';
66
66
  export const FORMAL_AI_SERVER_READY_TIMEOUT_MS = 90_000;
67
67
 
68
+ /** Where a Formal AI server reports its own version and memory compatibility. */
69
+ export const FORMAL_AI_HEALTH_PATH = '/health';
70
+
71
+ /**
72
+ * Bounded budget for the backend probe (issue #2208).
73
+ *
74
+ * Deliberately short: this runs immediately before the client executes, so a
75
+ * hanging endpoint must surface as a refusal rather than as a task that appears
76
+ * to be thinking. The server-start path has its own, much longer budget.
77
+ */
78
+ export const FORMAL_AI_BACKEND_PROBE_TIMEOUT_MS = 15_000;
79
+
80
+ /** Environment carrying the Hive-Mind-managed sidecar's identity into the task container (issue #2207/#2208). */
81
+ export const FORMAL_AI_SIDECAR_PROVENANCE_ENV = Object.freeze({
82
+ image: 'HIVE_MIND_FORMAL_AI_SIDECAR_IMAGE',
83
+ imageDigest: 'HIVE_MIND_FORMAL_AI_SIDECAR_DIGEST',
84
+ version: 'HIVE_MIND_FORMAL_AI_SIDECAR_VERSION',
85
+ imageSource: 'HIVE_MIND_FORMAL_AI_SIDECAR_SOURCE',
86
+ });
87
+
68
88
  export const resolveFormalAiApiKey = (env = process.env) => env.FORMAL_AI_API_KEY?.trim() || FORMAL_AI_DEFAULT_API_KEY;
69
89
 
70
90
  /**
@@ -164,6 +184,109 @@ export const waitForFormalAiServerReady = async ({ baseUrl, probePath = '/api/op
164
184
  return { ready: false, error: lastError || 'timed out' };
165
185
  };
166
186
 
187
+ /**
188
+ * Ask the endpoint that will actually answer the model requests who it is
189
+ * (issue #2208).
190
+ *
191
+ * Until this existed, `prepareFormalAiRuntime` read `formal-ai --version` from
192
+ * the *local* executable and logged the answer as "the Formal AI version" even
193
+ * when `HIVE_MIND_FORMAL_AI_BASE_URL` pointed at a container running a
194
+ * completely different release. Every provenance record therefore named a binary
195
+ * that never saw the request.
196
+ *
197
+ * Properties that matter:
198
+ *
199
+ * - **Bounded.** The whole probe shares one deadline, and each request carries
200
+ * its own abort signal, so an endpoint that accepts the connection and then
201
+ * goes quiet cannot stall the task.
202
+ * - **Authenticated.** The configured key is presented the way the served API
203
+ * expects it. A 401/403 is reported as an authentication problem and is *not*
204
+ * retried — retrying a rejected credential only delays the failure.
205
+ * - **Honest about malformed answers.** A body that is not JSON, or that
206
+ * carries no version, is a distinct outcome from "unreachable"; guessing a
207
+ * version here would recreate the defect this function exists to fix.
208
+ *
209
+ * @returns {Promise<{ok: boolean, kind: string, status: number|null, version: string|null, memory: object|null, health: object|null, error: string|null}>}
210
+ */
211
+ export const probeFormalAiBackend = async ({ baseUrl, apiKey = null, path = FORMAL_AI_HEALTH_PATH, timeoutMs = FORMAL_AI_BACKEND_PROBE_TIMEOUT_MS, fetchImpl = globalThis.fetch, intervalMs = 500, now = () => Date.now(), sleepImpl = delay } = {}) => {
212
+ if (!baseUrl) return { ok: false, kind: 'unreachable', status: null, version: null, memory: null, health: null, error: 'no base URL to probe' };
213
+ const url = `${String(baseUrl).replace(/\/+$/, '')}${path}`;
214
+ const headers = apiKey ? { Authorization: `Bearer ${apiKey}`, 'X-Api-Key': apiKey } : {};
215
+ const deadline = now() + timeoutMs;
216
+ let last = { ok: false, kind: 'unreachable', status: null, version: null, memory: null, health: null, error: 'not probed' };
217
+
218
+ for (;;) {
219
+ const remaining = deadline - now();
220
+ if (remaining <= 0) break;
221
+ try {
222
+ const response = await fetchImpl(url, { headers, signal: AbortSignal.timeout(Math.min(remaining, timeoutMs)) });
223
+ const status = response?.status ?? null;
224
+ if (status === 401 || status === 403) {
225
+ // Not retried: the credential is wrong, and it will still be wrong in 500ms.
226
+ return { ok: false, kind: 'unauthorized', status, version: null, memory: null, health: null, error: `the Formal AI endpoint rejected the configured credentials (HTTP ${status})` };
227
+ }
228
+ if (!response?.ok) {
229
+ last = { ok: false, kind: 'http-error', status, version: null, memory: null, health: null, error: `HTTP ${status}` };
230
+ } else {
231
+ const text = await response.text();
232
+ let health;
233
+ try {
234
+ health = JSON.parse(text);
235
+ } catch {
236
+ return { ok: false, kind: 'malformed', status, version: null, memory: null, health: null, error: `${path} did not return JSON: ${text.slice(0, 200)}` };
237
+ }
238
+ const version = typeof health?.version === 'string' ? health.version.trim() || null : null;
239
+ if (!version) return { ok: false, kind: 'no-version', status, version: null, memory: health?.memory ?? null, health, error: `${path} answered without a version field` };
240
+ return { ok: true, kind: 'ok', status, version, memory: health?.memory ?? null, health, error: null };
241
+ }
242
+ } catch (error) {
243
+ last = { ok: false, kind: 'unreachable', status: null, version: null, memory: null, health: null, error: error?.message || String(error) };
244
+ }
245
+ if (deadline - now() <= intervalMs) break;
246
+ await sleepImpl(intervalMs);
247
+ }
248
+ return last;
249
+ };
250
+
251
+ /**
252
+ * Turn a probe result into either the accepted backend description or an
253
+ * actionable refusal.
254
+ *
255
+ * Fail-closed (issue #2146) applies here too: a Formal AI task that cannot prove
256
+ * which release is serving it must stop, not proceed and record a guess.
257
+ *
258
+ * @param {object} probe - Output of {@link probeFormalAiBackend}.
259
+ * @param {object} context
260
+ * @param {string} context.baseUrl
261
+ * @param {string} [context.minimumVersion]
262
+ * @param {string|null} [context.expectedVersion] - Version the leased sidecar image was verified at.
263
+ * @returns {{version: string, memory: object|null}}
264
+ */
265
+ export const assertSupportedFormalAiBackend = (probe, { baseUrl, minimumVersion = FORMAL_AI_MINIMUM_VERSION, expectedVersion = null } = {}) => {
266
+ const where = `the Formal AI endpoint ${baseUrl}`;
267
+ if (!probe?.ok) {
268
+ const detail = probe?.error ? `: ${probe.error}` : '';
269
+ if (probe?.kind === 'unauthorized') throw new Error(`${where} refused the configured credentials${detail}. Set FORMAL_AI_API_KEY to a key the server accepts.`);
270
+ if (probe?.kind === 'malformed') throw new Error(`${where} answered ${FORMAL_AI_HEALTH_PATH} with something other than JSON${detail}. Hive Mind will not guess which Formal AI release is serving this task.`);
271
+ if (probe?.kind === 'no-version') throw new Error(`${where} answered ${FORMAL_AI_HEALTH_PATH} without a version${detail}. Hive Mind requires a serving backend that reports its version (Formal AI >= ${minimumVersion}).`);
272
+ if (probe?.kind === 'http-error') throw new Error(`${where} did not serve ${FORMAL_AI_HEALTH_PATH}${detail}. Check that HIVE_MIND_FORMAL_AI_BASE_URL points at a Formal AI server >= ${minimumVersion}.`);
273
+ throw new Error(`${where} could not be reached${detail}. Check HIVE_MIND_FORMAL_AI_BASE_URL and that the Formal AI server is running.`);
274
+ }
275
+ if (!isFormalAiVersionAtLeast(probe.version, minimumVersion)) {
276
+ throw new Error(`${where} serves Formal AI ${probe.version}, but Hive Mind requires >= ${minimumVersion}. Upgrade the server; the local wrapper's version does not change what answers the requests.`);
277
+ }
278
+ if (probe.memory?.compatible === false) {
279
+ throw new Error(`${where} reports incompatible persisted memory (migration_state=${probe.memory?.migration_state ?? 'unknown'}). Refusing to run a task against memory the serving release cannot read.`);
280
+ }
281
+ if (expectedVersion && expectedVersion !== probe.version) {
282
+ // A lease pins the sidecar's image for the whole task, so the endpoint
283
+ // answering with a different release means it is not the container Hive
284
+ // Mind verified and leased.
285
+ throw new Error(`${where} serves Formal AI ${probe.version}, but the leased Hive Mind sidecar image was verified as ${expectedVersion}. Refusing to record provenance for a backend that is not the accepted release.`);
286
+ }
287
+ return { version: probe.version, memory: probe.memory ?? null };
288
+ };
289
+
167
290
  /** Read the machine-readable client registry (`formal-ai clients --format json`). */
168
291
  export const loadFormalAiClientRegistry = async ({ formalAiPath = 'formal-ai', run = execFileAsync, env = process.env, timeoutMs = 30_000 } = {}) => {
169
292
  const result = await run(formalAiPath, ['clients', '--format', 'json'], { encoding: 'utf8', env: { ...process.env, ...env }, timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024 });
@@ -321,6 +444,69 @@ export const startFormalAiServer = async ({ cwd, host = FORMAL_AI_DEFAULT_HOST,
321
444
  };
322
445
  };
323
446
 
447
+ /**
448
+ * The provenance Hive Mind's sidecar lifecycle publishes for the container it
449
+ * leased to this task (issues #2207, #2208).
450
+ *
451
+ * The lease pins one verified image for the whole task, so these values say
452
+ * which release *should* be answering. They are a cross-check, never a
453
+ * substitute for asking the endpoint itself.
454
+ */
455
+ export const readFormalAiSidecarProvenance = (env = process.env) => {
456
+ const read = name => String(env[name] || '').trim() || null;
457
+ const image = read(FORMAL_AI_SIDECAR_PROVENANCE_ENV.image);
458
+ const imageDigest = read(FORMAL_AI_SIDECAR_PROVENANCE_ENV.imageDigest);
459
+ const version = read(FORMAL_AI_SIDECAR_PROVENANCE_ENV.version);
460
+ const imageSource = read(FORMAL_AI_SIDECAR_PROVENANCE_ENV.imageSource);
461
+ if (!image && !imageDigest && !version) return null;
462
+ return { image, imageDigest, version, imageSource };
463
+ };
464
+
465
+ /**
466
+ * Publish the leased sidecar's identity into a task's environment so the runtime
467
+ * inside the container can cross-check the endpoint it is pointed at.
468
+ *
469
+ * @param {object|null} sidecar - An `acquireFormalAiSidecar` result.
470
+ * @returns {object} Environment entries (empty when nothing is known).
471
+ */
472
+ export const buildFormalAiSidecarProvenanceEnv = (sidecar = null) => {
473
+ if (!sidecar) return {};
474
+ const entries = {
475
+ [FORMAL_AI_SIDECAR_PROVENANCE_ENV.image]: sidecar.imageReference || sidecar.image || null,
476
+ [FORMAL_AI_SIDECAR_PROVENANCE_ENV.imageDigest]: sidecar.imageDigest || null,
477
+ [FORMAL_AI_SIDECAR_PROVENANCE_ENV.version]: sidecar.servingVersion || sidecar.health?.version || null,
478
+ [FORMAL_AI_SIDECAR_PROVENANCE_ENV.imageSource]: sidecar.imageSource || null,
479
+ };
480
+ return Object.fromEntries(Object.entries(entries).filter(([, value]) => value));
481
+ };
482
+
483
+ /** One-line description of the backend for logs and session provenance. */
484
+ const describeFormalAiBackend = backend => [`${backend.version} at ${backend.baseUrl}`, backend.image ? `image ${backend.image}` : null, backend.imageDigest ? `digest ${backend.imageDigest}` : null].filter(Boolean).join(', ');
485
+
486
+ /**
487
+ * Query the endpoint that will serve this task and build its provenance record.
488
+ *
489
+ * @returns {Promise<object>} `{ baseUrl, version, memory, image, imageDigest, imageSource, leased, startedLocally, probedAt }`
490
+ */
491
+ const resolveFormalAiBackend = async ({ baseUrl, apiKey, env, deps, startedLocally }) => {
492
+ const sidecar = readFormalAiSidecarProvenance(env);
493
+ const probe = await (deps.probeBackendImpl || probeFormalAiBackend)({ baseUrl, apiKey, env });
494
+ const { version, memory } = assertSupportedFormalAiBackend(probe, { baseUrl, expectedVersion: sidecar?.version ?? null });
495
+ return {
496
+ baseUrl,
497
+ version,
498
+ memory,
499
+ image: sidecar?.image ?? null,
500
+ imageDigest: sidecar?.imageDigest ?? null,
501
+ imageSource: sidecar?.imageSource ?? null,
502
+ /** True when Hive Mind leased this endpoint from its own sidecar. */
503
+ leased: !!sidecar,
504
+ /** True when this process started the server it is now talking to. */
505
+ startedLocally,
506
+ probedAt: new Date().toISOString(),
507
+ };
508
+ };
509
+
324
510
  const runtimeCache = new Map();
325
511
  let exitHookInstalled = false;
326
512
 
@@ -357,16 +543,32 @@ export const prepareFormalAiRuntime = async ({ tool, workdir, log = async () =>
357
543
  const resolvedFormalAiPath = formalAiPath || env.HIVE_MIND_FORMAL_AI_PATH?.trim() || 'formal-ai';
358
544
  const cacheKey = `${tool}::${workdir}::${env.HIVE_MIND_FORMAL_AI_BASE_URL || ''}`;
359
545
  const cached = runtimeCache.get(cacheKey);
360
- if (cached) return cached.runtime;
546
+ if (cached) {
547
+ // Issue #2208: the cache key is the endpoint, not the release behind it. An
548
+ // external base URL can be re-pointed at a different container between
549
+ // tasks, so the cached provenance is re-checked instead of replayed.
550
+ const backend = await resolveFormalAiBackend({ baseUrl: cached.runtime.baseUrl, apiKey: resolveFormalAiApiKey(env), env, deps, startedLocally: cached.runtime.serverStarted });
551
+ if (backend.version !== cached.runtime.backend?.version || backend.imageDigest !== cached.runtime.backend?.imageDigest) {
552
+ await log(`🧠 Formal AI: serving backend changed to ${describeFormalAiBackend(backend)}`);
553
+ }
554
+ cached.runtime.backend = backend;
555
+ cached.runtime.formalAiVersion = backend.version;
556
+ return cached.runtime;
557
+ }
361
558
 
362
559
  installExitHook();
363
560
 
364
561
  // Issue #2146: `--no-tool-check` skipped the only version probe, allowing an
365
562
  // old Formal AI build to return the same unexecuted plan through all five
366
563
  // Claude/Codex restarts. Runtime safety cannot depend on preflight options.
367
- const formalAiVersion = await (deps.readVersionImpl || readFormalAiBinaryVersion)({ formalAiPath: resolvedFormalAiPath, env });
368
- assertSupportedFormalAiVersion(formalAiVersion);
369
- await log(`🧠 Formal AI: version ${formalAiVersion} (minimum ${FORMAL_AI_MINIMUM_VERSION})`);
564
+ //
565
+ // This is the *local wrapper*: the executable that starts the server and
566
+ // writes the client configuration. Issue #2208: it is not necessarily the
567
+ // release that answers the model requests, so it keeps its own name and its
568
+ // own compatibility check, and it is never reported as the serving version.
569
+ const formalAiWrapperVersion = await (deps.readVersionImpl || readFormalAiBinaryVersion)({ formalAiPath: resolvedFormalAiPath, env });
570
+ assertSupportedFormalAiVersion(formalAiWrapperVersion);
571
+ await log(`🧠 Formal AI: local wrapper version ${formalAiWrapperVersion} (minimum ${FORMAL_AI_MINIMUM_VERSION})`);
370
572
 
371
573
  const apiKey = resolveFormalAiApiKey(env);
372
574
  const externalBaseUrl = env.HIVE_MIND_FORMAL_AI_BASE_URL?.trim() || null;
@@ -386,6 +588,13 @@ export const prepareFormalAiRuntime = async ({ tool, workdir, log = async () =>
386
588
  await log(`🧠 Formal AI: using the configured server ${baseUrl}`, { verbose: true });
387
589
  }
388
590
 
591
+ // Before any client configuration is written, ask the endpoint who it is.
592
+ const backend = await resolveFormalAiBackend({ baseUrl, apiKey, env, deps, startedLocally: !!server });
593
+ await log(`🧠 Formal AI: serving backend ${describeFormalAiBackend(backend)}`);
594
+ if (backend.version !== formalAiWrapperVersion) {
595
+ await log(`🧠 Formal AI: local wrapper ${formalAiWrapperVersion} differs from the serving backend ${backend.version}; provenance records the backend`, { verbose: true });
596
+ }
597
+
389
598
  const clients = await (deps.loadRegistryImpl || loadFormalAiClientRegistry)({ formalAiPath: resolvedFormalAiPath, env });
390
599
  const client = findFormalAiClient(clients, tool);
391
600
  if (!client) throw new Error(`Formal AI does not list a client configuration for "${tool}"`);
@@ -429,7 +638,11 @@ export const prepareFormalAiRuntime = async ({ tool, workdir, log = async () =>
429
638
  client,
430
639
  notes,
431
640
  serverStarted: !!server,
432
- formalAiVersion,
641
+ /** The release that actually answers this task's model requests. */
642
+ formalAiVersion: backend.version,
643
+ /** The local executable that started the server and wrote the config. */
644
+ formalAiWrapperVersion,
645
+ backend,
433
646
  stop: async () => {
434
647
  runtimeCache.delete(cacheKey);
435
648
  await server?.stop?.();
@@ -46,7 +46,7 @@ import fs from 'node:fs';
46
46
  import { promisify } from 'node:util';
47
47
 
48
48
  import { FORMAL_AI_MINIMUM_VERSION, isFormalAiVersionAtLeast } from './formal-ai-version.lib.mjs';
49
- import { ensureFormalAiSidecarImage, resolveFormalAiSidecarImage } from './formal-ai-image.lib.mjs';
49
+ import { ensureFormalAiSidecarImage, readAcceptedFormalAiImage, resolveFormalAiSidecarImage } from './formal-ai-image.lib.mjs';
50
50
  import { isFormalAiModel } from './formal-ai-model.lib.mjs';
51
51
  import { getModelFromArgs } from './model-args.lib.mjs';
52
52
  import { withStateLock } from './state-lock.lib.mjs';
@@ -90,7 +90,7 @@ export const FORMAL_AI_MEMORY_PATH = `${FORMAL_AI_MEMORY_MOUNT}/memory.lino`;
90
90
  * issue #2154 taught us that "which image" and "is it actually pullable" are the
91
91
  * same question.
92
92
  */
93
- export { FORMAL_AI_IMAGE_REPOSITORY, resolveFormalAiSidecarImage, resolveFormalAiSidecarImageCandidates } from './formal-ai-image.lib.mjs';
93
+ export { FORMAL_AI_IMAGE_REPOSITORY, readAcceptedFormalAiImage, resolveAcceptedFormalAiImageCandidates, resolveFormalAiSidecarImage, resolveFormalAiSidecarImageCandidates } from './formal-ai-image.lib.mjs';
94
94
 
95
95
  /** Applied to the sidecar, its network and its volume so reconciliation can find them. */
96
96
  export const FORMAL_AI_SIDECAR_LABEL = 'com.link-assistant.hive-mind.formal-ai';
@@ -102,7 +102,13 @@ const SIDECAR_LOCK_NAME = 'formal-ai-sidecar';
102
102
  const DEFAULT_HEALTH_ATTEMPTS = 60;
103
103
  const DEFAULT_HEALTH_DELAY_MS = 1000;
104
104
 
105
- const EMPTY_STATE = Object.freeze({ version: 1, image: null, imageDigest: null, startedAt: null, leases: [], lastUpdate: null });
105
+ /**
106
+ * `lastUpdate` is the accepted-release record (issue #2207) and `serving` is the
107
+ * provenance of the container that last answered a lease (issue #2208). Neither
108
+ * is derivable from `image`/`imageDigest`, which are only a cache of whatever is
109
+ * running right now.
110
+ */
111
+ const EMPTY_STATE = Object.freeze({ version: 1, image: null, imageReference: null, imageDigest: null, startedAt: null, leases: [], lastUpdate: null, serving: null });
106
112
 
107
113
  /**
108
114
  * True when a task will be driven by Formal AI.
@@ -350,14 +356,21 @@ export const acquireFormalAiSidecar = async ({ sessionId, tool = null, model = n
350
356
  // #2154 the reference went straight into `docker run`, so a registry that
351
357
  // refused the pull surfaced as an unreadable `Command failed: docker run …`
352
358
  // dump and the task died even though a usable image sat on the host.
353
- const resolved = container.exists && container.image ? { image: container.image, source: 'running-sidecar', pulled: false } : await ensureFormalAiSidecarImage({ env, run, timeoutMs: imageTimeoutMs, log, verbose });
359
+ //
360
+ // `accepted` is what issue #2207 was missing: without it the candidate
361
+ // list was rebuilt from the bootstrap pin on every cold start, so an
362
+ // update that had already been pulled, migrated and verified was silently
363
+ // discarded by the very next task.
364
+ const accepted = readAcceptedFormalAiImage(state);
365
+ const resolved = container.exists && container.image ? { image: container.image, reference: container.image, source: 'running-sidecar', pulled: false } : await ensureFormalAiSidecarImage({ env, accepted, run, timeoutMs: imageTimeoutMs, log, verbose });
354
366
  const image = resolved.image;
367
+ const imageReference = resolved.reference || image;
355
368
 
356
369
  await ensureFormalAiNetwork({ run, timeoutMs, log, verbose });
357
370
  await ensureFormalAiMemoryVolume({ image, run, timeoutMs, log, verbose });
358
371
 
359
372
  if (!container.exists) {
360
- if (log) await log(`🧠 Starting the Formal AI sidecar (${image}, ${resolved.source}) on the internal network '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
373
+ if (log) await log(`🧠 Starting the Formal AI sidecar (${imageReference}${imageReference === image ? '' : ` @ ${image}`}, ${resolved.source}) on the internal network '${FORMAL_AI_SIDECAR_NETWORK_NAME}'`);
361
374
  await dockerText(run, buildFormalAiSidecarRunArgs({ image, env }), { timeoutMs });
362
375
  container = await inspectDockerContainer(FORMAL_AI_SIDECAR_CONTAINER_NAME, { run, timeoutMs });
363
376
  }
@@ -375,15 +388,27 @@ export const acquireFormalAiSidecar = async ({ sessionId, tool = null, model = n
375
388
  // and then fail on the agent-mode API the tasks depend on.
376
389
  const reportedVersion = health.health?.version ?? null;
377
390
  if (reportedVersion && !isFormalAiVersionAtLeast(reportedVersion, FORMAL_AI_MINIMUM_VERSION)) {
378
- throw new Error(`Formal AI sidecar image ${image} (${resolved.source}) runs formal-ai ${reportedVersion}, but Hive Mind requires >= ${FORMAL_AI_MINIMUM_VERSION}. Rebuild or repin the image (HIVE_MIND_FORMAL_AI_IMAGE) before running Formal AI tasks.`);
391
+ throw new Error(`Formal AI sidecar image ${imageReference} (${resolved.source}) runs formal-ai ${reportedVersion}, but Hive Mind requires >= ${FORMAL_AI_MINIMUM_VERSION}. Rebuild or repin the image (HIVE_MIND_FORMAL_AI_IMAGE) before running Formal AI tasks.`);
379
392
  }
380
393
 
381
394
  const address = await readFormalAiSidecarAddress({ run, timeoutMs });
382
395
  const acquiredAt = now().toISOString();
383
396
  const nextLeases = [...leases.filter(lease => lease.sessionId !== sessionId), { sessionId, tool, model, acquiredAt }];
384
- writeFormalAiSidecarState({ ...state, image: container.image || image, imageDigest: container.imageDigest, startedAt: state.startedAt || acquiredAt, leases: nextLeases }, { env, fsImpl });
397
+ // The provenance of what is *actually serving* this lease, so a task's
398
+ // evidence can name the release that answered its requests rather than
399
+ // whatever binary happens to sit next to the wrapper (issue #2208).
400
+ const serving = {
401
+ image: imageReference,
402
+ imageDigest: container.imageDigest ?? resolved.digest ?? null,
403
+ imageSource: resolved.source,
404
+ version: reportedVersion,
405
+ memorySchemaVersion: health.health?.memory?.schema_version ?? null,
406
+ acceptedAt: accepted?.updatedAt ?? null,
407
+ observedAt: acquiredAt,
408
+ };
409
+ writeFormalAiSidecarState({ ...state, image: container.image || image, imageReference, imageDigest: container.imageDigest, startedAt: state.startedAt || acquiredAt, leases: nextLeases, serving }, { env, fsImpl });
385
410
 
386
- if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: lease '${sessionId}' acquired (${nextLeases.length} active), image=${container.image || image} (${resolved.source}), digest=${container.imageDigest ?? 'unknown'}, address=${address ?? 'unknown'}, formal-ai=${health.health?.version ?? 'unknown'}, memory schema=${health.health?.memory?.schema_version ?? 'unknown'}`);
411
+ if (verbose && log) await log(`[VERBOSE] formal-ai-sidecar: lease '${sessionId}' acquired (${nextLeases.length} active), image=${imageReference} (${resolved.source}), digest=${container.imageDigest ?? 'unknown'}, address=${address ?? 'unknown'}, formal-ai=${reportedVersion ?? 'unknown'}, memory schema=${serving.memorySchemaVersion ?? 'unknown'}`);
387
412
 
388
413
  return {
389
414
  address,
@@ -394,8 +419,14 @@ export const acquireFormalAiSidecar = async ({ sessionId, tool = null, model = n
394
419
  containerName: FORMAL_AI_SIDECAR_CONTAINER_NAME,
395
420
  memoryVolume: FORMAL_AI_MEMORY_VOLUME_NAME,
396
421
  image: container.image || image,
422
+ imageReference,
397
423
  imageSource: resolved.source,
398
- imageDigest: container.imageDigest,
424
+ imageDigest: serving.imageDigest,
425
+ acceptedImage: accepted?.image ?? null,
426
+ acceptedDigest: accepted?.digest ?? null,
427
+ acceptedVersion: accepted?.version ?? null,
428
+ servingVersion: reportedVersion,
429
+ memorySchemaVersion: serving.memorySchemaVersion,
399
430
  health: health.health,
400
431
  leaseCount: nextLeases.length,
401
432
  };
@@ -453,6 +484,7 @@ export default {
453
484
  inspectDockerContainer,
454
485
  isFormalAiSidecarEnabled,
455
486
  isFormalAiTask,
487
+ readAcceptedFormalAiImage,
456
488
  readDockerImageDigest,
457
489
  readFormalAiSidecarAddress,
458
490
  readFormalAiSidecarState,
@@ -92,6 +92,11 @@ export const resolveFormalAiToolExecution = async ({ tool, model, toolPath, work
92
92
  env: runtime.env,
93
93
  home: runtime.home,
94
94
  client: runtime.client,
95
+ // Issue #2208: provenance names the backend that answered, and the local
96
+ // wrapper under its own name — the two are not interchangeable.
97
+ backend: runtime.backend,
98
+ formalAiVersion: runtime.formalAiVersion,
99
+ formalAiWrapperVersion: runtime.formalAiWrapperVersion,
95
100
  stop: runtime.stop,
96
101
  };
97
102
  };
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Shared runner for the `hive-models` bin command (issue #2202, R5).
5
+ *
6
+ * R5 asks for a listing that merges what this installation ships with what the
7
+ * providers are serving right now, "from fully supported, to hot loaded", per
8
+ * tool. This module is the CLI half of that; `telegram-models-command.lib.mjs`
9
+ * is the `/models` half, and both render through
10
+ * `model-catalogue-render.lib.mjs` so the two can never disagree.
11
+ *
12
+ * R6 is honoured here too: before printing a catalogue the runner gives the
13
+ * agentic CLIs a chance to update, because a stale `codex` binary is exactly
14
+ * what makes a new model look unavailable.
15
+ *
16
+ * @see https://github.com/link-assistant/hive-mind/issues/2202
17
+ */
18
+
19
+ import { ensureAgenticCliFreshness, describeFreshnessResult } from './agentic-cli-freshness.lib.mjs';
20
+ import { parseCliArgumentsWithLino } from './cli-arguments.lib.mjs';
21
+ import { MODEL_CATALOGUE_TOOLS, getMergedModelCatalogue } from './model-catalogue.lib.mjs';
22
+ import { formatModelCatalogueText } from './model-catalogue-render.lib.mjs';
23
+
24
+ export const HIVE_MODELS_HELP = `Usage: hive-models [--tool <name>...] [--refresh] [--details] [--json] [--no-update] [--verbose]
25
+
26
+ List the models Hive Mind can drive, merged from every source that can be read
27
+ without spending a token — the router's live catalogue, the provider listing
28
+ endpoints, the codex CLI's own catalogue, models.dev metadata, and the models
29
+ bundled with this installation.
30
+
31
+ Models are grouped so it is obvious what each one is:
32
+ Bundled and live shipped here and confirmed reachable now
33
+ Hot loaded a live source has it, this installation does not ship it
34
+ Bundled only shipped here, no live source confirmed it
35
+
36
+ Options:
37
+ -t, --tool <name> Restrict to one tool (${MODEL_CATALOGUE_TOOLS.join(', ')}).
38
+ Repeatable; defaults to every tool.
39
+ --refresh Ignore the cached answer and re-read every live source
40
+ --details Show context window, pricing, and which source had it
41
+ --json Print machine-readable JSON instead of text
42
+ --no-update Do not check the agentic CLIs for a newer version first
43
+ (also spelled --no-tool-update, as in /solve and /task)
44
+ -v, --verbose Print diagnostics to stderr
45
+ -h, --help Show this help and exit
46
+
47
+ Environment:
48
+ HIVE_MIND_MODELS_HOT_LOAD=0 Only list the bundled catalogue
49
+ HIVE_MIND_MODELS_ROUTER=0 Skip the router source specifically
50
+ HIVE_MIND_MODEL_CATALOGUE_TTL_MINUTES Raise the 60 minute cache lifetime
51
+ HIVE_MIND_AGENTIC_CLI_AUTO_UPDATE=0 Never update the CLIs
52
+
53
+ Examples:
54
+ hive-models # every tool, cached answers
55
+ hive-models --tool codex # just codex
56
+ hive-models --tool claude --details --refresh
57
+ hive-models --json | jq '.tools.claude.liveOnly'
58
+
59
+ Reference:
60
+ https://github.com/link-assistant/hive-mind/issues/2202
61
+ `;
62
+
63
+ const VALUE_FLAGS = new Set(['--tool', '-t']);
64
+ const BOOLEAN_FLAGS = new Set(['--refresh', '--details', '--json', '--no-update', '--no-tool-update', '--verbose', '-v', '--help', '-h']);
65
+
66
+ // `/solve`, `/hive` and `/task` spell the opt-out `--no-tool-update` (it lives in
67
+ // their `tool-*` namespace). Accept that spelling here too, so the flag an
68
+ // operator already knows works everywhere it makes sense (issue #2202, R6).
69
+ const normaliseUpdateFlag = arg => (arg === '--no-tool-update' ? '--no-update' : arg);
70
+
71
+ const createHiveModelsYargsConfig = yargsInstance => yargsInstance.usage('Usage: hive-models [--tool <name>...] [--refresh] [--details] [--json] [--no-update] [--verbose]').option('tool', { type: 'array', alias: 't', default: [] }).option('refresh', { type: 'boolean', default: false }).option('details', { type: 'boolean', default: false }).option('json', { type: 'boolean', default: false }).option('update', { type: 'boolean', default: true }).option('verbose', { type: 'boolean', alias: 'v', default: false }).option('help', { type: 'boolean', alias: 'h', default: false }).help(false).version(false).strict(false);
72
+
73
+ /**
74
+ * Parse argv for `hive-models`. Returns `error` as a string rather than
75
+ * throwing, so the bin can print it and exit non-zero.
76
+ */
77
+ export const parseHiveModelsArgs = argv => {
78
+ const result = { tools: [], refresh: false, details: false, json: false, update: true, verbose: false, help: false, error: null };
79
+ const help = argv.includes('--help') || argv.includes('-h');
80
+
81
+ for (let index = 0; index < argv.length; index += 1) {
82
+ const arg = argv[index];
83
+ const [name] = arg.split('=');
84
+ if (VALUE_FLAGS.has(name)) {
85
+ if (!arg.includes('=')) index += 1;
86
+ continue;
87
+ }
88
+ if (!BOOLEAN_FLAGS.has(arg)) {
89
+ result.error = `Unknown option: ${arg}`;
90
+ return result;
91
+ }
92
+ }
93
+
94
+ let parsed;
95
+ try {
96
+ parsed = parseCliArgumentsWithLino({
97
+ argv: argv.filter(arg => arg !== '--help' && arg !== '-h').map(normaliseUpdateFlag),
98
+ commandName: 'hive-models',
99
+ createYargsConfig: createHiveModelsYargsConfig,
100
+ lenv: { enabled: false },
101
+ getenv: { enabled: false },
102
+ });
103
+ } catch (err) {
104
+ result.error = err.message || String(err);
105
+ return result;
106
+ }
107
+
108
+ result.help = help;
109
+ result.refresh = parsed.refresh === true;
110
+ result.details = parsed.details === true;
111
+ result.json = parsed.json === true;
112
+ result.update = parsed.update !== false;
113
+ result.verbose = parsed.verbose === true || parsed.v === true;
114
+
115
+ const requested = []
116
+ .concat(parsed.tool ?? [])
117
+ .flatMap(entry =>
118
+ String(entry)
119
+ .split(',')
120
+ .map(part => part.trim().toLowerCase())
121
+ )
122
+ .filter(Boolean);
123
+ for (const tool of requested) {
124
+ if (!MODEL_CATALOGUE_TOOLS.includes(tool)) {
125
+ result.error = `Unknown tool: ${tool}. Known tools: ${MODEL_CATALOGUE_TOOLS.join(', ')}`;
126
+ return result;
127
+ }
128
+ if (!result.tools.includes(tool)) result.tools.push(tool);
129
+ }
130
+ if (result.tools.length === 0) result.tools = [...MODEL_CATALOGUE_TOOLS];
131
+ return result;
132
+ };
133
+
134
+ /**
135
+ * Top-level orchestrator used by the bin. `deps` is injected so tests can run
136
+ * the whole command without a network, a router, or a package registry.
137
+ */
138
+ export const runHiveModels = async (argv, deps = {}) => {
139
+ const { env = process.env, log = (...args) => console.log(...args), error = (...args) => console.error(...args), loadCatalogue = getMergedModelCatalogue, freshness = ensureAgenticCliFreshness } = deps;
140
+
141
+ const args = parseHiveModelsArgs(argv);
142
+ if (args.help) {
143
+ log(HIVE_MODELS_HELP);
144
+ return 0;
145
+ }
146
+ if (args.error) {
147
+ error(args.error);
148
+ return 1;
149
+ }
150
+
151
+ const debug = args.verbose ? (...parts) => error('[hive-models]', ...parts) : () => {};
152
+
153
+ // R6: refresh the CLIs before answering, so the list describes the binaries
154
+ // the next run will actually use. Best-effort — never fatal.
155
+ const refreshed = await freshness({ tools: args.tools, env, verbose: args.verbose, enabled: args.update, log: async message => debug(message) });
156
+ debug(`cli freshness: ${refreshed.status}${refreshed.reason ? ` (${refreshed.reason})` : ''}`);
157
+ const freshnessLine = describeFreshnessResult(refreshed);
158
+
159
+ const results = {};
160
+ let failures = 0;
161
+ for (const tool of args.tools) {
162
+ try {
163
+ results[tool] = await loadCatalogue({ tool, env, refresh: args.refresh, log: async message => debug(message) });
164
+ } catch (err) {
165
+ failures += 1;
166
+ error(`Could not build the ${tool} catalogue: ${err?.message ?? err}`);
167
+ }
168
+ }
169
+
170
+ if (args.json) {
171
+ log(JSON.stringify({ generatedAt: new Date().toISOString(), cliUpdate: refreshed, tools: results }, null, 2));
172
+ return failures > 0 && Object.keys(results).length === 0 ? 1 : 0;
173
+ }
174
+
175
+ if (freshnessLine) log(freshnessLine);
176
+ const sections = Object.values(results).map(merged => formatModelCatalogueText(merged, { details: args.details, defaultModel: merged.default }));
177
+ log(sections.join('\n\n'));
178
+ return failures > 0 && sections.length === 0 ? 1 : 0;
179
+ };
180
+
181
+ export default { HIVE_MODELS_HELP, parseHiveModelsArgs, runHiveModels };
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * `hive-models` — list every model Hive Mind can drive, merging the models
5
+ * bundled with this installation with the ones the providers are serving right
6
+ * now (issue #2202, R5).
7
+ *
8
+ * Live sources are read only through endpoints that cannot bill a token, and
9
+ * the merged answer is cached for an hour, so running this repeatedly is free.
10
+ *
11
+ * See issue #2202.
12
+ */
13
+
14
+ import { runHiveModels } from './hive-models.lib.mjs';
15
+ import { setupStdioLogInterceptor } from './lib.mjs';
16
+
17
+ setupStdioLogInterceptor();
18
+
19
+ const exitCode = await runHiveModels(process.argv.slice(2));
20
+ process.exit(exitCode);
@@ -19,7 +19,7 @@ import fs from 'node:fs';
19
19
  import os from 'node:os';
20
20
  import path from 'node:path';
21
21
  import { isExecutingSessionStatus, isTerminalSessionStatus } from './session-status.lib.mjs';
22
- import { acquireFormalAiSidecarForTask, attachFormalAiTaskContainer, releaseFormalAiSidecarForTask } from './formal-ai-isolation.lib.mjs';
22
+ import { acquireFormalAiSidecarForTask, attachFormalAiTaskContainer, buildFormalAiTaskEnv, releaseFormalAiSidecarForTask } from './formal-ai-isolation.lib.mjs';
23
23
  // The image references live in their own module so the Formal AI sidecar can
24
24
  // resolve the locally present Hive Mind image (which bakes `formal-ai`) without
25
25
  // importing this runner and creating a cycle. Re-exported here because callers
@@ -399,7 +399,7 @@ export async function executeWithIsolation(command, args, options = {}) {
399
399
  await releaseFormalAiSidecarForTask({ sidecar, sessionId, env: hostEnv, verbose });
400
400
  return failLaunch(formalAiRoutingError);
401
401
  }
402
- const taskEnv = sidecar ? { ...hostEnv, HIVE_MIND_FORMAL_AI_BASE_URL: sidecar.baseUrl } : hostEnv;
402
+ const taskEnv = buildFormalAiTaskEnv({ sidecar, env: hostEnv });
403
403
  const effectiveOptions =
404
404
  backend === 'docker'
405
405
  ? {
@@ -590,6 +590,7 @@ en
590
590
  usage "Usage: `/hive <github-url> [options]`"
591
591
  example "Example: `/hive https://github.com/owner/repo`"
592
592
  disabled "*/hive* - ❌ Disabled"
593
+ models "*/models* - List available models, merged from this installation and every live source. Usage: `/models [--tool claude|codex|...] [--details] [--refresh] [--all]`"
593
594
  limits "*/limits* - Show usage limits"
594
595
  version "*/version* - Show bot and runtime versions"
595
596
  language "*/language* `[en|ru|zh|hi]` - Set or show your preferred reply language (in-memory only, per-user)"
@@ -611,7 +612,7 @@ en
611
612
  isolation
612
613
  mode "🔒 *Isolation Mode:* `{{isolationBackend}}` (experimental)"
613
614
  group
614
- note "⚠️ *Note:* /solve, /do, /continue, /claude, /codex, /opencode, /agent, /gemini, /qwen, /task, /split, /fix, /hive, /queue, /limits, /version, /accept\\_invites, /merge, /stop and /start commands only work in group chats. /terminal\\_watch, /watch, /subscribe and /unsubscribe work in private and group chats."
615
+ note "⚠️ *Note:* /solve, /do, /continue, /claude, /codex, /opencode, /agent, /gemini, /qwen, /task, /split, /fix, /hive, /queue, /models, /limits, /version, /accept\\_invites, /merge, /stop and /start commands only work in group chats. /terminal\\_watch, /watch, /subscribe and /unsubscribe work in private and group chats."
615
616
  common
616
617
  options "🔧 *Common Options:*"
617
618
  model