@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,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
+ }