@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
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Router route dialects (issue #2202).
3
+ *
4
+ * Router 1.0.0 replaced every public route with one classified namespace —
5
+ * `/api/health`, `/api/management/*`, `/api/services/*` — and removed all the
6
+ * legacy root, `/v1/*` and overlapping `/api/*` aliases (upstream router#391).
7
+ * The two surfaces are **disjoint**: probing `0.119.0` and `1.2.0` side by side
8
+ * with Hive Mind's own `serve` arguments, every path that answers on one is a
9
+ * 404 on the other (`docs/case-studies/issue-2202/data/measurements/
10
+ * router-route-comparison-2026-09-04.md`, reproducible with
11
+ * `experiments/issue-2202/compare-router-routes.sh`).
12
+ *
13
+ * So `--use-router` cannot hard-code either shape. It has to know which router
14
+ * it is talking to and spell the paths accordingly, which is what this module
15
+ * is: two frozen tables plus the resolution rule, and nothing else. Keeping it
16
+ * a leaf module means `router-isolation.lib.mjs`, `router-sidecar.lib.mjs` and
17
+ * the model catalogue can all derive their URLs from one place, and a future
18
+ * dialect is a table entry rather than a grep.
19
+ *
20
+ * Why both are supported rather than just the newest: on `1.x` the GitHub proxy
21
+ * moved under `/api/services/github/api/v3`, and `gh` builds a custom host's
22
+ * REST base as `https://<host>/api/v3/` with no path-prefix setting — a
23
+ * limitation the router's own release notes state twice. There is therefore no
24
+ * client-side configuration that lets an unmodified `gh` reach the new prefix,
25
+ * and moving the pin unconditionally would delete the `gh` mediation issue
26
+ * #2164 shipped. Until upstream lands a `gh`-reachable base, the default pin
27
+ * stays on the legacy dialect and `1.x` is opt-in via `HIVE_MIND_ROUTER_IMAGE`,
28
+ * with the trade-off reported rather than discovered.
29
+ *
30
+ * @see https://github.com/link-assistant/hive-mind/issues/2202
31
+ * @see https://github.com/link-assistant/hive-mind/issues/2164
32
+ */
33
+
34
+ /**
35
+ * Pre-1.0 routes. Everything hangs off the origin: Anthropic at `/v1/messages`,
36
+ * the OpenAI-compatible surface at `/v1`, GitHub at `/api/v3` and `/api/graphql`,
37
+ * git at `/git`. One `/v1/models` serves every provider the router has adopted,
38
+ * so there is a single catalogue endpoint rather than one per service.
39
+ */
40
+ const LEGACY_DIALECT = Object.freeze({
41
+ id: 'legacy',
42
+ description: 'router < 1.0 (root, /v1/*, /api/v3)',
43
+ health: '/health',
44
+ // Pre-1.0 has no management namespace: token commands are CLI-only.
45
+ management: null,
46
+ services: Object.freeze({
47
+ anthropic: '',
48
+ openai: '/v1',
49
+ codex: '/v1',
50
+ qwen: '/v1',
51
+ // Never served on this dialect; callers must treat null as "not wired".
52
+ gemini: null,
53
+ }),
54
+ github: Object.freeze({
55
+ rest: '/api/v3',
56
+ graphql: '/api/graphql',
57
+ git: '/git',
58
+ }),
59
+ catalogues: Object.freeze([Object.freeze({ service: 'openai', path: '/v1/models', shape: 'openai' })]),
60
+ // An unmodified `gh` reaches the REST proxy, because its own base — /api/v3/ — is where the router serves it.
61
+ ghReachable: true,
62
+ });
63
+
64
+ /**
65
+ * Router >= 1.0. Three namespaces, one per route class, and one catalogue per
66
+ * adopted service. `/api/services/models` does not exist yet — the merged
67
+ * envelope is asked for upstream in link-assistant/router#417, but until it
68
+ * lands a client fans out.
69
+ *
70
+ * The catalogue routes answer a *superset* of the OpenAI envelope: `data` and
71
+ * `object` sit where OpenAI puts them, alongside `using_fallback`,
72
+ * `degraded_providers`, `degraded_reasons` and `catalog_conflicts`. The router
73
+ * keeps its own per-provider catalogue and retains the last known one when a
74
+ * refresh fails, so those fields are the difference between "this is live" and
75
+ * "this is what we last saw" — a reader that takes only `data[].id` discards
76
+ * the one signal that distinguishes them. Measured in
77
+ * `docs/case-studies/issue-2202/data/measurements/router-credentials-and-tokens-2026-09-04.md`.
78
+ */
79
+ const CANONICAL_DIALECT = Object.freeze({
80
+ id: 'canonical',
81
+ description: 'router >= 1.0 (/api/health, /api/management/*, /api/services/*)',
82
+ health: '/api/health',
83
+ management: '/api/management',
84
+ services: Object.freeze({
85
+ anthropic: '/api/services/anthropic',
86
+ openai: '/api/services/openai/v1',
87
+ codex: '/api/services/codex/v1',
88
+ qwen: '/api/services/qwen/v1',
89
+ gemini: '/api/services/gemini',
90
+ }),
91
+ github: Object.freeze({
92
+ rest: '/api/services/github/api/v3',
93
+ graphql: '/api/services/github/api/graphql',
94
+ git: '/api/services/github/git',
95
+ }),
96
+ catalogues: Object.freeze([Object.freeze({ service: 'anthropic', path: '/api/services/anthropic/v1/models', shape: 'anthropic' }), Object.freeze({ service: 'openai', path: '/api/services/openai/v1/models', shape: 'openai' }), Object.freeze({ service: 'codex', path: '/api/services/codex/v1/models', shape: 'openai' }), Object.freeze({ service: 'qwen', path: '/api/services/qwen/v1/models', shape: 'openai' }), Object.freeze({ service: 'gemini', path: '/api/services/gemini/v1beta/models', shape: 'gemini' })]),
97
+ // `gh` has no path-prefix setting, so it cannot prepend /api/services/github.
98
+ ghReachable: false,
99
+ });
100
+
101
+ export const ROUTER_ROUTE_DIALECTS = Object.freeze({
102
+ legacy: LEGACY_DIALECT,
103
+ canonical: CANONICAL_DIALECT,
104
+ });
105
+
106
+ /** The dialect assumed when the router's version cannot be determined. */
107
+ export const ROUTER_DEFAULT_UNKNOWN_DIALECT = 'canonical';
108
+
109
+ /**
110
+ * Which router service each tool speaks to.
111
+ *
112
+ * `agent` is grouped with Claude because it is Anthropic-shaped, and `opencode`
113
+ * with plain OpenAI because it uses chat completions rather than the Codex
114
+ * `responses` wire API.
115
+ */
116
+ export const ROUTER_TOOL_SERVICE = Object.freeze({
117
+ claude: 'anthropic',
118
+ agent: 'anthropic',
119
+ codex: 'codex',
120
+ opencode: 'openai',
121
+ qwen: 'qwen',
122
+ gemini: 'gemini',
123
+ });
124
+
125
+ /**
126
+ * Major version of a pinned router image reference.
127
+ *
128
+ * Handles `repo:1.2.0`, `repo:v1.2.0-rc.1` and a bare `1.2.0`. Returns null for
129
+ * a digest pin, a moving tag like `latest`, or anything else without a leading
130
+ * numeric component — the caller decides what an unknown version means.
131
+ *
132
+ * @param {string} image e.g. `ghcr.io/link-assistant/router:1.2.0`
133
+ * @returns {number|null}
134
+ */
135
+ export function parseRouterImageMajor(image) {
136
+ const raw = String(image || '').trim();
137
+ if (!raw) return null;
138
+ // Strip a digest pin first: it carries no version information at all.
139
+ const withoutDigest = raw.split('@')[0];
140
+ const lastColon = withoutDigest.lastIndexOf(':');
141
+ const lastSlash = withoutDigest.lastIndexOf('/');
142
+ // A colon before the last slash is a registry port, not a tag separator.
143
+ const tag = lastColon > lastSlash ? withoutDigest.slice(lastColon + 1) : withoutDigest;
144
+ const match = /^v?(\d+)\./.exec(tag.trim());
145
+ if (!match) return null;
146
+ return Number(match[1]);
147
+ }
148
+
149
+ /**
150
+ * Decide which route dialect a router speaks.
151
+ *
152
+ * Resolution order, most explicit first:
153
+ *
154
+ * 1. `HIVE_MIND_ROUTER_ROUTES` — an escape hatch for an operator running a
155
+ * build whose tag does not describe it (a fork, a digest pin, `latest`).
156
+ * 2. The pinned image tag, which is what Hive Mind itself starts.
157
+ * 3. {@link ROUTER_DEFAULT_UNKNOWN_DIALECT}, because an untagged or moving
158
+ * reference is far more likely to be a recent build than a pre-1.0 one.
159
+ *
160
+ * @returns {{dialect: object, source: 'env'|'image'|'default', error: string|null}}
161
+ */
162
+ export function resolveRouterRouteDialect({ image = null, env = process.env } = {}) {
163
+ const explicit = String(env?.HIVE_MIND_ROUTER_ROUTES || '')
164
+ .trim()
165
+ .toLowerCase();
166
+ if (explicit) {
167
+ const chosen = ROUTER_ROUTE_DIALECTS[explicit];
168
+ if (chosen) return { dialect: chosen, source: 'env', error: null };
169
+ return {
170
+ dialect: ROUTER_ROUTE_DIALECTS[ROUTER_DEFAULT_UNKNOWN_DIALECT],
171
+ source: 'default',
172
+ error: `HIVE_MIND_ROUTER_ROUTES must be one of ${Object.keys(ROUTER_ROUTE_DIALECTS).join(', ')}: ${explicit}`,
173
+ };
174
+ }
175
+ const major = parseRouterImageMajor(image);
176
+ if (major === null) {
177
+ return { dialect: ROUTER_ROUTE_DIALECTS[ROUTER_DEFAULT_UNKNOWN_DIALECT], source: 'default', error: null };
178
+ }
179
+ return { dialect: major >= 1 ? CANONICAL_DIALECT : LEGACY_DIALECT, source: 'image', error: null };
180
+ }
181
+
182
+ const trimOrigin = baseUrl => String(baseUrl || '').replace(/\/+$/, '');
183
+
184
+ /**
185
+ * Join an origin and a dialect path into a base URL a client can be handed.
186
+ *
187
+ * @returns {string|null} null when either side is missing, so "not served on
188
+ * this dialect" stays distinguishable from "served at the origin" (the
189
+ * Anthropic case on the legacy dialect, where the path is an empty string).
190
+ */
191
+ export function buildRouterRouteUrl(baseUrl, path) {
192
+ const origin = trimOrigin(baseUrl);
193
+ if (!origin || path === null || path === undefined) return null;
194
+ return `${origin}${path}`;
195
+ }
196
+
197
+ /**
198
+ * Base URL for one router service, e.g. `anthropic` or `codex`.
199
+ *
200
+ * @returns {string|null} null when the dialect does not serve it.
201
+ */
202
+ export function buildRouterServiceUrl({ baseUrl, dialect, service } = {}) {
203
+ if (!dialect || !service) return null;
204
+ const path = dialect.services?.[service];
205
+ if (path === null || path === undefined) return null;
206
+ return buildRouterRouteUrl(baseUrl, path);
207
+ }
208
+
209
+ /**
210
+ * Base URL for the service a given tool talks to.
211
+ *
212
+ * @returns {string|null}
213
+ */
214
+ export function buildRouterToolServiceUrl({ baseUrl, dialect, tool } = {}) {
215
+ const service = ROUTER_TOOL_SERVICE[String(tool || '').toLowerCase()];
216
+ if (!service) return null;
217
+ return buildRouterServiceUrl({ baseUrl, dialect, service });
218
+ }
219
+
220
+ /** Absolute URL of the router's unauthenticated health endpoint. */
221
+ export function buildRouterHealthUrl({ baseUrl, dialect } = {}) {
222
+ return buildRouterRouteUrl(baseUrl, dialect?.health);
223
+ }
224
+
225
+ /**
226
+ * Absolute model-catalogue endpoints for a dialect, with the response shape each
227
+ * one returns so a caller can normalise without sniffing.
228
+ *
229
+ * @returns {Array<{service: string, url: string, shape: 'anthropic'|'openai'|'gemini'}>}
230
+ */
231
+ export function buildRouterCatalogueEndpoints({ baseUrl, dialect } = {}) {
232
+ if (!dialect) return [];
233
+ const endpoints = [];
234
+ for (const entry of dialect.catalogues || []) {
235
+ const url = buildRouterRouteUrl(baseUrl, entry.path);
236
+ if (url) endpoints.push({ service: entry.service, url, shape: entry.shape });
237
+ }
238
+ return endpoints;
239
+ }
240
+
241
+ /**
242
+ * Git URL prefix that replaces `https://github.com/`.
243
+ *
244
+ * Always trailing-slashed: git matches `url.<prefix>.insteadOf` textually, and a
245
+ * missing slash silently produces `…/gitowner/repo`.
246
+ */
247
+ export function buildRouterGitUrlPrefix({ baseUrl, dialect } = {}) {
248
+ const url = buildRouterRouteUrl(baseUrl, dialect?.github?.git);
249
+ return url ? `${url}/` : null;
250
+ }
@@ -30,9 +30,9 @@ import os from 'node:os';
30
30
  import path from 'node:path';
31
31
  import { promisify } from 'node:util';
32
32
 
33
- import { attachDockerNetwork, DEFAULT_IMAGE_TIMEOUT_MS, dockerOk, dockerText, ensureDockerVolume, ensureInternalDockerNetwork, inspectDockerContainer, readDockerImageDigest, readSidecarState, reconcileSidecarLeases, resolveSidecarStatePath, sleep, writeSidecarState } from './docker-sidecar.lib.mjs';
33
+ import { attachDockerNetwork, DEFAULT_IMAGE_TIMEOUT_MS, dockerErrorMessage, dockerOk, dockerText, ensureDockerVolume, ensureInternalDockerNetwork, inspectDockerContainer, readDockerImageDigest, readSidecarState, reconcileSidecarLeases, resolveSidecarStatePath, sleep, writeSidecarState } from './docker-sidecar.lib.mjs';
34
34
  import { drainTaskSessionData } from './router-session-drain.lib.mjs';
35
- import { buildRouterTaskWiringScript, getInternalRouterBaseUrl, ROUTER_CREDENTIAL_MOUNTS, ROUTER_DATA_MOUNT, ROUTER_DATA_VOLUME_NAME, ROUTER_GH_CONFIG_MOUNT, ROUTER_SIDECAR_CONTAINER_NAME, ROUTER_SIDECAR_IMAGE, ROUTER_SIDECAR_LABEL, ROUTER_SIDECAR_NETWORK_ALIAS, ROUTER_SIDECAR_NETWORK_NAME, ROUTER_SIDECAR_PORT, ROUTER_TLS_DNS_NAMES, resolveRouterBaseUrl } from './router-isolation.lib.mjs';
35
+ import { buildRouterTaskWiringScript, getInternalRouterBaseUrl, resolveRouterBaseUrl, resolveRouterDialect, resolveRouterSidecarImage, ROUTER_CREDENTIAL_MOUNTS, ROUTER_DATA_MOUNT, ROUTER_DATA_VOLUME_NAME, ROUTER_GH_CONFIG_MOUNT, ROUTER_SIDECAR_CONTAINER_NAME, ROUTER_SIDECAR_LABEL, ROUTER_SIDECAR_NETWORK_ALIAS, ROUTER_SIDECAR_NETWORK_NAME, ROUTER_SIDECAR_PORT, ROUTER_TLS_DNS_NAMES } from './router-isolation.lib.mjs';
36
36
  import { withStateLock } from './state-lock.lib.mjs';
37
37
 
38
38
  const execFileAsync = promisify(execFile);
@@ -65,8 +65,10 @@ export const isRouterSidecarEnabled = (env = process.env) => {
65
65
  return raw !== '0' && raw !== 'false' && raw !== 'no';
66
66
  };
67
67
 
68
- /** Image the sidecar runs, overridable for pinning or a local build. */
69
- export const resolveRouterSidecarImage = (env = process.env) => String(env?.HIVE_MIND_ROUTER_IMAGE || '').trim() || ROUTER_SIDECAR_IMAGE;
68
+ // Image resolution and the route dialect derived from it now live in
69
+ // router-isolation.lib.mjs, the leaf both this module and the task wiring
70
+ // import (issue #2202). Re-exported so callers keep their import site.
71
+ export { resolveRouterDialect, resolveRouterSidecarImage };
70
72
 
71
73
  export const resolveRouterSidecarStatePath = (env = process.env) => resolveSidecarStatePath(STATE_FILE_NAME, env);
72
74
 
@@ -135,6 +137,17 @@ export const buildRouterSidecarRunArgs = ({ image, tokenSecret, credentialMounts
135
137
  args.push('--env', `${mount.envVar}=${mount.target}`, '--volume', `${mount.source}:${mount.target}${mount.readOnly ? ':ro' : ''}`);
136
138
  }
137
139
 
140
+ // The ChatGPT backend gates its newest models behind a recent client version,
141
+ // and answers `Model not found` for them when the header is absent — which is
142
+ // why the pin is at or above 0.120.0, where the router started sending one.
143
+ // Its bundled default tracks a recent Codex CLI, so it is deliberately left
144
+ // alone by default: forwarding whatever `codex` happens to be installed here
145
+ // could send an *older* version than the router already claims and re-gate the
146
+ // models this pin exists to reach. An operator who needs a specific one sets
147
+ // CODEX_CLIENT_VERSION and it is passed straight through.
148
+ const codexClientVersion = String(env?.CODEX_CLIENT_VERSION || '').trim();
149
+ if (codexClientVersion) args.push('--env', `CODEX_CLIENT_VERSION=${codexClientVersion}`);
150
+
138
151
  const extraArgs = String(env?.HIVE_MIND_ROUTER_EXTRA_ARGS || '').trim();
139
152
  if (extraArgs) args.push(...extraArgs.split(/\s+/));
140
153
 
@@ -151,8 +164,12 @@ export const buildRouterSidecarRunArgs = ({ image, tokenSecret, credentialMounts
151
164
  * `bun`, which is used here as the HTTP client instead of adding a dependency to
152
165
  * an image Hive Mind does not own.
153
166
  */
154
- export const checkRouterSidecarHealth = async ({ containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, timeoutMs = 30_000 } = {}) => {
155
- const probe = `fetch("https://127.0.0.1:${ROUTER_SIDECAR_PORT}/health").then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))`;
167
+ export const checkRouterSidecarHealth = async ({ containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, timeoutMs = 30_000, env = process.env, dialect = null } = {}) => {
168
+ // /health on router 0.x, /api/health on 1.x and a 404 either way if the
169
+ // wrong one is asked for, which would read as "unhealthy" and stall the
170
+ // acquire loop until it gave up (issue #2202).
171
+ const healthPath = (dialect || resolveRouterDialect({ env }).dialect).health;
172
+ const probe = `fetch("https://127.0.0.1:${ROUTER_SIDECAR_PORT}${healthPath}").then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))`;
156
173
  // The certificate names the alias, not 127.0.0.1, and this probe is a
157
174
  // liveness check on a loopback socket inside the container — there is no
158
175
  // network for anyone to sit in the middle of. Verification is disabled for
@@ -160,9 +177,12 @@ export const checkRouterSidecarHealth = async ({ containerName = ROUTER_SIDECAR_
160
177
  return dockerOk(run, ['exec', '--env', 'NODE_TLS_REJECT_UNAUTHORIZED=0', containerName, 'bun', '-e', probe], { timeoutMs });
161
178
  };
162
179
 
163
- export const waitForRouterSidecarHealth = async ({ containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, attempts = DEFAULT_HEALTH_ATTEMPTS, delayMs = DEFAULT_HEALTH_DELAY_MS, sleepImpl = sleep, log = null, verbose = false } = {}) => {
180
+ export const waitForRouterSidecarHealth = async ({ containerName = ROUTER_SIDECAR_CONTAINER_NAME, run = execFileAsync, attempts = DEFAULT_HEALTH_ATTEMPTS, delayMs = DEFAULT_HEALTH_DELAY_MS, sleepImpl = sleep, log = null, verbose = false, env = process.env, dialect = null } = {}) => {
181
+ // Resolved once rather than per attempt: the answer cannot change between
182
+ // them, and probing the wrong path would fail every attempt identically.
183
+ const routes = dialect || resolveRouterDialect({ env }).dialect;
164
184
  for (let attempt = 1; attempt <= attempts; attempt += 1) {
165
- if (await checkRouterSidecarHealth({ containerName, run })) {
185
+ if (await checkRouterSidecarHealth({ containerName, run, env, dialect: routes })) {
166
186
  if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: healthy after ${attempt} attempt(s)`);
167
187
  return { healthy: true, attempts: attempt };
168
188
  }
@@ -220,7 +240,7 @@ export const issueRouterTaskToken = async ({ sessionId, containerName = ROUTER_S
220
240
  if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: issued token ${tokenId || '(id unknown)'} for '${sessionId}'`);
221
241
  return { token, tokenId, error: null };
222
242
  } catch (error) {
223
- return { token: null, tokenId: null, error: error?.stderr?.toString?.().trim() || error?.message || String(error) };
243
+ return { token: null, tokenId: null, error: dockerErrorMessage(error) };
224
244
  }
225
245
  };
226
246
 
@@ -355,7 +375,7 @@ export const acquireRouterSidecar = async ({ sessionId, githubRepo = null, env =
355
375
  try {
356
376
  await dockerText(run, buildRouterSidecarRunArgs({ image, tokenSecret, credentialMounts, env }), { timeoutMs });
357
377
  } catch (error) {
358
- return { baseUrl: null, token: null, tokenId: null, leaseCount: state.leases.length, external: false, error: error?.stderr?.toString?.().trim() || error?.message || String(error) };
378
+ return { baseUrl: null, token: null, tokenId: null, leaseCount: state.leases.length, external: false, error: dockerErrorMessage(error) };
359
379
  }
360
380
  startedAt = now().toISOString();
361
381
  if (log) await log(`🔀 Router sidecar started with ${credentialMounts.length} credential mount(s); tasks will not receive vendor credentials directly`);
@@ -365,7 +385,7 @@ export const acquireRouterSidecar = async ({ sessionId, githubRepo = null, env =
365
385
  // acquire, including the ones that reuse a running container.
366
386
  await attachDockerNetwork({ network: ROUTER_SIDECAR_NETWORK_NAME, container: ROUTER_SIDECAR_CONTAINER_NAME, alias: ROUTER_SIDECAR_NETWORK_ALIAS, run, timeoutMs, log, verbose, logPrefix: LOG_PREFIX });
367
387
 
368
- const health = await waitForRouterSidecarHealth({ run, attempts: healthAttempts, delayMs: healthDelayMs, sleepImpl, log, verbose });
388
+ const health = await waitForRouterSidecarHealth({ run, attempts: healthAttempts, delayMs: healthDelayMs, sleepImpl, log, verbose, env });
369
389
  if (!health.healthy) {
370
390
  return { baseUrl: null, token: null, tokenId: null, leaseCount: state.leases.length, external: false, error: 'router sidecar did not become healthy' };
371
391
  }
@@ -432,7 +452,7 @@ export const registerRouterProvider = async ({ providerArgs, containerName = ROU
432
452
  // `docker exec` bypasses the image entrypoint, so the binary is named again.
433
453
  await dockerText(run, ['exec', containerName, 'router', ...providerArgs], { timeoutMs });
434
454
  } catch (error) {
435
- return { registered: false, error: error?.stderr?.toString?.().trim() || error?.message || String(error) };
455
+ return { registered: false, error: dockerErrorMessage(error) };
436
456
  }
437
457
  if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: registered provider '${providerArgs[providerArgs.indexOf('--name') + 1]}'`);
438
458
  return { registered: true, error: null };
@@ -479,7 +499,7 @@ export const wireRouterTaskContainer = async ({ sessionId, tool = 'claude', base
479
499
  try {
480
500
  await dockerText(run, ['exec', '--user', '0', sessionId, 'sh', '-c', script], { timeoutMs });
481
501
  } catch (error) {
482
- return { wired: false, error: error?.stderr?.toString?.().trim() || error?.message || String(error) };
502
+ return { wired: false, error: dockerErrorMessage(error) };
483
503
  }
484
504
  if (verbose && log) await log(`[VERBOSE] ${LOG_PREFIX}: wired '${sessionId}' (CA installed, github=${githubMode}${routerIp ? ` via ${routerIp}` : ''})`);
485
505
  return { wired: true, error: null };
@@ -65,6 +65,11 @@ export const SOLVE_OPTION_DEFINITIONS = {
65
65
  default: false,
66
66
  hidden: true,
67
67
  },
68
+ 'tool-update': {
69
+ type: 'boolean',
70
+ description: 'Check for a newer version of the agentic CLI before starting the task (issue #2202). Use --no-tool-update to skip.',
71
+ default: true,
72
+ },
68
73
  'tool-connection-check': {
69
74
  type: 'boolean',
70
75
  description: 'Perform tool connection check (enabled by default, use --no-tool-connection-check to skip). Does NOT affect model validation.',
@@ -90,6 +90,9 @@ const TIER_ALIASES = {
90
90
  fable: 'fable',
91
91
  'fable-5': 'fable',
92
92
  'claude-fable-5': 'fable',
93
+ // Fable 5.1 is the same escalation tier as Fable 5 (Issue #2202).
94
+ 'fable-5-1': 'fable',
95
+ 'claude-fable-5-1': 'fable',
93
96
  };
94
97
 
95
98
  /**
package/src/solve.mjs CHANGED
@@ -220,6 +220,18 @@ if (argv.subAgentModel) await validateAndExitOnInvalidClaudeSubAgentModel(argv.s
220
220
  // Perform all system checks (skip tool connection check in dry-run or when --skip-tool-connection-check; model validation always runs)
221
221
  const prepareOnly = argv.dryRun || argv.onlyPrepareCommand;
222
222
  const skipToolConnectionCheck = prepareOnly || argv.skipToolConnectionCheck || argv.toolConnectionCheck === false;
223
+ // Issue #2202 (R6): before we start driving the agentic CLI, make sure it is the
224
+ // version the task needs — a stale binary is the usual reason a brand-new model
225
+ // name is rejected. Best effort by design: `ensureAgenticCliFreshness` never
226
+ // throws, and the updater underneath refuses to swap a binary while other tasks
227
+ // are running. This run's own issue is excluded, because the idle gate detects
228
+ // tasks by scanning process command lines and would otherwise find us.
229
+ if (!prepareOnly && argv.toolUpdate !== false) {
230
+ const { describeFreshnessResult, ensureAgenticCliFreshness } = await import('./agentic-cli-freshness.lib.mjs');
231
+ const freshness = await ensureAgenticCliFreshness({ tools: [tool], verbose: argv.verbose, log: message => log(message, { verbose: true }), ignoreTasks: [issueUrl] });
232
+ const freshnessLine = describeFreshnessResult(freshness);
233
+ if (freshnessLine) await log(`🔄 ${freshnessLine}`);
234
+ }
223
235
  const { cascadePlaywrightMcpDisable, ensureSolvePlaywrightMcpReady } = await import('./playwright-mcp.lib.mjs');
224
236
  await cascadePlaywrightMcpDisable(argv, log);
225
237
  if (!(await performSystemChecks(argv.minDiskSpace || 10240, skipToolConnectionCheck, argv.model, argv))) {
@@ -68,6 +68,11 @@ export const createYargsConfig = yargsInstance =>
68
68
  description: '[EXPERIMENTAL] Route model traffic through the hive-mind-router sidecar instead of mounting AI credentials into the task container (issue #2164)',
69
69
  default: false,
70
70
  })
71
+ .option('tool-update', {
72
+ type: 'boolean',
73
+ description: 'Check for a newer version of the agentic CLI before starting the task (issue #2202). Use --no-tool-update to skip.',
74
+ default: true,
75
+ })
71
76
  .option('screen-name', {
72
77
  type: 'string',
73
78
  description: 'Screen session name when --isolation screen is used',
package/src/task.mjs CHANGED
@@ -43,6 +43,7 @@ if (earlyArgs.length === 0 || earlyArgs.includes('--help') || earlyArgs.includes
43
43
  console.log(' --model, -m Model to use');
44
44
  console.log(' --isolation agent-commander isolation mode [default: docker]');
45
45
  console.log(' --use-router [EXPERIMENTAL] Route model traffic through the hive-mind-router sidecar (issue #2164)');
46
+ console.log(' --no-tool-update Skip the agentic CLI version check before starting');
46
47
  console.log(' --dry-run Print split output without creating GitHub issues');
47
48
  console.log(' --verbose, -v Enable verbose logging');
48
49
  console.log(' --output-format Output format (text or json) [default: text]');
@@ -293,6 +294,17 @@ try {
293
294
  await log(formatAligned('🔒', 'Isolation:', argv.isolation));
294
295
  await log(formatAligned('✂️', 'Split mode:', argv.split ? `enabled (count: ${argv.splitCount})` : 'disabled'));
295
296
 
297
+ // Issue #2202 (R6): refresh the agentic CLI before the run drives it, for the
298
+ // same reason /solve does — and, for the same reason, not on a dry run, which
299
+ // drives no CLI at all. Never fatal: a registry outage must not cost the
300
+ // operator their task.
301
+ if (!argv.dryRun && argv.toolUpdate !== false) {
302
+ const { describeFreshnessResult, ensureAgenticCliFreshness } = await import('./agentic-cli-freshness.lib.mjs');
303
+ const freshness = await ensureAgenticCliFreshness({ tools: [argv.tool], verbose: argv.verbose, log: message => log(message, { verbose: true }), ignoreTasks: argv.split ? [taskInput] : [] });
304
+ const freshnessLine = describeFreshnessResult(freshness);
305
+ if (freshnessLine) await log(`🔄 ${freshnessLine}`);
306
+ }
307
+
296
308
  const result = argv.split ? await runSplitMode() : await runClarifyOrDecomposeMode();
297
309
 
298
310
  if (argv.outputFormat === 'json') {
@@ -471,6 +471,9 @@ const { registerMergeCommand } = await import('./telegram-merge-command.lib.mjs'
471
471
  registerMergeCommand(bot, sharedCommandOpts);
472
472
  const { registerSolveQueueCommand } = await import('./telegram-solve-queue-command.lib.mjs');
473
473
  const { handleSolveQueueCommand } = registerSolveQueueCommand(bot, { ...sharedCommandOpts, getSolveQueue, safeReply, resolveLocale: resolveLocaleFromTelegramCtx });
474
+ // Issue #2202 (R5): /models lists the merged model catalogue per tool.
475
+ const { registerModelsCommand } = await import('./telegram-models-command.lib.mjs');
476
+ const { handleModelsCommand } = registerModelsCommand(bot, { ...sharedCommandOpts, safeReply });
474
477
  const { registerSubscribeCommands } = await import('./telegram-subscribers.lib.mjs'); // #1688
475
478
  registerSubscribeCommands(bot, sharedCommandOpts);
476
479
  const { registerTaskCommands } = await import('./telegram-task-command.lib.mjs');
@@ -1027,7 +1030,7 @@ bot.on('message', async (ctx, next) => {
1027
1030
  const solveHandlers = Object.fromEntries(SOLVE_COMMAND_NAMES.map(command => [command, handleSolveCommand]));
1028
1031
  const taskHandlers = Object.fromEntries(TASK_COMMAND_NAMES.map(command => [command, handleTaskCommand]));
1029
1032
  const fixHandlers = Object.fromEntries(FIX_COMMAND_NAMES.map(command => [command, handleFixCommand]));
1030
- const handlers = { ...solveHandlers, ...taskHandlers, ...fixHandlers, auth: handleAuthCommand, hive: handleHiveCommand, queue: handleSolveQueueCommand };
1033
+ const handlers = { ...solveHandlers, ...taskHandlers, ...fixHandlers, auth: handleAuthCommand, hive: handleHiveCommand, queue: handleSolveQueueCommand, models: handleModelsCommand };
1031
1034
 
1032
1035
  const handler = handlers[extracted.command];
1033
1036
  if (!handler) return next();
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Telegram /models command implementation (issue #2202, R5).
3
+ *
4
+ * Shows the merged model catalogue for a tool: what this installation ships,
5
+ * what the live sources are serving right now, and which of the two a given
6
+ * model is in. Every source it reads is a listing endpoint that cannot bill a
7
+ * token (R7), and the answer is cached for an hour (R9), so asking often is
8
+ * free.
9
+ *
10
+ * Usage in chat:
11
+ * /models -> the default tool (claude)
12
+ * /models --tool codex -> one specific tool
13
+ * /models --all -> every tool, one message each
14
+ * /models --details -> add context window and pricing (R8)
15
+ * /models --refresh -> ignore the cache and re-read the sources
16
+ * /models --no-update -> skip the CLI version check (R6)
17
+ *
18
+ * @see https://github.com/link-assistant/hive-mind/issues/2202
19
+ */
20
+
21
+ import { ensureAgenticCliFreshness, describeFreshnessResult } from './agentic-cli-freshness.lib.mjs';
22
+ import { MODEL_CATALOGUE_TOOLS, getMergedModelCatalogue } from './model-catalogue.lib.mjs';
23
+ import { formatModelCatalogueTelegram } from './model-catalogue-render.lib.mjs';
24
+ import { safeReply as defaultSafeReply } from './telegram-safe-reply.lib.mjs';
25
+
26
+ const GROUP_ONLY_MESSAGE = '❌ The /models command only works in group chats. Please add this bot to a group and make it an admin.';
27
+
28
+ /** The tool answered when the operator names none — the one Hive Mind drives by default. */
29
+ export const DEFAULT_MODELS_COMMAND_TOOL = 'claude';
30
+
31
+ /**
32
+ * Parse the argument tail of a `/models` message.
33
+ *
34
+ * Deliberately forgiving: a chat is not a shell, so `--tool codex`,
35
+ * `--tool=codex`, and a bare `codex` all mean the same thing. Anything it
36
+ * cannot make sense of comes back as `error` so the handler can say so instead
37
+ * of silently answering a different question.
38
+ */
39
+ export const parseModelsCommandArgs = (text = '') => {
40
+ const result = { tools: [], all: false, refresh: false, details: false, update: true, error: null };
41
+ const tokens = String(text).trim().split(/\s+/).slice(1).filter(Boolean);
42
+
43
+ for (let index = 0; index < tokens.length; index += 1) {
44
+ const token = tokens[index];
45
+ const lower = token.toLowerCase();
46
+ if (lower === '--all' || lower === 'all') {
47
+ result.all = true;
48
+ continue;
49
+ }
50
+ if (lower === '--refresh' || lower === 'refresh') {
51
+ result.refresh = true;
52
+ continue;
53
+ }
54
+ if (lower === '--details' || lower === '--detail' || lower === 'details') {
55
+ result.details = true;
56
+ continue;
57
+ }
58
+ if (lower === '--no-update' || lower === '--no-tool-update') {
59
+ result.update = false;
60
+ continue;
61
+ }
62
+ let value;
63
+ if (lower.startsWith('--tool=')) value = lower.slice('--tool='.length);
64
+ else if (lower === '--tool' || lower === '-t') value = (tokens[++index] ?? '').toLowerCase();
65
+ else if (!lower.startsWith('-')) value = lower;
66
+ else {
67
+ result.error = `Unknown option: ${token}`;
68
+ return result;
69
+ }
70
+
71
+ for (const entry of value.split(',').filter(Boolean)) {
72
+ if (!MODEL_CATALOGUE_TOOLS.includes(entry)) {
73
+ result.error = `Unknown tool: ${entry}. Known tools: ${MODEL_CATALOGUE_TOOLS.join(', ')}`;
74
+ return result;
75
+ }
76
+ if (!result.tools.includes(entry)) result.tools.push(entry);
77
+ }
78
+ }
79
+
80
+ if (result.all) result.tools = [...MODEL_CATALOGUE_TOOLS];
81
+ if (result.tools.length === 0) result.tools = [DEFAULT_MODELS_COMMAND_TOOL];
82
+ return result;
83
+ };
84
+
85
+ /**
86
+ * Registers the /models command handler with the bot.
87
+ *
88
+ * @param {Object} bot Telegraf bot instance
89
+ * @param {Object} options the shared command options every telegram command takes
90
+ * @returns {{ handleModelsCommand: Function }} the handler, for the text fallback
91
+ */
92
+ export function registerModelsCommand(bot, options = {}) {
93
+ const { VERBOSE = false, isOldMessage, isForwardedOrReply, isGroupChat, isChatAuthorized, isTopicAuthorized, buildAuthErrorMessage, addBreadcrumb, safeReply, loadCatalogue = getMergedModelCatalogue, freshness = ensureAgenticCliFreshness, env = process.env } = options;
94
+
95
+ async function handleModelsCommand(ctx) {
96
+ VERBOSE && console.log('[VERBOSE] /models command received');
97
+
98
+ if (addBreadcrumb) {
99
+ await addBreadcrumb({
100
+ category: 'telegram.command',
101
+ message: '/models command received',
102
+ level: 'info',
103
+ data: { chatId: ctx.chat?.id, chatType: ctx.chat?.type, userId: ctx.from?.id, username: ctx.from?.username },
104
+ });
105
+ }
106
+
107
+ const reply = (text, replyOptions = {}) => (safeReply || defaultSafeReply)(ctx, text, { reply_to_message_id: ctx.message?.message_id, ...replyOptions });
108
+
109
+ if (isOldMessage?.(ctx)) {
110
+ VERBOSE && console.log('[VERBOSE] /models ignored: old message');
111
+ return;
112
+ }
113
+ if (isForwardedOrReply?.(ctx)) {
114
+ VERBOSE && console.log('[VERBOSE] /models ignored: forwarded or reply');
115
+ return;
116
+ }
117
+ if (isGroupChat && !isGroupChat(ctx)) {
118
+ VERBOSE && console.log('[VERBOSE] /models ignored: not a group chat');
119
+ await reply(GROUP_ONLY_MESSAGE);
120
+ return;
121
+ }
122
+ const authorize = isTopicAuthorized || (isChatAuthorized ? context => isChatAuthorized(context.chat.id) : () => true);
123
+ if (!authorize(ctx)) {
124
+ VERBOSE && console.log('[VERBOSE] /models ignored: not authorized');
125
+ await reply(buildAuthErrorMessage ? buildAuthErrorMessage(ctx) : `❌ This chat (ID: ${ctx.chat.id}) is not authorized.`);
126
+ return;
127
+ }
128
+
129
+ const args = parseModelsCommandArgs(ctx.message?.text ?? '');
130
+ if (args.error) {
131
+ await reply(`❌ ${args.error}`);
132
+ return;
133
+ }
134
+
135
+ // R6: give the agentic CLIs a chance to update before we describe what they
136
+ // can run. Best-effort — a failed refresh must not cost the operator their
137
+ // answer, so the outcome is reported and then ignored.
138
+ const refreshed = await freshness({ tools: args.tools, env, enabled: args.update, verbose: VERBOSE, log: async message => VERBOSE && console.log(`[VERBOSE] /models ${message}`) });
139
+ const freshnessLine = describeFreshnessResult(refreshed);
140
+ if (freshnessLine) await reply(freshnessLine);
141
+
142
+ for (const tool of args.tools) {
143
+ try {
144
+ const merged = await loadCatalogue({ tool, env, refresh: args.refresh });
145
+ await reply(formatModelCatalogueTelegram(merged, { details: args.details }));
146
+ } catch (error) {
147
+ await reply(`⚠️ Could not build the ${tool} catalogue: ${error?.message ?? error}`);
148
+ }
149
+ }
150
+ }
151
+
152
+ bot.command(/^models$/i, handleModelsCommand);
153
+
154
+ return { handleModelsCommand };
155
+ }
156
+
157
+ export default { DEFAULT_MODELS_COMMAND_TOOL, parseModelsCommandArgs, registerModelsCommand };
@@ -92,7 +92,7 @@ export function buildTelegramHelpMessage({ locale = null, chatId, chatType = '',
92
92
  message.push('');
93
93
  }
94
94
 
95
- const simpleCommandKeys = ['telegram.help_solve_queue', 'telegram.help_limits', 'telegram.help_version', 'telegram.help_language', 'telegram.help_accept_invites', 'telegram.help_merge', 'telegram.help_merge_usage', 'telegram.help_merge_description', 'telegram.help_subscribe', 'telegram.help_help', 'telegram.help_stop_start', 'telegram.help_stop_uuid', 'telegram.help_log', 'telegram.help_terminal_watch'];
95
+ const simpleCommandKeys = ['telegram.help_solve_queue', 'telegram.help_models', 'telegram.help_limits', 'telegram.help_version', 'telegram.help_language', 'telegram.help_accept_invites', 'telegram.help_merge', 'telegram.help_merge_usage', 'telegram.help_merge_description', 'telegram.help_subscribe', 'telegram.help_help', 'telegram.help_stop_start', 'telegram.help_stop_uuid', 'telegram.help_log', 'telegram.help_terminal_watch'];
96
96
  for (const key of simpleCommandKeys) addLine(message, key, {}, locale);
97
97
  message.push('');
98
98
  addLine(message, 'telegram.help_notifications', {}, locale);