@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,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One place that spawns a bounded, non-blocking subprocess and reports a timeout as its own
|
|
3
|
+
* distinguishable outcome — shared by `uvxInstall.ts`'s `defaultRunUv` (probes and installs `uv`)
|
|
4
|
+
* and `binaryInstall.ts`'s default `ExecuteCommand` (extracts a downloaded archive with `tar`/
|
|
5
|
+
* `unzip`). Both used to go through `execFileSync`, which blocks Node's single event loop for the
|
|
6
|
+
* whole run — on this daemon that loop also carries the tunnel to the server, its heartbeats, and
|
|
7
|
+
* every live agent session, so a stalled `uv tool install` or a hung `tar` froze all of it, for as
|
|
8
|
+
* long as the stall lasted, since neither call carried a timeout at all.
|
|
9
|
+
*
|
|
10
|
+
* `execFile` (the callback form, not `execFileSync`) never blocks the loop, and its own `timeout`
|
|
11
|
+
* option kills the child once the budget is spent. The one thing that option does not do on its own
|
|
12
|
+
* is say so clearly: a killed child's callback error looks, at a glance, like any other non-zero
|
|
13
|
+
* exit. This wraps that distinction up front — `timedOut: true` plus a message naming the command,
|
|
14
|
+
* its args, and the budget — so neither caller has to re-derive it, and neither can mistake a
|
|
15
|
+
* timeout for a silent success or an indistinguishable ordinary failure.
|
|
16
|
+
*/
|
|
17
|
+
import { execFile } from 'node:child_process';
|
|
18
|
+
/**
|
|
19
|
+
* Runs `command args`, bounded by `timeoutMs`. A run that does not finish in time is killed (Node's
|
|
20
|
+
* default `SIGTERM`) and reported as `{ ok: false, timedOut: true, message }`, the message naming
|
|
21
|
+
* what timed out and after how long — never thrown, so every caller keeps the "never blocks, never
|
|
22
|
+
* throws" contract `defaultRunUv`/`runCommand` document for themselves.
|
|
23
|
+
*
|
|
24
|
+
* `code` carries through the child's `ENOENT` (command not found) so callers that branch on it —
|
|
25
|
+
* `binaryInstall.ts`'s zip-extraction fallback — keep working unchanged; it is `undefined` on a
|
|
26
|
+
* timeout, which is never `ENOENT`.
|
|
27
|
+
*/
|
|
28
|
+
export function runTimedCommand(command, args, timeoutMs) {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
execFile(command, args, { encoding: 'utf8', timeout: timeoutMs, windowsHide: true }, (error, stdout) => {
|
|
31
|
+
if (!error) {
|
|
32
|
+
resolve({ ok: true, stdout: stdout.toString() });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const err = error;
|
|
36
|
+
if (err.killed) {
|
|
37
|
+
resolve({
|
|
38
|
+
ok: false,
|
|
39
|
+
timedOut: true,
|
|
40
|
+
message: `${command} ${args.join(' ')} did not finish within ${timeoutMs}ms.`,
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
resolve({ ok: false, timedOut: false, message: err.message, code: err.code });
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `uv tool` counterpart to `installProbe.ts`: uvx-distributed adapters (`fast-agent`,
|
|
3
|
+
* `minion-code` — see the ACP registry's `distribution.uvx`) are, from this task on, installed by
|
|
4
|
+
* us with `uv tool install` rather than launched with `uvx <pkg>`. That switch is the whole point
|
|
5
|
+
* of this module.
|
|
6
|
+
*
|
|
7
|
+
* ## Why the launch path had to change, not just the probe
|
|
8
|
+
*
|
|
9
|
+
* `uvx <pkg>` resolves through uv's own ephemeral-environment cache — directories named by a hash
|
|
10
|
+
* of the resolved environment, a layout this codebase has never verified and has no reason to
|
|
11
|
+
* trust. "Is it installed, and which version" is unanswerable against that cache the same way it
|
|
12
|
+
* would be unanswerable against npm's `_npx/<hash>` before `installProbe.ts` existed. `uv tool
|
|
13
|
+
* install` instead puts the tool under `uv tool dir` — one stable path per machine that `uv tool
|
|
14
|
+
* list` describes completely — which is the same guarantee `readNpxCache`/`findOnPath` give for the
|
|
15
|
+
* npx and binary paths. Everything here exists to read that directory the way `installProbe.ts`
|
|
16
|
+
* reads npm's.
|
|
17
|
+
*
|
|
18
|
+
* ## Measured evidence (2026-09-05, uv 0.11.5, macOS aarch64) — this is what the parsing below is
|
|
19
|
+
* built on, not a guess
|
|
20
|
+
*
|
|
21
|
+
* The probe package was `cowsay` — small and uncontroversial — installed and then removed once the
|
|
22
|
+
* layout was recorded, specifically so this module would not be built against one real registry
|
|
23
|
+
* package's executable name (see below for why that name is never hardcoded).
|
|
24
|
+
*
|
|
25
|
+
* ```
|
|
26
|
+
* $ uv tool dir
|
|
27
|
+
* /Users/…/.local/share/uv/tools
|
|
28
|
+
*
|
|
29
|
+
* $ uv tool install cowsay
|
|
30
|
+
* + cowsay==6.1
|
|
31
|
+
* Installed 1 executable: cowsay
|
|
32
|
+
*
|
|
33
|
+
* $ uv tool list
|
|
34
|
+
* cowsay v6.1
|
|
35
|
+
* - cowsay
|
|
36
|
+
*
|
|
37
|
+
* $ uv tool list --show-paths
|
|
38
|
+
* cowsay v6.1 (/Users/…/.local/share/uv/tools/cowsay)
|
|
39
|
+
* - cowsay (/Users/…/.local/bin/cowsay)
|
|
40
|
+
* ```
|
|
41
|
+
*
|
|
42
|
+
* A second probe, `httpie` — chosen because its declared executables (`http`, `httpie`, `https`)
|
|
43
|
+
* outnumber and don't all match the package name — settled two questions the `cowsay` probe alone
|
|
44
|
+
* left open:
|
|
45
|
+
*
|
|
46
|
+
* 1. A tool can expose more than one executable. `uv tool list` lists them as indented `- <name>`
|
|
47
|
+
* lines under the tool's `<name> vX.Y.Z` header line, one header can be followed by several.
|
|
48
|
+
* 2. The tool's own venv `bin/` directory (`<uv tool dir>/<pkg>/bin/`) is **not** the set of
|
|
49
|
+
* entrypoints — it also holds that venv's private `python`/`pip` and every console script any
|
|
50
|
+
* *dependency* installed (`pygmentize`, `markdown-it`, `normalizer`, …, none of them httpie's).
|
|
51
|
+
* `readdir`-ing that directory and treating every executable file as an entrypoint would measure
|
|
52
|
+
* what uv happened to put in the venv, not what the tool declared. The actual entrypoint set is
|
|
53
|
+
* exactly what `uv tool list` reports, confirmed against `~/.local/bin` (`uv tool dir --bin`):
|
|
54
|
+
* `ls -la` there showed only the three declared symlinks (`http`, `httpie`, `https`), none of the
|
|
55
|
+
* venv's other scripts.
|
|
56
|
+
*
|
|
57
|
+
* Consequently every function below runs `uv tool list --show-paths`, never a directory read: that
|
|
58
|
+
* one flag makes uv report each executable's resolved absolute path directly, which sidesteps
|
|
59
|
+
* reconstructing `<tool dir>/<pkg>/bin/<exe>` (`Scripts\<exe>.exe` on Windows) by hand.
|
|
60
|
+
*
|
|
61
|
+
* ## Picking one entrypoint when a tool declares several
|
|
62
|
+
*
|
|
63
|
+
* Neither `fast-agent-acp` nor `minion-code`'s actual entrypoint name has been measured — the brief
|
|
64
|
+
* for this task explicitly rules out installing them just to read that off, since that would bake
|
|
65
|
+
* one package's executable name into the source the same way a hardcoded probe would. So this picks
|
|
66
|
+
* structurally, from what `uv tool list` reports for the requested package, in order: the only
|
|
67
|
+
* executable when there is exactly one; otherwise the one whose name matches the package name
|
|
68
|
+
* (true for `httpie` among its three); otherwise **neither** — this refuses rather than guesses.
|
|
69
|
+
* uv's listing order is stable on one machine but not a documented contract, this path has never
|
|
70
|
+
* been exercised against either real registry package, and a wrong guess here launches the wrong
|
|
71
|
+
* binary silently, which is worse than a refusal a caller can act on. See {@link uvToolEntrypoint}
|
|
72
|
+
* for the resulting three-outcome contract.
|
|
73
|
+
*/
|
|
74
|
+
import { runTimedCommand } from './timedCommand.js';
|
|
75
|
+
/**
|
|
76
|
+
* Wall-clock budget for one `uv tool list` / `uv tool dir` read — a local, no-network directory
|
|
77
|
+
* probe, not a download. "A few seconds is generous" for that: 10s leaves ample headroom above
|
|
78
|
+
* every measured run (low tens of milliseconds — see this module's own doc comment) while still
|
|
79
|
+
* keeping a genuinely stuck `uv` (a broken shim, a filesystem wedged mid-fsck) from parking
|
|
80
|
+
* `AcpAgents.roster` or a launch indefinitely.
|
|
81
|
+
*/
|
|
82
|
+
export const UV_PROBE_TIMEOUT_MS = 10_000;
|
|
83
|
+
/**
|
|
84
|
+
* Wall-clock budget for one `uv tool install <spec>` — a real package download, not a probe.
|
|
85
|
+
*
|
|
86
|
+
* Matches `installProbe.ts`'s `ADAPTER_INSTALL_TIMEOUT_MS` and `binaryInstall.ts`'s
|
|
87
|
+
* `ARCHIVE_DOWNLOAD_TIMEOUT_MS` deliberately, for the same reason `binaryInstall.ts`'s own constant
|
|
88
|
+
* gives for matching the first of those: this is the third of three siblings (npx, binary archive,
|
|
89
|
+
* uvx tool) fetching a whole ACP adapter over the network, and there is no reason the one added
|
|
90
|
+
* last should time out sooner than the other two just because it arrived later.
|
|
91
|
+
*/
|
|
92
|
+
export const UV_TOOL_INSTALL_TIMEOUT_MS = 300_000;
|
|
93
|
+
/**
|
|
94
|
+
* Default `RunUv`: runs `uv <args>` through {@link runTimedCommand} (never `execFileSync`, see this
|
|
95
|
+
* type's own doc comment), with every failure — a non-zero exit, `uv` not being on `PATH` at all
|
|
96
|
+
* (`ENOENT`), or the run exceeding `timeoutMs` — folded into `{ ok: false }` rather than thrown. A
|
|
97
|
+
* machine without `uv` installed is a normal machine, not an error condition this module gets to
|
|
98
|
+
* raise; a `uv` that hangs past its budget is reported with a message naming the command and the
|
|
99
|
+
* budget, not silently swallowed.
|
|
100
|
+
*/
|
|
101
|
+
async function defaultRunUv(args, timeoutMs) {
|
|
102
|
+
const result = await runTimedCommand('uv', args, timeoutMs);
|
|
103
|
+
if (result.ok)
|
|
104
|
+
return { ok: true, stdout: result.stdout };
|
|
105
|
+
return { ok: false, stdout: '', error: result.message };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Parses `uv tool list --show-paths` output (also tolerates the plain `uv tool list` form, with no
|
|
109
|
+
* `(<path>)` suffixes, which is what makes the fixtures above double as fixtures for both). A tool
|
|
110
|
+
* header line reads `<name> vX.Y.Z` or `<name> vX.Y.Z (<tool dir>)`; each executable line under it
|
|
111
|
+
* reads `- <name>` or `- <name> (<absolute path>)`. `No tools installed` (uv's output for an empty
|
|
112
|
+
* roster) matches neither pattern and yields no entries, which is the correct answer.
|
|
113
|
+
*/
|
|
114
|
+
function parseToolList(stdout) {
|
|
115
|
+
const entries = [];
|
|
116
|
+
let current;
|
|
117
|
+
for (const raw of stdout.split('\n')) {
|
|
118
|
+
const line = raw.trim();
|
|
119
|
+
if (!line)
|
|
120
|
+
continue;
|
|
121
|
+
const exeMatch = /^-\s+(\S+)(?:\s+\((.+)\))?$/.exec(line);
|
|
122
|
+
if (exeMatch) {
|
|
123
|
+
current?.executables.push({ name: exeMatch[1], path: exeMatch[2] ?? '' });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const headerMatch = /^(\S+)\s+v(\S+)(?:\s+\(.+\))?$/.exec(line);
|
|
127
|
+
if (headerMatch) {
|
|
128
|
+
current = { name: headerMatch[1], version: headerMatch[2], executables: [] };
|
|
129
|
+
entries.push(current);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return entries;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* One `uv tool list --show-paths` snapshot, parsed. This is the single place that actually spawns
|
|
136
|
+
* `uv` for the "what's installed" question — {@link findTool} delegates to it rather than shelling
|
|
137
|
+
* out itself, and `AcpAgents.roster` calls it directly, exactly **once per roster build**, so it can
|
|
138
|
+
* look every uvx-distributed agent up in one snapshot instead of spawning `uv` again per agent (see
|
|
139
|
+
* that call site's own doc comment for why a per-agent spawn was a defect worth hoisting out of).
|
|
140
|
+
* `uv` missing, failing, or timing out degrades to `[]` — "nothing installed" — the same as every
|
|
141
|
+
* other reader of {@link RunUv} in this module; it never throws. `timeoutMs` defaults to
|
|
142
|
+
* {@link UV_PROBE_TIMEOUT_MS} — this is the probe half of `RunUv`'s two budgets, never the
|
|
143
|
+
* `uv tool install` one.
|
|
144
|
+
*/
|
|
145
|
+
export async function listUvTools(run = defaultRunUv, timeoutMs = UV_PROBE_TIMEOUT_MS) {
|
|
146
|
+
const result = await run(['tool', 'list', '--show-paths'], timeoutMs);
|
|
147
|
+
if (!result.ok)
|
|
148
|
+
return [];
|
|
149
|
+
return parseToolList(result.stdout);
|
|
150
|
+
}
|
|
151
|
+
function findToolIn(tools, pkg) {
|
|
152
|
+
return tools.find((entry) => entry.name === pkg);
|
|
153
|
+
}
|
|
154
|
+
async function findTool(pkg, run) {
|
|
155
|
+
return findToolIn(await listUvTools(run), pkg);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The version of `pkg` that `uv tool list` reports as installed, or `undefined` when it is not
|
|
159
|
+
* installed, or when `uv` itself is missing, fails, or times out.
|
|
160
|
+
*/
|
|
161
|
+
export async function uvToolVersion(pkg, run = defaultRunUv) {
|
|
162
|
+
return (await findTool(pkg, run))?.version;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Picks `pkg`'s launchable executable — the one `resolveLaunch`'s uvx branch runs instead of
|
|
166
|
+
* shelling out to `uvx <pkg>` — out of an already-parsed `uv tool list --show-paths` snapshot
|
|
167
|
+
* ({@link listUvTools}).
|
|
168
|
+
*
|
|
169
|
+
* Three shapes, in order:
|
|
170
|
+
* 1. Exactly one executable declared → that one, whatever it is named. One candidate is not a
|
|
171
|
+
* guess.
|
|
172
|
+
* 2. Several declared, and one matches the package's own name (true for `httpie` among its three,
|
|
173
|
+
* measured) → that one.
|
|
174
|
+
* 3. Several declared, none matching → **refuse** (`ambiguous`, carrying every candidate name).
|
|
175
|
+
* uv's listing order is stable on one machine but is not a documented contract (see the module
|
|
176
|
+
* docstring), and this path has never been exercised against a real uvx-distributed agent —
|
|
177
|
+
* installing `fast-agent-acp`/`minion-code` to find out was ruled out of scope for this task.
|
|
178
|
+
* Guessing the first one and launching it would trade a diagnosable refusal for an ACP handshake
|
|
179
|
+
* that hangs or fails for a reason nobody can trace back to this choice, so this reports the
|
|
180
|
+
* ambiguity instead of resolving it.
|
|
181
|
+
*
|
|
182
|
+
* `not-installed` also covers: no such tool in `uv tool list`, `uv` itself missing or failing, a
|
|
183
|
+
* matched executable whose recorded path is empty (the plain, no-`--show-paths` form this also
|
|
184
|
+
* parses carries no paths at all) — none of these are launchable either, and none of them are
|
|
185
|
+
* ambiguous the way case 3 is.
|
|
186
|
+
*
|
|
187
|
+
* Takes the parsed listing rather than a runner on purpose: a caller that already read
|
|
188
|
+
* `uv tool list` once (`AcpAgents.roster`, so 39 registry entries share one spawn) hands that
|
|
189
|
+
* result straight in here instead of this function spawning `uv` again per package. {@link
|
|
190
|
+
* uvToolEntrypoint} is the thin wrapper for the one real caller (the actual launch path) that has
|
|
191
|
+
* no such snapshot yet and needs to read it fresh.
|
|
192
|
+
*/
|
|
193
|
+
export function uvToolEntrypointFrom(pkg, tools) {
|
|
194
|
+
const entry = findToolIn(tools, pkg);
|
|
195
|
+
if (!entry || entry.executables.length === 0)
|
|
196
|
+
return { ok: false, reason: 'not-installed' };
|
|
197
|
+
if (entry.executables.length === 1) {
|
|
198
|
+
const [only] = entry.executables;
|
|
199
|
+
return only.path ? { ok: true, path: only.path } : { ok: false, reason: 'not-installed' };
|
|
200
|
+
}
|
|
201
|
+
const match = entry.executables.find((exe) => exe.name === pkg);
|
|
202
|
+
if (match) {
|
|
203
|
+
return match.path ? { ok: true, path: match.path } : { ok: false, reason: 'not-installed' };
|
|
204
|
+
}
|
|
205
|
+
return { ok: false, reason: 'ambiguous', candidates: entry.executables.map((exe) => exe.name) };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* `uvToolEntrypointFrom`, but reading `uv tool list --show-paths` itself first — the real launch
|
|
209
|
+
* path's entry point (`resolveLaunch`'s uvx branch with no pre-fetched listing), where doing its
|
|
210
|
+
* own `uv` lookup at connect time is correct and expected, unlike the roster path (see {@link
|
|
211
|
+
* uvToolEntrypointFrom}'s doc comment).
|
|
212
|
+
*/
|
|
213
|
+
export async function uvToolEntrypoint(pkg, run = defaultRunUv) {
|
|
214
|
+
return uvToolEntrypointFrom(pkg, await listUvTools(run));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The package name half of a `uv tool install` specifier: `pkg==1.2.3` → `pkg`. Returns the input
|
|
218
|
+
* unchanged when it carries no `==` pin — the ACP registry is not required to pin `uvx` specifiers
|
|
219
|
+
* the way it pins `npx` ones (see `registryTypes.ts`'s `UvxDistribution`).
|
|
220
|
+
*/
|
|
221
|
+
export function uvPackageName(spec) {
|
|
222
|
+
const at = spec.indexOf('==');
|
|
223
|
+
return at > 0 ? spec.slice(0, at) : spec;
|
|
224
|
+
}
|
|
225
|
+
/** The version half of a `uv tool install` specifier: `pkg==1.2.3` → `1.2.3`; `undefined` with no pin. */
|
|
226
|
+
export function uvPackageVersion(spec) {
|
|
227
|
+
const at = spec.indexOf('==');
|
|
228
|
+
return at > 0 ? spec.slice(at + 2) : undefined;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* `uv tool install <spec>` — the uvx counterpart to `installNpxPackage`. Short-circuits to
|
|
232
|
+
* `alreadyInstalled: true` without spawning `uv tool install` when a quick `uv tool list` probe
|
|
233
|
+
* (bounded by {@link UV_PROBE_TIMEOUT_MS}) already reports the pinned version — the same "don't
|
|
234
|
+
* repeat work that's already done" rule `installNpxPackage` follows against the npx cache. The
|
|
235
|
+
* actual install, when it runs, is a real download and gets {@link UV_TOOL_INSTALL_TIMEOUT_MS}, not
|
|
236
|
+
* the probe's budget.
|
|
237
|
+
*/
|
|
238
|
+
export async function installUvTool(spec, run = defaultRunUv) {
|
|
239
|
+
const pkg = uvPackageName(spec);
|
|
240
|
+
const pinned = uvPackageVersion(spec);
|
|
241
|
+
if (pinned && (await uvToolVersion(pkg, run)) === pinned) {
|
|
242
|
+
return { ok: true, alreadyInstalled: true };
|
|
243
|
+
}
|
|
244
|
+
const result = await run(['tool', 'install', spec], UV_TOOL_INSTALL_TIMEOUT_MS);
|
|
245
|
+
if (result.ok)
|
|
246
|
+
return { ok: true, alreadyInstalled: false };
|
|
247
|
+
return { ok: false, error: result.error ?? `uv tool install ${spec} failed` };
|
|
248
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request-shaped event names the platform knows how to answer.
|
|
3
|
+
*
|
|
4
|
+
* The suffix is a promise from the bridge: "this event needs exactly one answer". The platform can
|
|
5
|
+
* only keep that promise for names it has a card for — anything else lands as a generic timeline
|
|
6
|
+
* item, never parks the run, and leaves the native agent blocked on an answer no UI can give.
|
|
7
|
+
*
|
|
8
|
+
* Membership here exempts a name from that blanket rejection and NOTHING else. In particular it does
|
|
9
|
+
* not exempt the event from its own payload rules: `permission_request`'s required-field checks live
|
|
10
|
+
* in their own name-keyed branch below and run regardless of what this set says.
|
|
11
|
+
*
|
|
12
|
+
* Same-source with the server's `TimelineEventWire.KNOWN_REQUEST_EVENT_NAMES`: adding a request-shaped
|
|
13
|
+
* event means a named decode branch there and an entry in both sets. That claim is no longer just a
|
|
14
|
+
* comment -- `validate.test.ts` reads the Kotlin set out of `TimelineEventWire.kt` and diffs it
|
|
15
|
+
* against this one, which is why this constant is exported. Note this is deliberately NOT
|
|
16
|
+
* the same list as `ExternalAgentEventApplier.parksRun()`, which also recognizes
|
|
17
|
+
* `TimelineEvent.UserInteractionRequested` — that type is how the *platform's own* agents raise a
|
|
18
|
+
* question. Its two wire names behave differently here, and the difference is the suffix, not the
|
|
19
|
+
* family:
|
|
20
|
+
*
|
|
21
|
+
* - `elicitation_request` (`TimelineEvent.InteractionRequested`) is request-shaped and absent from
|
|
22
|
+
* the set, so a bridge emitting it is rejected. That stays true after Plan B minted the name:
|
|
23
|
+
* the platform now *has* the vocabulary but still has no bridge decode branch for it, so it still
|
|
24
|
+
* cannot answer one. Adding it here would exempt it into a generic item — a card with no button.
|
|
25
|
+
* - `elicitation` (`TimelineEvent.UserInteractionRequested`) carries no request suffix, so this gate
|
|
26
|
+
* never looks at it and a bridge emitting it passes through. That is now correct rather than
|
|
27
|
+
* merely harmless: `TimelineEventWire.decode` has a named `elicitation` branch that rebuilds the
|
|
28
|
+
* typed event, so such a card does park the run and can be answered. The branch is strict —
|
|
29
|
+
* anything it cannot rebuild into the platform's own `UserInteractionRequest` (with none of that
|
|
30
|
+
* model's invariants relaxed) is dropped rather than landed, because a landed-but-unrebuilt
|
|
31
|
+
* payload is byte-identical to a real question card and would render as an answerable card the
|
|
32
|
+
* run never parks on. Well-formedness is defined in exactly one place, that decoder; this gate
|
|
33
|
+
* deliberately does not carry a second copy of the shape rules to drift from it.
|
|
34
|
+
*/
|
|
35
|
+
export const KNOWN_REQUEST_EVENT_NAMES = new Set(['permission_request']);
|
|
36
|
+
/**
|
|
37
|
+
* Does this name carry the "answer me exactly once" promise?
|
|
38
|
+
*
|
|
39
|
+
* `_request` is the live spelling (ACP request/response semantics). `_requested` is the retired one
|
|
40
|
+
* and is still caught on purpose: with no compatibility layer, a bridge that has not been upgraded
|
|
41
|
+
* emits an *unknown* request-shaped event, and it must be rejected rather than pass through as an
|
|
42
|
+
* ordinary event — the server would land it as a generic item that parks nothing and shows no
|
|
43
|
+
* buttons, stranding the agent on an answer that can never arrive.
|
|
44
|
+
*/
|
|
45
|
+
function looksLikeRequestEvent(eventName) {
|
|
46
|
+
return eventName.endsWith('_request') || eventName.endsWith('_requested');
|
|
47
|
+
}
|
|
48
|
+
function nonEmptyString(value) {
|
|
49
|
+
return typeof value === 'string' && value.length > 0;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Validate one bridge-emitted event at the outbound boundary.
|
|
53
|
+
*
|
|
54
|
+
* The server also decodes defensively, but it drops silently — a bridge author would only see
|
|
55
|
+
* "output is mysteriously missing". Rejecting here, with a reason, is what makes a contract
|
|
56
|
+
* violation debuggable. Returns null when the event is fine.
|
|
57
|
+
*
|
|
58
|
+
* Total by design: the disposal contract for a violating event is "drop + diagnostic", never an
|
|
59
|
+
* exception, so even a hostile payload (throwing getters, Proxies) yields a rejection. Note that
|
|
60
|
+
* validating getter-bearing payloads is inherently advisory — a getter can return valid values now
|
|
61
|
+
* and different ones at serialization time.
|
|
62
|
+
*/
|
|
63
|
+
export function validateWireEvent(event) {
|
|
64
|
+
try {
|
|
65
|
+
return inspect(event);
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
return { reason: 'event not inspectable', detail: String(err) };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function inspect(event) {
|
|
72
|
+
if (!nonEmptyString(event?.eventName))
|
|
73
|
+
return { reason: 'missing eventName' };
|
|
74
|
+
if (event.eventName === 'final_result') {
|
|
75
|
+
return {
|
|
76
|
+
reason: 'bridges must never emit final_result',
|
|
77
|
+
detail: 'it would terminally close the session run; signal turn end via onTurnDone instead',
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (looksLikeRequestEvent(event.eventName) && !KNOWN_REQUEST_EVENT_NAMES.has(event.eventName)) {
|
|
81
|
+
return {
|
|
82
|
+
reason: `unknown request-shaped event '${event.eventName}'`,
|
|
83
|
+
detail: 'the _request suffix promises the platform will answer, and it only knows how to answer ' +
|
|
84
|
+
`${[...KNOWN_REQUEST_EVENT_NAMES].join(', ')}; the server drops this event while the ` +
|
|
85
|
+
'watermark advances past it, so the agent would wait forever for an answer that can never ' +
|
|
86
|
+
'be replayed. Ask the user through permission_request, or emit a non-request event name.',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const payload = event.payload;
|
|
90
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
91
|
+
return { reason: 'payload must be an object' };
|
|
92
|
+
}
|
|
93
|
+
// Keyed on the event name alone, independent of the suffix gate above. `permission_request` is
|
|
94
|
+
// exempt from that gate (it is in KNOWN_REQUEST_EVENT_NAMES), so these checks are the *only* thing
|
|
95
|
+
// standing between a malformed approval card and the wire.
|
|
96
|
+
if (event.eventName === 'permission_request') {
|
|
97
|
+
if (!nonEmptyString(payload.requestId)) {
|
|
98
|
+
return { reason: 'invalid permission_request', detail: 'requestId must be a non-empty string' };
|
|
99
|
+
}
|
|
100
|
+
// ACP `RequestPermissionRequest.toolCall` (`types.gen.d.ts:108`) is a whole `ToolCallUpdate`
|
|
101
|
+
// (`:140`), whose only required field is `toolCallId` (`:143`) — everything else absent means
|
|
102
|
+
// "unchanged". So `toolCallId` is the only thing worth insisting on, and it is worth insisting
|
|
103
|
+
// on: the server's decoder is `AcpToolCallUpdate.fromWire(...) ?: return null`, a silent frame
|
|
104
|
+
// drop that takes the whole approval card with it while the agent stays blocked on
|
|
105
|
+
// `session/prompt`. The flattened spelling this replaced required a top-level `name` and a
|
|
106
|
+
// synthesized `prompt`; neither is a field ACP has here.
|
|
107
|
+
const toolCall = payload.toolCall;
|
|
108
|
+
if (toolCall === null || typeof toolCall !== 'object' || Array.isArray(toolCall)) {
|
|
109
|
+
return { reason: 'invalid permission_request', detail: 'toolCall must be an object' };
|
|
110
|
+
}
|
|
111
|
+
if (!nonEmptyString(toolCall.toolCallId)) {
|
|
112
|
+
return { reason: 'invalid permission_request', detail: 'toolCall.toolCallId must be a non-empty string' };
|
|
113
|
+
}
|
|
114
|
+
const options = payload.options;
|
|
115
|
+
if (!Array.isArray(options) || options.length === 0) {
|
|
116
|
+
return {
|
|
117
|
+
reason: 'invalid permission_request',
|
|
118
|
+
detail: 'options must be a non-empty array; an option-less card parks the session with no way to answer',
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
for (const option of options) {
|
|
122
|
+
if (option === null || typeof option !== 'object' || Array.isArray(option)) {
|
|
123
|
+
return { reason: 'invalid permission_request', detail: 'each option must be an object' };
|
|
124
|
+
}
|
|
125
|
+
const entry = option;
|
|
126
|
+
for (const field of ['optionId', 'name', 'kind']) {
|
|
127
|
+
if (!nonEmptyString(entry[field])) {
|
|
128
|
+
return { reason: 'invalid permission_request', detail: `option.${field} must be a non-empty string` };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|