@platless/axom-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/dist/args.js +29 -0
- package/dist/cli.js +76 -0
- package/dist/commands/auth.js +52 -0
- package/dist/commands/execute.js +40 -0
- package/dist/commands/guide.js +38 -0
- package/dist/commands/record.js +99 -0
- package/dist/commands/skills.js +69 -0
- package/dist/commands/struct.js +50 -0
- package/dist/commands/update.js +88 -0
- package/dist/commands/whoami.js +8 -0
- package/dist/commands/workstation.js +171 -0
- package/dist/config.js +131 -0
- package/dist/credentialBroker.js +340 -0
- package/dist/credentials.js +211 -0
- package/dist/install/agentsDir.js +35 -0
- package/dist/install/claude/commands.js +89 -0
- package/dist/install/claude/materialize.js +42 -0
- package/dist/install/installer.js +45 -0
- package/dist/install/targets/claude.js +154 -0
- package/dist/mcp.js +152 -0
- package/dist/output.js +37 -0
- package/dist/workstation/acp/acpAgents.js +1287 -0
- package/dist/workstation/acp/acpClient.js +691 -0
- package/dist/workstation/acp/acpSession.js +1047 -0
- package/dist/workstation/acp/adoption.js +396 -0
- package/dist/workstation/acp/auth.js +80 -0
- package/dist/workstation/acp/authUrlSniffer.js +52 -0
- package/dist/workstation/acp/binaryInstall.js +411 -0
- package/dist/workstation/acp/configOptions.js +257 -0
- package/dist/workstation/acp/contract.js +494 -0
- package/dist/workstation/acp/draftInterceptor.js +154 -0
- package/dist/workstation/acp/draftSubagents.js +38 -0
- package/dist/workstation/acp/installErrors.js +89 -0
- package/dist/workstation/acp/installProbe.js +363 -0
- package/dist/workstation/acp/permission.js +327 -0
- package/dist/workstation/acp/registry/catalog.js +29 -0
- package/dist/workstation/acp/registry/launch.js +128 -0
- package/dist/workstation/acp/registry/registryClient.js +160 -0
- package/dist/workstation/acp/registry/registryTypes.js +8 -0
- package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
- package/dist/workstation/acp/test/fakeAgent.js +435 -0
- package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
- package/dist/workstation/acp/timedCommand.js +47 -0
- package/dist/workstation/acp/uvxInstall.js +248 -0
- package/dist/workstation/acp/validate.js +134 -0
- package/dist/workstation/agentSessions.js +1628 -0
- package/dist/workstation/channels/agent.js +96 -0
- package/dist/workstation/channels/computer.js +91 -0
- package/dist/workstation/channels/management.js +231 -0
- package/dist/workstation/channels/url.js +42 -0
- package/dist/workstation/config.js +47 -0
- package/dist/workstation/connection/encryptedChannel.js +261 -0
- package/dist/workstation/connection/retryPolicy.js +14 -0
- package/dist/workstation/connection/supervisor.js +353 -0
- package/dist/workstation/crypto.js +63 -0
- package/dist/workstation/daemon.js +225 -0
- package/dist/workstation/daemonctl.js +95 -0
- package/dist/workstation/exec.js +112 -0
- package/dist/workstation/journal.js +257 -0
- package/dist/workstation/names.js +17 -0
- package/dist/workstation/oauth/browser.js +24 -0
- package/dist/workstation/oauth/deviceAuthorization.js +198 -0
- package/dist/workstation/osinfo.js +4 -0
- package/dist/workstation/protocol/agent.js +606 -0
- package/dist/workstation/protocol/common.js +136 -0
- package/dist/workstation/protocol/computer.js +31 -0
- package/dist/workstation/protocol/management.js +116 -0
- package/dist/workstation/register.js +42 -0
- package/dist/workstation/service/installer.js +362 -0
- package/dist/workstation/service/launchd.js +46 -0
- package/dist/workstation/service/loginShell.js +36 -0
- package/dist/workstation/service/systemd.js +48 -0
- package/dist/workstation/service/types.js +73 -0
- package/dist/workstation/supervision.js +52 -0
- package/dist/workstation/systemMetrics.js +271 -0
- package/dist/workstation/update/artifact.js +61 -0
- package/dist/workstation/update/coordinator.js +240 -0
- package/dist/workstation/update/npmInstaller.js +91 -0
- package/dist/workstation/update/redact.js +36 -0
- package/dist/workstation/update/runUpdate.js +215 -0
- package/dist/workstation/update/stateStore.js +87 -0
- package/dist/workstation/update/taskRegistry.js +142 -0
- package/dist/workstation/update/types.js +3 -0
- package/dist/workstation/update/watchdog.js +311 -0
- package/dist/workstation/version.js +12 -0
- package/package.json +39 -0
- package/skills/axom/SKILL.md +83 -0
- package/skills/axom-definition/SKILL.md +74 -0
- package/skills/axom-platform/SKILL.md +78 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Client capability key (canonical, draft #1992): `clientCapabilities.subagents = {}`. */
|
|
2
|
+
export const DRAFT_SUBAGENTS_CAPABILITY = 'subagents';
|
|
3
|
+
/**
|
|
4
|
+
* The JetBrains AIR extension envelope, which is how `asyncTasks` is negotiated at all — there is
|
|
5
|
+
* no canonical field for it, and the canonical `subagents` field can be stripped by a released SDK
|
|
6
|
+
* that has never heard of it, so the AIR list carries a fallback for that one too.
|
|
7
|
+
* Keys and version verbatim from `claude-agent-acp/src/air-extension.ts`.
|
|
8
|
+
*/
|
|
9
|
+
export const AIR_META_KEY = 'jetbrains';
|
|
10
|
+
export const AIR_EXTENSION_VERSION = 1;
|
|
11
|
+
export const AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY = 'nativeSubagentSessions';
|
|
12
|
+
export const AIR_ASYNC_TASKS_CAPABILITY = 'asyncTasks';
|
|
13
|
+
/** The `_meta.jetbrains` payload advertising what this client understands. */
|
|
14
|
+
export function airCapabilityMeta(...capabilities) {
|
|
15
|
+
return { [AIR_META_KEY]: { air: { version: AIR_EXTENSION_VERSION, capabilities } } };
|
|
16
|
+
}
|
|
17
|
+
/** Every draft variant name, for tests and for the mapper's own documentation. */
|
|
18
|
+
export const DRAFT_SESSION_UPDATE_NAMES = [
|
|
19
|
+
'subagent_spawned',
|
|
20
|
+
'subagent_state_update',
|
|
21
|
+
'async_task_spawned',
|
|
22
|
+
'async_task_progress',
|
|
23
|
+
'async_task_state_update',
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* The one cast this file exists to contain, mirroring upstream's `asSdkSessionNotification`.
|
|
27
|
+
*
|
|
28
|
+
* `ClientCapabilities` is a closed published type with no `subagents` member, so declaring the
|
|
29
|
+
* canonical draft field makes the object structurally unassignable — and TypeScript answers that by
|
|
30
|
+
* dropping the `initialize` overload, which silently turns the response into `unknown`. That is a
|
|
31
|
+
* worse failure than the cast: it un-types every field of the handshake reply.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately narrow: it casts capabilities only, at the single call site that builds them. When
|
|
34
|
+
* #1992 lands in the SDK this function is deleted and the call site keeps its shape.
|
|
35
|
+
*/
|
|
36
|
+
export function asSdkClientCapabilities(capabilities) {
|
|
37
|
+
return capabilities;
|
|
38
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns what a failed adapter install/update actually learned into a short, actionable sentence —
|
|
3
|
+
* the toast a person reads — instead of Node's own `Command failed: npx --yes --package=… -- …`
|
|
4
|
+
* dump landing there verbatim. That is not a hypothetical: it is exactly what a real `Update` click
|
|
5
|
+
* produced for a registry entry pinned to an unpublished npm version, with npm's multi-line
|
|
6
|
+
* `notarget` stanza and a path to a debug log nobody asked to read.
|
|
7
|
+
*
|
|
8
|
+
* Nothing here deletes that raw text: {@link logInstallFailureDetail} writes it to the daemon's own
|
|
9
|
+
* log, untruncated, at every call site in `acpAgents.ts` that turns a failure into one of this
|
|
10
|
+
* module's short sentences — npx (both the ordinary case and the pinned-fallback-also-failed case),
|
|
11
|
+
* uvx, and binary (whose `error` already carries the extraction step's own detail, see
|
|
12
|
+
* `binaryInstall.ts`). None of the three install paths' `console.*` calls existed before this module
|
|
13
|
+
* did; they were added alongside it specifically so demoting the raw detail out of the UI would not
|
|
14
|
+
* also delete the only copy of it. This module only decides what the *headline* says; the detail a
|
|
15
|
+
* person would need to actually debug the failure is logged, in full, right where that headline is
|
|
16
|
+
* built.
|
|
17
|
+
*
|
|
18
|
+
* Three shapes, matching the three things a failed install can mean (see `acpClient.ts`'s
|
|
19
|
+
* `isMissingPackageVersion`/`stderrShowsMissingPackageVersion` — the one predicate every ETARGET
|
|
20
|
+
* check in this codebase reuses rather than re-deriving):
|
|
21
|
+
*
|
|
22
|
+
* 1. An ordinary failure with no ETARGET in sight: a network hiccup, a full disk, a missing archive
|
|
23
|
+
* tool, a timeout. {@link friendlyOrdinaryInstallError}.
|
|
24
|
+
* 2. The registry pins a version that is not published upstream, and the unpinned retry
|
|
25
|
+
* (`unpinnedFallbackFor`, `registry/launch.ts`) that tries to work around it fails too.
|
|
26
|
+
* {@link friendlyPinnedVersionUnavailableAndFallbackFailed}.
|
|
27
|
+
* 3. The same missing pin, but the unpinned retry succeeds — not a failure at all, so it produces no
|
|
28
|
+
* message here. It produces a {@link PinnedVersionUnavailable} fact instead, which
|
|
29
|
+
* `PersonalWorkstationService` (`axom-engine`) persists so a settings card can explain itself
|
|
30
|
+
* after a reload instead of offering an `Update` that can never converge (the version now
|
|
31
|
+
* installed can never equal the registry's own unreachable pin).
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* Strips a leading `Command failed: <argv>` line — what Node's `execFile` puts first in
|
|
35
|
+
* `error.message`, and the exact preamble that made the reported toast unreadable — keeping only
|
|
36
|
+
* what actually went wrong. A no-op on text that never had one, so this is safe to run on every raw
|
|
37
|
+
* detail this module sees, not just npx's.
|
|
38
|
+
*/
|
|
39
|
+
function withoutCommandPreamble(text) {
|
|
40
|
+
const lines = text.split('\n');
|
|
41
|
+
if (/^Command failed:/i.test(lines[0] ?? ''))
|
|
42
|
+
lines.shift();
|
|
43
|
+
const rest = lines.map((line) => line.trim()).filter(Boolean).join(' ');
|
|
44
|
+
return rest || text.trim();
|
|
45
|
+
}
|
|
46
|
+
/** Bounded so a person reading a toast is not handed npm's full stanza — the daemon log still has it. */
|
|
47
|
+
const SHORT_DETAIL_MAX_CHARS = 300;
|
|
48
|
+
function shortDetail(rawDetail) {
|
|
49
|
+
const cleaned = withoutCommandPreamble(rawDetail);
|
|
50
|
+
return cleaned.length > SHORT_DETAIL_MAX_CHARS ? `${cleaned.slice(0, SHORT_DETAIL_MAX_CHARS)}…` : cleaned;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Writes the full, untruncated failure `rawDetail` to the daemon's own log — never over the wire,
|
|
54
|
+
* never shown in the UI — right before it is boiled down into one of this module's short sentences.
|
|
55
|
+
* Call this at every site that calls {@link friendlyOrdinaryInstallError} or
|
|
56
|
+
* {@link friendlyPinnedVersionUnavailableAndFallbackFailed} for an actual failure (not case 3's
|
|
57
|
+
* quiet success), so a person with shell access to the workstation can still diagnose a cause none
|
|
58
|
+
* of the three classified shapes covers — a month from now, with no memory of tonight.
|
|
59
|
+
*
|
|
60
|
+
* Written in the same voice as the `console.warn` pair around the ETARGET fallback in
|
|
61
|
+
* `acpAgents.ts` (`[acp] …`), so the two read as one log, not two authors: names the agent, which
|
|
62
|
+
* distribution and version spec the daemon was trying to fetch, and then the raw detail itself,
|
|
63
|
+
* verbatim — not bounded by {@link SHORT_DETAIL_MAX_CHARS}, which exists for the toast, not the log.
|
|
64
|
+
*/
|
|
65
|
+
export function logInstallFailureDetail(agentId, distribution, versionSpec, rawDetail) {
|
|
66
|
+
console.warn(`[acp] install failed for agent '${agentId}' via ${distribution} '${versionSpec}': ${rawDetail}`);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The reassurance clause every failure message ends with, when — and only when — there is a fact to
|
|
70
|
+
* back it up: [existingVersion] is `undefined` unless this call actually found a version of the
|
|
71
|
+
* adapter already on disk, read fresh at the moment the attempt failed. Never invents comfort it
|
|
72
|
+
* cannot back: an agent that has never been installed gets no claim that "the version you have"
|
|
73
|
+
* still works, because there isn't one.
|
|
74
|
+
*/
|
|
75
|
+
function reassurance(existingVersion) {
|
|
76
|
+
return existingVersion
|
|
77
|
+
? ` The version already installed (${existingVersion}) was not touched and still works.`
|
|
78
|
+
: '';
|
|
79
|
+
}
|
|
80
|
+
/** Case 1: an ordinary failure, no ETARGET involved — network, disk, a missing archive tool, a timeout. */
|
|
81
|
+
export function friendlyOrdinaryInstallError(agentId, rawDetail, existingVersion) {
|
|
82
|
+
return (`Could not fetch the adapter for '${agentId}': ${shortDetail(rawDetail)}. Try again in a moment, ` +
|
|
83
|
+
`or check the daemon log if it keeps failing.${reassurance(existingVersion)}`);
|
|
84
|
+
}
|
|
85
|
+
/** Case 2: the registry's pin is missing upstream, and the unpinned retry meant to work around it failed too. */
|
|
86
|
+
export function friendlyPinnedVersionUnavailableAndFallbackFailed(agentId, pinned, fallbackDetail, existingVersion) {
|
|
87
|
+
return (`The registry pins '${agentId}' to version ${pinned}, which is not published. Installing the ` +
|
|
88
|
+
`latest available version instead failed too: ${shortDetail(fallbackDetail)}.${reassurance(existingVersion)}`);
|
|
89
|
+
}
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything this daemon knows about what is **actually on this machine** — the half of the
|
|
3
|
+
* external-agent story the ACP registry cannot tell, because the registry describes packages and
|
|
4
|
+
* this describes a filesystem.
|
|
5
|
+
*
|
|
6
|
+
* ## The rule these functions exist to keep
|
|
7
|
+
*
|
|
8
|
+
* The settings page previously reported "Ready to launch" for every agent whose registry entry had
|
|
9
|
+
* an `npx` or `uvx` block. That is a property of the *entry*, identical on every machine on earth,
|
|
10
|
+
* and it was rendered as a claim about the reader's laptop — so agents nobody had installed read as
|
|
11
|
+
* ready, and Antigravity, which was installed, read as unusable. Nothing here may repeat that:
|
|
12
|
+
* every function below answers by looking at the disk, and returns "no"/"unknown" rather than
|
|
13
|
+
* inferring from metadata.
|
|
14
|
+
*
|
|
15
|
+
* ## Nothing here starts an agent
|
|
16
|
+
*
|
|
17
|
+
* {@link findOnPath} is `which`, not `command --version`: presence on `PATH` is the whole question,
|
|
18
|
+
* and the roster is built for all 39 registry agents at once, so a probe that executed anything
|
|
19
|
+
* would fan out 39 processes on every scan. {@link readNpxCache} is two `readdir`/`readFile`
|
|
20
|
+
* passes over npm's own cache directory. The one function here that does spawn something,
|
|
21
|
+
* {@link installNpxPackage}, runs `node -e ""` under `npm exec --package=…` — it downloads and
|
|
22
|
+
* unpacks the adapter and deliberately never executes it.
|
|
23
|
+
*/
|
|
24
|
+
import { execFile } from 'node:child_process';
|
|
25
|
+
import { accessSync, constants, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import semver from 'semver';
|
|
29
|
+
export function probeEnvironment(env = process.env) {
|
|
30
|
+
return {
|
|
31
|
+
path: env.PATH,
|
|
32
|
+
pathExt: env.PATHEXT,
|
|
33
|
+
platform: process.platform,
|
|
34
|
+
home: os.homedir(),
|
|
35
|
+
npmCache: env.npm_config_cache,
|
|
36
|
+
localAppData: env.LOCALAPPDATA,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const DEFAULT_PATHEXT = '.COM;.EXE;.BAT;.CMD';
|
|
40
|
+
/**
|
|
41
|
+
* `which`, with no subprocess: the first entry of `PATH` that holds an executable file called
|
|
42
|
+
* `command`, or `undefined`.
|
|
43
|
+
*
|
|
44
|
+
* **Deliberately does not run the program.** "Is it installed" is answered by the file being there;
|
|
45
|
+
* `--version` would answer a different, more expensive question, and asking it once per registry
|
|
46
|
+
* agent would start 39 programs behind a settings page that only wanted a list.
|
|
47
|
+
*
|
|
48
|
+
* `statSync` rather than `lstatSync` on purpose — vendor installers routinely drop a symlink into
|
|
49
|
+
* `~/.local/bin`, and a probe that refused to follow it would report a working CLI as missing.
|
|
50
|
+
*/
|
|
51
|
+
export function findOnPath(command, env = probeEnvironment()) {
|
|
52
|
+
if (!command)
|
|
53
|
+
return undefined;
|
|
54
|
+
const platform = env.platform ?? process.platform;
|
|
55
|
+
const delimiter = platform === 'win32' ? ';' : ':';
|
|
56
|
+
const extensions = platform === 'win32'
|
|
57
|
+
? (env.pathExt ?? DEFAULT_PATHEXT).split(';').filter(Boolean)
|
|
58
|
+
: [''];
|
|
59
|
+
for (const directory of (env.path ?? '').split(delimiter)) {
|
|
60
|
+
if (!directory)
|
|
61
|
+
continue;
|
|
62
|
+
for (const extension of extensions) {
|
|
63
|
+
const candidate = path.join(directory, command + extension);
|
|
64
|
+
try {
|
|
65
|
+
if (!statSync(candidate).isFile())
|
|
66
|
+
continue;
|
|
67
|
+
// X_OK is meaningless on win32 (node reports every readable file as executable), so the
|
|
68
|
+
// extension list above is what stands in for it there.
|
|
69
|
+
if (platform !== 'win32')
|
|
70
|
+
accessSync(candidate, constants.X_OK);
|
|
71
|
+
return candidate;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Not there, not a file, or not ours to run. Keep looking; a PATH entry that does not
|
|
75
|
+
// exist at all is entirely normal.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
/** npm's cache directory, by npm's own rules. `_npx/` lives inside it. */
|
|
82
|
+
export function npmCacheDir(env = probeEnvironment()) {
|
|
83
|
+
if (env.npmCache)
|
|
84
|
+
return env.npmCache;
|
|
85
|
+
const platform = env.platform ?? process.platform;
|
|
86
|
+
const home = env.home ?? os.homedir();
|
|
87
|
+
return platform === 'win32' ? path.join(env.localAppData ?? home, 'npm-cache') : path.join(home, '.npm');
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The package name inside an npm specifier: `@scope/name@1.2.3` → `@scope/name`, `name@1.2.3` →
|
|
91
|
+
* `name`. Returns the input unchanged when it carries no version.
|
|
92
|
+
*/
|
|
93
|
+
export function npmPackageName(spec) {
|
|
94
|
+
const at = spec.lastIndexOf('@');
|
|
95
|
+
return at > 0 ? spec.slice(0, at) : spec;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The version half of an npm specifier: `@scope/name@1.2.3` → `1.2.3`, `name@1.2.3` → `1.2.3`.
|
|
99
|
+
* `undefined` when the specifier carries no version, which the ACP registry never actually does —
|
|
100
|
+
* see `registry/launch.ts`'s "pinned specifier, never bare" — but this stays honest for whatever
|
|
101
|
+
* calls it next.
|
|
102
|
+
*/
|
|
103
|
+
export function npmPackageVersion(spec) {
|
|
104
|
+
const name = npmPackageName(spec);
|
|
105
|
+
return name === spec ? undefined : spec.slice(name.length + 1);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Every npm-distributed package `npx` has actually unpacked into this machine's npm cache, indexed
|
|
109
|
+
* by package name, read once so a 39-agent roster costs one directory scan rather than 39.
|
|
110
|
+
*
|
|
111
|
+
* ## Why this reads `node_modules/`, not a manifest field
|
|
112
|
+
*
|
|
113
|
+
* The previous version of this function trusted `<npm cache>/_npx/<hash>/package.json`'s
|
|
114
|
+
* `_npx.packages` array — the specifier npm was asked to install, written verbatim by npm itself.
|
|
115
|
+
* That held on the machine it was written and verified on, and nowhere else: **measured** on a
|
|
116
|
+
* second real machine (four npm majors older), the same manifest carries no `_npx` key at all, and
|
|
117
|
+
* the specifier that *is* there (in `dependencies`) has been rewritten into a semver range
|
|
118
|
+
* (`^0.70.0` for a registry entry that asked for exactly `0.70.0`). Both of those are choices a
|
|
119
|
+
* manifest writer made, not properties of the download, so a check built on either one is a bet on
|
|
120
|
+
* one npm version's choices rather than on what actually happened on disk.
|
|
121
|
+
*
|
|
122
|
+
* What every npm version agrees on, because it is what "installed" *means*, is that a package that
|
|
123
|
+
* was fetched has its own `package.json` sitting under `node_modules/<name>/` with that package's
|
|
124
|
+
* own `name` and `version` — npm did not write that file, the published package did, years before
|
|
125
|
+
* npm ever downloaded it. Reading that instead means nothing here depends on which fields one
|
|
126
|
+
* particular npm chooses to leave in the manifest one level up, or on reproducing npm's own cache
|
|
127
|
+
* hash: this still just walks `_npx/*` and looks inside.
|
|
128
|
+
*
|
|
129
|
+
* ## Why the directory alone is not enough
|
|
130
|
+
*
|
|
131
|
+
* An install that was interrupted (a killed `npm`, a dropped link) can leave a cache entry behind
|
|
132
|
+
* with `node_modules` missing or partial, and reporting that as installed would be precisely the
|
|
133
|
+
* kind of unchecked claim this module exists to prevent. So an entry counts only when the package's
|
|
134
|
+
* own `package.json` is present and its `name` matches the directory it was found under — a stray
|
|
135
|
+
* or half-written file does not.
|
|
136
|
+
*/
|
|
137
|
+
export function readNpxCache(env = probeEnvironment()) {
|
|
138
|
+
const npxDir = path.join(npmCacheDir(env), '_npx');
|
|
139
|
+
const index = new Map();
|
|
140
|
+
let entries;
|
|
141
|
+
try {
|
|
142
|
+
entries = readdirSync(npxDir, { withFileTypes: true });
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// No `_npx` at all — a machine that has never run npx. Nothing is cached, which is an answer.
|
|
146
|
+
return index;
|
|
147
|
+
}
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
if (!entry.isDirectory())
|
|
150
|
+
continue;
|
|
151
|
+
const nodeModules = path.join(npxDir, entry.name, 'node_modules');
|
|
152
|
+
let packageDirs;
|
|
153
|
+
try {
|
|
154
|
+
packageDirs = readdirSync(nodeModules, { withFileTypes: true });
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
// A manifest with nothing unpacked behind it — an interrupted install, or (on some npm
|
|
158
|
+
// versions) no manifest at all. Either way, there is nothing here to report as installed.
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
for (const dir of packageDirs) {
|
|
162
|
+
if (!dir.isDirectory())
|
|
163
|
+
continue;
|
|
164
|
+
if (dir.name.startsWith('@')) {
|
|
165
|
+
// A scope directory: the real package is one level further down.
|
|
166
|
+
let scopedDirs;
|
|
167
|
+
try {
|
|
168
|
+
scopedDirs = readdirSync(path.join(nodeModules, dir.name), { withFileTypes: true });
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
for (const scoped of scopedDirs) {
|
|
174
|
+
if (!scoped.isDirectory())
|
|
175
|
+
continue;
|
|
176
|
+
recordNpxPackage(path.join(nodeModules, dir.name, scoped.name), `${dir.name}/${scoped.name}`, index);
|
|
177
|
+
}
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
recordNpxPackage(path.join(nodeModules, dir.name), dir.name, index);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return index;
|
|
184
|
+
}
|
|
185
|
+
/** Reads one `node_modules/<name>/package.json` and records it, only if it is what it claims to be. */
|
|
186
|
+
function recordNpxPackage(dir, expectedName, index) {
|
|
187
|
+
let manifest;
|
|
188
|
+
try {
|
|
189
|
+
manifest = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const name = manifest?.name;
|
|
195
|
+
const version = manifest?.version;
|
|
196
|
+
if (name !== expectedName || typeof version !== 'string')
|
|
197
|
+
return;
|
|
198
|
+
const list = index.get(name) ?? [];
|
|
199
|
+
list.push({ name, version });
|
|
200
|
+
index.set(name, list);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* The version of one registry specifier's package (`@scope/name@1.2.3` → the `name` half) that is
|
|
204
|
+
* **actually cached** on disk, per {@link readNpxCache} — never whether it matches the specifier's
|
|
205
|
+
* pinned version.
|
|
206
|
+
*
|
|
207
|
+
* Deleted `npxCacheSatisfies` (the boolean this replaces) for a concrete failure it caused: the ACP
|
|
208
|
+
* registry pins an exact adapter version, and the moment upstream published a patch — the registry
|
|
209
|
+
* moving from `claude-agent-acp@0.74.0` to `@0.75.0` while the machine still had 0.74.0 unpacked —
|
|
210
|
+
* an agent that was configured, working and verified got rendered as never having been checked.
|
|
211
|
+
* The version is the fact; "is an update available" is a conclusion drawn by comparing it against
|
|
212
|
+
* the registry's pin, and those are two different questions that must not be collapsed into one
|
|
213
|
+
* boolean.
|
|
214
|
+
*
|
|
215
|
+
* npm's npx cache routinely holds several versions of the same package side by side (see
|
|
216
|
+
* {@link readNpxCache}'s doc), so the selection rule is: report the registry's pinned version when
|
|
217
|
+
* it is among those cached (so an exact match never gets shadowed by a newer sibling); otherwise
|
|
218
|
+
* report the greatest by semver; versions that do not parse as semver sort after every valid one,
|
|
219
|
+
* compared as strings.
|
|
220
|
+
*/
|
|
221
|
+
export function npxCacheVersion(spec, cache) {
|
|
222
|
+
const found = cache.get(npmPackageName(spec));
|
|
223
|
+
if (!found || found.length === 0)
|
|
224
|
+
return undefined;
|
|
225
|
+
return pickInstalledVersion(found.map((p) => p.version), npmPackageVersion(spec));
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The version-selection rule shared by every install surface that can find more than one version
|
|
229
|
+
* of the same thing sitting on disk at once — {@link npxCacheVersion} here, and `binaryInstall.ts`'s
|
|
230
|
+
* `installedBinaryVersion` (a binary adapter's layout, `<root>/<agent>/<version>/`, is deliberately
|
|
231
|
+
* built to let several versions coexist the same way npm's `_npx` cache does). Extracted rather than
|
|
232
|
+
* reimplemented a second time, so both surfaces answer "which one do I mean" identically: the
|
|
233
|
+
* `pinned` version when it is among `versions` (an exact match must never be shadowed by a newer
|
|
234
|
+
* sibling); otherwise the greatest by semver; versions that do not parse as semver sort after every
|
|
235
|
+
* valid one, compared as plain strings.
|
|
236
|
+
*/
|
|
237
|
+
export function pickInstalledVersion(versions, pinned) {
|
|
238
|
+
if (versions.length === 0)
|
|
239
|
+
return undefined;
|
|
240
|
+
if (pinned && versions.includes(pinned))
|
|
241
|
+
return pinned;
|
|
242
|
+
return [...versions].sort((a, b) => {
|
|
243
|
+
const av = semver.valid(a);
|
|
244
|
+
const bv = semver.valid(b);
|
|
245
|
+
if (av && bv)
|
|
246
|
+
return semver.rcompare(av, bv);
|
|
247
|
+
if (av)
|
|
248
|
+
return -1;
|
|
249
|
+
if (bv)
|
|
250
|
+
return 1;
|
|
251
|
+
return b.localeCompare(a);
|
|
252
|
+
})[0];
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Wall-clock budget for one adapter download.
|
|
256
|
+
*
|
|
257
|
+
* ## It is no longer the client's HTTP ceiling
|
|
258
|
+
*
|
|
259
|
+
* This used to be 20s, justified by the settings page's 30s `AXOM_REQUEST_TIMEOUT_MS`: a longer
|
|
260
|
+
* budget would have meant telling the reader the request failed while this process kept working
|
|
261
|
+
* invisibly. That premise is gone. Install, update and try-run are asynchronous tasks now
|
|
262
|
+
* (design §6.4) — the REST call returns a task id immediately and the page polls it — so nothing is
|
|
263
|
+
* waiting on an HTTP round trip and the only thing 20s still bounded was the *work*.
|
|
264
|
+
*
|
|
265
|
+
* And it bounded it far too tightly. A cold `claude-acp` install unpacks
|
|
266
|
+
* `@anthropic-ai/claude-agent-sdk`, measured at 243 MB (design §2.3); on any ordinary connection
|
|
267
|
+
* that does not finish in 20 seconds, so the install died every time — now behind a spinner, which
|
|
268
|
+
* is worse than before, not better. The budget's job is to bound a download that will *never*
|
|
269
|
+
* finish (a hung mirror, a proxy black hole), not one that is merely large.
|
|
270
|
+
*
|
|
271
|
+
* ## What still bounds it from above
|
|
272
|
+
*
|
|
273
|
+
* `AgentChannelSession.AGENT_CHANNEL_INSTALL_TIMEOUT_MS` on the server, which must stay above the
|
|
274
|
+
* worst case this daemon can spend: {@link runDependencyInstall}'s
|
|
275
|
+
* {@link DEPENDENCY_INSTALL_TIMEOUT_MS} followed by one adapter fetch at this budget — `pi-acp` is
|
|
276
|
+
* exactly that pair. Raising either constant means raising that one too, and its own comment says
|
|
277
|
+
* the same thing back.
|
|
278
|
+
*
|
|
279
|
+
* A download that does run out comes back as a failure that says so, and trying again resumes from
|
|
280
|
+
* npm's own partial cache rather than starting over.
|
|
281
|
+
*/
|
|
282
|
+
export const ADAPTER_INSTALL_TIMEOUT_MS = 300_000;
|
|
283
|
+
/**
|
|
284
|
+
* Wall-clock budget for one {@link runDependencyInstall} — a curated `dependency.install` argv, in
|
|
285
|
+
* practice an `npm install -g` of a vendor CLI.
|
|
286
|
+
*
|
|
287
|
+
* Its own constant rather than {@link ADAPTER_INSTALL_TIMEOUT_MS} reused, because the two are
|
|
288
|
+
* consecutive halves of a single `AcpAgents.install` for a catalog agent with a dependency and the
|
|
289
|
+
* server's tunnel budget has to be able to add them up. Same size today; that is a fact about the
|
|
290
|
+
* work, not a reason to collapse them into one name.
|
|
291
|
+
*/
|
|
292
|
+
export const DEPENDENCY_INSTALL_TIMEOUT_MS = 300_000;
|
|
293
|
+
/**
|
|
294
|
+
* Download and unpack one npx-distributed adapter into npm's cache **without running it**.
|
|
295
|
+
*
|
|
296
|
+
* `npx --yes --package=<spec> -- <node> -e ""` installs `<spec>` exactly as the launch spec's
|
|
297
|
+
* `npx --yes <spec>` would — same cache directory, same unpacked package underneath it, which is
|
|
298
|
+
* why {@link readNpxCache} can see it afterwards (measured, not assumed) — and then runs *node*,
|
|
299
|
+
* not the adapter. Running the adapter is what the first real launch does; this is only meant to
|
|
300
|
+
* make that launch fast, and an adapter started here would open stdio and sit waiting for an ACP
|
|
301
|
+
* `initialize` that is never coming.
|
|
302
|
+
*
|
|
303
|
+
* `process.execPath` rather than the string `node`: `npx` puts the freshly installed package's
|
|
304
|
+
* `bin` directory first on `PATH` for the child, and an absolute path cannot be shadowed by it.
|
|
305
|
+
*
|
|
306
|
+
* The same `npx`/`npx.cmd` choice `resolveLaunch` makes, for the same reason — this has to warm the
|
|
307
|
+
* cache that *that* command will read.
|
|
308
|
+
*/
|
|
309
|
+
export function installNpxPackage(spec, options = {}) {
|
|
310
|
+
const env = options.env ?? probeEnvironment();
|
|
311
|
+
// 钉版本才谈得上"已经装过":`spec` 不带版本时 `npmPackageVersion` 是 `undefined`,而缓存里一个
|
|
312
|
+
// 都没有时 `npxCacheVersion` 同样是 `undefined` —— 两个 `undefined` 相等,若不单独守住这一条,
|
|
313
|
+
// 一个从未被任何 npx 调用装过的包名,只要没剥版本前先被查过缓存,就会在这里被判定成"已经装好",
|
|
314
|
+
// 一次 npx 都不用起。registry 自己从不发不钉版本的 spec(见 `registry/launch.ts` 的说明),这条分支
|
|
315
|
+
// 过去因此从没被真正走到过;`unpinnedFallbackFor` 的回退——`AcpAgents.installNpxWithFallback`——
|
|
316
|
+
// 是第一个会把不带版本的 spec 递进来的调用方,伪装成功比真的失败更糟:界面会说"已安装",磁盘上
|
|
317
|
+
// 其实什么都没有。
|
|
318
|
+
const pinnedVersion = npmPackageVersion(spec);
|
|
319
|
+
if (pinnedVersion !== undefined && npxCacheVersion(spec, readNpxCache(env)) === pinnedVersion) {
|
|
320
|
+
return Promise.resolve({ ok: true, alreadyInstalled: true });
|
|
321
|
+
}
|
|
322
|
+
const platform = env.platform ?? process.platform;
|
|
323
|
+
const command = options.npxCommand ?? (platform === 'win32' ? 'npx.cmd' : 'npx');
|
|
324
|
+
return new Promise((resolve) => {
|
|
325
|
+
execFile(command, ['--yes', `--package=${spec}`, '--', process.execPath, '-e', ''], { timeout: options.timeoutMs ?? ADAPTER_INSTALL_TIMEOUT_MS, windowsHide: true }, (error, _stdout, stderr) => {
|
|
326
|
+
if (!error) {
|
|
327
|
+
resolve({ ok: true, alreadyInstalled: false });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
// npm says what actually went wrong (a 404 specifier, a proxy refusal, no disk) on stderr;
|
|
331
|
+
// `error.message` alone is "Command failed: npx …" plus, for a timeout, nothing at all.
|
|
332
|
+
const rawStderr = stderr.toString();
|
|
333
|
+
const detail = rawStderr.trim().split('\n').slice(-4).join(' ').trim();
|
|
334
|
+
resolve({
|
|
335
|
+
ok: false,
|
|
336
|
+
error: detail ? `${error.message.split('\n')[0]}: ${detail}` : error.message,
|
|
337
|
+
stderr: rawStderr,
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Run one literal argv from `registry/catalog.ts`'s `dependency.install` — the *other* thing in
|
|
344
|
+
* this module that spawns anything, and the reason it may: that field is a security boundary
|
|
345
|
+
* (`catalog.ts`'s own doc comment), never a string, and this function is its only executor.
|
|
346
|
+
*
|
|
347
|
+
* `command` and `args` arrive pre-split and go to `execFile` exactly as given, with no
|
|
348
|
+
* `{ shell: true }` anywhere in this file. That is the whole guarantee: nothing downstream of the
|
|
349
|
+
* catalog — not registry data, not an agent's own stdout, not user input — ever has a chance to be
|
|
350
|
+
* interpolated into a string a shell would re-parse, because there is no shell in this path at all.
|
|
351
|
+
*/
|
|
352
|
+
export function runDependencyInstall(command, args, options = {}) {
|
|
353
|
+
return new Promise((resolve) => {
|
|
354
|
+
execFile(command, args, { timeout: options.timeoutMs ?? DEPENDENCY_INSTALL_TIMEOUT_MS, windowsHide: true }, (error, _stdout, stderr) => {
|
|
355
|
+
if (!error) {
|
|
356
|
+
resolve({ ok: true });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const detail = stderr.toString().trim().split('\n').slice(-4).join(' ').trim();
|
|
360
|
+
resolve({ ok: false, error: detail ? `${error.message.split('\n')[0]}: ${detail}` : error.message });
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
}
|