@link-assistant/hive-mind 2.17.0 → 2.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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))) {
@@ -35,6 +35,9 @@ const {
35
35
  } = githubLib;
36
36
 
37
37
  // Import git-related functions for identity validation and repair
38
+ // Issue #2194: recovery diagnostics for URLs that had to be repaired before parsing.
39
+ const { formatUrlRepairs, hasNotableRepair, revealHiddenCharacters } = await import('./github-url-recovery.lib.mjs');
40
+
38
41
  const gitLib = await import('./git.lib.mjs');
39
42
  const { checkGitIdentity, repairGitIdentity } = gitLib;
40
43
 
@@ -83,6 +86,16 @@ export const validateGitHubUrl = issueUrl => {
83
86
  return { isValid: false, isIssueUrl: null, isPrUrl: null };
84
87
  }
85
88
 
89
+ // Issue #2194: the URL needed repair before it could be understood. Say so up
90
+ // front, so a wrong guess is visible before a whole session runs against the
91
+ // wrong entity.
92
+ if (hasNotableRepair(parsedUrl.repairs)) {
93
+ console.error('ℹ️ Repaired the GitHub URL before solving:');
94
+ console.error(` You typed: ${revealHiddenCharacters(issueUrl)}`);
95
+ console.error(` Using: ${parsedUrl.canonical || parsedUrl.normalized}`);
96
+ console.error(` Repaired: ${formatUrlRepairs(parsedUrl.repairs, { notableOnly: true })}`);
97
+ }
98
+
86
99
  // Check if it's an issue or pull request
87
100
  const isIssueUrl = parsedUrl.type === 'issue';
88
101
  const isPrUrl = parsedUrl.type === 'pull';
@@ -102,9 +115,12 @@ export const validateGitHubUrl = issueUrl => {
102
115
  isIssueUrl,
103
116
  isPrUrl,
104
117
  normalizedUrl: parsedUrl.normalized,
118
+ canonicalUrl: parsedUrl.canonical || parsedUrl.normalized,
105
119
  owner: parsedUrl.owner,
106
120
  repo: parsedUrl.repo,
107
121
  number: parsedUrl.number,
122
+ repairs: parsedUrl.repairs || [],
123
+ recovered: Boolean(parsedUrl.recovered),
108
124
  };
109
125
  };
110
126
 
@@ -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') {
@@ -169,6 +169,8 @@ const { formatUsageMessage, formatCodexLimitsSection, getAllCachedLimits } = lim
169
169
  const { handleShowLimitsFlag, captureStartSnapshotAndAppend } = await import('./telegram-show-limits.lib.mjs'); // #594
170
170
  const { getVersionInfo, formatVersionMessage } = await import('./version-info.lib.mjs');
171
171
  const { escapeMarkdown, escapeMarkdownV2, cleanNonPrintableChars, makeSpecialCharsVisible } = await import('./telegram-markdown.lib.mjs');
172
+ const { formatUrlRepairs, hasNotableRepair, namesGitHubHost, revealHiddenCharacters } = await import('./github-url-recovery.lib.mjs'); // #2194
173
+
172
174
  const { getSolveQueue, createQueueExecuteCallback } = await import('./telegram-solve-queue.lib.mjs');
173
175
  const { applySolveToolAlias, getFirstParsedPositionalArg, getSolveCommandNameFromText, getSolveToolAliasFromText, moveArgumentToFront, parseArgsWithYargs, parseCommandArgs, SOLVE_COMMAND_NAMES } = await import('./telegram-solve-command.lib.mjs');
174
176
  const { executeStartScreen: executeStartScreenCommand, buildExecuteAndUpdateMessage } = await import('./telegram-command-execution.lib.mjs');
@@ -315,22 +317,28 @@ async function validateGitHubUrl(args, options = {}) {
315
317
  if (!rawUrl) return { valid: false, error: t('telegram.missing_github_url', { commandName }, { locale }) };
316
318
  // Issue #1102: Clean non-printable chars (Zero-Width Space, BOM, etc.) from URLs
317
319
  const url = cleanNonPrintableChars(rawUrl);
318
- if (!url.includes('github.com')) return { valid: false, error: t('telegram.first_arg_must_be_github_url', {}, { locale }) };
320
+ // Issue #2194: the host may be typed in any case (GITHUB.COM) or hidden behind
321
+ // look-alike punctuation, and "github.com" in the path of another host is not a
322
+ // GitHub URL at all — so the recovery layer's host check makes the call, not a
323
+ // substring test that both misses the first case and accepts the second.
324
+ if (!namesGitHubHost(url)) return { valid: false, error: t('telegram.first_arg_must_be_github_url', {}, { locale }) };
319
325
  const parsed = parseGitHubUrl(url);
320
326
  if (!parsed.valid) return { valid: false, error: parsed.error || 'Invalid GitHub URL', suggestion: parsed.suggestion };
327
+ // Issue #2194: tell the user which URL we actually understood when we had to repair theirs.
328
+ const recoveryNotice = hasNotableRepair(parsed.repairs) ? t('telegram.url_recovered', { original: escapeMarkdown(makeSpecialCharsVisible(rawUrl)), used: escapeMarkdown(parsed.canonical), repairs: escapeMarkdown(formatUrlRepairs(parsed.repairs, { notableOnly: true })) }, { locale }) : null;
321
329
  if (!allowedTypes.includes(parsed.type)) {
322
330
  const allowedTypesStr = allowedTypes.map(t => (t === 'pull' ? 'pull request' : t)).join(', ');
323
331
  const baseUrl = `https://github.com/${parsed.owner}/${parsed.repo}`;
324
332
  const escapedUrl = escapeMarkdown(url),
325
333
  escapedBaseUrl = escapeMarkdown(baseUrl); // Issue #1102: escape for Markdown
326
334
  let error;
327
- if (parsed.type === 'issues_list') error = t('telegram.url_issues_list_error', { url: escapedUrl, example: `${escapedBaseUrl}/issues/1` }, { locale });
328
- else if (parsed.type === 'pulls_list') error = t('telegram.url_pulls_list_error', { url: escapedUrl, example: `${escapedBaseUrl}/pull/1` }, { locale });
335
+ if (parsed.type === 'issues_list') error = t('telegram.url_issues_list_error', { url: escapedBaseUrl, example: `${escapedBaseUrl}/issues/1` }, { locale });
336
+ else if (parsed.type === 'pulls_list') error = t('telegram.url_pulls_list_error', { url: escapedBaseUrl, example: `${escapedBaseUrl}/pull/1` }, { locale });
329
337
  else if (parsed.type === 'repo') error = t('telegram.url_repo_error', { allowedTypes: allowedTypesStr, url: escapedUrl, example: `${escapedBaseUrl}/issues/1` }, { locale });
330
338
  else error = t('telegram.url_must_be_type', { allowedTypes: allowedTypesStr, type: parsed.type.replace('_', ' ') }, { locale });
331
339
  return { valid: false, error };
332
340
  }
333
- return { valid: true, parsed, normalizedUrl: url };
341
+ return { valid: true, parsed, normalizedUrl: url, recoveryNotice };
334
342
  }
335
343
 
336
344
  const executeAndUpdateMessage = buildExecuteAndUpdateMessage({ resolveIsolation, ISOLATION_BACKEND, isolationRunner, VERBOSE, executeStartScreen, trackSession, untrackSession, AUTO_WATCH_MESSAGE, startAutoTerminalWatchForSession, bot, formatExecutingWorkSessionMessage, formatStartingWorkSessionMessage });
@@ -463,6 +471,9 @@ const { registerMergeCommand } = await import('./telegram-merge-command.lib.mjs'
463
471
  registerMergeCommand(bot, sharedCommandOpts);
464
472
  const { registerSolveQueueCommand } = await import('./telegram-solve-queue-command.lib.mjs');
465
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 });
466
477
  const { registerSubscribeCommands } = await import('./telegram-subscribers.lib.mjs'); // #1688
467
478
  registerSubscribeCommands(bot, sharedCommandOpts);
468
479
  const { registerTaskCommands } = await import('./telegram-task-command.lib.mjs');
@@ -539,6 +550,10 @@ async function handleSolveCommand(ctx) {
539
550
  }
540
551
 
541
552
  VERBOSE && console.log(`[VERBOSE] ${solveCommandDisplay} passed all checks, executing...`);
553
+ // Issue #2194: the incoming text is the only record of what the user actually
554
+ // typed, and the log for that issue did not contain it — so an invisible
555
+ // character in the URL was invisible in the log too. Reveal it here.
556
+ VERBOSE && console.log(`[VERBOSE] ${solveCommandDisplay} raw text: ${revealHiddenCharacters(ctx.message.text)}`);
542
557
  const solveToolAlias = getSolveToolAliasFromText(ctx.message.text);
543
558
  let userArgs = parseCommandArgs(ctx.message.text);
544
559
 
@@ -607,6 +622,8 @@ async function handleSolveCommand(ctx) {
607
622
  await safeReply(ctx, errorMsg, { reply_to_message_id: ctx.message.message_id });
608
623
  return;
609
624
  }
625
+ // Issue #2194: the link needed repair, so show what we understood before we act on it.
626
+ if (validation.recoveryNotice) await safeReply(ctx, validation.recoveryNotice, { reply_to_message_id: ctx.message.message_id });
610
627
  userArgs = moveArgumentToFront(userArgs, validation.normalizedUrl, cleanNonPrintableChars);
611
628
  // Issue #2166: hand the spawned session the same canonical URL that is shown
612
629
  // in the chat, so the echo and the actual work can never disagree.
@@ -826,6 +843,8 @@ async function handleHiveCommand(ctx) {
826
843
  await safeReply(ctx, errorMsg, { reply_to_message_id: ctx.message.message_id });
827
844
  return;
828
845
  }
846
+ // Issue #2194: the link needed repair, so show what we understood before we act on it.
847
+ if (validation.recoveryNotice) await safeReply(ctx, validation.recoveryNotice, { reply_to_message_id: ctx.message.message_id });
829
848
  // Normalize issues_list/pulls_list to base repo URL, or use cleaned URL
830
849
  let normalizedArgs = moveArgumentToFront(userArgs, validation.normalizedUrl, cleanNonPrintableChars);
831
850
  const p = validation.parsed;
@@ -1011,7 +1030,7 @@ bot.on('message', async (ctx, next) => {
1011
1030
  const solveHandlers = Object.fromEntries(SOLVE_COMMAND_NAMES.map(command => [command, handleSolveCommand]));
1012
1031
  const taskHandlers = Object.fromEntries(TASK_COMMAND_NAMES.map(command => [command, handleTaskCommand]));
1013
1032
  const fixHandlers = Object.fromEntries(FIX_COMMAND_NAMES.map(command => [command, handleFixCommand]));
1014
- 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 };
1015
1034
 
1016
1035
  const handler = handlers[extracted.command];
1017
1036
  if (!handler) return next();