@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,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Adoption" — a user picking up a native agent session that already exists on this machine and
|
|
3
|
+
* having its history replayed into Axom's timeline. Two ACP methods, both **optional on the
|
|
4
|
+
* agent** and gated by `AcpConnection.capabilities` (Task 3): {@link listNativeSessions} calls
|
|
5
|
+
* `session/list`, gated on `capabilities.list`; {@link replayNativeSession} calls `session/load`,
|
|
6
|
+
* gated on `capabilities.loadSession`. Both degrade to "nothing to show" rather than an error when
|
|
7
|
+
* unsupported — see the doc comment on each for why, and the deleted `bridges/types.ts`'s
|
|
8
|
+
* `AgentBridge.listNativeSessions`/`BridgeSession.backfill` KDoc for the contract this carries
|
|
9
|
+
* forward verbatim ("the directory hides the grouping, not an error").
|
|
10
|
+
*
|
|
11
|
+
* ## `session/resume` is deliberately NOT used here — read this before "fixing" it back
|
|
12
|
+
*
|
|
13
|
+
* The plan text that spawned this task says "`session/resume(replayFrom)`". Both do not exist in
|
|
14
|
+
* `@agentclientprotocol/sdk@1.4.0`'s stable v1 entry point: `ResumeSessionRequest` has no
|
|
15
|
+
* `replayFrom` field (that name only appears under `@agentclientprotocol/sdk/experimental/v2`,
|
|
16
|
+
* which Global Constraint 1 forbids importing), and — more fundamentally — `resumeSession`'s own
|
|
17
|
+
* doc comment in `dist/acp.d.ts` says it "Resumes an existing session **without** replaying the
|
|
18
|
+
* message history (**unlike** `session/load`)". `session/load` (`loadSession`, gated by the
|
|
19
|
+
* top-level `AgentCapabilities.loadSession: boolean`, not `sessionCapabilities`) is the ACP v1
|
|
20
|
+
* method whose own doc comment says the agent should "Stream the entire conversation history back
|
|
21
|
+
* to the client via notifications" — i.e. it is the actual replay mechanism this file needs.
|
|
22
|
+
* `session/resume` exists for a client that already has a session's history cached elsewhere and
|
|
23
|
+
* wants to keep working without paying for a redundant replay; Axom has never seen an adopted
|
|
24
|
+
* session's history before, so that case never applies here.
|
|
25
|
+
*
|
|
26
|
+
* Verified both target adapters actually support the method this file calls: `claude-agent-acp`
|
|
27
|
+
* 0.70.0 and `codex-acp` 1.6.2 both declare `agentCapabilities.loadSession: true` and both
|
|
28
|
+
* implement `session/load` (`.superpowers/research/acp-sdk-registry.md`, "VERIFIED" section).
|
|
29
|
+
*
|
|
30
|
+
* ## Observing the replayed `session/update` notifications
|
|
31
|
+
*
|
|
32
|
+
* Unlike `session/new` (Task 4's `AcpSession`, built on the SDK's `ClientContext.buildSession(...)`
|
|
33
|
+
* convenience wrapper), the SDK has no `SessionBuilder`-equivalent helper for `session/load` — the
|
|
34
|
+
* only public, supported way to see raw `session/update` notifications outside of that helper is a
|
|
35
|
+
* `ClientApp`-level `onNotification(methods.client.session.update, ...)` handler (the pattern the
|
|
36
|
+
* SDK's own `acp.test.js` uses throughout for exactly this). That has to be registered before
|
|
37
|
+
* `ClientApp.connect()` copies its handler table (`acpClient.ts`'s `ConnectAgentOptions.configure`
|
|
38
|
+
* doc comment; `permission.ts`'s file doc comment says the same for `session/request_permission`).
|
|
39
|
+
* {@link createReplayCapture}/{@link ReplayCapture.register} exist for that: a caller passes
|
|
40
|
+
* `capture.register` into `connectAgent`'s `configure` hook — composable with `permission.ts`'s
|
|
41
|
+
* `registerPermissionHandlers` in the same hook, since `configure` is one function a caller can do
|
|
42
|
+
* several registrations inside of — and later, once the connection exists, passes the *same*
|
|
43
|
+
* `capture` into {@link replayNativeSession}. The registered handler sees *every* `session/update`
|
|
44
|
+
* notification on the connection, not just the one being replayed (verified against
|
|
45
|
+
* `SessionUpdateRouter.handleMessage`, which always returns `Handled.no(message)` — it never consumes
|
|
46
|
+
* the message, so dispatch keeps walking the handler chain to whatever else is registered, including
|
|
47
|
+
* the SDK's own internal `session/new`-scoped router for a live `AcpSession` on the *same* connection).
|
|
48
|
+
* That is exactly the axis review found this file getting wrong in fix round 1: a stray comment here
|
|
49
|
+
* used to say this "never interferes with a live `AcpSession` on some other connection" — true, but
|
|
50
|
+
* irrelevant, since the real conflict is a live `AcpSession` on the *same* connection (the shape a
|
|
51
|
+
* caller composing this with `registerPermissionHandlers` in one `configure` hook produces routinely).
|
|
52
|
+
* Without gating, the handler above would buffer *every* session's updates forever — only
|
|
53
|
+
* {@link ReplayCapture.take} ever releases a buffered entry, and it is only ever called for the one
|
|
54
|
+
* `sessionId` being replayed, so any other session's notifications arriving on this connection (most
|
|
55
|
+
* concretely: a live `AcpSession` opened on the same connection right after adoption, which Task 8
|
|
56
|
+
* needs) would accumulate unbounded for the life of the connection. {@link ReplayCapture.arm} exists
|
|
57
|
+
* to close that: the handler now only buffers a `sessionId` that has been armed, {@link
|
|
58
|
+
* replayNativeSession} arms its target *before* sending `session/load`, and `take()` disarms on its
|
|
59
|
+
* way out — so the buffer's upper bound is whatever is currently being replayed, never every session
|
|
60
|
+
* this connection has ever seen an update for.
|
|
61
|
+
*
|
|
62
|
+
* ## Replay goes through the same mapping and folding the live path uses — reimplemented, not shared
|
|
63
|
+
*
|
|
64
|
+
* {@link replayNativeSession} maps every replayed `SessionUpdate` through the exact same
|
|
65
|
+
* `mapSessionUpdate` (`sessionUpdateMapper.ts`) the live path (`AcpSession`) uses, and every mapped
|
|
66
|
+
* event through the same `validateWireEvent` (`validate.ts`) outbound check — so it can never
|
|
67
|
+
* emit anything the server can't decode, and (per `mapSessionUpdate`'s own contract) can never emit
|
|
68
|
+
* `final_result`. What it does NOT reuse is `AcpSession`'s own `agent_message` synthesis /
|
|
69
|
+
* synthesis: that lives in `AcpSession` as push-based, event-sink-driven instance state (see its
|
|
70
|
+
* class doc comment), a different execution shape than this file's pull-based `AsyncGenerator` over
|
|
71
|
+
* an already-complete update list. Task 4's report flagged this by name: "the replay path needs its
|
|
72
|
+
* own equivalent folding and message-completion logic — do not assume the live path's state machine
|
|
73
|
+
* is doing it for you." {@link foldReplayEvents} is that equivalent, reimplemented as a single pass
|
|
74
|
+
* over a finished array instead of instance fields mutated across live callbacks.
|
|
75
|
+
*
|
|
76
|
+
* **The `user_message` half of that duplication is gone.** This file used to carry its own copy of
|
|
77
|
+
* `AcpSession.foldUserMessageChunk`, and the two had already drifted: replay flushed a buffered user
|
|
78
|
+
* message on *any* intervening event, the live path only on a different `messageId` or turn end, so
|
|
79
|
+
* the same transcript produced a different number of cards depending on which path replayed it.
|
|
80
|
+
* Phase 7 moved the fold to `CoworkEngine`, which already buffered `agent_message_chunk` by exactly
|
|
81
|
+
* the ACP rule that covers all three chunk kinds (`ContentChunk`, `types.gen.d.ts:3456`).
|
|
82
|
+
* `user_message_chunk` now passes through here untouched, and `ExternalAgentEventApplier`'s
|
|
83
|
+
* `onBackfillDone` is what closes the last buffered message.
|
|
84
|
+
*/
|
|
85
|
+
import { methods } from '@agentclientprotocol/sdk';
|
|
86
|
+
import { validateWireEvent } from './validate.js';
|
|
87
|
+
import { mapSessionUpdate, SessionMapperState } from './sessionUpdateMapper.js';
|
|
88
|
+
/** `NativeSessionInfo.title` is truncated to this many characters — mirrors the deleted `claudeCode/adoption.ts`'s display-safety guard (`TITLE_MAX_LENGTH`). */
|
|
89
|
+
const TITLE_MAX_LENGTH = 80;
|
|
90
|
+
/** `SessionInfo.updatedAt` is an ISO 8601 string or `null`/absent; missing or unparseable sorts as oldest (0) rather than throwing. */
|
|
91
|
+
function parseUpdatedAt(value) {
|
|
92
|
+
if (!value)
|
|
93
|
+
return 0;
|
|
94
|
+
const parsed = Date.parse(value);
|
|
95
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* List an agent's native sessions scoped to `query.cwd`, newest first.
|
|
99
|
+
*
|
|
100
|
+
* When the agent doesn't advertise `sessionCapabilities.list` (`connection.capabilities.list`),
|
|
101
|
+
* this returns an empty result **without ever calling `session/list`** — matching the deleted
|
|
102
|
+
* `AgentBridge.listNativeSessions?`'s contract verbatim ("the directory hides the grouping, not an
|
|
103
|
+
* error"). Not calling the method at all (rather than calling it and treating any error as "empty")
|
|
104
|
+
* matters because "honour the advertised capability, not the router" (Task 3/5's own rule) cuts both
|
|
105
|
+
* ways: `codex-acp` routes `session/fork` without declaring it, so a client that called unadvertised
|
|
106
|
+
* methods anyway would be relying on exactly the coupling that rule exists to rule out.
|
|
107
|
+
*/
|
|
108
|
+
export async function listNativeSessions(connection, query) {
|
|
109
|
+
if (!connection.capabilities.list)
|
|
110
|
+
return { sessions: [] };
|
|
111
|
+
const response = await connection.connection.agent.request(methods.agent.session.list, {
|
|
112
|
+
...(query.cwd ? { cwd: query.cwd } : {}),
|
|
113
|
+
...(query.cursor ? { cursor: query.cursor } : {}),
|
|
114
|
+
});
|
|
115
|
+
const sessions = response.sessions
|
|
116
|
+
.map((info) => ({
|
|
117
|
+
nativeSessionId: info.sessionId,
|
|
118
|
+
title: (info.title ?? '').slice(0, TITLE_MAX_LENGTH),
|
|
119
|
+
updatedAtEpochMs: parseUpdatedAt(info.updatedAt),
|
|
120
|
+
cwd: info.cwd,
|
|
121
|
+
}))
|
|
122
|
+
.sort((a, b) => b.updatedAtEpochMs - a.updatedAtEpochMs);
|
|
123
|
+
return response.nextCursor != null ? { sessions, nextCursor: response.nextCursor } : { sessions };
|
|
124
|
+
}
|
|
125
|
+
export function createReplayCapture() {
|
|
126
|
+
const armed = new Set();
|
|
127
|
+
const buffered = new Map();
|
|
128
|
+
const listeners = new Map();
|
|
129
|
+
return {
|
|
130
|
+
register(app) {
|
|
131
|
+
app.onNotification(methods.client.session.update, (ctx) => {
|
|
132
|
+
// Armed wins over listening: while a session/load replay is in flight, its history must land
|
|
133
|
+
// in the buffer rather than in a live session's read loop — see `listen`'s doc comment.
|
|
134
|
+
if (armed.has(ctx.params.sessionId)) {
|
|
135
|
+
const list = buffered.get(ctx.params.sessionId) ?? [];
|
|
136
|
+
list.push(ctx.params.update);
|
|
137
|
+
buffered.set(ctx.params.sessionId, list);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// Neither armed nor listened to: dropped, so this handler cannot accumulate every session
|
|
141
|
+
// the connection has ever seen an update for (see the file doc comment).
|
|
142
|
+
listeners.get(ctx.params.sessionId)?.(ctx.params.update);
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
arm(sessionId) {
|
|
146
|
+
armed.add(sessionId);
|
|
147
|
+
},
|
|
148
|
+
take(sessionId) {
|
|
149
|
+
armed.delete(sessionId);
|
|
150
|
+
const list = buffered.get(sessionId) ?? [];
|
|
151
|
+
buffered.delete(sessionId);
|
|
152
|
+
return list;
|
|
153
|
+
},
|
|
154
|
+
count(sessionId) {
|
|
155
|
+
return buffered.get(sessionId)?.length ?? 0;
|
|
156
|
+
},
|
|
157
|
+
listen(sessionId, sink) {
|
|
158
|
+
listeners.set(sessionId, sink);
|
|
159
|
+
return () => {
|
|
160
|
+
// Only clear our own entry: a later listen() for the same sessionId may already have
|
|
161
|
+
// replaced it, and unsubscribing then would silence the session that is actually running.
|
|
162
|
+
if (listeners.get(sessionId) === sink)
|
|
163
|
+
listeners.delete(sessionId);
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/** `payload[key]` narrowed to `string`, or `null` — same helper `acpSession.ts` uses for the identical reason (`payload` is `Record<string, unknown>` by construction). */
|
|
169
|
+
function stringField(payload, key) {
|
|
170
|
+
const value = payload[key];
|
|
171
|
+
return typeof value === 'string' ? value : null;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Map a completed list of replayed `SessionUpdate`s to wire events, applying the same
|
|
175
|
+
* message-completion synthesis {@link AcpSession} applies to the live stream — see the file doc
|
|
176
|
+
* comment for why this is a reimplementation, not a shared call, and `acpSession.ts`'s
|
|
177
|
+
* `trackMessageBoundary`/`flushMessageCompleted` for the rule being mirrored:
|
|
178
|
+
*
|
|
179
|
+
* - `agent_message` is synthesized whenever the streaming `agent_message_chunk`/`agent_thought_chunk`
|
|
180
|
+
* `messageId` changes, and once more at the very end of the list (there is no live "turn end"
|
|
181
|
+
* signal to hang this off during replay — a whole adopted session's history has no `PromptResponse`
|
|
182
|
+
* in it at all — so "end of the replayed list" is this function's equivalent of `AcpSession.finishTurn`'s
|
|
183
|
+
* unconditional turn-end flush).
|
|
184
|
+
* - A `user_message_chunk` still forces that synthesis: a user turn beginning means whatever the
|
|
185
|
+
* assistant was streaming has necessarily already ended. The chunk itself is emitted verbatim —
|
|
186
|
+
* folding it is the server's job now (see the file doc comment).
|
|
187
|
+
*
|
|
188
|
+
* Exported directly (not just through {@link replayNativeSession}) so its synthesis behavior can be
|
|
189
|
+
* pinned by unit tests with no process spawning involved — the process-spawning tests are reserved
|
|
190
|
+
* for what only a real subprocess can prove (capability gating, the actual RPC shape, ordering
|
|
191
|
+
* against the real `session/load` response).
|
|
192
|
+
*/
|
|
193
|
+
export function foldReplayEvents(updates) {
|
|
194
|
+
const events = [];
|
|
195
|
+
// Fresh per replay, not shared with the live session that may follow: this call reconstructs one
|
|
196
|
+
// adopted session's history from its own `tool_call`s, and a registry carried in from elsewhere
|
|
197
|
+
// could name a call this history never created.
|
|
198
|
+
const mapperState = new SessionMapperState();
|
|
199
|
+
let sawMessageContent = false;
|
|
200
|
+
let currentMessageId = null;
|
|
201
|
+
function flushMessageCompleted() {
|
|
202
|
+
if (!sawMessageContent)
|
|
203
|
+
return;
|
|
204
|
+
events.push({ eventName: 'agent_message', payload: { messageId: currentMessageId ?? 'replay' } });
|
|
205
|
+
sawMessageContent = false;
|
|
206
|
+
currentMessageId = null;
|
|
207
|
+
}
|
|
208
|
+
for (const update of updates) {
|
|
209
|
+
for (const event of mapSessionUpdate(update, mapperState)) {
|
|
210
|
+
if (event.eventName === 'user_message_chunk') {
|
|
211
|
+
// A user turn beginning means whatever the assistant was streaming has necessarily already
|
|
212
|
+
// ended — flush it first, mirroring AcpSession.finishTurn's ordering at a live turn
|
|
213
|
+
// boundary (just the mirror image here, since replay sees the *next* user turn rather than
|
|
214
|
+
// an explicit turn-end signal). The chunk itself then goes through verbatim: the fold that
|
|
215
|
+
// used to live here is the server's now.
|
|
216
|
+
flushMessageCompleted();
|
|
217
|
+
events.push(event);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (event.eventName === 'agent_message_chunk' || event.eventName === 'agent_thought_chunk') {
|
|
221
|
+
const messageId = stringField(event.payload, 'messageId');
|
|
222
|
+
if (sawMessageContent && messageId !== null && currentMessageId !== null && messageId !== currentMessageId) {
|
|
223
|
+
flushMessageCompleted();
|
|
224
|
+
}
|
|
225
|
+
sawMessageContent = true;
|
|
226
|
+
if (messageId !== null)
|
|
227
|
+
currentMessageId = messageId;
|
|
228
|
+
}
|
|
229
|
+
events.push(event);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
flushMessageCompleted();
|
|
233
|
+
return events;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Wait until the `session/update` notifications an agent sent *before* answering a request have
|
|
237
|
+
* actually been **dispatched**, which is **not** implied by that request having responded.
|
|
238
|
+
*
|
|
239
|
+
* The SDK handles the two message kinds on different schedules (`jsonrpc.js`'s
|
|
240
|
+
* `receiveWireMessage`): a response is applied **synchronously** via `handleResponse` the moment the
|
|
241
|
+
* reader pulls it off the stream, while a notification is handed to `processIncomingMessage`, which
|
|
242
|
+
* is *not* awaited by the read loop and which awaits each registered handler in turn. So even though
|
|
243
|
+
* the agent writes every `session/update` before its response — and the reader therefore reads them
|
|
244
|
+
* first — the response's `await` can resume before a single one of our handlers has run. Measured
|
|
245
|
+
* directly on both request paths that have this shape:
|
|
246
|
+
*
|
|
247
|
+
* - `session/load` ({@link replayNativeSession}): the order observed was `response`, then the
|
|
248
|
+
* updates, so `take()` on the spot returned a partial history, or none at all.
|
|
249
|
+
* - `session/prompt` (`acpSession.ts`'s `AcpSession.attach`): the turn's terminal `stop` message was
|
|
250
|
+
* enqueued **ahead of** the tail of the same turn's deltas — `agent_message` and the
|
|
251
|
+
* `TurnResult` landing mid-stream, with the remaining text arriving as orphans after the item the
|
|
252
|
+
* server had already closed.
|
|
253
|
+
*
|
|
254
|
+
* The difference between "works" and "reorders" is nothing but how many handlers happen to be
|
|
255
|
+
* registered on the connection — which is why the same code looked correct until the manager wired
|
|
256
|
+
* permission handlers onto the same `ClientApp`.
|
|
257
|
+
*
|
|
258
|
+
* The one guarantee that survives is ordering *into* the dispatcher: by the time the response is
|
|
259
|
+
* applied, every preceding notification's dispatch has been **started**. This waits for a macrotask
|
|
260
|
+
* boundary, which drains a chain of synchronous handlers' microtask continuations, and keeps waiting
|
|
261
|
+
* while `count()` is still growing.
|
|
262
|
+
*
|
|
263
|
+
* ## This is a heuristic. It is bounded **and** it truncates — three ways, all measured
|
|
264
|
+
*
|
|
265
|
+
* Nothing enforces the premise it rests on, so read these as live failure modes rather than as
|
|
266
|
+
* theoretical corners:
|
|
267
|
+
*
|
|
268
|
+
* | Condition | Measured outcome |
|
|
269
|
+
* |---|---|
|
|
270
|
+
* | Any handler on the chain awaits a **macrotask** (even `setTimeout(…, 0)`) | 6 updates → **0** replayed |
|
|
271
|
+
* | The agent answers **before** it has finished streaming | 6 updates → **4** replayed |
|
|
272
|
+
* | The stream outlasts `maxRounds` | 3000 updates → **2086** replayed |
|
|
273
|
+
*
|
|
274
|
+
* The first is why "every handler on this chain is synchronous" is a **prerequisite**, and why it is
|
|
275
|
+
* written down at the place that actually establishes it — `acpAgents.ts`'s `AcpAgents.open`
|
|
276
|
+
* `configure` hook, the one site where handlers get registered — rather than only here. A function
|
|
277
|
+
* cannot enforce a property of a registration it never sees.
|
|
278
|
+
*
|
|
279
|
+
* The second is outside this function's reach entirely: an agent that responds mid-stream has broken
|
|
280
|
+
* the ordering guarantee this is built on, and no amount of waiting recovers it.
|
|
281
|
+
*
|
|
282
|
+
* The third is `maxRounds`, the backstop against an agent that keeps streaming forever — a wait with
|
|
283
|
+
* no exit would hang the whole open. It is reachable in practice, so hitting it is **logged** rather
|
|
284
|
+
* than returned quietly: that diagnostic is the whole line between "bounded degradation" and
|
|
285
|
+
* "invisible data loss", and it is the only one of the three this function can see happening to
|
|
286
|
+
* itself.
|
|
287
|
+
*
|
|
288
|
+
* `count` is supplied by the caller rather than read off a `ReplayCapture` because the two callers
|
|
289
|
+
* count different things: the replay path counts buffered updates for one armed `sessionId`, the
|
|
290
|
+
* live path counts updates delivered into an attached session's queue.
|
|
291
|
+
*/
|
|
292
|
+
export async function settleUpdates(sessionId, count, maxRounds = 10) {
|
|
293
|
+
let previous = -1;
|
|
294
|
+
for (let round = 0; round < maxRounds; round += 1) {
|
|
295
|
+
const before = count();
|
|
296
|
+
// setImmediate, not setTimeout(0): it runs in the check phase, after the poll phase has drained
|
|
297
|
+
// whatever the transport already had readable, so one turn covers both the microtasks and any
|
|
298
|
+
// further messages sitting in the socket buffer.
|
|
299
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
300
|
+
const after = count();
|
|
301
|
+
if (after === before && after === previous)
|
|
302
|
+
return;
|
|
303
|
+
previous = before;
|
|
304
|
+
}
|
|
305
|
+
// Falling out of the loop is not "settled" — it is "gave up while the agent was still streaming".
|
|
306
|
+
// Whatever arrives from here on may be reordered against the turn/replay boundary this call was
|
|
307
|
+
// holding, or dropped outright. Never silent: a session that quietly loses the tail of its history
|
|
308
|
+
// is indistinguishable from an agent that had no history to give.
|
|
309
|
+
console.error(`[acp] session/update stream for '${sessionId}' was still growing after ${maxRounds} settle rounds ` +
|
|
310
|
+
`(${count()} updates so far) — continuing anyway; later updates may be reordered or lost.`);
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Replay one native session's history as wire events via `session/load` — see the file doc comment
|
|
314
|
+
* for why `session/load`, not `session/resume`.
|
|
315
|
+
*
|
|
316
|
+
* When the agent doesn't advertise `agentCapabilities.loadSession` (`connection.capabilities.loadSession`),
|
|
317
|
+
* this yields nothing **without ever calling `session/load`** — same "hidden grouping, not an error"
|
|
318
|
+
* contract as {@link listNativeSessions}, applied to the capability that actually gates replay.
|
|
319
|
+
*
|
|
320
|
+
* Every yielded event has already gone through {@link foldReplayEvents} (same mapping the live path
|
|
321
|
+
* uses, plus the same `agent_message` synthesis) and `validateWireEvent` (same outbound check the live path's
|
|
322
|
+
* `permission_request` events go through) — an event that fails validation is dropped with a
|
|
323
|
+
* diagnostic, never thrown, matching `validate.ts`'s own "drop + diagnostic, never an
|
|
324
|
+
* exception" design. `mapSessionUpdate` never produces `final_result` (Task 4's contract, unchanged
|
|
325
|
+
* here since this function adds no new event-producing path of its own) and `foldReplayEvents` adds
|
|
326
|
+
* no new event names beyond what `mapSessionUpdate` and this file's own `agent_message` synthesis
|
|
327
|
+
* already produce — both already appear in the server's understood vocabulary
|
|
328
|
+
* (`sessionUpdateMapper.test.ts`'s `SERVER_UNDERSTOOD_EVENT_NAMES`), so nothing here needs to extend
|
|
329
|
+
* it.
|
|
330
|
+
*
|
|
331
|
+
* ## `session/load` also activates the session, not just replays it — and Task 8 relies on that
|
|
332
|
+
*
|
|
333
|
+
* The `session/load` request below sends `mcpServers: []`. That is not incidental — `session/load`
|
|
334
|
+
* is not a read-only "give me the history" call: per its own spec, the agent restores this
|
|
335
|
+
* `sessionId` as its **current** session, the same way `session/new` would, just with history
|
|
336
|
+
* attached instead of starting empty.
|
|
337
|
+
*
|
|
338
|
+
* Task 8 resolved the fork this once handed forward: **Axom keeps prompting on
|
|
339
|
+
* `params.nativeSessionId`, the session `session/load` just activated**, rather than opening a fresh
|
|
340
|
+
* `session/new` and treating the replay as read-only history. `agentSessions.ts` receives a
|
|
341
|
+
* `nativeSessionId` on reconnect as well as on adoption (the server persists it and hands it back),
|
|
342
|
+
* so "open a new session and replay read-only" would silently drop the agent's own context on every
|
|
343
|
+
* single reconnect — a regression against the deleted Claude Code bridge, which really did resume.
|
|
344
|
+
* `AcpSession.attach` (`acpSession.ts`) is what that costs: the SDK's `ActiveSession` has a private
|
|
345
|
+
* constructor reachable only through `SessionBuilder.start()`, i.e. only through `session/new`, so
|
|
346
|
+
* driving an already-loaded `sessionId` means hand-building the same small `DrivenSession` surface
|
|
347
|
+
* around raw `session/prompt` / `session/cancel` requests and {@link ReplayCapture.listen}.
|
|
348
|
+
*
|
|
349
|
+
* `mcpServers: []` is consistent with the other path rather than a shortfall of this one:
|
|
350
|
+
* `AcpSession.open`'s `buildSession(cwd).start()` attaches no MCP servers either (`OpenSessionOptions`
|
|
351
|
+
* carries only `cwd`), because Axom does not configure MCP servers for external agents at all.
|
|
352
|
+
*/
|
|
353
|
+
export async function* replayNativeSession(connection, capture, params) {
|
|
354
|
+
if (!connection.capabilities.loadSession)
|
|
355
|
+
return;
|
|
356
|
+
// Arm before requesting — the agent may start sending session/update notifications for this
|
|
357
|
+
// sessionId as soon as it receives the request, and the capture's handler drops anything for a
|
|
358
|
+
// sessionId that isn't armed yet (see ReplayCapture.arm's doc comment).
|
|
359
|
+
capture.arm(params.nativeSessionId);
|
|
360
|
+
// The transport diverts updates for a session it does not know is handled here, because the SDK's
|
|
361
|
+
// router would otherwise drop them on the floor. During a replay this capture *is* the handler,
|
|
362
|
+
// so the claim has to be made explicitly — and withdrawn in the same `finally` that disarms.
|
|
363
|
+
const unmarkSession = connection.markSessionKnown(params.nativeSessionId);
|
|
364
|
+
let updates;
|
|
365
|
+
try {
|
|
366
|
+
const response = await connection.connection.agent.request(methods.agent.session.load, {
|
|
367
|
+
sessionId: params.nativeSessionId,
|
|
368
|
+
cwd: params.cwd,
|
|
369
|
+
mcpServers: [],
|
|
370
|
+
});
|
|
371
|
+
params.onLoaded?.(response);
|
|
372
|
+
// The response resolving is NOT the end of the replay — see {@link settleUpdates} for the SDK
|
|
373
|
+
// scheduling that makes `take()` on the spot return a partial history.
|
|
374
|
+
await settleUpdates(params.nativeSessionId, () => capture.count(params.nativeSessionId));
|
|
375
|
+
}
|
|
376
|
+
finally {
|
|
377
|
+
unmarkSession();
|
|
378
|
+
// In a `finally` because `arm()` has no other exit. A rejected `session/load` — a stale id the
|
|
379
|
+
// agent has since deleted, the case `AcpAgents.startSession` catches and falls back from — used
|
|
380
|
+
// to throw straight past `take()`, leaving the id armed and whatever had already been buffered
|
|
381
|
+
// for it held until the connection died. That contradicts this file's own claim that "the
|
|
382
|
+
// buffer's upper bound is whatever is currently being replayed": the fallback opens a *different*
|
|
383
|
+
// session id, so nothing would ever come back to take this one. `take()` is both halves at once
|
|
384
|
+
// (release the buffer, disarm), which is why the failure path calls the same function the
|
|
385
|
+
// success path does rather than a disarm of its own.
|
|
386
|
+
updates = capture.take(params.nativeSessionId);
|
|
387
|
+
}
|
|
388
|
+
for (const event of foldReplayEvents(updates)) {
|
|
389
|
+
const rejection = validateWireEvent(event);
|
|
390
|
+
if (rejection) {
|
|
391
|
+
console.error(`[acp] dropping a replayed event that fails validateWireEvent: ${rejection.reason} (session ${params.nativeSessionId})`);
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
yield event;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP authentication: `authenticate({ methodId })`.
|
|
3
|
+
*
|
|
4
|
+
* The method is `authenticate`, **not** `auth/login` — that spelling appears nowhere in
|
|
5
|
+
* `@agentclientprotocol/sdk@1.4.0`; the spec draft this plan started from said otherwise and has
|
|
6
|
+
* been corrected (see the plan's "已核实的 SDK 事实").
|
|
7
|
+
*
|
|
8
|
+
* `methodId` must come from the agent's own advertised `authMethods` (surfaced on `AcpConnection` by
|
|
9
|
+
* Task 3): the ACP spec's `AuthMethodTerminal` doc comment says the client "MUST NOT pass this
|
|
10
|
+
* method to `authenticate`" — a terminal method carries its own `args`/`env` for a TUI the client
|
|
11
|
+
* would have to run as a *separate interactive process*, not a JSON-RPC round trip. So
|
|
12
|
+
* {@link AcpAuth.authenticate} refuses two shapes of bad input before ever writing to the wire: a
|
|
13
|
+
* `methodId` the agent never advertised, and one it advertised as `type: 'terminal'`.
|
|
14
|
+
*
|
|
15
|
+
* ## No authentication *state* lives here, deliberately
|
|
16
|
+
*
|
|
17
|
+
* There is no registry-sourced auth state (the plan's "已核实的 registry 事实" confirms the registry
|
|
18
|
+
* carries no auth fields at all) and ACP v1 has no "am I authenticated?" query. The only positive
|
|
19
|
+
* evidence anywhere in this client is a session an agent accepted, which `acpAgents.ts` records as
|
|
20
|
+
* `AuthObservation.authenticated?: true` — **true or absent, never `false`**.
|
|
21
|
+
*
|
|
22
|
+
* This class used to keep a local flag alongside that: it started `false`, documented as "unknown /
|
|
23
|
+
* not-yet-authenticated is treated the same as not authenticated", and flipped on `authenticate()`.
|
|
24
|
+
* That sentence is the exact conflation the rest of this branch exists to prevent —
|
|
25
|
+
* `ExternalAgentAuthState` has two members and neither of them is "not authenticated",
|
|
26
|
+
* `ExternalAgentAuthBadge` renders only those two, and a banned-word scan keeps the phrasing out of
|
|
27
|
+
* the UI. An agent already holding a good long-lived credential on disk read `false` there, so
|
|
28
|
+
* anyone who ever wired it to the roster would have shown that user a "sign in again" prompt
|
|
29
|
+
* indistinguishable from a real one. Nothing read it, so it is gone rather than reworded, together
|
|
30
|
+
* with `canLogout`/`logout()` — whose only effect beyond one RPC was flipping that same flag.
|
|
31
|
+
*
|
|
32
|
+
* `logout` itself is still visible where it is a fact rather than a state: `acpClient.ts` reduces
|
|
33
|
+
* `agentCapabilities.auth.logout != null` onto `AcpConnection.capabilities.logout`, which is where
|
|
34
|
+
* a future caller with an actual use for it should start.
|
|
35
|
+
*/
|
|
36
|
+
import { methods } from '@agentclientprotocol/sdk';
|
|
37
|
+
/** `methodId` does not match any entry in `AcpConnection.authMethods` — the agent never advertised it. */
|
|
38
|
+
export class AuthMethodNotAdvertisedError extends Error {
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* `methodId` names a real advertised method, but its `type` is `'terminal'`. ACP requires the client
|
|
42
|
+
* to run those as a separate interactive process, never to pass them to `authenticate`.
|
|
43
|
+
*/
|
|
44
|
+
export class TerminalAuthMethodError extends Error {
|
|
45
|
+
}
|
|
46
|
+
function describeAdvertised(connection) {
|
|
47
|
+
return connection.authMethods.length > 0 ? connection.authMethods.map((method) => method.id).join(', ') : '(none)';
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The `authenticate` operation for one `AcpConnection`, and the two refusals that guard it. Holds no
|
|
51
|
+
* authentication state — see the file doc comment for why there is none to hold.
|
|
52
|
+
*/
|
|
53
|
+
export class AcpAuth {
|
|
54
|
+
connection;
|
|
55
|
+
constructor(connection) {
|
|
56
|
+
this.connection = connection;
|
|
57
|
+
}
|
|
58
|
+
/** The agent's advertised authentication methods, unchanged from `AcpConnection.authMethods`. */
|
|
59
|
+
get authMethods() {
|
|
60
|
+
return this.connection.authMethods;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Sends `authenticate({ methodId })` to the agent, after refusing two shapes of bad input the
|
|
64
|
+
* agent could not have asked for: a `methodId` it never advertised in `initialize`, and one it
|
|
65
|
+
* advertised as a terminal method (which ACP forbids passing here at all). Neither refusal talks
|
|
66
|
+
* to the agent — see `auth.test.ts` for how that is pinned (a sink file the fake agent only writes
|
|
67
|
+
* once it actually receives the request).
|
|
68
|
+
*/
|
|
69
|
+
async authenticate(methodId) {
|
|
70
|
+
const method = this.connection.authMethods.find((candidate) => candidate.id === methodId);
|
|
71
|
+
if (!method) {
|
|
72
|
+
throw new AuthMethodNotAdvertisedError(`Agent did not advertise auth method '${methodId}'. Advertised methods: ${describeAdvertised(this.connection)}.`);
|
|
73
|
+
}
|
|
74
|
+
if (method.type === 'terminal') {
|
|
75
|
+
throw new TerminalAuthMethodError(`Auth method '${methodId}' is a terminal method; ACP requires the client run it as a separate ` +
|
|
76
|
+
'interactive process instead of passing it to authenticate().');
|
|
77
|
+
}
|
|
78
|
+
await this.connection.connection.agent.request(methods.agent.authenticate, { methodId });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Catches a login URL an agent prints to stderr instead of delivering through
|
|
3
|
+
* `elicitation/create` — see this task's brief, spec §6.2. Measured on a real machine: with the
|
|
4
|
+
* client already declaring `elicitation.url` (`acpClient.ts`'s `clientCapabilities`), antigravity's
|
|
5
|
+
* `authenticate(oauth-personal)` sends no `elicitation/create` at all. It prints
|
|
6
|
+
* `"Open the following link to authenticate the ACP server: https://…"` to stderr and then blocks
|
|
7
|
+
* on a `localhost` callback. ACP does not require an agent to hand a URL to the client through
|
|
8
|
+
* elicitation, so this is a gap in the protocol, not a shortcut a particular agent took — and it
|
|
9
|
+
* means the elicitation channel alone leaves that agent's sign-in doing nothing when clicked.
|
|
10
|
+
*
|
|
11
|
+
* Global Constraint (this task's brief): the criterion may not name a vendor. No hostname, no
|
|
12
|
+
* domain, no agent id may appear here — a later mechanical scan checks the test suite for registry
|
|
13
|
+
* agent id literals. So the match is purely on the *shape* of the line: an absolute `https://` URL,
|
|
14
|
+
* plus wording that invites the reader to open it. Both conditions are required — an ordinary log
|
|
15
|
+
* line that happens to contain a URL (a settings path, a status report) must not match just because
|
|
16
|
+
* it has one, and a line that talks about opening something without ever naming a URL has nothing
|
|
17
|
+
* to hand out.
|
|
18
|
+
*/
|
|
19
|
+
/** Case-insensitive: the same verbs a login prompt actually uses to invite the click. */
|
|
20
|
+
const INVITATION_WORDS = /\b(?:open|visit|authenticate)\b/i;
|
|
21
|
+
/** An absolute `https://` URL, taken up to (not including) the next whitespace — never the prose around it. */
|
|
22
|
+
const ABSOLUTE_URL = /https:\/\/\S+/;
|
|
23
|
+
/**
|
|
24
|
+
* ANSI CSI escape sequences (`ESC [ <params> <final-letter>`, e.g. an SGR colour code like
|
|
25
|
+
* `\x1b[33m`). Stripped before matching anything else: `\bopen\b` finds no word boundary between
|
|
26
|
+
* the "33" and the "m" in `…\x1b[33mOpen…` — the escape sequence's own trailing letter reads as
|
|
27
|
+
* though it were fused onto the next word, so a coloured line misses entirely rather than
|
|
28
|
+
* partially. Most agents run non-interactively (no TTY) and suppress colour on their own, but not
|
|
29
|
+
* all of them do, and this is a silent total miss rather than a degraded match.
|
|
30
|
+
*/
|
|
31
|
+
// eslint-disable-next-line no-control-regex -- matching the literal ESC (0x1b) control character is the point
|
|
32
|
+
const ANSI_ESCAPE = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
33
|
+
/** Punctuation prose wraps a URL in (`<…>`, a trailing `.`, a comment in `(…)`) — never part of the URL itself. */
|
|
34
|
+
const TRAILING_DECORATION = /[)\]>'".,]+$/;
|
|
35
|
+
function stripAnsi(line) {
|
|
36
|
+
return line.replace(ANSI_ESCAPE, '');
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Returns the URL if `line` both invites the reader to open something and contains an absolute
|
|
40
|
+
* `https://` URL, `undefined` otherwise. Takes only the URL itself, not surrounding prose on either
|
|
41
|
+
* side — including punctuation prose glues directly onto the end of it (`<https://x.test/a>.`
|
|
42
|
+
* yields `https://x.test/a`, not `https://x.test/a>.`).
|
|
43
|
+
*/
|
|
44
|
+
export function sniffAuthUrl(line) {
|
|
45
|
+
const clean = stripAnsi(line);
|
|
46
|
+
if (!INVITATION_WORDS.test(clean))
|
|
47
|
+
return undefined;
|
|
48
|
+
const match = ABSOLUTE_URL.exec(clean);
|
|
49
|
+
if (!match)
|
|
50
|
+
return undefined;
|
|
51
|
+
return match[0].replace(TRAILING_DECORATION, '');
|
|
52
|
+
}
|