@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,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP's two client-side *requests* that block the agent on a user answer — `session/request_permission`
|
|
3
|
+
* and `elicitation/create` — plus the one *notification* that pairs with the latter,
|
|
4
|
+
* `elicitation/complete`. All three are registered onto a `ClientApp` through `connectAgent`'s
|
|
5
|
+
* `configure` hook (see `acpClient.ts`'s `ConnectAgentOptions.configure` doc comment): `ClientApp.connect()`
|
|
6
|
+
* copies its handler table at connect time, so a handler added afterwards would compile fine and
|
|
7
|
+
* simply never fire — this file exists specifically to be wired in before that copy happens.
|
|
8
|
+
*
|
|
9
|
+
* ## `session/request_permission`
|
|
10
|
+
*
|
|
11
|
+
* This is the plan's single largest risk (see the plan's "审批与提问" section) because it is a real
|
|
12
|
+
* round trip to a human: the agent process is blocked on `session/prompt` until this resolves, so
|
|
13
|
+
* every path through {@link handleRequestPermission} must eventually settle it, including the path
|
|
14
|
+
* where nobody ever answers.
|
|
15
|
+
*
|
|
16
|
+
* - **Empty `options` is refused outright**, before anything reaches the user (`validate.ts`'s
|
|
17
|
+
* `permission_request` rule: an option-less card parks the session on something with no button to
|
|
18
|
+
* answer, leaving Stop as the only way out). This is not a synthesized "allow once" — it is a hard
|
|
19
|
+
* no, mirroring the deleted bridge's identical rule.
|
|
20
|
+
* - Otherwise the agent's own `options` (already `{optionId, name, kind}`, ACP's own vocabulary) are
|
|
21
|
+
* passed straight through into the `permission_request` wire event — unlike the deleted Claude
|
|
22
|
+
* Code bridge, this client does not invent its own two-option vocabulary, because ACP already hands
|
|
23
|
+
* over whatever the agent decided to offer.
|
|
24
|
+
* - The pending answer is tracked through `AcpSession.registerPendingApproval`/`.answer` — the same
|
|
25
|
+
* map `AcpSession.close()` already drains (and `registerPendingApproval` itself refuses to add to,
|
|
26
|
+
* resolving immediately instead, once already closed) with an abandonment sentinel for every entry
|
|
27
|
+
* still pending, and the connection dying underneath a pending request drains it the same way (Task
|
|
28
|
+
* 4's contract, extended by this task's review). That sentinel — and any other answer that does not
|
|
29
|
+
* name one of the options actually offered — resolves to `{outcome:'rejected'}` here rather than
|
|
30
|
+
* being forced into a fabricated selection.
|
|
31
|
+
* - **Agent-side cancellation** (`ctx.signal` aborting) settles the request as refused and purges the
|
|
32
|
+
* pending entry, instead of leaving a card nobody is listening to answer.
|
|
33
|
+
*
|
|
34
|
+
* ## `elicitation/create` / `elicitation/complete`
|
|
35
|
+
*
|
|
36
|
+
* `elicitation/create`'s `mode` union is **open** (`"form" | "url" | string`, per
|
|
37
|
+
* `CreateElicitationRequest`'s schema comment: "Custom or future elicitation mode" — a bare `string`
|
|
38
|
+
* arm, unlike `session/update`'s closed 15-member union). Axom's wire vocabulary now *has* the
|
|
39
|
+
* `elicitation_request`/`elicitation` pair (Plan B renamed the platform's own interaction events onto
|
|
40
|
+
* ACP's words). The two names are not in the same state: `elicitation_request` (`InteractionRequested`)
|
|
41
|
+
* still has no bridge decode path — `TimelineEventWire.decode` has no branch for it, so a bridge
|
|
42
|
+
* emitting it is rejected outright — but bare `elicitation` (`UserInteractionRequested`) has had a named
|
|
43
|
+
* decode branch since `TimelineEventWire.kt:127`, and it is answerable end to end: decode rebuilds the
|
|
44
|
+
* typed event, `CoworkEngine` lands a `USER_INTERACTION` item and parks the turn, and once the user
|
|
45
|
+
* answers, `ExternalAgentSessionRuntime.execute` forwards the reply through `channel.prompt(runId,
|
|
46
|
+
* context.userText, ...)`. `'form'` still declines — it ships an arbitrary JSON-Schema
|
|
47
|
+
* `requestedSchema` (`ElicitationSchema`) that nothing on Axom's wire translates into that fixed Q&A
|
|
48
|
+
* shape — and the open `default` arm still declines everything else the SDK or a nonstandard agent
|
|
49
|
+
* might send, for the same reason: neither has anywhere to go yet, and declining is what keeps them
|
|
50
|
+
* defensible — it never hangs the agent and it never fabricates a user's consent for input nobody was
|
|
51
|
+
* ever shown.
|
|
52
|
+
*
|
|
53
|
+
* `'url'` is different: it is the shape some agents use to hand a sign-in link to the reader (Codex's
|
|
54
|
+
* device-code flow reaches it through `elicitation/create` once `elicitation.url` is declared in
|
|
55
|
+
* `acpClient.ts`'s `clientCapabilities` — see that file). {@link handleCreateElicitation} **accepts**
|
|
56
|
+
* it immediately and hands `elicitationId`/`url` out through the injected `present` callback. Per ACP's
|
|
57
|
+
* own semantics, `accept` here means "I have put this URL in front of the user", not "the user
|
|
58
|
+
* finished" — completion is a *separate* event, the agent's own `elicitation/complete` notification
|
|
59
|
+
* (`CompleteElicitationNotification`), which {@link handleElicitationComplete} turns into a `dismiss`
|
|
60
|
+
* call. Both callbacks are injected so this module and its tests stay decoupled from whatever transport
|
|
61
|
+
* carries the URL from the workstation to the server and on to the browser. Today exactly one
|
|
62
|
+
* caller supplies real ones — `acpAgents.ts`'s `authenticate`, on the throwaway connection it opens
|
|
63
|
+
* for a single sign-in, where they become the `agent_auth_url` / `agent_auth_url_done` frames. A
|
|
64
|
+
* session connection deliberately supplies no `present` (see `AcpAgents.open`): a `url` elicitation
|
|
65
|
+
* raised there belongs to no login, so it is declined rather than shown.
|
|
66
|
+
*
|
|
67
|
+
* `dismiss` covers only the provenance the protocol can retract. An agent that printed its URL to
|
|
68
|
+
* stderr instead never issued the `elicitationId` the workstation invented for it and can never
|
|
69
|
+
* complete it, so `authenticate` remembers every id it handed out and retracts what is left when
|
|
70
|
+
* the login settles.
|
|
71
|
+
*/
|
|
72
|
+
import { methods } from '@agentclientprotocol/sdk';
|
|
73
|
+
import { validateWireEvent } from './validate.js';
|
|
74
|
+
/** A value guaranteed not to equal any real `optionId` in `input.options` for a given call — used to purge a pending entry without accidentally supplying a matchable answer. Regenerated per call rather than a fixed literal, for the same anti-collision reasoning as `AcpSession`'s own abandonment sentinel (review finding, task-5 item 6). */
|
|
75
|
+
function unmatchableAnswerFor(options) {
|
|
76
|
+
let candidate = '__acp_permission_cancelled__';
|
|
77
|
+
const optionIds = new Set(options.map((o) => o.optionId));
|
|
78
|
+
while (optionIds.has(candidate))
|
|
79
|
+
candidate = `${candidate}_`;
|
|
80
|
+
return candidate;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* ACP's `ToolCallUpdate` (`types.gen.d.ts:140`) verbatim, minus every key the agent left out.
|
|
84
|
+
*
|
|
85
|
+
* Absence is load-bearing here in ACP's own terms: everything but `toolCallId` is optional and an
|
|
86
|
+
* omitted field means "unchanged" (`:135-136`), so writing an explicit `null` in its place would be
|
|
87
|
+
* a claim the agent never made. `undefined`-valued keys are dropped rather than serialized, matching
|
|
88
|
+
* `sessionUpdateMapper.ts`'s `compact`.
|
|
89
|
+
*
|
|
90
|
+
* `_meta` **is** forwarded, and verbatim, matching `sessionUpdateMapper.ts`'s `tool_call`/
|
|
91
|
+
* `tool_call_update` handling: it is ACP's own extension slot, and this function copies the object
|
|
92
|
+
* without reading anything inside it (Global Constraint 3, "transport, not translation"). Claude's
|
|
93
|
+
* adapter attaches `parentToolUseId` here on a permission request raised by a subagent's tool call
|
|
94
|
+
* (`acp-agent.js:4160`) — this module has no business knowing that key exists, only that the object
|
|
95
|
+
* belongs to the call and must survive the trip.
|
|
96
|
+
*/
|
|
97
|
+
function toolCallPayload(toolCall) {
|
|
98
|
+
const fields = {
|
|
99
|
+
toolCallId: toolCall.toolCallId,
|
|
100
|
+
name: toolCall.name ?? undefined,
|
|
101
|
+
title: toolCall.title ?? undefined,
|
|
102
|
+
kind: toolCall.kind ?? undefined,
|
|
103
|
+
status: toolCall.status ?? undefined,
|
|
104
|
+
content: toolCall.content && toolCall.content.length > 0 ? toolCall.content : undefined,
|
|
105
|
+
locations: toolCall.locations && toolCall.locations.length > 0 ? toolCall.locations : undefined,
|
|
106
|
+
rawInput: toolCall.rawInput,
|
|
107
|
+
rawOutput: toolCall.rawOutput,
|
|
108
|
+
_meta: toolCall._meta ?? undefined,
|
|
109
|
+
};
|
|
110
|
+
return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* ACP `RequestPermissionRequest` (`types.gen.d.ts:108`) on Axom's wire: `{toolCall, options}` plus
|
|
114
|
+
* `requestId`.
|
|
115
|
+
*
|
|
116
|
+
* `requestId` is the one admitted extension — ACP correlates the answer through the JSON-RPC request
|
|
117
|
+
* id, which lives in the envelope (`ClientRequestContext.requestId`), and an event stream has no
|
|
118
|
+
* envelope. `sessionId`, ACP's third field (`:112`), is deliberately not carried: the event is
|
|
119
|
+
* already being delivered on a specific run.
|
|
120
|
+
*
|
|
121
|
+
* **`toolCall` is nested, not flattened.** The flattened version lifted the tool's
|
|
122
|
+
* `name`/`title`/`kind`/`arguments` to the top level, which put `name` (the tool's programmatic name,
|
|
123
|
+
* `:169`) beside `options[].name` (`PermissionOption.name`, `:604`, the button's label) — one word
|
|
124
|
+
* meaning two things in one payload, an ambiguity ACP itself does not have. It also had nowhere to
|
|
125
|
+
* put `ToolCallUpdate.content`, so a permission card for an edit could never show the diff it was
|
|
126
|
+
* asking about. The synthesized `prompt` went with it: it was a display string computed from
|
|
127
|
+
* `toolCall.title`, and the renderer computes it from the same field now
|
|
128
|
+
* (`WorkTimelineInteractions.foldPermission`) instead of storing a second, drifting copy.
|
|
129
|
+
*/
|
|
130
|
+
function buildPermissionRequestedEvent(input) {
|
|
131
|
+
return {
|
|
132
|
+
eventName: 'permission_request',
|
|
133
|
+
payload: {
|
|
134
|
+
requestId: input.requestId,
|
|
135
|
+
toolCall: toolCallPayload(input.toolCall),
|
|
136
|
+
// Passed straight through: ACP's own `{optionId, name, kind}` already matches
|
|
137
|
+
// `permission_request`'s wire shape field-for-field, so there is no second vocabulary to
|
|
138
|
+
// invent the way the deleted Claude Code bridge had to for the SDK's plain `canUseTool` boolean.
|
|
139
|
+
options: input.options.map((option) => ({ optionId: option.optionId, name: option.name, kind: option.kind })),
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Resolve one `session/request_permission` round trip to a {@link PermissionDecision}.
|
|
145
|
+
*
|
|
146
|
+
* The empty-`options` guard runs before anything is registered or emitted — see the class doc
|
|
147
|
+
* comment for why this must stay a hard refusal, never a synthesized option. Note this guard alone is
|
|
148
|
+
* not the only thing standing between an empty-options request and an emitted card: the
|
|
149
|
+
* `validateWireEvent` check further down independently rejects the same malformed event (belt and
|
|
150
|
+
* suspenders on the plan's hardest rule — see `permission.test.ts`'s "rejects outright" test for how
|
|
151
|
+
* the two are told apart).
|
|
152
|
+
*
|
|
153
|
+
* **Cancellation** (review finding, task-5 item 5): if `input.signal` is already aborted when this
|
|
154
|
+
* runs, or aborts before an answer arrives, this settles `'rejected'` and purges the pending entry via
|
|
155
|
+
* `sink.answer` — the agent gave up on this specific request, so nothing should still expect an
|
|
156
|
+
* answer for it. Session-level abandonment (the session closing, or the connection dying) is instead
|
|
157
|
+
* `AcpSession`'s own job (`registerPendingApproval` resolving immediately when already closed, and
|
|
158
|
+
* `runReadLoop` draining on an aborted connection) — see `acpSession.ts`.
|
|
159
|
+
*
|
|
160
|
+
* The eventual answer is whatever string reaches `sink`'s registered resolver: normally one of
|
|
161
|
+
* `input.options`' own `optionId`s (relayed from the user through `AcpSession.answer`), but both
|
|
162
|
+
* `AcpSession`'s own abandonment sentinel and this function's cancellation purge use values guaranteed
|
|
163
|
+
* not to collide with a real `optionId` (review finding, task-5 item 6) precisely so an answer that
|
|
164
|
+
* does not name one of the options actually offered resolves to `'rejected'` here rather than being
|
|
165
|
+
* forced into a selection the user never made.
|
|
166
|
+
*/
|
|
167
|
+
export async function handleRequestPermission(input, sink) {
|
|
168
|
+
if (input.options.length === 0) {
|
|
169
|
+
return { outcome: 'rejected' };
|
|
170
|
+
}
|
|
171
|
+
if (input.signal?.aborted) {
|
|
172
|
+
// The agent had already cancelled this request before we even got to it — nothing left to
|
|
173
|
+
// register or emit for.
|
|
174
|
+
return { outcome: 'rejected' };
|
|
175
|
+
}
|
|
176
|
+
const event = buildPermissionRequestedEvent(input);
|
|
177
|
+
const rejection = validateWireEvent(event);
|
|
178
|
+
if (rejection) {
|
|
179
|
+
// Defensive only — every field above is derived from ACP's own required, typed schema, so a
|
|
180
|
+
// conforming agent can never actually trip this. A misbehaving one must not park the session on
|
|
181
|
+
// a card the platform itself would drop, so this refuses exactly like the empty-options case
|
|
182
|
+
// rather than emitting something `validateWireEvent` already knows is broken.
|
|
183
|
+
console.error(`[acp] refusing to raise a permission card that fails validateWireEvent: ${rejection.reason}`);
|
|
184
|
+
return { outcome: 'rejected' };
|
|
185
|
+
}
|
|
186
|
+
const optionIds = new Set(input.options.map((option) => option.optionId));
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
let settled = false;
|
|
189
|
+
const settle = (decision) => {
|
|
190
|
+
if (settled)
|
|
191
|
+
return;
|
|
192
|
+
settled = true;
|
|
193
|
+
resolve(decision);
|
|
194
|
+
};
|
|
195
|
+
sink.registerPendingApproval(input.requestId, (outcome) => {
|
|
196
|
+
settle(optionIds.has(outcome) ? { outcome: 'answered', optionId: outcome } : { outcome: 'rejected' });
|
|
197
|
+
});
|
|
198
|
+
input.signal?.addEventListener('abort', () => {
|
|
199
|
+
settle({ outcome: 'rejected' });
|
|
200
|
+
// Purge the now-moot pending entry so a stray later answer doesn't linger against a request
|
|
201
|
+
// the agent already gave up on. The `settle` above has already fixed this promise's outcome
|
|
202
|
+
// (the `settled` guard makes the resolver this triggers a no-op), so the value passed here
|
|
203
|
+
// only has to avoid accidentally matching a real option id.
|
|
204
|
+
sink.answer(input.requestId, unmatchableAnswerFor(input.options));
|
|
205
|
+
}, { once: true });
|
|
206
|
+
sink.emitEvent(event);
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/** `{action: 'decline'}` — a valid `CreateElicitationResponse` under every arm of its action union. */
|
|
210
|
+
function declineElicitation() {
|
|
211
|
+
return { action: 'decline' };
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Answer `elicitation/create`. `'url'` accepts and hands the URL out through `deps.present` **only
|
|
215
|
+
* when both hold**: the request itself actually carries a string `elicitationId`/`url` (a
|
|
216
|
+
* nonconforming agent that omits either gets declined, never `present(undefined, undefined)`), and a
|
|
217
|
+
* real `deps.present` is wired (see {@link PresentElicitationUrl}'s doc comment — its absence means
|
|
218
|
+
* declining, not accepting into a void). Every other arm still declines — see the class doc comment
|
|
219
|
+
* for why. Accepting here does **not** mean the user finished anything; it means "the URL has been put
|
|
220
|
+
* in front of them", matching ACP's own `accept` semantics for this mode — which is exactly why both
|
|
221
|
+
* guards exist: an `accept` this module cannot back with a shown URL would make that claim false.
|
|
222
|
+
*
|
|
223
|
+
* The `switch` has a real `default`, not just `'form'`/`'url'` cases, because `CreateElicitationRequest['mode']`
|
|
224
|
+
* is `"form" | "url" | string` — an **open** union (unlike `session/update`'s closed 15-member one).
|
|
225
|
+
* Falling through with no default would mean the client's response is `undefined` for any mode the SDK
|
|
226
|
+
* or a nonstandard agent invents next, sending a malformed JSON-RPC result the agent may wait on
|
|
227
|
+
* forever instead of a clean, immediate decline.
|
|
228
|
+
*/
|
|
229
|
+
export function handleCreateElicitation(request, deps) {
|
|
230
|
+
switch (request.mode) {
|
|
231
|
+
case 'form':
|
|
232
|
+
// No form-rendering UI exists on Axom's wire yet — see the class doc comment.
|
|
233
|
+
return declineElicitation();
|
|
234
|
+
case 'url': {
|
|
235
|
+
const { elicitationId, url } = request;
|
|
236
|
+
if (typeof elicitationId !== 'string' || typeof url !== 'string') {
|
|
237
|
+
// A conforming agent's `ElicitationUrlMode` always has both as required strings
|
|
238
|
+
// (`types.gen.d.ts`) — this is defense against one that does not conform, not the normal
|
|
239
|
+
// path. The elicitation still gets answered (declined), never left to hang.
|
|
240
|
+
return declineElicitation();
|
|
241
|
+
}
|
|
242
|
+
if (!deps.present) {
|
|
243
|
+
// Nobody to show it to yet — see `PresentElicitationUrl`'s doc comment. Declining here is
|
|
244
|
+
// what an agent (or an MCP server proxied through one) raising this mid-turn can actually
|
|
245
|
+
// act on, unlike an `accept` that then goes nowhere.
|
|
246
|
+
return declineElicitation();
|
|
247
|
+
}
|
|
248
|
+
deps.present(elicitationId, url);
|
|
249
|
+
return { action: 'accept' };
|
|
250
|
+
}
|
|
251
|
+
default:
|
|
252
|
+
// The open arm: "Custom or future elicitation mode" per the SDK's own schema comment. This is
|
|
253
|
+
// the branch the plan's "elicitation whose union arm the SDK does not close" requirement is
|
|
254
|
+
// about — it must stay reachable and must not fall through silently.
|
|
255
|
+
return declineElicitation();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* `elicitation/complete` fires only for an elicitation the client previously accepted — now the `'url'`
|
|
260
|
+
* mode, once the agent considers that round trip finished. This turns it into a `dismiss` call so
|
|
261
|
+
* whatever is showing the URL knows to stop. It still needs a real handler registered even before any
|
|
262
|
+
* transport exists to drive `deps.dismiss` meaningfully: `ClientApp` requires one for every
|
|
263
|
+
* notification method it is asked to route, and an agent sending this must not crash the connection.
|
|
264
|
+
*/
|
|
265
|
+
export function handleElicitationComplete(notification, deps) {
|
|
266
|
+
deps.dismiss(notification.elicitationId);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Register this module's three handlers onto `app`. Must run inside `connectAgent`'s `configure`
|
|
270
|
+
* hook — see the file doc comment for why a post-connect `.onRequest`/`.onNotification` would compile
|
|
271
|
+
* but never fire.
|
|
272
|
+
*
|
|
273
|
+
* `elicitationDeps` is **required, deliberately with no default** (fix round 1 finding): a defaulted
|
|
274
|
+
* no-op pair would let a call site that forgot to wire real callbacks compile and stay green forever
|
|
275
|
+
* while quietly accepting-and-dropping every `url` elicitation it sees — `tsc` clean, tests green, no
|
|
276
|
+
* error anywhere, the same shape as an incident this repo already had from an unlisted case falling
|
|
277
|
+
* through a silent `else`. Making the argument mandatory turns a missed wiring into a build failure at
|
|
278
|
+
* the call site instead. Its own `present` may still be omitted (see {@link PresentElicitationUrl}) —
|
|
279
|
+
* that is `acpAgents.ts`'s session connection saying, on purpose, that a `url` elicitation raised
|
|
280
|
+
* outside a login has nowhere to go, and it declines rather than accepting into nothing.
|
|
281
|
+
*/
|
|
282
|
+
export function registerPermissionHandlers(app, sink, elicitationDeps) {
|
|
283
|
+
app
|
|
284
|
+
.onRequest(methods.client.session.requestPermission, async (ctx) => {
|
|
285
|
+
const decision = await handleRequestPermission({ requestId: String(ctx.requestId), toolCall: ctx.params.toolCall, options: ctx.params.options, signal: ctx.signal }, sink);
|
|
286
|
+
return {
|
|
287
|
+
outcome: decision.outcome === 'answered' ? { outcome: 'selected', optionId: decision.optionId } : { outcome: 'cancelled' },
|
|
288
|
+
};
|
|
289
|
+
})
|
|
290
|
+
.onRequest(methods.client.elicitation.create, (ctx) => handleCreateElicitation(ctx.params, elicitationDeps))
|
|
291
|
+
.onNotification(methods.client.elicitation.complete, (ctx) => handleElicitationComplete(ctx.params, elicitationDeps));
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* A `PermissionSink` that is not available yet — `configure` runs before `AcpConnection` exists,
|
|
295
|
+
* which is itself before `AcpSession.open()` can run, so there is no real `AcpSession` at the moment
|
|
296
|
+
* `registerPermissionHandlers` must be called. `bind()` supplies the real one once `AcpSession.open()`
|
|
297
|
+
* resolves; every call routed through this handle before that happens is a genuine ordering bug (the
|
|
298
|
+
* agent cannot reference a `sessionId` that does not exist yet, so `session/request_permission` cannot
|
|
299
|
+
* arrive before `bind()` in a conforming agent) and fails loudly rather than silently dropping a
|
|
300
|
+
* permission card.
|
|
301
|
+
*/
|
|
302
|
+
export function createDeferredPermissionSink() {
|
|
303
|
+
let bound = null;
|
|
304
|
+
function requireBound() {
|
|
305
|
+
if (!bound)
|
|
306
|
+
throw new Error('[acp] permission sink used before AcpSession.open() bound one');
|
|
307
|
+
return bound;
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
sink: {
|
|
311
|
+
emitEvent: (event) => requireBound().emitEvent(event),
|
|
312
|
+
registerPendingApproval: (requestId, resolve) => requireBound().registerPendingApproval(requestId, resolve),
|
|
313
|
+
answer: (requestId, outcome) => requireBound().answer(requestId, outcome),
|
|
314
|
+
},
|
|
315
|
+
bind(target) {
|
|
316
|
+
// Review finding (task-5, item 4): silently replacing an already-bound sink on a second call
|
|
317
|
+
// is exactly how one connection carrying two sessions would misroute — session A's cards would
|
|
318
|
+
// start landing in session B's sink. Failing loudly here can't catch every way to violate the
|
|
319
|
+
// one-connection-per-session invariant (see this file's `PermissionSink` doc comment), but it
|
|
320
|
+
// catches the one this module can actually see.
|
|
321
|
+
if (bound) {
|
|
322
|
+
throw new Error('[acp] createDeferredPermissionSink.bind() called twice on the same handle');
|
|
323
|
+
}
|
|
324
|
+
bound = target;
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const AXOM_AGENT_CATALOG = [
|
|
2
|
+
{ registryAgent: 'claude-acp', defaultName: 'claude-agent', defaultLabel: 'Claude Agent' },
|
|
3
|
+
{ registryAgent: 'codex-acp', defaultName: 'codex', defaultLabel: 'Codex' },
|
|
4
|
+
// `gemini` was here and is deliberately gone. `@google/gemini-cli --acp` now refuses `session/new`
|
|
5
|
+
// outright ("This client is no longer supported for Gemini Code Assist for individuals … migrate
|
|
6
|
+
// to the Antigravity suite"), so recommending it puts an agent in the popover's top tier that
|
|
7
|
+
// cannot open a session for whoever takes the recommendation. It stays in the ACP registry and can
|
|
8
|
+
// still be added by hand from the collapsed section; what is withdrawn is Axom's own endorsement,
|
|
9
|
+
// which is the only thing this file publishes.
|
|
10
|
+
{
|
|
11
|
+
registryAgent: 'pi-acp',
|
|
12
|
+
defaultName: 'pi',
|
|
13
|
+
defaultLabel: 'Pi',
|
|
14
|
+
dependency: {
|
|
15
|
+
check: 'pi',
|
|
16
|
+
// 不用 `curl -fsSL https://pi.dev/install.sh | sh`:读过那个脚本,它最终跑的就是这一条
|
|
17
|
+
// (`npm install -g --ignore-scripts @earendil-works/pi-coding-agent`),装出来的是同一个
|
|
18
|
+
// npm 包 —— pi 没有原生分发。脚本唯一的增值是 Node 不够新时帮你装 Node,而那一步是交互式的
|
|
19
|
+
// (读 /dev/tty),在 daemon 里会直接打 "No terminal detected" 退出。而 `curl | sh` 与上面
|
|
20
|
+
// 那条安全约束正面冲突。
|
|
21
|
+
install: ['npm', 'install', '-g', '@earendil-works/pi-coding-agent'],
|
|
22
|
+
requiresNode: '>=22.19.0',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
const BY_ID = new Map(AXOM_AGENT_CATALOG.map((entry) => [entry.registryAgent, entry]));
|
|
27
|
+
export function catalogEntryFor(registryAgent) {
|
|
28
|
+
return BY_ID.get(registryAgent);
|
|
29
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { binaryTargetFor, installedBinaryVersion, platformTriple } from '../binaryInstall.js';
|
|
3
|
+
import { npmPackageName } from '../installProbe.js';
|
|
4
|
+
import { uvPackageName, uvToolEntrypoint, uvToolEntrypointFrom } from '../uvxInstall.js';
|
|
5
|
+
export class UnsupportedDistributionError extends Error {
|
|
6
|
+
}
|
|
7
|
+
/** `npx --yes <specifier> [dist args]`,两个调用方共用的一处构造:钉版本的和不钉版本的。 */
|
|
8
|
+
function npxLaunch(specifier, npx, platform) {
|
|
9
|
+
return {
|
|
10
|
+
command: platform === 'win32' ? 'npx.cmd' : 'npx',
|
|
11
|
+
// `--yes` 让首次拉包不停在交互确认上。
|
|
12
|
+
args: ['--yes', specifier, ...(npx.args ?? [])],
|
|
13
|
+
env: { ...(npx.env ?? {}) },
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* registry 只给原料,不给命令行 —— 没有 `command` 字段,启动方式要从 `distribution` 推导。
|
|
18
|
+
*
|
|
19
|
+
* 三种分发方式现在都支持:`npx`/`uvx` 走各自安装侧维护的缓存/工具目录(见 `installProbe.ts`、
|
|
20
|
+
* `uvxInstall.ts`),`binary` 走 `binaryInstall.ts`(Task 8)—— 按六个平台三元组挑一个目标、
|
|
21
|
+
* 从 `adapterRoot` 找已经装好的版本。三条路径共同的规则是:**从不猜**,找不到就抛
|
|
22
|
+
* `UnsupportedDistributionError`,理由写清楚是"这个平台没有发行版"还是"这台机器还没装",而不是
|
|
23
|
+
* 笼统的一句"不支持"。
|
|
24
|
+
*/
|
|
25
|
+
export async function resolveLaunch(agent, platform,
|
|
26
|
+
// 六个平台三元组里的一半;只有 binary 分支用到。
|
|
27
|
+
arch,
|
|
28
|
+
// `<workstation config dir>/acp-adapters` —— `binaryInstall.ts` 解包落地的根目录,只有 binary
|
|
29
|
+
// 分支用到。npx 走 npm 自己的 `_npx` 缓存,uvx 走 `uv tool dir`,都不需要这个根目录。
|
|
30
|
+
adapterRoot,
|
|
31
|
+
// 仅测试注入:真实调用永远走 `uvxInstall.ts` 的默认 `uv` 实现。签名上留成可选,两个真实调用点
|
|
32
|
+
// (`acpAgents.ts`)不用跟着改。下面的 `uvTools` 给出时,这个参数被忽略 —— 见它的说明。
|
|
33
|
+
run,
|
|
34
|
+
// 已经读好的 `uv tool list` 快照(`listUvTools`)。给了就直接查表,不再自己起 `uv` 子进程 ——
|
|
35
|
+
// `AcpAgents.roster` 为整份 39 条 registry 只读一次,把结果传进这里,而不是让每个 uvx agent
|
|
36
|
+
// 在这条路径上各自再起一次 `uv`。真正的启动路径(`resolveLaunchFor`)不传这个参数,继续用
|
|
37
|
+
// `run`/默认实现现读一次,这里的现读对它而言正是期望行为。
|
|
38
|
+
//
|
|
39
|
+
// 这个函数整体是 async 的唯一原因:`uv` 现在是非阻塞调用(`RunUv` 返回 `Promise`,见
|
|
40
|
+
// `uvxInstall.ts`),没有预取快照时这里必须 `await` 它。给了 `uvTools` 的那条路径
|
|
41
|
+
// (`AcpAgents.resolvable`)完全同步、不等待任何东西,包成 `async` 只是让两条路径共用同一个
|
|
42
|
+
// 签名——`Promise.resolve` 包一层同步返回值的开销,跟每次 roster 扫描本来就要付的成本比可以
|
|
43
|
+
// 忽略不计。
|
|
44
|
+
uvTools) {
|
|
45
|
+
const { npx, uvx, binary } = agent.distribution;
|
|
46
|
+
// specifier 里已内联精确版本,不会追 latest —— 除非 registry 钉的那个版本 npm 根本没有,
|
|
47
|
+
// 见 {@link unpinnedFallbackFor}。
|
|
48
|
+
if (npx)
|
|
49
|
+
return npxLaunch(npx.package, npx, platform);
|
|
50
|
+
if (uvx) {
|
|
51
|
+
// 不走 `uvx <pkg>`:那条路用 uv 自己的临时环境缓存,目录名带哈希,"装没装"读不可靠。
|
|
52
|
+
// 改成我们自己 `uv tool install`、启动时直接跑装出来的入口点 —— 与 binary 一样,
|
|
53
|
+
// 落到"我们装的、我们知道装在哪"这一条统一规则上。
|
|
54
|
+
const outcome = uvTools
|
|
55
|
+
? uvToolEntrypointFrom(uvPackageName(uvx.package), uvTools)
|
|
56
|
+
: await uvToolEntrypoint(uvPackageName(uvx.package), run);
|
|
57
|
+
if (outcome.ok) {
|
|
58
|
+
return { command: outcome.path, args: [...(uvx.args ?? [])], env: { ...(uvx.env ?? {}) } };
|
|
59
|
+
}
|
|
60
|
+
if (outcome.reason === 'ambiguous') {
|
|
61
|
+
// 装了,但不敢猜:多个可执行文件里没有一个跟包名同名,uv 的列出顺序又不是文档化契约——
|
|
62
|
+
// 静默启动错的那个,换来的是一次没人能追回这里的 ACP 握手挂死或失败,比拒绝启动更糟。
|
|
63
|
+
throw new UnsupportedDistributionError(`Agent '${agent.id}' is distributed through uvx and its tool is installed, but this build ` +
|
|
64
|
+
`cannot tell which of its executables is the ACP adapter: ${outcome.candidates.join(', ')}.`);
|
|
65
|
+
}
|
|
66
|
+
throw new UnsupportedDistributionError(`Agent '${agent.id}' is distributed through uvx but its tool is not installed on this machine.`);
|
|
67
|
+
}
|
|
68
|
+
if (binary) {
|
|
69
|
+
// 六个平台三元组里挑一个;`windows-aarch64` 在实测的 registry 快照上只有 9/18 的覆盖,
|
|
70
|
+
// "这个平台没有发行版"是常态,不是异常——`installBinary`(`binaryInstall.ts`)在下载前就
|
|
71
|
+
// 用同一个函数做同一个判断,这里只是启动路径上再问一次同一件事,答案必须一致。
|
|
72
|
+
const triple = platformTriple(platform, arch);
|
|
73
|
+
const target = triple ? binaryTargetFor(agent, triple) : undefined;
|
|
74
|
+
if (!target) {
|
|
75
|
+
throw new UnsupportedDistributionError(`Agent '${agent.id}' publishes no archive for ${triple ?? `${platform}-${arch}`}.`);
|
|
76
|
+
}
|
|
77
|
+
// 装没装是这台机器磁盘上的事实,不是 registry 条目的属性 —— 跟 npx/uvx 分支一样,没装就说
|
|
78
|
+
// "没装",不是"不支持"。
|
|
79
|
+
//
|
|
80
|
+
// `agent.version` 必须传:同一个 agent 的多个版本可以并存(`<root>/<agent>/<version>/`,
|
|
81
|
+
// 就是为此设计的),`pickInstalledVersion` 的规则是"钉的版本在就取它,否则取最大"。
|
|
82
|
+
// `AcpAgents.roster` 报告 `adapterVersion` 时传了它,这里不传就会在两个版本并存时报告一个、
|
|
83
|
+
// 启动另一个 —— 名册说 0.75.0、跑起来是 0.74.0,而且没有任何一处会说出这件事。
|
|
84
|
+
const version = installedBinaryVersion(agent.id, adapterRoot, agent.version);
|
|
85
|
+
if (!version) {
|
|
86
|
+
throw new UnsupportedDistributionError(`Agent '${agent.id}' has no installed adapter on this machine.`);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
command: path.join(adapterRoot, agent.id, version, target.cmd),
|
|
90
|
+
args: [...(target.args ?? [])],
|
|
91
|
+
env: { ...(target.env ?? {}) },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
throw new UnsupportedDistributionError(`Agent '${agent.id}' declares no launchable distribution.`);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* 同一个 agent,但把 registry 钉的版本从 specifier 上剥掉 —— 上游钉了一个 npm 上并不存在的版本时,
|
|
98
|
+
* 唯一还能把这个 agent 救回来的启动方式。
|
|
99
|
+
*
|
|
100
|
+
* ## 为什么需要它
|
|
101
|
+
*
|
|
102
|
+
* 2026-09-04 实测:registry 的 `codex-acp` 条目声明
|
|
103
|
+
* `@agentclientprotocol/codex-acp@1.10.0`,而 npm 上这个包的版本停在 `1.9.0`
|
|
104
|
+
* (`dist-tags.latest` 也是 `1.9.0`)。于是每一次 codex 启动都是 `npm error code ETARGET`,npx 退出,
|
|
105
|
+
* 子进程死掉 —— 两台真实工作站同时失去了 codex。这是上游的缺陷;这里能做的是不让它把 agent 砖掉。
|
|
106
|
+
*
|
|
107
|
+
* ## 代价,以及为什么仍然值得
|
|
108
|
+
*
|
|
109
|
+
* 不钉版本装回来的,是 npm 此刻叫 `latest` 的那一个 —— **registry 从来没有指定过它**。它可能比钉的
|
|
110
|
+
* 版本新(常见,上游多半正是发版发歪了),也可能是一个协议行为不同、甚至跟这个客户端谈不拢
|
|
111
|
+
* `initialize` 的版本。所以这条路只在 ETARGET 上走(见 `acpClient.ts` 的 `isMissingPackageVersion`),
|
|
112
|
+
* 只走一次,而且两条 warn 日志会把「跑的不是 registry 要的那个版本」说穿。
|
|
113
|
+
*
|
|
114
|
+
* ## 三种「没有回退可言」的情况,都返回 undefined
|
|
115
|
+
*
|
|
116
|
+
* 没有 npx 分发(`uvx` 走的是 uv 自己的缓存,`binary` 这个版本压根不支持)、specifier 本来就不带版本
|
|
117
|
+
* (剥无可剥,npm 已经在追 latest 了)。剥版本用 `npmPackageName`,它按最后一个 `@` 切,所以
|
|
118
|
+
* `@scope/name@1.10.0` → `@scope/name`,而前导的 `@` 不会被当成版本分隔符。
|
|
119
|
+
*/
|
|
120
|
+
export function unpinnedFallbackFor(agent, platform) {
|
|
121
|
+
const npx = agent.distribution.npx;
|
|
122
|
+
if (!npx)
|
|
123
|
+
return undefined;
|
|
124
|
+
const unpinned = npmPackageName(npx.package);
|
|
125
|
+
if (unpinned === npx.package)
|
|
126
|
+
return undefined;
|
|
127
|
+
return { pinned: npx.package, unpinned, launch: npxLaunch(unpinned, npx, platform) };
|
|
128
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
/** `latest` is `max-age=300`; a fixed 2026-08 snapshot exists too but registry.ts always tracks `latest`. */
|
|
4
|
+
export const REGISTRY_URL = 'https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json';
|
|
5
|
+
const CACHE_FILE = 'acp-registry-cache.json';
|
|
6
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
|
|
7
|
+
function errorMessage(error) {
|
|
8
|
+
return error instanceof Error ? error.message : String(error);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Validates only what launch and identity depend on (`id`, and that `distribution` has at least
|
|
12
|
+
* one channel). The envelope itself — and every other per-agent field — is passed through as-is:
|
|
13
|
+
* the live registry.json does not pass its own schema (`extensions` is baked in by the builder
|
|
14
|
+
* against an `additionalProperties:false` schema), so strict validation here would empty the whole
|
|
15
|
+
* agent list in production instead of just ignoring a field this client doesn't use yet.
|
|
16
|
+
*/
|
|
17
|
+
function isRegistryAgent(value) {
|
|
18
|
+
if (typeof value !== 'object' || value === null)
|
|
19
|
+
return false;
|
|
20
|
+
const candidate = value;
|
|
21
|
+
if (typeof candidate.id !== 'string' || candidate.id.length === 0)
|
|
22
|
+
return false;
|
|
23
|
+
const distribution = candidate.distribution;
|
|
24
|
+
if (typeof distribution !== 'object' || distribution === null)
|
|
25
|
+
return false;
|
|
26
|
+
const channels = distribution;
|
|
27
|
+
return channels.npx != null || channels.uvx != null || channels.binary != null;
|
|
28
|
+
}
|
|
29
|
+
function parseAgents(document) {
|
|
30
|
+
if (typeof document !== 'object' || document === null) {
|
|
31
|
+
throw new Error('ACP agent registry document is not a JSON object');
|
|
32
|
+
}
|
|
33
|
+
const agents = document.agents;
|
|
34
|
+
if (!Array.isArray(agents)) {
|
|
35
|
+
throw new Error('ACP agent registry document is missing an "agents" array');
|
|
36
|
+
}
|
|
37
|
+
// Silently drop entries that don't even have an id/distribution rather than failing the whole
|
|
38
|
+
// list: a single malformed entry must not take every other agent down with it.
|
|
39
|
+
return agents.filter(isRegistryAgent);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Fetches the ACP agent registry with ETag revalidation and a last-known-good on-disk cache.
|
|
43
|
+
*
|
|
44
|
+
* `document.version` ("1.0.0") is a frozen constant, not a content version — freshness is tracked
|
|
45
|
+
* via the HTTP ETag (`if-none-match` / 304), never by comparing that field.
|
|
46
|
+
*/
|
|
47
|
+
export class RegistryClient {
|
|
48
|
+
cacheDir;
|
|
49
|
+
cachePath;
|
|
50
|
+
fetchImpl;
|
|
51
|
+
url;
|
|
52
|
+
requestTimeoutMs;
|
|
53
|
+
/**
|
|
54
|
+
* Why the last {@link readCache} produced nothing, when the reason was something other than "there
|
|
55
|
+
* is no cache yet". Read only by {@link fallbackOrThrow}.
|
|
56
|
+
*
|
|
57
|
+
* A corrupt cache and a cold start both degrade to "no cache", which is right — a bad file must not
|
|
58
|
+
* make the registry unusable. But collapsing them in the *message* sends whoever is debugging to
|
|
59
|
+
* look for a network problem on a machine whose only actual fault is one unreadable file, which
|
|
60
|
+
* nothing else on it would ever mention.
|
|
61
|
+
*/
|
|
62
|
+
cacheDefect = null;
|
|
63
|
+
constructor(options) {
|
|
64
|
+
this.cacheDir = options.cacheDir;
|
|
65
|
+
this.cachePath = path.join(this.cacheDir, CACHE_FILE);
|
|
66
|
+
this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
67
|
+
this.url = options.url ?? REGISTRY_URL;
|
|
68
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
69
|
+
}
|
|
70
|
+
/** Cache-first: returns the cached list immediately (if any) and refreshes in the background. */
|
|
71
|
+
async load() {
|
|
72
|
+
const cached = await this.readCache();
|
|
73
|
+
if (cached) {
|
|
74
|
+
// A stale cache is still useful; a background refresh failure must not surface to a caller
|
|
75
|
+
// that already got an answer.
|
|
76
|
+
void this.refresh().catch(() => undefined);
|
|
77
|
+
return cached.agents;
|
|
78
|
+
}
|
|
79
|
+
return this.refresh();
|
|
80
|
+
}
|
|
81
|
+
/** Forces a network fetch. Falls back to the cache on any failure; throws only on a cold start. */
|
|
82
|
+
async refresh() {
|
|
83
|
+
const cached = await this.readCache();
|
|
84
|
+
const headers = {};
|
|
85
|
+
if (cached?.etag)
|
|
86
|
+
headers['if-none-match'] = cached.etag;
|
|
87
|
+
let response;
|
|
88
|
+
try {
|
|
89
|
+
response = await this.fetchImpl(this.url, { headers, signal: AbortSignal.timeout(this.requestTimeoutMs) });
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
return this.fallbackOrThrow(cached, errorMessage(error));
|
|
93
|
+
}
|
|
94
|
+
if (response.status === 304) {
|
|
95
|
+
return this.fallbackOrThrow(cached, 'responded 304 Not Modified but no cached copy exists to revalidate against');
|
|
96
|
+
}
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
return this.fallbackOrThrow(cached, `responded with HTTP ${response.status}`);
|
|
99
|
+
}
|
|
100
|
+
let body;
|
|
101
|
+
try {
|
|
102
|
+
body = await response.json();
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
return this.fallbackOrThrow(cached, `returned a body that could not be parsed as JSON (${errorMessage(error)})`);
|
|
106
|
+
}
|
|
107
|
+
const agents = parseAgents(body);
|
|
108
|
+
const etag = response.headers.get('etag') ?? undefined;
|
|
109
|
+
await this.writeCache({ agents, etag });
|
|
110
|
+
return agents;
|
|
111
|
+
}
|
|
112
|
+
fallbackOrThrow(cached, reason) {
|
|
113
|
+
if (cached)
|
|
114
|
+
return cached.agents;
|
|
115
|
+
const cacheState = this.cacheDefect ?? 'no cached copy exists on this machine';
|
|
116
|
+
throw new Error(`ACP agent registry at ${this.url} is unreachable (${reason}) and ${cacheState}`);
|
|
117
|
+
}
|
|
118
|
+
async readCache() {
|
|
119
|
+
this.cacheDefect = null;
|
|
120
|
+
try {
|
|
121
|
+
const raw = await fs.readFile(this.cachePath, 'utf8');
|
|
122
|
+
const parsed = JSON.parse(raw);
|
|
123
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
124
|
+
return this.unusableCache('it does not contain a JSON object');
|
|
125
|
+
const candidate = parsed;
|
|
126
|
+
if (!Array.isArray(candidate.agents))
|
|
127
|
+
return this.unusableCache('it has no "agents" array');
|
|
128
|
+
return {
|
|
129
|
+
agents: candidate.agents.filter(isRegistryAgent),
|
|
130
|
+
etag: typeof candidate.etag === 'string' ? candidate.etag : undefined,
|
|
131
|
+
fetchedAt: typeof candidate.fetchedAt === 'string' ? candidate.fetchedAt : new Date(0).toISOString(),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
// A corrupt or unreadable file degrades to "no cache" exactly like a cold start does — that
|
|
136
|
+
// part is deliberate. Only the *diagnostic* distinguishes them.
|
|
137
|
+
if (error.code === 'ENOENT')
|
|
138
|
+
return null;
|
|
139
|
+
return this.unusableCache(errorMessage(error));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Records why the cache file could not be used, and degrades to "no cache". */
|
|
143
|
+
unusableCache(detail) {
|
|
144
|
+
this.cacheDefect = `the cached copy at ${this.cachePath} could not be used (${detail})`;
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
async writeCache(data) {
|
|
148
|
+
await fs.mkdir(this.cacheDir, { recursive: true });
|
|
149
|
+
const payload = { agents: data.agents, etag: data.etag, fetchedAt: new Date().toISOString() };
|
|
150
|
+
const temporary = path.join(this.cacheDir, `.${CACHE_FILE}.${process.pid}.${Date.now()}.tmp`);
|
|
151
|
+
try {
|
|
152
|
+
await fs.writeFile(temporary, `${JSON.stringify(payload)}\n`, 'utf8');
|
|
153
|
+
await fs.rename(temporary, this.cachePath);
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
await fs.rm(temporary, { force: true }).catch(() => undefined);
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shape of the ACP agent registry (`https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json`).
|
|
3
|
+
*
|
|
4
|
+
* There is no `command` field: how to launch an agent is derived from `distribution` (see
|
|
5
|
+
* `launch.ts`, Task 2). `version` on the document itself is a frozen constant ("1.0.0"), not a
|
|
6
|
+
* content version — freshness is tracked via ETag, not this field.
|
|
7
|
+
*/
|
|
8
|
+
export {};
|