@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.
Files changed (90) hide show
  1. package/README.md +64 -0
  2. package/dist/args.js +29 -0
  3. package/dist/cli.js +76 -0
  4. package/dist/commands/auth.js +52 -0
  5. package/dist/commands/execute.js +40 -0
  6. package/dist/commands/guide.js +38 -0
  7. package/dist/commands/record.js +99 -0
  8. package/dist/commands/skills.js +69 -0
  9. package/dist/commands/struct.js +50 -0
  10. package/dist/commands/update.js +88 -0
  11. package/dist/commands/whoami.js +8 -0
  12. package/dist/commands/workstation.js +171 -0
  13. package/dist/config.js +131 -0
  14. package/dist/credentialBroker.js +340 -0
  15. package/dist/credentials.js +211 -0
  16. package/dist/install/agentsDir.js +35 -0
  17. package/dist/install/claude/commands.js +89 -0
  18. package/dist/install/claude/materialize.js +42 -0
  19. package/dist/install/installer.js +45 -0
  20. package/dist/install/targets/claude.js +154 -0
  21. package/dist/mcp.js +152 -0
  22. package/dist/output.js +37 -0
  23. package/dist/workstation/acp/acpAgents.js +1287 -0
  24. package/dist/workstation/acp/acpClient.js +691 -0
  25. package/dist/workstation/acp/acpSession.js +1047 -0
  26. package/dist/workstation/acp/adoption.js +396 -0
  27. package/dist/workstation/acp/auth.js +80 -0
  28. package/dist/workstation/acp/authUrlSniffer.js +52 -0
  29. package/dist/workstation/acp/binaryInstall.js +411 -0
  30. package/dist/workstation/acp/configOptions.js +257 -0
  31. package/dist/workstation/acp/contract.js +494 -0
  32. package/dist/workstation/acp/draftInterceptor.js +154 -0
  33. package/dist/workstation/acp/draftSubagents.js +38 -0
  34. package/dist/workstation/acp/installErrors.js +89 -0
  35. package/dist/workstation/acp/installProbe.js +363 -0
  36. package/dist/workstation/acp/permission.js +327 -0
  37. package/dist/workstation/acp/registry/catalog.js +29 -0
  38. package/dist/workstation/acp/registry/launch.js +128 -0
  39. package/dist/workstation/acp/registry/registryClient.js +160 -0
  40. package/dist/workstation/acp/registry/registryTypes.js +8 -0
  41. package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
  42. package/dist/workstation/acp/test/fakeAgent.js +435 -0
  43. package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
  44. package/dist/workstation/acp/timedCommand.js +47 -0
  45. package/dist/workstation/acp/uvxInstall.js +248 -0
  46. package/dist/workstation/acp/validate.js +134 -0
  47. package/dist/workstation/agentSessions.js +1628 -0
  48. package/dist/workstation/channels/agent.js +96 -0
  49. package/dist/workstation/channels/computer.js +91 -0
  50. package/dist/workstation/channels/management.js +231 -0
  51. package/dist/workstation/channels/url.js +42 -0
  52. package/dist/workstation/config.js +47 -0
  53. package/dist/workstation/connection/encryptedChannel.js +261 -0
  54. package/dist/workstation/connection/retryPolicy.js +14 -0
  55. package/dist/workstation/connection/supervisor.js +353 -0
  56. package/dist/workstation/crypto.js +63 -0
  57. package/dist/workstation/daemon.js +225 -0
  58. package/dist/workstation/daemonctl.js +95 -0
  59. package/dist/workstation/exec.js +112 -0
  60. package/dist/workstation/journal.js +257 -0
  61. package/dist/workstation/names.js +17 -0
  62. package/dist/workstation/oauth/browser.js +24 -0
  63. package/dist/workstation/oauth/deviceAuthorization.js +198 -0
  64. package/dist/workstation/osinfo.js +4 -0
  65. package/dist/workstation/protocol/agent.js +606 -0
  66. package/dist/workstation/protocol/common.js +136 -0
  67. package/dist/workstation/protocol/computer.js +31 -0
  68. package/dist/workstation/protocol/management.js +116 -0
  69. package/dist/workstation/register.js +42 -0
  70. package/dist/workstation/service/installer.js +362 -0
  71. package/dist/workstation/service/launchd.js +46 -0
  72. package/dist/workstation/service/loginShell.js +36 -0
  73. package/dist/workstation/service/systemd.js +48 -0
  74. package/dist/workstation/service/types.js +73 -0
  75. package/dist/workstation/supervision.js +52 -0
  76. package/dist/workstation/systemMetrics.js +271 -0
  77. package/dist/workstation/update/artifact.js +61 -0
  78. package/dist/workstation/update/coordinator.js +240 -0
  79. package/dist/workstation/update/npmInstaller.js +91 -0
  80. package/dist/workstation/update/redact.js +36 -0
  81. package/dist/workstation/update/runUpdate.js +215 -0
  82. package/dist/workstation/update/stateStore.js +87 -0
  83. package/dist/workstation/update/taskRegistry.js +142 -0
  84. package/dist/workstation/update/types.js +3 -0
  85. package/dist/workstation/update/watchdog.js +311 -0
  86. package/dist/workstation/version.js +12 -0
  87. package/package.json +39 -0
  88. package/skills/axom/SKILL.md +83 -0
  89. package/skills/axom-definition/SKILL.md +74 -0
  90. 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
+ }