@link-assistant/hive-mind 2.11.13 → 2.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/package.json +4 -1
  3. package/src/agent-command.lib.mjs +74 -0
  4. package/src/agent.lib.mjs +59 -34
  5. package/src/agentic-cli-updater.lib.mjs +241 -0
  6. package/src/claude.connection.lib.mjs +209 -0
  7. package/src/claude.lib.mjs +6 -202
  8. package/src/codex.lib.mjs +0 -128
  9. package/src/formal-ai-isolation.lib.mjs +62 -0
  10. package/src/formal-ai-maintenance.lib.mjs +106 -0
  11. package/src/formal-ai-model.lib.mjs +25 -0
  12. package/src/formal-ai-runtime.lib.mjs +10 -0
  13. package/src/formal-ai-sidecar.lib.mjs +565 -0
  14. package/src/formal-ai-updater.lib.mjs +294 -0
  15. package/src/formal-ai-version.lib.mjs +100 -0
  16. package/src/formal-ai.lib.mjs +11 -16
  17. package/src/github-rate-limit.lib.mjs +3 -0
  18. package/src/github-url-parser.lib.mjs +255 -0
  19. package/src/github.lib.mjs +22 -343
  20. package/src/hive.mjs +0 -152
  21. package/src/interactive-mode.lib.mjs +0 -43
  22. package/src/isolation-runner.lib.mjs +44 -173
  23. package/src/limits.lib.mjs +0 -89
  24. package/src/model-args.lib.mjs +32 -0
  25. package/src/models/index.mjs +5 -19
  26. package/src/session-monitor.lib.mjs +14 -172
  27. package/src/solve.auto-merge.lib.mjs +70 -164
  28. package/src/solve.mjs +31 -193
  29. package/src/solve.repository.lib.mjs +0 -83
  30. package/src/solve.results.lib.mjs +2 -92
  31. package/src/solve.session.lib.mjs +52 -19
  32. package/src/solve.tool-uncommitted.lib.mjs +22 -0
  33. package/src/state-lock.lib.mjs +82 -0
  34. package/src/telegram-bot.mjs +17 -65
  35. package/src/telegram-fix-command.lib.mjs +1 -8
  36. package/src/telegram-merge-queue.lib.mjs +3 -155
  37. package/src/telegram-solve-queue.lib.mjs +9 -168
  38. package/src/telegram-task-command.lib.mjs +1 -8
  39. package/src/use-m-bootstrap.lib.mjs +6 -5
  40. package/src/use-with-retry.lib.mjs +128 -2
  41. package/src/working-session-summary.lib.mjs +47 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.12.1
4
+
5
+ ### Patch Changes
6
+
7
+ - d606c77: Restore Node 24 command execution, make runtime-loaded dependencies reproducible,
8
+ and strengthen CI status, security, warning, and smoke-test enforcement.
9
+ - 098b01a: Report auto-resumed limit-reset sessions with their start marker, summary, and execution log.
10
+
11
+ ## 2.12.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 93c54bd: Make Formal AI the only model a Formal AI task can reach, and run it on demand. Formal AI Agent flags stay separate argv values, a Formal AI task refuses to start on an Agent CLI older than 0.25.8 (earlier releases answer with their default model when they cannot parse `--model`), the supported Formal AI runtime is pinned and enforced, Agent provider drift fails closed, and structured Formal AI output is preserved in GitHub comments.
16
+
17
+ Formal AI now runs as an on-demand sidecar: it starts for the first Formal AI task, is reachable only over an internal Docker network, stops after the last lease is released, and keeps its memory volume across restarts. While idle, the sidecar image is refreshed through the Formal AI persisted-memory upgrade contract (preflight, backup, receipt, health check, rollback), and the installed agentic CLIs are refreshed too.
18
+
19
+ The distributed images bootstrap Formal AI 0.339.1 and pin start-command 0.32.1. The Docker builder stages install `pkg-config` and the OpenSSL headers and link OpenSSL statically as defense in depth: Formal AI 0.333.0–0.338.0 reached OpenSSL through `web-capture` and failed to build without them; 0.339.0 removed the dependency, but the root causes remain open upstream.
20
+
3
21
  ## 2.11.13
4
22
 
5
23
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.11.13",
3
+ "version": "2.12.1",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -88,5 +88,8 @@
88
88
  "*.{js,mjs,json,md}": [
89
89
  "prettier --write"
90
90
  ]
91
+ },
92
+ "allowScripts": {
93
+ "@sentry/node-cpu-profiler@2.4.2": true
91
94
  }
92
95
  }
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Pure Agent command/event policy helpers.
5
+ *
6
+ * Keeping argv construction out of agent.lib.mjs makes it possible to test the
7
+ * exact process shape without loading use-m or starting an Agent process.
8
+ */
9
+
10
+ const SAFE_SHELL_WORD = /^[a-zA-Z0-9_\-./=,+@:]+$/;
11
+
12
+ const shellQuote = value => {
13
+ const stringValue = String(value);
14
+ if (SAFE_SHELL_WORD.test(stringValue)) return stringValue;
15
+ return `'${stringValue.replaceAll("'", "'\\''")}'`;
16
+ };
17
+
18
+ /** Build Agent's arguments as individual process.argv atoms. */
19
+ export const buildAgentArgs = ({ model, verbose = false, resume = null, streamingInput = false } = {}) => {
20
+ if (!model) throw new Error('Agent model is required');
21
+
22
+ const args = ['--model', String(model)];
23
+ if (verbose) args.push('--verbose');
24
+ if (resume) args.push('--resume', String(resume), '--no-fork');
25
+ if (streamingInput) args.push('--input-format', 'stream-json', '--output-format', 'stream-json');
26
+ return args;
27
+ };
28
+
29
+ /** Render argv for logs/dry-run output without changing the execution shape. */
30
+ export const formatAgentArgsForDisplay = args => (args || []).map(shellQuote).join(' ');
31
+
32
+ /**
33
+ * Agent emits idle/disposal records after terminal errors too. Only these
34
+ * explicit records prove that a preceding error was recovered.
35
+ */
36
+ export const isAgentStrongCompletionEvent = data => {
37
+ if (!data || typeof data !== 'object') return false;
38
+ if (data.type === 'step_finish' && data.part?.reason === 'stop') return true;
39
+ return data.type === 'result' && (data.status === 'success' || data.subtype === 'success');
40
+ };
41
+
42
+ /** Events that mean the CLI is currently waiting, used only by live input. */
43
+ export const isAgentIdleEvent = data => {
44
+ if (!data || typeof data !== 'object') return false;
45
+ if (['session.idle', 'session_idle', 'idle'].includes(data.type)) return true;
46
+ if (data.type === 'log' && ['exiting loop', 'Agent exiting'].includes(data.message)) return true;
47
+ return isAgentStrongCompletionEvent(data);
48
+ };
49
+
50
+ /**
51
+ * Fail closed when Agent resolves a Formal AI request to any other provider.
52
+ * The critical parser record is handled before the provider can make a request;
53
+ * the resolved-provider record is a second, independent guard.
54
+ */
55
+ export const detectFormalAiAgentRoutingMismatch = (record, expectedModel) => {
56
+ if (expectedModel !== 'formalai/formal-ai' || !record || typeof record !== 'object' || record.type !== 'log') return null;
57
+
58
+ const message = String(record.message || '');
59
+ if (/CRITICAL: --model flag detected/i.test(message) && /default model will be used instead/i.test(message)) {
60
+ return `Agent could not parse the requested ${expectedModel} model and announced that its default model would be used instead`;
61
+ }
62
+
63
+ if (message !== 'using explicit provider/model' || !record.providerID || !record.modelID) return null;
64
+ const actualModel = `${record.providerID}/${record.modelID}`;
65
+ return actualModel === expectedModel ? null : `Agent requested ${expectedModel} but selected ${actualModel}; stopping before another provider can be used`;
66
+ };
67
+
68
+ export default {
69
+ buildAgentArgs,
70
+ detectFormalAiAgentRoutingMismatch,
71
+ formatAgentArgsForDisplay,
72
+ isAgentIdleEvent,
73
+ isAgentStrongCompletionEvent,
74
+ };
package/src/agent.lib.mjs CHANGED
@@ -31,6 +31,7 @@ import { firstErrorText, stringifyErrorValue } from './error-text.lib.mjs';
31
31
  import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
32
32
  import { attachStreamingInput, finalizeBidirectionalHandler, setupBidirectionalHandler } from './bidirectional-interactive.lib.mjs';
33
33
  import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
34
+ import { buildAgentArgs, detectFormalAiAgentRoutingMismatch, formatAgentArgsForDisplay, isAgentIdleEvent, isAgentStrongCompletionEvent } from './agent-command.lib.mjs';
34
35
 
35
36
  export { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage };
36
37
 
@@ -355,6 +356,22 @@ export const agentCliSupportsLiveInput = versionOutput => {
355
356
  return !!version && semver.gte(version, MIN_AGENT_LIVE_INPUT_VERSION);
356
357
  };
357
358
 
359
+ /**
360
+ * Agent only fails closed on a `--model` argv it cannot parse from js-0.25.8
361
+ * onwards (link-assistant/agent#293, fixed by PR #294): earlier releases logged
362
+ * a CRITICAL record and then answered with their *default* model. Issue #2146
363
+ * requires Formal AI to be the only model a task can reach, and a guard that
364
+ * reads the CRITICAL record can only stop the run after Agent has already
365
+ * decided, so a Formal AI task refuses to start below this release.
366
+ */
367
+ export const MIN_AGENT_FORMAL_AI_VERSION = '0.25.8';
368
+
369
+ /** True when this Agent CLI aborts instead of silently picking another model. */
370
+ export const agentCliFailsClosedOnModelMismatch = versionOutput => {
371
+ const version = getAgentCliVersion(versionOutput);
372
+ return !!version && semver.gte(version, MIN_AGENT_FORMAL_AI_VERSION);
373
+ };
374
+
358
375
  // Function to validate Agent connection
359
376
  export const validateAgentConnection = async (model = defaultModels.agent, options = {}) => {
360
377
  // Map model alias to full ID
@@ -401,6 +418,19 @@ export const validateAgentConnection = async (model = defaultModels.agent, optio
401
418
  return false;
402
419
  }
403
420
 
421
+ if (isFormalAiModel(model) && !(agentVersion && semver.gte(agentVersion, MIN_AGENT_FORMAL_AI_VERSION))) {
422
+ await log(`❌ Formal AI tasks require @link-assistant/agent >= ${MIN_AGENT_FORMAL_AI_VERSION}`, { level: 'error' });
423
+ await log(' Older releases answer with their default model when they cannot parse the requested one', { level: 'error' });
424
+ await log(' (link-assistant/agent#293), and issue #2146 forbids any model other than Formal AI.', { level: 'error' });
425
+ if (agentVersion) {
426
+ await log(` Installed Agent CLI version: ${agentVersion}`, { level: 'error' });
427
+ } else {
428
+ await log(' Could not determine the installed Agent CLI version.', { level: 'error' });
429
+ }
430
+ await log(' Update with: bun install -g @link-assistant/agent@latest', { level: 'error' });
431
+ return false;
432
+ }
433
+
404
434
  // Test basic Agent functionality with a simple "hi" message
405
435
  // Agent uses the same JSON interface as OpenCode
406
436
  const testResult = await $`printf "hi" | timeout ${Math.floor(timeouts.opencodeCli / 1000)} agent --model ${mappedModel}`;
@@ -600,17 +630,8 @@ export const executeAgentCommand = async params => {
600
630
  const toolInvocation = await resolveFormalAiToolExecution({ tool: 'agent', model: argv.model, toolPath: agentPath, workdir: tempDir, log, verbose: argv.verbose, prepareOnly: isPrepareOnly(argv), env: agentEnv });
601
631
  Object.assign(agentEnv, toolInvocation.env);
602
632
 
603
- // Build agent command arguments
604
- let agentArgs = `--model ${mappedModel}`;
605
-
606
- // Propagate verbose flag to agent for detailed debugging output
607
- if (argv.verbose) {
608
- agentArgs += ' --verbose';
609
- }
610
-
611
633
  if (argv.resume) {
612
634
  await log(`🔄 Resuming from session: ${argv.resume}`);
613
- agentArgs += ` --resume ${argv.resume} --no-fork`;
614
635
  }
615
636
 
616
637
  // Agent supports stdin in both plain text and JSON format
@@ -631,9 +652,11 @@ export const executeAgentCommand = async params => {
631
652
  });
632
653
  }
633
654
  const streamingInput = !!bidirectionalHandler;
634
- if (streamingInput) {
635
- agentArgs += ' --input-format stream-json --output-format stream-json';
636
- }
655
+ // Issue #2146: command-stream treats an interpolated string as one argv
656
+ // atom. The old `--model formalai/formal-ai --verbose` string made Agent
657
+ // ignore the requested model and contact its default provider.
658
+ const agentArgs = buildAgentArgs({ model: mappedModel, verbose: argv.verbose, resume: argv.resume, streamingInput });
659
+ const displayedAgentArgs = formatAgentArgsForDisplay(agentArgs);
637
660
 
638
661
  let promptFile = null;
639
662
  if (!streamingInput) {
@@ -643,7 +666,7 @@ export const executeAgentCommand = async params => {
643
666
  await fs.writeFile(promptFile, combinedPrompt);
644
667
  }
645
668
 
646
- const fullCommand = streamingInput ? `(cd "${tempDir}" && ${toolInvocation.displayCommand} ${agentArgs})` : `(cd "${tempDir}" && cat "${promptFile}" | ${toolInvocation.displayCommand} ${agentArgs})`;
669
+ const fullCommand = streamingInput ? `(cd "${tempDir}" && ${toolInvocation.displayCommand} ${displayedAgentArgs})` : `(cd "${tempDir}" && cat "${promptFile}" | ${toolInvocation.displayCommand} ${displayedAgentArgs})`;
647
670
 
648
671
  const preparedResult = await logPreparedToolCommand({ argv, fullCommand, log, formatAligned });
649
672
  if (preparedResult) return preparedResult;
@@ -691,26 +714,21 @@ export const executeAgentCommand = async params => {
691
714
  // Post-hoc detection on fullOutput can miss errors if NDJSON lines get concatenated without newlines
692
715
  let streamingErrorDetected = false;
693
716
  let streamingErrorMessage = null;
694
- // Issue #1276: Track successful completion events to clear error flags
695
- // When agent emits session.idle or disposal events, it means it recovered and completed successfully
717
+ // Only a strong terminal success record may clear a preceding error.
718
+ // `session.idle` is also emitted after terminal API failures (#2146).
696
719
  let agentCompletedSuccessfully = false;
697
720
  // Issue #2141: a fatal startup log record (e.g. ProviderModelNotFoundError)
698
721
  // that the agent CLI reports without any `{"type":"error"}` event.
699
722
  let fatalLogErrorMessage = null;
723
+ // A Formal AI run must never continue after Agent resolves another model.
724
+ let formalAiRoutingErrorMessage = null;
700
725
  // Issue #1250: Accumulate token usage during streaming instead of parsing fullOutput later
701
726
  // This fixes the issue where NDJSON lines get concatenated without newlines, breaking JSON.parse
702
727
  const streamingTokenUsage = createAgentTokenUsage();
703
728
  const accumulateTokenUsage = data => accumulateAgentStepFinishUsage(streamingTokenUsage, data);
704
- const isAgentSuccessfulCompletionEvent = data => {
705
- if (data.type === 'session.idle' || data.type === 'session_idle' || data.type === 'idle') return true;
706
- if (data.type === 'log' && data.message === 'exiting loop') return true;
707
- if (data.type === 'step_finish' && data.part?.reason === 'stop') return true;
708
- if (data.type === 'result' && (data.status === 'success' || data.subtype === 'success')) return true;
709
- return false;
710
- };
711
729
  const markBidirectionalStateFromAgentEvent = async data => {
712
730
  if (!bidirectionalHandler) return;
713
- if (isAgentSuccessfulCompletionEvent(data)) {
731
+ if (isAgentIdleEvent(data)) {
714
732
  if (typeof bidirectionalHandler.markAiIdle === 'function') {
715
733
  try {
716
734
  await bidirectionalHandler.markAiIdle();
@@ -754,6 +772,17 @@ export const executeAgentCommand = async params => {
754
772
  // Issue #1250: Accumulate token usage during streaming
755
773
  accumulateTokenUsage(data);
756
774
  await markBidirectionalStateFromAgentEvent(data);
775
+ if (!formalAiRoutingErrorMessage) {
776
+ const routingMismatch = detectFormalAiAgentRoutingMismatch(data, mappedModel);
777
+ if (routingMismatch) {
778
+ formalAiRoutingErrorMessage = routingMismatch;
779
+ await log(`🛑 ${routingMismatch}`, { level: 'error' });
780
+ // Agent emits its parser warning and selected provider before its
781
+ // first HTTP request. Stop immediately instead of trusting a later
782
+ // error or cost report to reveal that the wrong LLM was used.
783
+ execCommand?.kill?.('SIGTERM');
784
+ }
785
+ }
757
786
  // Issue #1201: Detect error events during streaming for reliable detection
758
787
  if (data.type === 'error' || data.type === 'step_error') {
759
788
  streamingErrorDetected = true;
@@ -807,17 +836,7 @@ export const executeAgentCommand = async params => {
807
836
  // Explicit result message (like Claude outputs)
808
837
  lastTextContent = data.result;
809
838
  }
810
- // Issue #1276: Detect successful completion events
811
- // When agent emits session.idle or log with "exiting loop" message, it completed successfully
812
- // This means any previous error events were recovered from (e.g., timeout then retry)
813
- if (isAgentSuccessfulCompletionEvent(data)) {
814
- agentCompletedSuccessfully = true;
815
- }
816
- // Issue #1296: Detect step_finish with reason "stop" as successful completion
817
- // This is a clear marker of success - agent finished normally, not due to error or limit
818
- // When this event appears, we should ignore any error events that appeared earlier in the stream
819
- // (e.g., timeout errors that were recovered from via retry logic)
820
- if (data.type === 'step_finish' && data.part?.reason === 'stop') agentCompletedSuccessfully = true;
839
+ if (isAgentStrongCompletionEvent(data)) agentCompletedSuccessfully = true;
821
840
  };
822
841
 
823
842
  const handleAgentStreamEvents = async events => {
@@ -867,6 +886,12 @@ export const executeAgentCommand = async params => {
867
886
  // Issue #2141: the detection now renders structured payloads as text.
868
887
  const outputError = detectAgentErrorsInOutput(fullOutput);
869
888
 
889
+ if (formalAiRoutingErrorMessage) {
890
+ outputError.detected = true;
891
+ outputError.type = 'AgentModelRoutingMismatch';
892
+ outputError.match = formalAiRoutingErrorMessage;
893
+ }
894
+
870
895
  // Issue #1276: Clear streaming error detection if agent completed successfully
871
896
  // When an error occurs during execution (e.g., timeout) but the agent recovers and completes,
872
897
  // we should NOT treat it as a failure. The exit code is the authoritative success indicator.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Idle-only refresh of the agentic CLIs (issue #2146, PR #2147 review).
3
+ *
4
+ * The maintainer's review closed with:
5
+ *
6
+ * "Do same auto update for claude, codex, agent and other agentic CLIs (when
7
+ * no tasks are active and new version is available) or support update inside
8
+ * each separate task's docker."
9
+ *
10
+ * This module implements the first option: while the host has no active task,
11
+ * compare each CLI's installed version against the registry and reinstall only
12
+ * the ones that moved. The second option is deliberately not taken — updating
13
+ * inside a task's container would change the toolchain mid-run and make the
14
+ * task's own logs unreproducible.
15
+ *
16
+ * Two packages are excluded on purpose:
17
+ *
18
+ * - `@link-assistant/hive-mind` — replacing the package that owns the running
19
+ * process mid-flight is how you get a half-swapped bot. Hive Mind is updated
20
+ * by redeploying its image.
21
+ * - `start-command` — pinned by exact version in the Dockerfile because each
22
+ * bump encodes a behavioural fix Hive Mind depends on (see the pin comment
23
+ * in `Dockerfile`). Bumping it is a reviewed change, not a background one.
24
+ *
25
+ * @see https://github.com/link-assistant/hive-mind/issues/2146
26
+ */
27
+
28
+ import { execFile } from 'node:child_process';
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { promisify } from 'node:util';
32
+
33
+ import { resolveBotStateDir } from './session-store.lib.mjs';
34
+ import { withStateLock } from './state-lock.lib.mjs';
35
+
36
+ const execFileAsync = promisify(execFile);
37
+
38
+ const STATE_FILE_NAME = 'agentic-cli-updates.json';
39
+ const CLI_UPDATE_LOCK_NAME = 'agentic-cli-update';
40
+ const DEFAULT_COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
41
+ const DEFAULT_INSTALL_TIMEOUT_MS = 15 * 60 * 1000;
42
+
43
+ /** Default minimum gap between registry checks, so a busy bot does not poll npm continuously. */
44
+ export const DEFAULT_CLI_UPDATE_INTERVAL_MS = 6 * 60 * 60 * 1000;
45
+
46
+ /**
47
+ * The CLIs Hive Mind drives, in the same order the Dockerfile installs them.
48
+ *
49
+ * `installer: 'self'` is for Claude Code, which ships a native binary through
50
+ * its own installer script; `claude update` is the supported refresh path and
51
+ * `bun install -g` would fight the postinstall link (issue #1633).
52
+ */
53
+ export const AGENTIC_CLI_TARGETS = Object.freeze([
54
+ { id: 'claude', package: '@anthropic-ai/claude-code', binary: 'claude', installer: 'self', updateArgs: ['update'] },
55
+ { id: 'codex', package: '@openai/codex', binary: 'codex', installer: 'bun' },
56
+ { id: 'agent', package: '@link-assistant/agent', binary: 'agent', installer: 'bun' },
57
+ { id: 'gemini', package: '@google/gemini-cli', binary: 'gemini', installer: 'bun' },
58
+ { id: 'qwen', package: '@qwen-code/qwen-code', binary: 'qwen', installer: 'bun' },
59
+ { id: 'copilot', package: '@github/copilot', binary: 'copilot', installer: 'bun' },
60
+ { id: 'opencode', package: 'opencode-ai', binary: 'opencode', installer: 'bun' },
61
+ ]);
62
+
63
+ /** Auto-update is on by default; operators can disable it or narrow it to a subset. */
64
+ export const isAgenticCliAutoUpdateEnabled = (env = process.env) => {
65
+ const raw = String(env.HIVE_MIND_AGENTIC_CLI_AUTO_UPDATE ?? '')
66
+ .trim()
67
+ .toLowerCase();
68
+ if (!raw) return true;
69
+ return !['0', 'false', 'no', 'off'].includes(raw);
70
+ };
71
+
72
+ const parseIdList = value =>
73
+ String(value ?? '')
74
+ .split(',')
75
+ .map(entry => entry.trim().toLowerCase())
76
+ .filter(Boolean);
77
+
78
+ /**
79
+ * Resolve which CLIs this host should refresh.
80
+ *
81
+ * `HIVE_MIND_AGENTIC_CLI_UPDATE_ONLY` is an allow-list and
82
+ * `HIVE_MIND_AGENTIC_CLI_UPDATE_EXCLUDE` a deny-list, both by target id.
83
+ */
84
+ export const listAgenticCliUpdateTargets = (env = process.env) => {
85
+ const only = parseIdList(env.HIVE_MIND_AGENTIC_CLI_UPDATE_ONLY);
86
+ const excluded = new Set(parseIdList(env.HIVE_MIND_AGENTIC_CLI_UPDATE_EXCLUDE));
87
+ return AGENTIC_CLI_TARGETS.filter(target => (only.length === 0 || only.includes(target.id)) && !excluded.has(target.id));
88
+ };
89
+
90
+ /** First semantic version in a CLI's `--version` output, which is rarely bare. */
91
+ export const parseCliVersion = text => String(text ?? '').match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/)?.[0] ?? null;
92
+
93
+ export const resolveAgenticCliStatePath = (env = process.env) => path.join(resolveBotStateDir(env), STATE_FILE_NAME);
94
+
95
+ /** Read the refresh journal. A missing or corrupt file is an empty journal, never a throw. */
96
+ export const readAgenticCliState = ({ env = process.env, fsImpl = fs } = {}) => {
97
+ try {
98
+ const parsed = JSON.parse(fsImpl.readFileSync(resolveAgenticCliStatePath(env), 'utf8'));
99
+ return { version: 1, lastCheckedAt: null, tools: {}, ...parsed };
100
+ } catch {
101
+ return { version: 1, lastCheckedAt: null, tools: {} };
102
+ }
103
+ };
104
+
105
+ /** Persist the refresh journal atomically. */
106
+ export const writeAgenticCliState = (state, { env = process.env, fsImpl = fs } = {}) => {
107
+ const target = resolveAgenticCliStatePath(env);
108
+ fsImpl.mkdirSync(path.dirname(target), { recursive: true });
109
+ const temporary = `${target}.tmp`;
110
+ fsImpl.writeFileSync(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
111
+ fsImpl.renameSync(temporary, target);
112
+ return state;
113
+ };
114
+
115
+ /** Installed version of one CLI, or null when the binary is absent or mute. */
116
+ export const readInstalledCliVersion = async (target, { run = execFileAsync, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS } = {}) => {
117
+ try {
118
+ const result = await run(target.binary, ['--version'], { encoding: 'utf8', timeout: timeoutMs });
119
+ return parseCliVersion(`${result?.stdout ?? ''}${result?.stderr ?? ''}`);
120
+ } catch {
121
+ return null;
122
+ }
123
+ };
124
+
125
+ /** Latest version the npm registry publishes for one CLI, or null when unreachable. */
126
+ export const readLatestPublishedVersion = async (target, { run = execFileAsync, timeoutMs = DEFAULT_COMMAND_TIMEOUT_MS } = {}) => {
127
+ try {
128
+ const result = await run('npm', ['view', target.package, 'version'], { encoding: 'utf8', timeout: timeoutMs });
129
+ return parseCliVersion(result?.stdout);
130
+ } catch {
131
+ return null;
132
+ }
133
+ };
134
+
135
+ /** Reinstall one CLI at the latest published version. */
136
+ export const installAgenticCli = async (target, { run = execFileAsync, timeoutMs = DEFAULT_INSTALL_TIMEOUT_MS } = {}) => {
137
+ if (target.installer === 'self') return run(target.binary, target.updateArgs ?? ['update'], { encoding: 'utf8', timeout: timeoutMs });
138
+ return run('bun', ['install', '-g', `${target.package}@latest`], { encoding: 'utf8', timeout: timeoutMs });
139
+ };
140
+
141
+ /**
142
+ * Refresh every configured agentic CLI, but only while the host is idle.
143
+ *
144
+ * Idleness is the same signal `hive cleanup` trusts: a task is active when it
145
+ * holds a process or a non-terminal start-command session. Replacing a CLI
146
+ * binary under a running task would swap the toolchain mid-run.
147
+ *
148
+ * @returns {Promise<{status: 'disabled'|'busy'|'throttled'|'checked', updated: object[], upToDate: object[], failed: object[]}>}
149
+ */
150
+ export const updateAgenticClisWhenIdle = async ({ env = process.env, fsImpl = fs, run = execFileAsync, log = null, verbose = false, getActiveTasksImpl = null, now = () => new Date(), minIntervalMs = DEFAULT_CLI_UPDATE_INTERVAL_MS, force = false, lockOptions = {} } = {}) => {
151
+ if (!isAgenticCliAutoUpdateEnabled(env)) {
152
+ if (verbose && log) await log('[VERBOSE] agentic-cli-updater: disabled by HIVE_MIND_AGENTIC_CLI_AUTO_UPDATE');
153
+ return { status: 'disabled', updated: [], upToDate: [], failed: [] };
154
+ }
155
+
156
+ return withStateLock(
157
+ CLI_UPDATE_LOCK_NAME,
158
+ async () => {
159
+ const state = readAgenticCliState({ env, fsImpl });
160
+ const nowMs = now().getTime();
161
+ const lastCheckedMs = Date.parse(state.lastCheckedAt ?? '') || 0;
162
+ if (!force && lastCheckedMs && nowMs - lastCheckedMs < minIntervalMs) {
163
+ if (verbose && log) await log(`[VERBOSE] agentic-cli-updater: last checked ${Math.round((nowMs - lastCheckedMs) / 60000)} min ago; throttled`);
164
+ return { status: 'throttled', updated: [], upToDate: [], failed: [] };
165
+ }
166
+
167
+ const getActiveTasks = getActiveTasksImpl ?? (await import('./cleanup.os.lib.mjs')).getActiveTasks;
168
+ const activeTasks = await getActiveTasks({ resolveBranches: false });
169
+ if (activeTasks.length > 0) {
170
+ if (verbose && log) await log(`[VERBOSE] agentic-cli-updater: ${activeTasks.length} active task(s); deferring the CLI refresh`);
171
+ return { status: 'busy', activeTaskCount: activeTasks.length, updated: [], upToDate: [], failed: [] };
172
+ }
173
+
174
+ const updated = [];
175
+ const upToDate = [];
176
+ const failed = [];
177
+ const tools = { ...state.tools };
178
+
179
+ for (const target of listAgenticCliUpdateTargets(env)) {
180
+ const installed = await readInstalledCliVersion(target, { run });
181
+ if (!installed) {
182
+ // Not installed on this host (image variants differ); nothing to refresh.
183
+ if (verbose && log) await log(`[VERBOSE] agentic-cli-updater: ${target.id} is not installed here; skipping`);
184
+ continue;
185
+ }
186
+
187
+ const latest = await readLatestPublishedVersion(target, { run });
188
+ if (!latest) {
189
+ failed.push({ id: target.id, installed, error: `could not read the published version of ${target.package}` });
190
+ continue;
191
+ }
192
+ if (latest === installed) {
193
+ upToDate.push({ id: target.id, version: installed });
194
+ tools[target.id] = { ...tools[target.id], version: installed, checkedAt: now().toISOString() };
195
+ continue;
196
+ }
197
+
198
+ if (log) await log(`⬆️ Updating ${target.id} ${installed} → ${latest}`);
199
+ try {
200
+ await installAgenticCli(target, { run });
201
+ } catch (error) {
202
+ const message = error?.stderr?.toString?.().trim() || error?.message || String(error);
203
+ if (log) await log(`⚠️ Could not update ${target.id}: ${message}`);
204
+ failed.push({ id: target.id, installed, latest, error: message });
205
+ continue;
206
+ }
207
+
208
+ // Trust the binary, not the installer's exit code: a package can install
209
+ // and still fail to link its bin.
210
+ const after = await readInstalledCliVersion(target, { run });
211
+ if (after === latest) {
212
+ updated.push({ id: target.id, from: installed, to: after });
213
+ tools[target.id] = { version: after, previousVersion: installed, updatedAt: now().toISOString(), checkedAt: now().toISOString() };
214
+ } else {
215
+ failed.push({ id: target.id, installed, latest, error: `after install the binary reports ${after ?? 'nothing'}` });
216
+ }
217
+ }
218
+
219
+ writeAgenticCliState({ ...state, lastCheckedAt: now().toISOString(), tools }, { env, fsImpl });
220
+ if (log && updated.length > 0) await log(`✅ Agentic CLIs updated while idle: ${updated.map(entry => `${entry.id} ${entry.from}→${entry.to}`).join(', ')}`);
221
+ if (verbose && log) await log(`[VERBOSE] agentic-cli-updater: ${updated.length} updated, ${upToDate.length} current, ${failed.length} failed`);
222
+ return { status: 'checked', updated, upToDate, failed };
223
+ },
224
+ { env, fsImpl, log, ...lockOptions }
225
+ );
226
+ };
227
+
228
+ export default {
229
+ AGENTIC_CLI_TARGETS,
230
+ DEFAULT_CLI_UPDATE_INTERVAL_MS,
231
+ installAgenticCli,
232
+ isAgenticCliAutoUpdateEnabled,
233
+ listAgenticCliUpdateTargets,
234
+ parseCliVersion,
235
+ readAgenticCliState,
236
+ readInstalledCliVersion,
237
+ readLatestPublishedVersion,
238
+ resolveAgenticCliStatePath,
239
+ updateAgenticClisWhenIdle,
240
+ writeAgenticCliState,
241
+ };