@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.
- package/CHANGELOG.md +18 -0
- package/package.json +4 -1
- package/src/agent-command.lib.mjs +74 -0
- package/src/agent.lib.mjs +59 -34
- package/src/agentic-cli-updater.lib.mjs +241 -0
- package/src/claude.connection.lib.mjs +209 -0
- package/src/claude.lib.mjs +6 -202
- package/src/codex.lib.mjs +0 -128
- package/src/formal-ai-isolation.lib.mjs +62 -0
- package/src/formal-ai-maintenance.lib.mjs +106 -0
- package/src/formal-ai-model.lib.mjs +25 -0
- package/src/formal-ai-runtime.lib.mjs +10 -0
- package/src/formal-ai-sidecar.lib.mjs +565 -0
- package/src/formal-ai-updater.lib.mjs +294 -0
- package/src/formal-ai-version.lib.mjs +100 -0
- package/src/formal-ai.lib.mjs +11 -16
- package/src/github-rate-limit.lib.mjs +3 -0
- package/src/github-url-parser.lib.mjs +255 -0
- package/src/github.lib.mjs +22 -343
- package/src/hive.mjs +0 -152
- package/src/interactive-mode.lib.mjs +0 -43
- package/src/isolation-runner.lib.mjs +44 -173
- package/src/limits.lib.mjs +0 -89
- package/src/model-args.lib.mjs +32 -0
- package/src/models/index.mjs +5 -19
- package/src/session-monitor.lib.mjs +14 -172
- package/src/solve.auto-merge.lib.mjs +70 -164
- package/src/solve.mjs +31 -193
- package/src/solve.repository.lib.mjs +0 -83
- package/src/solve.results.lib.mjs +2 -92
- package/src/solve.session.lib.mjs +52 -19
- package/src/solve.tool-uncommitted.lib.mjs +22 -0
- package/src/state-lock.lib.mjs +82 -0
- package/src/telegram-bot.mjs +17 -65
- package/src/telegram-fix-command.lib.mjs +1 -8
- package/src/telegram-merge-queue.lib.mjs +3 -155
- package/src/telegram-solve-queue.lib.mjs +9 -168
- package/src/telegram-task-command.lib.mjs +1 -8
- package/src/use-m-bootstrap.lib.mjs +6 -5
- package/src/use-with-retry.lib.mjs +128 -2
- 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.
|
|
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
|
-
|
|
635
|
-
|
|
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} ${
|
|
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
|
-
//
|
|
695
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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
|
+
};
|