@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.
- package/CHANGELOG.md +36 -0
- package/README.hi.md +2 -0
- package/README.md +2 -0
- package/README.ru.md +2 -0
- package/README.zh.md +2 -0
- package/package.json +3 -2
- package/src/agentic-cli-freshness.lib.mjs +118 -0
- package/src/agentic-cli-updater.lib.mjs +8 -4
- package/src/docker-sidecar.lib.mjs +17 -1
- package/src/github-url-parser.lib.mjs +80 -23
- package/src/github-url-recovery.lib.mjs +514 -0
- package/src/hive-models.lib.mjs +181 -0
- package/src/hive-models.mjs +20 -0
- package/src/hive.mjs +10 -0
- package/src/locales/en.lino +9 -1
- package/src/locales/hi.lino +9 -1
- package/src/locales/ru.lino +9 -1
- package/src/locales/zh.lino +9 -1
- package/src/model-catalogue-fetch.lib.mjs +333 -0
- package/src/model-catalogue-render.lib.mjs +191 -0
- package/src/model-catalogue-sources.lib.mjs +224 -0
- package/src/model-catalogue.lib.mjs +385 -0
- package/src/models/catalog.mjs +408 -0
- package/src/models/index.mjs +23 -362
- package/src/router-isolation.lib.mjs +103 -19
- package/src/router-routes.lib.mjs +250 -0
- package/src/router-sidecar.lib.mjs +33 -13
- package/src/solve.config.lib.mjs +5 -0
- package/src/solve.escalate.lib.mjs +3 -0
- package/src/solve.mjs +12 -0
- package/src/solve.validation.lib.mjs +16 -0
- package/src/task.config.lib.mjs +5 -0
- package/src/task.mjs +12 -0
- package/src/telegram-bot.mjs +24 -5
- package/src/telegram-models-command.lib.mjs +157 -0
- 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,
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 };
|
package/src/solve.config.lib.mjs
CHANGED
|
@@ -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.',
|
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
|
|
package/src/task.config.lib.mjs
CHANGED
|
@@ -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') {
|
package/src/telegram-bot.mjs
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
328
|
-
else if (parsed.type === 'pulls_list') error = t('telegram.url_pulls_list_error', { url:
|
|
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();
|