@link-assistant/hive-mind 2.13.4 → 2.14.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +29 -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 +1 -1
  7. package/src/agent.lib.mjs +5 -1
  8. package/src/claude.lib.mjs +5 -158
  9. package/src/claude.session-tokens.lib.mjs +180 -0
  10. package/src/codex.diagnostics.lib.mjs +135 -0
  11. package/src/codex.lib.mjs +8 -121
  12. package/src/config.lib.mjs +9 -0
  13. package/src/docker-sidecar.lib.mjs +276 -0
  14. package/src/formal-ai-maintenance.lib.mjs +2 -14
  15. package/src/formal-ai-sidecar.lib.mjs +17 -137
  16. package/src/gemini.lib.mjs +5 -1
  17. package/src/git-push-guard.lib.mjs +230 -0
  18. package/src/git-retry.lib.mjs +97 -0
  19. package/src/github-pr-idempotency.lib.mjs +83 -0
  20. package/src/github-rate-limit.lib.mjs +44 -41
  21. package/src/hive.mjs +8 -150
  22. package/src/hive.repository-fallback.lib.mjs +125 -0
  23. package/src/hive.startup-checks.lib.mjs +57 -0
  24. package/src/isolation-runner.lib.mjs +94 -287
  25. package/src/isolation-runner.parsers.lib.mjs +292 -0
  26. package/src/lib.mjs +79 -18
  27. package/src/opencode.lib.mjs +5 -1
  28. package/src/qwen.lib.mjs +5 -1
  29. package/src/router-isolation.lib.mjs +496 -0
  30. package/src/router-logs.lib.mjs +143 -0
  31. package/src/router-maintenance.lib.mjs +77 -0
  32. package/src/router-session-drain.lib.mjs +153 -0
  33. package/src/router-sidecar.lib.mjs +516 -0
  34. package/src/router-task-isolation.lib.mjs +121 -0
  35. package/src/session-monitor.lib.mjs +12 -272
  36. package/src/session-monitor.queries.lib.mjs +304 -0
  37. package/src/solve.auto-pr-push-sync.lib.mjs +176 -0
  38. package/src/solve.auto-pr.lib.mjs +40 -154
  39. package/src/solve.config.lib.mjs +11 -0
  40. package/src/solve.mjs +8 -158
  41. package/src/solve.mode.lib.mjs +191 -0
  42. package/src/task.config.lib.mjs +5 -0
  43. package/src/task.mjs +1 -0
  44. package/src/telegram-bot.mjs +18 -0
  45. package/src/telegram-solve-queue.lib.mjs +19 -272
  46. package/src/telegram-solve-queue.throttling.lib.mjs +323 -0
  47. package/src/transient-errors.lib.mjs +238 -0
@@ -26,6 +26,9 @@ import { acquireFormalAiSidecarForTask, attachFormalAiTaskContainer, releaseForm
26
26
  // importing this runner and creating a cycle. Re-exported here because callers
27
27
  // and tests have always reached them through the isolation runner. See #2154.
28
28
  import { getDockerIsolationImage } from './hive-mind-image.lib.mjs';
29
+ import { buildRouterGitConfigEntries, buildRouterTaskEnv, getRouterSuppressedCredentialPaths, hasUseRouterFlag, isRouterEnabled, resolveRouterBaseUrl, resolveRouterGitHubRouting } from './router-isolation.lib.mjs';
30
+ import { acquireRouterForTask, attachRouterTaskContainer, registerFormalAiWithRouter, releaseRouterForTask } from './router-task-isolation.lib.mjs';
31
+ import { buildGitConfigEnv, GIT_PUSH_GUARD_CONTAINER_DIR, GIT_PUSH_GUARD_ESCAPE_ENV, hasForcePushOptIn, installGitPushGuard } from './git-push-guard.lib.mjs';
29
32
  export { getDockerIsolationImage, resolveDockerIsolationImageTag } from './hive-mind-image.lib.mjs';
30
33
  let commandStreamDollarPromise = null;
31
34
  async function getCommandStreamDollar() {
@@ -47,6 +50,10 @@ async function getCommandStreamDollar() {
47
50
  }
48
51
  // Re-export the shared status predicates so existing callers that reach them via the isolation-runner module (e.g. session-monitor's `runner.isExecutingSessionStatus`) keep working. The canonical definitions live in session-status.lib.mjs so the killed/terminated/oom vocabulary stays consistent everywhere (issue #1927).
49
52
  export { isExecutingSessionStatus, isTerminalSessionStatus, isKilledSessionStatus } from './session-status.lib.mjs';
53
+ // Issue #2175: the `$` output parsers live in their own module to keep this file
54
+ // under the 1350-line warning threshold. Re-exported so importers are unaffected.
55
+ import { isUnknownDockerExitCode, parseSessionExitFooter, parseSessionListOutput, parseSessionStatusOutput, parseStartCommandExecutionUuid, readSessionExitFromLog, shouldFallbackToScreenStatus } from './isolation-runner.parsers.lib.mjs';
56
+ export { isUnknownDockerExitCode, parseSessionExitFooter, parseSessionListOutput, parseSessionStatusOutput, parseStartCommandExecutionUuid, readSessionExitFromLog, shouldFallbackToScreenStatus };
50
57
  // Valid isolation backends
51
58
  const VALID_ISOLATION_BACKENDS = ['screen', 'tmux', 'docker'];
52
59
  const DOCKER_CONTAINER_HOME = '/home/box';
@@ -59,17 +66,6 @@ const DOCKER_ISOLATION_SHELL = 'sh';
59
66
  const DOCKER_ISOLATION_LOW_DISK_GIB = 40;
60
67
  // Docker-only start gate used to capture the container writable-layer baseline before the task command begins cloning or generating files. The parent releases the gate immediately after `docker inspect --size`; the fallback keeps the task from hanging forever if the parent exits at the wrong time.
61
68
  const DOCKER_START_GATE_WAIT_TENTHS = 300;
62
- // Sentinel start-command's detached docker logger records when it cannot capture the container's real exit code. A terminal `$ --status` carrying this value is ambiguous — the container may still be running — so we cross-check it against a live `docker inspect` before concluding the session finished. See #1939. The upstream emission of this premature sentinel was fixed in start-command 0.29.1 (link-foundation/start#136), which the Hive Mind images now pin; this cross-check is retained as defense-in-depth so an older `$` on an operator's PATH cannot resurrect the bug.
63
- const DOCKER_UNKNOWN_EXIT_CODE = -1;
64
- function normalizeProcessIds(value) {
65
- if (!value || typeof value !== 'object') return {};
66
- const out = {};
67
- for (const [key, raw] of Object.entries(value)) {
68
- const number = Number(raw);
69
- if (Number.isInteger(number) && number > 0) out[key] = number;
70
- }
71
- return out;
72
- }
73
69
  function normalizeTool(tool) {
74
70
  return String(tool || 'claude')
75
71
  .trim()
@@ -121,21 +117,31 @@ export function resolveHostDockerSock({ env = process.env } = {}) {
121
117
  * commit. See issue #1939. Tool credentials are deliberately scoped: Codex
122
118
  * sessions do not receive Claude files and Claude sessions do not receive Codex
123
119
  * files.
120
+ *
121
+ * Issue #2164 (EXPERIMENTAL): with `useRouter` the vendor credential mounts are
122
+ * withheld entirely, so the task never holds the subscription — it reaches the
123
+ * `hive-mind-router` sidecar with its own scoped token instead. Git identity is
124
+ * still mounted, because it carries no secret and `solve` aborts without it
125
+ * (issue #1939). The gh config is only withheld when `ghRouted` says gh has
126
+ * somewhere else to go; otherwise the task would lose GitHub access entirely.
124
127
  */
125
- export function getDockerIsolationAuthMounts({ tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = {}) {
128
+ export function getDockerIsolationAuthMounts({ tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync, useRouter = false, ghRouted = false } = {}) {
126
129
  const mounts = [];
127
130
  const normalizedTool = normalizeTool(tool);
128
- maybeAddMount(mounts, env.GH_CONFIG_DIR || path.join(homeDir, '.config', 'gh'), path.join(DOCKER_CONTAINER_HOME, '.config', 'gh'), existsSync);
131
+ const suppressed = useRouter ? new Set(getRouterSuppressedCredentialPaths({ tool: normalizedTool, ghRouted })) : new Set();
132
+ if (!suppressed.has('.config/gh')) {
133
+ maybeAddMount(mounts, env.GH_CONFIG_DIR || path.join(homeDir, '.config', 'gh'), path.join(DOCKER_CONTAINER_HOME, '.config', 'gh'), existsSync);
134
+ }
129
135
  // Git identity (tool-agnostic, required for commits). Honor the same env vars git itself reads for an alternate global config location (GIT_CONFIG_GLOBAL) and the XDG base dir, falling back to the conventional `~/.gitconfig` and `~/.config/git`. Missing host paths are skipped, so a container image that already bakes a git identity is left untouched. See issue #1939.
130
136
  maybeAddMount(mounts, env.GIT_CONFIG_GLOBAL || path.join(homeDir, '.gitconfig'), path.join(DOCKER_CONTAINER_HOME, '.gitconfig'), existsSync);
131
137
  maybeAddMount(mounts, env.XDG_CONFIG_HOME ? path.join(env.XDG_CONFIG_HOME, 'git') : path.join(homeDir, '.config', 'git'), path.join(DOCKER_CONTAINER_HOME, '.config', 'git'), existsSync);
132
138
  if (normalizedTool === 'codex') {
133
- maybeAddMount(mounts, path.join(homeDir, '.codex'), path.join(DOCKER_CONTAINER_HOME, '.codex'), existsSync);
139
+ if (!suppressed.has('.codex')) maybeAddMount(mounts, path.join(homeDir, '.codex'), path.join(DOCKER_CONTAINER_HOME, '.codex'), existsSync);
134
140
  // Issue #2074: Codex also discovers persistent user Agent Skills from ~/.agents/skills. Propagate that standard location alongside .codex so direct and Docker-isolated solver sessions expose the same capabilities.
135
- maybeAddMount(mounts, path.join(homeDir, '.agents'), path.join(DOCKER_CONTAINER_HOME, '.agents'), existsSync);
141
+ if (!suppressed.has('.agents')) maybeAddMount(mounts, path.join(homeDir, '.agents'), path.join(DOCKER_CONTAINER_HOME, '.agents'), existsSync);
136
142
  } else if (normalizedTool === 'claude') {
137
- maybeAddMount(mounts, path.join(homeDir, '.claude'), path.join(DOCKER_CONTAINER_HOME, '.claude'), existsSync);
138
- maybeAddMount(mounts, path.join(homeDir, '.claude.json'), path.join(DOCKER_CONTAINER_HOME, '.claude.json'), existsSync);
143
+ if (!suppressed.has('.claude')) maybeAddMount(mounts, path.join(homeDir, '.claude'), path.join(DOCKER_CONTAINER_HOME, '.claude'), existsSync);
144
+ if (!suppressed.has('.claude.json')) maybeAddMount(mounts, path.join(homeDir, '.claude.json'), path.join(DOCKER_CONTAINER_HOME, '.claude.json'), existsSync);
139
145
  }
140
146
  return mounts;
141
147
  }
@@ -198,7 +204,18 @@ export async function resolveFormalAiIsolationEnv(env = process.env, { lookup =
198
204
  * reused instead of re-downloaded — no `--pull` plumbing required (issue #1879).
199
205
  */
200
206
  export function buildDockerIsolationStartArgs(command, args = [], options = {}) {
201
- const { sessionId, tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync } = options;
207
+ const { sessionId, tool = 'claude', env = process.env, homeDir = os.homedir(), existsSync = fs.existsSync, useRouter = false, routerToken = null, installGuard = installGitPushGuard } = options;
208
+ // Issue #2164 (EXPERIMENTAL): router isolation replaces the credential mounts
209
+ // with a scoped token pointing at the `hive-mind-router` sidecar. It only
210
+ // engages when a token was actually issued; without one the task would have
211
+ // neither credentials nor a route, so we fail open to the default mounts
212
+ // rather than launching an agent that cannot reach any model.
213
+ const routerActive = isRouterEnabled({ useRouter, env }) && Boolean(routerToken);
214
+ const routerEndpoint = routerActive ? resolveRouterBaseUrl({ env }) : { baseUrl: null, external: false };
215
+ const routerBaseUrl = routerEndpoint.baseUrl;
216
+ const routerGitHub = routerActive ? resolveRouterGitHubRouting({ env, external: Boolean(routerEndpoint.external) }) : { mode: 'off', ghHost: null };
217
+ const routerEnv = routerActive && routerBaseUrl ? buildRouterTaskEnv({ tool, baseUrl: routerBaseUrl, token: routerToken, githubMode: routerGitHub.mode, ghHost: routerGitHub.ghHost, homeDir: DOCKER_CONTAINER_HOME }) : {};
218
+ const routerWired = Object.keys(routerEnv).length > 0;
202
219
  const image = getDockerIsolationImage({ env });
203
220
  const startArgs = ['--isolated', 'docker', '--image', image];
204
221
  if (shouldRunPrivilegedDockerIsolation(image, env)) {
@@ -212,8 +229,33 @@ export function buildDockerIsolationStartArgs(command, args = [], options = {})
212
229
  if (env.HIVE_MIND_FORMAL_AI_BASE_URL) {
213
230
  startArgs.push('-e', `HIVE_MIND_FORMAL_AI_BASE_URL=${env.HIVE_MIND_FORMAL_AI_BASE_URL}`);
214
231
  }
215
- for (const mount of getDockerIsolationAuthMounts({ tool, env, homeDir, existsSync })) {
216
- startArgs.push('--volume', `${mount.source}:${mount.target}`);
232
+ for (const [name, value] of Object.entries(routerEnv)) {
233
+ startArgs.push('-e', `${name}=${value}`);
234
+ }
235
+ const mounts = getDockerIsolationAuthMounts({ tool, env, homeDir, existsSync, useRouter: routerWired, ghRouted: routerWired && routerGitHub.mode !== 'off' });
236
+ // Issue #2164 (R13): a routed task also loses the ability to destroy remote
237
+ // history by accident. The hook lives on the host and is mounted read-only, so
238
+ // the task cannot edit the rule it is being held to; git is pointed at it with
239
+ // GIT_CONFIG_* rather than `git config --global`, because the container's
240
+ // ~/.gitconfig is the operator's own file. This is one layer of three (see
241
+ // git-push-guard.lib.mjs) and `--no-verify` still gets past it.
242
+ if (routerWired) {
243
+ // One `GIT_CONFIG_COUNT` covers both the hook and the router's git
244
+ // transport: git shares the counter across all of them, so they have to be
245
+ // built together or the second would silently replace the first.
246
+ const gitConfigEntries = buildRouterGitConfigEntries({ baseUrl: routerBaseUrl, token: routerToken, githubMode: routerGitHub.mode });
247
+ const guard = installGuard({ env, homeDir });
248
+ if (guard.installed) {
249
+ mounts.push({ source: guard.dir, target: GIT_PUSH_GUARD_CONTAINER_DIR, readOnly: true });
250
+ gitConfigEntries.unshift(['core.hooksPath', GIT_PUSH_GUARD_CONTAINER_DIR]);
251
+ if (hasForcePushOptIn(args)) startArgs.push('-e', `${GIT_PUSH_GUARD_ESCAPE_ENV}=1`);
252
+ }
253
+ for (const [name, value] of Object.entries(buildGitConfigEnv(gitConfigEntries))) {
254
+ startArgs.push('-e', `${name}=${value}`);
255
+ }
256
+ }
257
+ for (const mount of mounts) {
258
+ startArgs.push('--volume', `${mount.source}:${mount.target}${mount.readOnly ? ':ro' : ''}`);
217
259
  }
218
260
  const taskCommand = buildShellCommand(command, args);
219
261
  startArgs.push('--detached', '--session', sessionId, '--', buildDockerStartGatedCommand(taskCommand, sessionId));
@@ -269,233 +311,6 @@ async function runStartCommand(binPath, startCommandArgs) {
269
311
  export function generateSessionId() {
270
312
  return crypto.randomUUID();
271
313
  }
272
- const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
273
- /**
274
- * Extract start-command's own execution UUID from a launch banner.
275
- *
276
- * Issue #2154: an isolated task has two UUIDs. Hive Mind generates the session
277
- * name and passes it as `--session` (it also becomes the container name);
278
- * start-command mints a separate execution UUID and prints it as the `session`
279
- * field of its launch banner:
280
- *
281
- * ```
282
- * │ session edc7b051-e12f-4f7b-b677-c885f3208407
283
- * │ container 0a3627ef-f1f1-4801-a073-3678b9453db7
284
- * ```
285
- *
286
- * `$ --list` shows the execution UUID, while Telegram and the logs showed the
287
- * session UUID, so the two views could not be joined — which is why three
288
- * refused tasks and two healthy ones looked equally unaccounted for. Returning
289
- * it lets the caller record both.
290
- *
291
- * Only a well-formed UUID is returned; a banner we do not recognise yields
292
- * null rather than a guess, because a wrong correlation is worse than none.
293
- *
294
- * @param {string} output - Raw stdout from the detached `$` launch
295
- * @returns {string|null}
296
- */
297
- export function parseStartCommandExecutionUuid(output) {
298
- const raw = (output || '').trim();
299
- if (!raw) return null;
300
- try {
301
- const parsed = JSON.parse(raw);
302
- const data = Array.isArray(parsed) ? parsed[0] : parsed;
303
- const uuid = data?.uuid || data?.session || null;
304
- if (typeof uuid === 'string' && UUID_PATTERN.test(uuid.trim())) return uuid.trim();
305
- } catch {
306
- // Human-readable banner — fall through.
307
- }
308
- // The banner is box-drawn (`│ session <uuid>`); tolerate the prefix, an
309
- // ASCII `|`, or no prefix at all.
310
- const match = raw.match(/^[\s│|]*session\s+([^\s]+)\s*$/im);
311
- const candidate = match?.[1]?.trim();
312
- return candidate && UUID_PATTERN.test(candidate) ? candidate : null;
313
- }
314
- /**
315
- * Parse output from `$ --status <session>`.
316
- *
317
- * start-command versions used in the wild may return JSON when
318
- * `--output-format json` is supported, or human-readable key/value text.
319
- * Keep the parser tolerant so completion monitoring survives either format.
320
- *
321
- * @param {string} output - Raw stdout from `$ --status`
322
- * @returns {{exists: boolean, uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, currentTime: string|null, logPath: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, sessionName: string|null, processIds: Object, raw: string}}
323
- */
324
- export function parseSessionStatusOutput(output) {
325
- const raw = (output || '').trim();
326
- if (!raw) {
327
- return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, oomKilled: null, raw: '' };
328
- }
329
- const normalizeBooleanField = value => {
330
- if (typeof value === 'boolean') return value;
331
- if (value === null || value === undefined) return null;
332
- const normalized = String(value).trim().toLowerCase();
333
- if (['true', '1', 'yes'].includes(normalized)) return true;
334
- if (['false', '0', 'no'].includes(normalized)) return false;
335
- return null;
336
- };
337
- try {
338
- const parsed = JSON.parse(raw);
339
- const data = Array.isArray(parsed) ? parsed[0] : parsed;
340
- // start-command (link-foundation/start) reports the isolation backend at `options.isolated` in both JSON and links-notation output. Older hypothetical layouts used `options.isolation` or a top-level `isolation` field — keep accepting all three so we are tolerant of future renames. See https://github.com/link-assistant/hive-mind/issues/1700.
341
- const isolationCandidate = (typeof data?.isolation === 'string' && data.isolation) || (typeof data?.options?.isolated === 'string' && data.options.isolated) || (typeof data?.options?.isolation === 'string' && data.options.isolation) || null;
342
- const topPid = Number(data?.pid);
343
- const processIds = normalizeProcessIds(data?.processIds);
344
- if (Number.isInteger(topPid) && topPid > 0 && processIds.pid == null) processIds.pid = topPid;
345
- return {
346
- exists: true,
347
- uuid: data?.uuid || null,
348
- status: typeof data?.status === 'string' ? data.status.toLowerCase() : null,
349
- exitCode: data?.exitCode !== undefined && data?.exitCode !== null ? Number(data.exitCode) : null,
350
- startTime: data?.startTime || null,
351
- endTime: data?.endTime || null,
352
- currentTime: data?.currentTime || null,
353
- logPath: data?.logPath || null,
354
- command: data?.command || null,
355
- isolation: isolationCandidate ? isolationCandidate.toLowerCase() : null,
356
- workingDirectory: data?.workingDirectory || null,
357
- sessionName: data?.sessionName || data?.options?.sessionName || null,
358
- processIds,
359
- oomKilled: normalizeBooleanField(data?.oomKilled ?? data?.OOMKilled ?? data?.options?.oomKilled ?? data?.state?.oomKilled ?? data?.State?.OOMKilled),
360
- raw,
361
- };
362
- } catch {
363
- // Fall through to text parsing.
364
- }
365
- const firstLine =
366
- raw
367
- .split('\n')
368
- .find(line => line.trim() && !line.includes(' '))
369
- ?.trim() || null;
370
- const readField = name => {
371
- const match = raw.match(new RegExp(`^\\s*${name}\\s+"?([^"\\n]+)"?\\s*$`, 'mi'));
372
- return match ? match[1].trim() : null;
373
- };
374
- const readBooleanField = name => normalizeBooleanField(readField(name));
375
- const status = readField('status')?.toLowerCase() || null;
376
- const exitCodeText = readField('exitCode');
377
- // `start-command` links-notation output nests the isolation backend under `options` as `isolated <backend>` (not `isolation`). The leading indent varies by depth, but `readField` is anchored with `^\s*` which already matches indented lines. Older code only looked for `isolation`, which
378
- // returned null for every real session and made /log + /terminal_watch
379
- // reject screen/tmux/docker sessions. See issue #1700.
380
- const isolationText = readField('isolated') || readField('isolation');
381
- const processIds = {};
382
- for (const name of ['pid', 'wrapperPid', 'childPid', 'processPid', 'commandPid']) {
383
- const value = readField(name);
384
- const number = Number(value);
385
- if (Number.isInteger(number) && number > 0) processIds[name] = number;
386
- }
387
- return {
388
- exists: Boolean(status || firstLine),
389
- uuid: readField('uuid') || firstLine,
390
- status,
391
- exitCode: exitCodeText !== null ? Number(exitCodeText) : null,
392
- startTime: readField('startTime'),
393
- endTime: readField('endTime'),
394
- currentTime: readField('currentTime'),
395
- logPath: readField('logPath'),
396
- command: readField('command'),
397
- isolation: isolationText?.toLowerCase() || null,
398
- workingDirectory: readField('workingDirectory'),
399
- sessionName: readField('sessionName'),
400
- processIds,
401
- oomKilled: readBooleanField('oomKilled'),
402
- raw,
403
- };
404
- }
405
- /**
406
- * Decide whether a detached-docker exit code is "unknown" (not a real result).
407
- *
408
- * start-command's detached docker logger writes the exit-code footer only after
409
- * `docker logs -f` returns, capturing the real code via `docker inspect`. When
410
- * it cannot capture one it records the sentinel `-1`. A `$ --status` that
411
- * reports a terminal status ("executed") while still carrying that sentinel — or
412
- * no exit code at all — is therefore ambiguous: the container may actually still
413
- * be running. Callers treat such a status as provisional and cross-check the
414
- * live container before declaring the session finished. See issue #1939.
415
- *
416
- * @param {number|null|undefined} exitCode
417
- * @returns {boolean} True when the exit code carries no real result.
418
- */
419
- export function isUnknownDockerExitCode(exitCode) {
420
- return exitCode === null || exitCode === undefined || Number(exitCode) === DOCKER_UNKNOWN_EXIT_CODE;
421
- }
422
- export function shouldFallbackToScreenStatus(statusResult) {
423
- return !statusResult?.exists || !statusResult?.status;
424
- }
425
- /**
426
- * Parse the footer start-command appends to every execution log when the wrapped
427
- * command exits. The footer is authoritative about the terminal exit code even
428
- * when `$ --status` is wrong: start-command writes it from the command's own
429
- * `close`/`exited` handler, so its presence proves the command terminated.
430
- *
431
- * Footer shape (see start-command spawn-helpers.js):
432
- *
433
- * ==================================================
434
- * Finished: 2026-06-14 19:10:49.822
435
- * Exit Code: 137
436
- *
437
- * Issue #1927: start-command's `enrichDetachedStatus` can flip a completed
438
- * `executed/137` record back to `executing` (nulling the exit code) when a
439
- * lingering shell keeps the screen session alive — so `$ --status` reports
440
- * `executing` forever and the bot never notices the kill. Reading this footer
441
- * lets hive-mind detect the real terminal exit regardless of that flip.
442
- *
443
- * @param {string} text - Log text (typically the tail of the log file)
444
- * @returns {{finished: boolean, exitCode: number|null, endTime: string|null}}
445
- */
446
- export function parseSessionExitFooter(text) {
447
- if (!text) return { finished: false, exitCode: null, endTime: null };
448
- // Match the LAST footer block in the text (a re-run could append more than
449
- // one). Anchor on the `=` separator so command output that merely prints
450
- // "Exit Code: N" mid-stream is not mistaken for the footer.
451
- const re = /={10,}\s*\r?\nFinished:\s*([^\r\n]+)\r?\nExit Code:\s*(-?\d+)/g;
452
- let match;
453
- let last = null;
454
- while ((match = re.exec(text)) !== null) last = match;
455
- if (!last) return { finished: false, exitCode: null, endTime: null };
456
- return { finished: true, exitCode: Number(last[2]), endTime: last[1].trim() };
457
- }
458
- /**
459
- * Read the terminal exit code from the tail of a start-command execution log.
460
- *
461
- * Only the last `tailBytes` of the file are read (the footer lives at the end),
462
- * so this is cheap even for multi-megabyte logs. Never throws — a missing or
463
- * unreadable log yields `{ finished: false }`.
464
- *
465
- * @param {string} logPath
466
- * @param {Object} [options]
467
- * @param {Object} [options.fsImpl=fs] - Injectable fs (for tests)
468
- * @param {number} [options.tailBytes=16384] - How many trailing bytes to scan
469
- * @param {boolean} [options.verbose]
470
- * @returns {{finished: boolean, exitCode: number|null, endTime: string|null}}
471
- */
472
- export function readSessionExitFromLog(logPath, options = {}) {
473
- const { fsImpl = fs, tailBytes = 16384, verbose = false } = options;
474
- if (!logPath) return { finished: false, exitCode: null, endTime: null };
475
- try {
476
- const { size } = fsImpl.statSync(logPath);
477
- if (!size) return { finished: false, exitCode: null, endTime: null };
478
- const start = Math.max(0, size - tailBytes);
479
- const length = size - start;
480
- const buffer = Buffer.alloc(length);
481
- const fd = fsImpl.openSync(logPath, 'r');
482
- try {
483
- fsImpl.readSync(fd, buffer, 0, length, start);
484
- } finally {
485
- fsImpl.closeSync(fd);
486
- }
487
- const result = parseSessionExitFooter(buffer.toString('utf8'));
488
- if (verbose && result.finished) {
489
- console.log(`[VERBOSE] isolation-runner: log footer for ${logPath} reports exit ${result.exitCode} (finished ${result.endTime})`);
490
- }
491
- return result;
492
- } catch (error) {
493
- if (verbose) {
494
- console.log(`[VERBOSE] isolation-runner: could not read exit footer from ${logPath}: ${error.message}`);
495
- }
496
- return { finished: false, exitCode: null, endTime: null };
497
- }
498
- }
499
314
  /**
500
315
  * Find the `$` CLI binary path
501
316
  * @returns {Promise<string|null>} Path to `$` binary or null
@@ -588,6 +403,25 @@ export async function executeWithIsolation(command, args, options = {}) {
588
403
  const hostEnv = options.env || process.env;
589
404
  const { sidecar, error: sidecarError } = await acquireFormalAiSidecarForTask({ backend, args, model: options.model ?? null, tool: options.tool ?? null, sessionId, env: hostEnv, verbose });
590
405
  if (sidecarError) return failLaunch(sidecarError);
406
+ // Issue #2164 (EXPERIMENTAL): --use-router replaces the task's credential
407
+ // mounts with a token scoped to it alone. Like the Formal AI lease this is
408
+ // taken before the container exists, because the token is part of the
409
+ // environment the container is created with — and like it, it fails closed.
410
+ const { router, error: routerError } = await acquireRouterForTask({ backend, useRouter: options.useRouter === true || hasUseRouterFlag(args), model: options.model ?? null, tool: options.tool ?? 'claude', githubRepo: options.githubRepo ?? null, sessionId, env: hostEnv, verbose });
411
+ if (routerError) {
412
+ await releaseFormalAiSidecarForTask({ sidecar, sessionId, env: hostEnv, verbose });
413
+ return failLaunch(routerError);
414
+ }
415
+ // R11: when both sidecars are up, the router is taught to serve `formal-ai`
416
+ // itself, so that model is mediated and audited like every other one. Done
417
+ // before the container is created because the provider has to exist by the
418
+ // time the task issues its first request.
419
+ const formalAiRoutingError = await registerFormalAiWithRouter({ router, sidecar, verbose });
420
+ if (formalAiRoutingError) {
421
+ await releaseRouterForTask({ router, sessionId, env: hostEnv, verbose });
422
+ await releaseFormalAiSidecarForTask({ sidecar, sessionId, env: hostEnv, verbose });
423
+ return failLaunch(formalAiRoutingError);
424
+ }
591
425
  const taskEnv = sidecar ? { ...hostEnv, HIVE_MIND_FORMAL_AI_BASE_URL: sidecar.baseUrl } : hostEnv;
592
426
  const effectiveOptions =
593
427
  backend === 'docker'
@@ -596,7 +430,7 @@ export async function executeWithIsolation(command, args, options = {}) {
596
430
  env: await resolveFormalAiIsolationEnv(taskEnv),
597
431
  }
598
432
  : options;
599
- const startCommandArgs = buildStartCommandArgs(command, args, { ...effectiveOptions, sessionId });
433
+ const startCommandArgs = buildStartCommandArgs(command, args, { ...effectiveOptions, sessionId, useRouter: Boolean(router), routerToken: router?.token ?? null });
600
434
  if (verbose) {
601
435
  console.log(`[VERBOSE] isolation-runner: ${[binPath, ...startCommandArgs].map(shellQuote).join(' ')}`);
602
436
  if (backend === 'docker') {
@@ -620,6 +454,7 @@ export async function executeWithIsolation(command, args, options = {}) {
620
454
  }
621
455
  let containerFilesystemStartBytes = null;
622
456
  let formalAiAttachError = null;
457
+ let routerAttachError = null;
623
458
  if (result.success && backend === 'docker') {
624
459
  try {
625
460
  containerFilesystemStartBytes = await getDockerContainerWritableLayerSize(sessionId, verbose);
@@ -632,10 +467,17 @@ export async function executeWithIsolation(command, args, options = {}) {
632
467
  // start sequence, and doing it here keeps the attach fail-closed on any
633
468
  // installed version instead of silently one-network on older parsers.
634
469
  formalAiAttachError = await attachFormalAiTaskContainer({ sidecar, sessionId, verbose });
470
+ routerAttachError = await attachRouterTaskContainer({ router, sessionId, env: hostEnv, verbose });
635
471
  } finally {
636
472
  await releaseDockerContainerStartGate(sessionId, verbose);
637
473
  }
638
474
  }
475
+ if (router && (!result.success || formalAiAttachError || routerAttachError)) {
476
+ // Fail closed for the same reason the acquire does: a task that cannot
477
+ // reach the router must not be left running with no route to a model.
478
+ if (routerAttachError) await removeDockerContainer(sessionId, verbose);
479
+ await releaseRouterForTask({ router, sessionId, env: hostEnv, verbose });
480
+ }
639
481
  if (sidecar && (!result.success || formalAiAttachError)) {
640
482
  // Fail closed: without the internal network the task cannot reach Formal
641
483
  // AI, and issue #2146 forbids falling back to another model.
@@ -645,6 +487,10 @@ export async function executeWithIsolation(command, args, options = {}) {
645
487
  return failLaunch(`Formal AI task container could not be attached to the internal Formal AI network, so the task was stopped instead of falling back to another model (issue #2146): ${formalAiAttachError}`, { output: result.output });
646
488
  }
647
489
  }
490
+ if (routerAttachError) {
491
+ if (sidecar) await releaseFormalAiSidecarForTask({ sidecar, sessionId, env: hostEnv, verbose });
492
+ return failLaunch(`The task container could not be joined to the router (internal network, CA trust or api.github.com interception), so it was stopped rather than run without a route to any model (issue #2164): ${routerAttachError}`, { output: result.output });
493
+ }
648
494
  // Issue #1939: capture the freshly-launched docker session's reported status
649
495
  // and the live container state together, so the next iteration has the data to
650
496
  // diagnose a premature "executed/-1" status (problem #1) or a surprise image
@@ -700,45 +546,6 @@ export async function querySessionStatus(sessionId, verbose = false) {
700
546
  return { exists: false, uuid: null, status: null, exitCode: null, startTime: null, endTime: null, currentTime: null, logPath: null, command: null, isolation: null, workingDirectory: null, sessionName: null, processIds: {}, raw: '' };
701
547
  }
702
548
  }
703
- /**
704
- * Parse output from `$ --list --output-format json`.
705
- *
706
- * start-command may return a top-level array, or an object with an
707
- * `executions`/`sessions` array. Each entry is normalized to the same shape used
708
- * by {@link parseSessionStatusOutput} (uuid/status/exitCode/command/isolation/…).
709
- * Tolerant of unknown layouts — anything unparseable yields an empty list.
710
- *
711
- * @param {string} output - Raw stdout from `$ --list`
712
- * @returns {Array<{uuid: string|null, status: string|null, exitCode: number|null, startTime: string|null, endTime: string|null, command: string|null, isolation: string|null, workingDirectory: string|null, sessionName: string|null}>}
713
- */
714
- export function parseSessionListOutput(output) {
715
- const raw = (output || '').trim();
716
- if (!raw) return [];
717
- let parsed;
718
- try {
719
- parsed = JSON.parse(raw);
720
- } catch {
721
- return [];
722
- }
723
- const records = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.executions) ? parsed.executions : Array.isArray(parsed?.sessions) ? parsed.sessions : parsed && typeof parsed === 'object' ? [parsed] : [];
724
- return records
725
- .map(data => {
726
- if (!data || typeof data !== 'object') return null;
727
- const isolationCandidate = (typeof data.isolation === 'string' && data.isolation) || (typeof data.options?.isolated === 'string' && data.options.isolated) || (typeof data.options?.isolation === 'string' && data.options.isolation) || null;
728
- return {
729
- uuid: data.uuid || data.session || data.sessionId || null,
730
- status: typeof data.status === 'string' ? data.status.toLowerCase() : null,
731
- exitCode: data.exitCode !== undefined && data.exitCode !== null ? Number(data.exitCode) : null,
732
- startTime: data.startTime || null,
733
- endTime: data.endTime || null,
734
- command: data.command || null,
735
- isolation: isolationCandidate ? isolationCandidate.toLowerCase() : null,
736
- workingDirectory: data.workingDirectory || null,
737
- sessionName: data.sessionName || data.options?.sessionName || null,
738
- };
739
- })
740
- .filter(Boolean);
741
- }
742
549
  /**
743
550
  * List all executions known to start-command via `$ --list --output-format json`.
744
551
  *