@platless/axom-cli 0.1.0 → 0.3.13
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 +1 -1
- package/dist/authScopes.js +19 -0
- package/dist/axom-patch/cli/README.md +110 -0
- package/dist/axom-patch/cli/pi-steering.json +37 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +34 -0
- package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +49 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +81 -0
- package/dist/axom-patch/cli/qoder-foreground-output.md +41 -0
- package/dist/axom-patch/cli/qoder-steering.json +52 -0
- package/dist/axom-patch/cli/qoder-steering.md +30 -0
- package/dist/axom-patch/cli/qoder-subagent-origin.json +73 -0
- package/dist/axom-patch/cli/qoder-subagent-subwork.md +73 -0
- package/dist/axom-patch/cli/runtime.json +37 -0
- package/dist/axom-patch/cli/steering/claude-steering-js.patch +89 -0
- package/dist/axom-patch/cli/steering/claude-steering-types.patch +28 -0
- package/dist/axom-patch/cli/steering/codex-steering.patch +57 -0
- package/dist/axom-patch/cli/steering/pi-steering.js +79 -0
- package/dist/cli.js +9 -1
- package/dist/commands/auth.js +4 -13
- package/dist/commands/execute.js +4 -4
- package/dist/commands/record.js +6 -6
- package/dist/commands/struct.js +4 -4
- package/dist/commands/workstation.js +2 -2
- package/dist/credentials.js +2 -2
- package/dist/install/claude/commands.js +13 -13
- package/dist/mcp.js +32 -14
- package/dist/workstation/acp/acpAgents.js +447 -166
- package/dist/workstation/acp/acpClient.js +232 -18
- package/dist/workstation/acp/acpSession.js +502 -74
- package/dist/workstation/acp/activityBusy.js +43 -0
- package/dist/workstation/acp/activityReporting.js +54 -0
- package/dist/workstation/acp/adoption.js +107 -49
- package/dist/workstation/acp/authUrlSniffer.js +36 -0
- package/dist/workstation/acp/contract.js +11 -6
- package/dist/workstation/acp/delegationMcp.js +24 -0
- package/dist/workstation/acp/draftInterceptor.js +9 -0
- package/dist/workstation/acp/managedPatches.js +228 -0
- package/dist/workstation/acp/patchAssets.js +63 -0
- package/dist/workstation/acp/permission.js +300 -34
- package/dist/workstation/acp/qoderStartup.js +45 -0
- package/dist/workstation/acp/registry/catalog.js +263 -4
- package/dist/workstation/acp/registry/detect.js +55 -0
- package/dist/workstation/acp/sessionScope.js +19 -0
- package/dist/workstation/acp/sessionUpdateMapper.js +39 -21
- package/dist/workstation/acp/terminalLogin.js +197 -0
- package/dist/workstation/acp/test/fakeAgent.js +185 -9
- package/dist/workstation/acp/validate.js +33 -5
- package/dist/workstation/agentSessions.js +532 -44
- package/dist/workstation/channels/agent.js +7 -1
- package/dist/workstation/channels/computer.js +152 -4
- package/dist/workstation/channels/management.js +3 -3
- package/dist/workstation/config.js +3 -1
- package/dist/workstation/connection/encryptedChannel.js +88 -21
- package/dist/workstation/daemon.js +25 -3
- package/dist/workstation/desktop/binding.js +36 -0
- package/dist/workstation/desktop/bridge.js +212 -0
- package/dist/workstation/desktop/flow-control.js +52 -0
- package/dist/workstation/desktop/focused-text.js +130 -0
- package/dist/workstation/desktop/protocol.js +35 -0
- package/dist/workstation/fileOps.js +277 -0
- package/dist/workstation/filePaths.js +174 -0
- package/dist/workstation/fileWorker.js +81 -0
- package/dist/workstation/fileWorkerClient.js +121 -0
- package/dist/workstation/oauth/deviceAuthorization.js +21 -12
- package/dist/workstation/protocol/agent.js +154 -5
- package/dist/workstation/protocol/common.js +6 -0
- package/dist/workstation/protocol/computer.js +92 -3
- package/dist/workstation/qoderAdapter/backgroundProcess.js +106 -0
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +262 -0
- package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
- package/dist/workstation/qoderAdapter/contract.js +78 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +30 -0
- package/dist/workstation/qoderAdapter/history.js +87 -0
- package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
- package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
- package/dist/workstation/qoderAdapter/main.js +93 -0
- package/dist/workstation/qoderAdapter/mapper.js +117 -0
- package/dist/workstation/qoderAdapter/observerPlugin.js +245 -0
- package/dist/workstation/qoderAdapter/observerReceiver.js +187 -0
- package/dist/workstation/qoderAdapter/permissions.js +61 -0
- package/dist/workstation/qoderAdapter/profile.js +93 -0
- package/dist/workstation/qoderAdapter/sdkDriver.js +49 -0
- package/dist/workstation/qoderAdapter/session.js +319 -0
- package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
- package/dist/workstation/qoderAdapter/subwork.js +77 -0
- package/dist/workstation/qoderAdapter/taskControl.js +38 -0
- package/dist/workstation/qoderAdapter/taskEvent.js +26 -0
- package/dist/workstation/qoderAdapter/taskLedger.js +163 -0
- package/dist/workstation/qoderAdapter/types.js +1 -0
- package/dist/workstation/qoderAdapter/usage.js +37 -0
- package/dist/workstation/register.js +4 -0
- package/dist/workstation/tools/browser.js +394 -0
- package/dist/workstation/tools/client.js +269 -0
- package/dist/workstation/tools/core.js +50 -0
- package/dist/workstation/tools/files.js +525 -0
- package/dist/workstation/tools/keeperProcess.js +114 -0
- package/dist/workstation/tools/terminal.js +214 -0
- package/dist/workstation/tools/validation.js +50 -0
- package/dist/workstation/update/coordinator.js +29 -0
- package/dist/workstation/update/npmInstaller.js +2 -0
- package/dist/workstation/update/ptyPreflight.js +24 -0
- package/dist/workstation/update/taskRegistry.js +44 -20
- package/dist/workstation/update/watchdog.js +66 -0
- package/package.json +14 -4
- package/skills/axom/SKILL.md +55 -17
- package/skills/axom-definition/SKILL.md +32 -32
- package/skills/axom-platform/SKILL.md +39 -39
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「这条会话的 agent 自己说它在忙吗」——一个**弱**判据,不是归一化器。
|
|
3
|
+
*
|
|
4
|
+
* 真正的 vendor 原文 → 规范三态只发生在服务端 `axom-core` 的 `agentActivityOf`
|
|
5
|
+
* (前序 spec 裁决 L1:归一化只在一处)。这里要回答的问题窄得多,答案只有两种,
|
|
6
|
+
* 而且两边对「读不懂」的处置是同一条推理:服务端答 `null`(保留上一个已知状态,不写库),
|
|
7
|
+
* 这里答 `false`(本来源不主张忙碌,判据退回 `liveness.turnRunning`)。两边都不把未知
|
|
8
|
+
* 伪装成已知——答 `true` 会造出一个永不消失的假忙碌,正是本轮要消灭的东西。
|
|
9
|
+
*
|
|
10
|
+
* 两个键分属两套词汇表(claude 的 `state`、codex 的 `type`),**不做或运算**:
|
|
11
|
+
* `state` 在就只看 `state`。混读等于承认一份原文可以同时是两种方言。
|
|
12
|
+
*/
|
|
13
|
+
export function activityIsBusy(raw) {
|
|
14
|
+
if (!raw)
|
|
15
|
+
return false;
|
|
16
|
+
if (typeof raw.state === 'string')
|
|
17
|
+
return raw.state === 'running' || raw.state === 'requires_action';
|
|
18
|
+
if (typeof raw.type === 'string')
|
|
19
|
+
return raw.type === 'active' && !codexBlockedOnHumanOnly(raw);
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* codex 的 `activeFlags` 里表示「agent 正被人挡住、没有在算」的标记。
|
|
24
|
+
*
|
|
25
|
+
* **和会话状态问的不是同一个问题。** 服务端的 `agentActivityOf` 要答「vendor 说它在干什么」,
|
|
26
|
+
* 这里要答「排干时有没有在途工作会被重启破坏」。一条停在审批卡或提问上的会话,两个答案不同:
|
|
27
|
+
* 它确实处在 vendor 说的 `active`,但**没有任何在途工作**,重启它不损失什么。把这种会话算成忙,
|
|
28
|
+
* 会让一次优雅更新白等满 30 分钟排干再报 BUSY —— 正是本分支要消灭的那个形状,只是换了个入口。
|
|
29
|
+
* 2026-09-21 在真机上抓到的第一条 codex 原文就是这一种:
|
|
30
|
+
* `{"type":"active","activeFlags":["waitingOnUserInput"]}`。
|
|
31
|
+
*
|
|
32
|
+
* 值域的取证、证据等级,以及「不认识的标记为什么保持忙」的理由,见 `axom-core` 的
|
|
33
|
+
* `AgentActivityMapping.kt` 里 `CODEX_BLOCKED_ON_HUMAN_FLAGS` 的 KDoc —— 那里是权威说明,
|
|
34
|
+
* 这两处必须同时改,所以判据故意写成同一个形状(非空 + 每一项都认得)。
|
|
35
|
+
*/
|
|
36
|
+
const CODEX_BLOCKED_ON_HUMAN_FLAGS = new Set(['waitingOnApproval', 'waitingOnUserInput']);
|
|
37
|
+
/** `activeFlags` 非空**且每一项**都在 {@link CODEX_BLOCKED_ON_HUMAN_FLAGS} 里。 */
|
|
38
|
+
function codexBlockedOnHumanOnly(raw) {
|
|
39
|
+
const flags = raw.activeFlags;
|
|
40
|
+
if (!Array.isArray(flags) || flags.length === 0)
|
|
41
|
+
return false;
|
|
42
|
+
return flags.every((flag) => typeof flag === 'string' && CODEX_BLOCKED_ON_HUMAN_FLAGS.has(flag));
|
|
43
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 两条厂商特定的活动状态采集路径。**只做搬运,不做归一化**——vendor→规范三态的映射
|
|
3
|
+
* 只发生在服务端 ingest 一处(裁决 L1:normalise in one place, server ingest)。
|
|
4
|
+
*
|
|
5
|
+
* 判 agent 用 `InitializeResponse.agentInfo.name`:ACP 的 `Implementation.name` 文档自述
|
|
6
|
+
* "intended for programmatic or logical use",而且用户改不了它(`agentName` 是工作站配置里的
|
|
7
|
+
* 条目名,随时可以改)。
|
|
8
|
+
*/
|
|
9
|
+
const CLAUDE_AGENT = '@agentclientprotocol/claude-agent-acp';
|
|
10
|
+
const CODEX_AGENT = '@agentclientprotocol/codex-acp';
|
|
11
|
+
/** 这个 agent 有没有配套的活动状态采集器;按 InitializeResponse.agentInfo.name 判。 */
|
|
12
|
+
export function reportsActivity(agentName) {
|
|
13
|
+
return agentName === CLAUDE_AGENT || agentName === CODEX_AGENT;
|
|
14
|
+
}
|
|
15
|
+
/** Certified AIR task-stop compatibility, independent of activity collection. These adapters
|
|
16
|
+
* implement `_session/async_task/stop`; this is NOT a native child-session cancel declaration.
|
|
17
|
+
* Keep this separate even while the certified provider sets happen to coincide.
|
|
18
|
+
*/
|
|
19
|
+
export function supportsAsyncTaskStop(agentName) {
|
|
20
|
+
return agentName === CLAUDE_AGENT || agentName === CODEX_AGENT;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* claude-agent-acp 的 `emitRawSDKMessages`(`acp-agent.d.ts:645`,0.75.1 起存在):
|
|
24
|
+
* 命中过滤器的原始 SDK 消息会以 `extNotification("_claude/sdkMessage", …)` 推回来。
|
|
25
|
+
* 只要状态那一种——过滤器是 `SDKMessageFilter { type, subtype? }`(`:609`)。
|
|
26
|
+
*/
|
|
27
|
+
export function newSessionMetaFor(agentName) {
|
|
28
|
+
if (agentName !== CLAUDE_AGENT)
|
|
29
|
+
return undefined;
|
|
30
|
+
return { claudeCode: { emitRawSDKMessages: [{ type: 'system', subtype: 'session_state_changed' }] } };
|
|
31
|
+
}
|
|
32
|
+
function asObject(value) {
|
|
33
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
34
|
+
? value
|
|
35
|
+
: null;
|
|
36
|
+
}
|
|
37
|
+
/** claude:把 _claude/sdkMessage 的 params 提成 vendor 原文,非状态消息返回 null。 */
|
|
38
|
+
export function claudeActivityRaw(params) {
|
|
39
|
+
const message = asObject(params?.message);
|
|
40
|
+
if (!message)
|
|
41
|
+
return null;
|
|
42
|
+
if (message.type !== 'system' || message.subtype !== 'session_state_changed')
|
|
43
|
+
return null;
|
|
44
|
+
// 整条消息原样带走,不挑字段:服务端要的是证据,而将来 SDK 给这条消息加字段时
|
|
45
|
+
// 这里不该是需要改的地方。
|
|
46
|
+
return message;
|
|
47
|
+
}
|
|
48
|
+
/** codex:把 session_info_update 的 _meta 提成 vendor 原文,没有状态返回 null。 */
|
|
49
|
+
export function codexActivityRaw(meta) {
|
|
50
|
+
const codex = asObject(meta?.codex);
|
|
51
|
+
if (!codex)
|
|
52
|
+
return null;
|
|
53
|
+
return asObject(codex.threadStatus);
|
|
54
|
+
}
|
|
@@ -82,9 +82,13 @@
|
|
|
82
82
|
* `user_message_chunk` now passes through here untouched, and `ExternalAgentEventApplier`'s
|
|
83
83
|
* `onBackfillDone` is what closes the last buffered message.
|
|
84
84
|
*/
|
|
85
|
+
import { randomUUID } from 'node:crypto';
|
|
85
86
|
import { methods } from '@agentclientprotocol/sdk';
|
|
86
87
|
import { validateWireEvent } from './validate.js';
|
|
87
|
-
import {
|
|
88
|
+
import { SessionMapperScopes, scopeKey } from './sessionScope.js';
|
|
89
|
+
import { mapSessionUpdate } from './sessionUpdateMapper.js';
|
|
90
|
+
import { newSessionMetaFor } from './activityReporting.js';
|
|
91
|
+
import { buildSessionMcpServers } from './delegationMcp.js';
|
|
88
92
|
/** `NativeSessionInfo.title` is truncated to this many characters — mirrors the deleted `claudeCode/adoption.ts`'s display-safety guard (`TITLE_MAX_LENGTH`). */
|
|
89
93
|
const TITLE_MAX_LENGTH = 80;
|
|
90
94
|
/** `SessionInfo.updatedAt` is an ISO 8601 string or `null`/absent; missing or unparseable sorts as oldest (0) rather than throwing. */
|
|
@@ -94,6 +98,24 @@ function parseUpdatedAt(value) {
|
|
|
94
98
|
const parsed = Date.parse(value);
|
|
95
99
|
return Number.isNaN(parsed) ? 0 : parsed;
|
|
96
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* `session/delete` per id. Only when the agent advertised `sessionCapabilities.delete` — ACP forbids calling an unadvertised method. Codex's delete is an archive (`thread/archive`); that is still "gone from session/list", which is all the caller needs.
|
|
103
|
+
*/
|
|
104
|
+
export async function deleteNativeSessions(connection, ids) {
|
|
105
|
+
if (!connection.capabilities.delete)
|
|
106
|
+
return ids.map((nativeSessionId) => ({ nativeSessionId, outcome: 'unsupported' }));
|
|
107
|
+
const results = [];
|
|
108
|
+
for (const nativeSessionId of ids) {
|
|
109
|
+
try {
|
|
110
|
+
await connection.connection.agent.request(methods.agent.session.delete, { sessionId: nativeSessionId });
|
|
111
|
+
results.push({ nativeSessionId, outcome: 'deleted' });
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
results.push({ nativeSessionId, outcome: 'failed', error: error instanceof Error ? error.message : String(error) });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return results;
|
|
118
|
+
}
|
|
97
119
|
/**
|
|
98
120
|
* List an agent's native sessions scoped to `query.cwd`, newest first.
|
|
99
121
|
*
|
|
@@ -126,17 +148,29 @@ export function createReplayCapture() {
|
|
|
126
148
|
const armed = new Set();
|
|
127
149
|
const buffered = new Map();
|
|
128
150
|
const listeners = new Map();
|
|
151
|
+
const takeScoped = (sessionId) => {
|
|
152
|
+
armed.delete(sessionId);
|
|
153
|
+
const list = buffered.get(sessionId) ?? [];
|
|
154
|
+
buffered.delete(sessionId);
|
|
155
|
+
return list;
|
|
156
|
+
};
|
|
157
|
+
const capture = (sessionId, notification) => {
|
|
158
|
+
if (!armed.has(sessionId))
|
|
159
|
+
return false;
|
|
160
|
+
const list = buffered.get(sessionId) ?? [];
|
|
161
|
+
list.push(notification);
|
|
162
|
+
buffered.set(sessionId, list);
|
|
163
|
+
return true;
|
|
164
|
+
};
|
|
129
165
|
return {
|
|
166
|
+
capture,
|
|
167
|
+
takeScoped,
|
|
130
168
|
register(app) {
|
|
131
169
|
app.onNotification(methods.client.session.update, (ctx) => {
|
|
132
170
|
// Armed wins over listening: while a session/load replay is in flight, its history must land
|
|
133
171
|
// in the buffer rather than in a live session's read loop — see `listen`'s doc comment.
|
|
134
|
-
if (
|
|
135
|
-
const list = buffered.get(ctx.params.sessionId) ?? [];
|
|
136
|
-
list.push(ctx.params.update);
|
|
137
|
-
buffered.set(ctx.params.sessionId, list);
|
|
172
|
+
if (capture(ctx.params.sessionId, ctx.params))
|
|
138
173
|
return;
|
|
139
|
-
}
|
|
140
174
|
// Neither armed nor listened to: dropped, so this handler cannot accumulate every session
|
|
141
175
|
// the connection has ever seen an update for (see the file doc comment).
|
|
142
176
|
listeners.get(ctx.params.sessionId)?.(ctx.params.update);
|
|
@@ -146,10 +180,7 @@ export function createReplayCapture() {
|
|
|
146
180
|
armed.add(sessionId);
|
|
147
181
|
},
|
|
148
182
|
take(sessionId) {
|
|
149
|
-
|
|
150
|
-
const list = buffered.get(sessionId) ?? [];
|
|
151
|
-
buffered.delete(sessionId);
|
|
152
|
-
return list;
|
|
183
|
+
return takeScoped(sessionId).map(({ update }) => update);
|
|
153
184
|
},
|
|
154
185
|
count(sessionId) {
|
|
155
186
|
return buffered.get(sessionId)?.length ?? 0;
|
|
@@ -191,45 +222,43 @@ function stringField(payload, key) {
|
|
|
191
222
|
* against the real `session/load` response).
|
|
192
223
|
*/
|
|
193
224
|
export function foldReplayEvents(updates) {
|
|
225
|
+
return foldScopedReplayEvents(updates.map((update) => ({ sessionId: 'root', update })), 'root');
|
|
226
|
+
}
|
|
227
|
+
/** Fold each origin independently, without reordering interleaved native notifications. */
|
|
228
|
+
export function foldScopedReplayEvents(updates, rootNativeSessionId) {
|
|
194
229
|
const events = [];
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
230
|
+
const scopes = new SessionMapperScopes();
|
|
231
|
+
const rootRuntimeId = randomUUID();
|
|
232
|
+
const messages = new Map();
|
|
233
|
+
const emit = (event, nativeSessionId) => {
|
|
234
|
+
events.push(nativeSessionId === rootNativeSessionId ? event : { ...event, acpSessionId: nativeSessionId });
|
|
235
|
+
};
|
|
236
|
+
const flush = (key) => {
|
|
237
|
+
const message = messages.get(key);
|
|
238
|
+
if (!message)
|
|
203
239
|
return;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
}
|
|
240
|
+
emit({ eventName: 'agent_message', payload: { messageId: message.messageId ?? 'replay' } }, message.nativeSessionId);
|
|
241
|
+
messages.delete(key);
|
|
242
|
+
};
|
|
243
|
+
for (const { sessionId: nativeSessionId, update } of updates) {
|
|
244
|
+
const origin = { rootRuntimeId, nativeSessionId };
|
|
245
|
+
const key = scopeKey(origin);
|
|
246
|
+
for (const event of mapSessionUpdate(update, scopes.get(origin))) {
|
|
247
|
+
if (event.eventName === 'user_message_chunk')
|
|
248
|
+
flush(key);
|
|
220
249
|
if (event.eventName === 'agent_message_chunk' || event.eventName === 'agent_thought_chunk') {
|
|
221
250
|
const messageId = stringField(event.payload, 'messageId');
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
if (messageId !== null)
|
|
227
|
-
currentMessageId = messageId;
|
|
251
|
+
const previous = messages.get(key);
|
|
252
|
+
if (previous && messageId !== null && previous.messageId !== null && messageId !== previous.messageId)
|
|
253
|
+
flush(key);
|
|
254
|
+
messages.set(key, { nativeSessionId, messageId: messageId ?? messages.get(key)?.messageId ?? null });
|
|
228
255
|
}
|
|
229
|
-
|
|
256
|
+
emit(event, nativeSessionId);
|
|
230
257
|
}
|
|
231
258
|
}
|
|
232
|
-
|
|
259
|
+
for (const key of messages.keys())
|
|
260
|
+
flush(key);
|
|
261
|
+
scopes.clear();
|
|
233
262
|
return events;
|
|
234
263
|
}
|
|
235
264
|
/**
|
|
@@ -330,7 +359,7 @@ export async function settleUpdates(sessionId, count, maxRounds = 10) {
|
|
|
330
359
|
*
|
|
331
360
|
* ## `session/load` also activates the session, not just replays it — and Task 8 relies on that
|
|
332
361
|
*
|
|
333
|
-
* The `session/load` request below sends
|
|
362
|
+
* The `session/load` request below sends the current MCP list. That is not incidental — `session/load`
|
|
334
363
|
* is not a read-only "give me the history" call: per its own spec, the agent restores this
|
|
335
364
|
* `sessionId` as its **current** session, the same way `session/new` would, just with history
|
|
336
365
|
* attached instead of starting empty.
|
|
@@ -346,13 +375,32 @@ export async function settleUpdates(sessionId, count, maxRounds = 10) {
|
|
|
346
375
|
* driving an already-loaded `sessionId` means hand-building the same small `DrivenSession` surface
|
|
347
376
|
* around raw `session/prompt` / `session/cancel` requests and {@link ReplayCapture.listen}.
|
|
348
377
|
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
378
|
+
* Both paths use `buildSessionMcpServers`, so an adopted or reconnected session receives the same
|
|
379
|
+
* user servers and a freshly issued delegation lease when managed delegation is enabled.
|
|
380
|
+
*
|
|
381
|
+
* ## Also opts claude into activity reporting, the same as `session/new` — closing a real gap, not a cosmetic one
|
|
382
|
+
*
|
|
383
|
+
* `AcpSession.open`'s `session/new` request carries `_meta.claudeCode.emitRawSDKMessages`
|
|
384
|
+
* (`newSessionMetaFor`, `activityReporting.ts`) so claude's collector pushes
|
|
385
|
+
* `extNotification("_claude/sdkMessage", …)` back on the connection. `session/load` used to send no
|
|
386
|
+
* `_meta` at all, so every session reached through this function — every adoption, and every
|
|
387
|
+
* reconnect after a daemon restart, since a fresh child process's session table is always empty and
|
|
388
|
+
* therefore always takes the `createSession` branch of `getOrCreateSession` — permanently lost
|
|
389
|
+
* activity reporting the moment it was (re)loaded, while `agent_opened.reportsActivity` kept
|
|
390
|
+
* claiming `true`. Verified directly against the installed `claude-agent-acp@0.79.0` adapter
|
|
391
|
+
* (`dist/acp-agent.js`): `loadSession` → `getOrCreateSession` → (no matching in-process session, the
|
|
392
|
+
* case that always applies to a freshly spawned agent) → `createSession({ …, _meta: params._meta },
|
|
393
|
+
* { resume: params.sessionId })`, and `createSession` reads
|
|
394
|
+
* `sessionMeta?.claudeCode?.emitRawSDKMessages` off that same `_meta` exactly as `session/new` does.
|
|
395
|
+
* `newSessionMetaFor` is reused verbatim rather than re-implementing the same agent-name check here,
|
|
396
|
+
* so there is exactly one place that decides which agents opt in.
|
|
352
397
|
*/
|
|
353
398
|
export async function* replayNativeSession(connection, capture, params) {
|
|
354
399
|
if (!connection.capabilities.loadSession)
|
|
355
400
|
return;
|
|
401
|
+
// Exclusive ownership must precede every mutation of the shared capture/known-session state.
|
|
402
|
+
// A rejected concurrent load must not consume or disarm the active owner's buffer.
|
|
403
|
+
const releaseCapture = connection.captureSessionUpdates((notification) => capture.capture(params.nativeSessionId, notification));
|
|
356
404
|
// Arm before requesting — the agent may start sending session/update notifications for this
|
|
357
405
|
// sessionId as soon as it receives the request, and the capture's handler drops anything for a
|
|
358
406
|
// sessionId that isn't armed yet (see ReplayCapture.arm's doc comment).
|
|
@@ -361,12 +409,21 @@ export async function* replayNativeSession(connection, capture, params) {
|
|
|
361
409
|
// router would otherwise drop them on the floor. During a replay this capture *is* the handler,
|
|
362
410
|
// so the claim has to be made explicitly — and withdrawn in the same `finally` that disarms.
|
|
363
411
|
const unmarkSession = connection.markSessionKnown(params.nativeSessionId);
|
|
412
|
+
// Same opt-in `session/new` sends, on the request that actually reaches the agent for every
|
|
413
|
+
// adoption/reconnect — see the doc comment above. `undefined` for every non-claude agent, in which
|
|
414
|
+
// case the spread below adds nothing, so a non-claude `session/load` is byte-for-byte the request
|
|
415
|
+
// it always sent.
|
|
416
|
+
const meta = newSessionMetaFor(connection.agentInfo?.name);
|
|
364
417
|
let updates;
|
|
365
418
|
try {
|
|
419
|
+
const mcpServers = buildSessionMcpServers(params.mcpServers, params.delegationMcp);
|
|
420
|
+
if (params.delegationMcp)
|
|
421
|
+
connection.redactStderrSecret(Object.values(params.delegationMcp.headers)[0]);
|
|
366
422
|
const response = await connection.connection.agent.request(methods.agent.session.load, {
|
|
367
423
|
sessionId: params.nativeSessionId,
|
|
368
424
|
cwd: params.cwd,
|
|
369
|
-
mcpServers
|
|
425
|
+
mcpServers,
|
|
426
|
+
...(meta ? { _meta: meta } : {}),
|
|
370
427
|
});
|
|
371
428
|
params.onLoaded?.(response);
|
|
372
429
|
// The response resolving is NOT the end of the replay — see {@link settleUpdates} for the SDK
|
|
@@ -374,6 +431,7 @@ export async function* replayNativeSession(connection, capture, params) {
|
|
|
374
431
|
await settleUpdates(params.nativeSessionId, () => capture.count(params.nativeSessionId));
|
|
375
432
|
}
|
|
376
433
|
finally {
|
|
434
|
+
releaseCapture();
|
|
377
435
|
unmarkSession();
|
|
378
436
|
// In a `finally` because `arm()` has no other exit. A rejected `session/load` — a stale id the
|
|
379
437
|
// agent has since deleted, the case `AcpAgents.startSession` catches and falls back from — used
|
|
@@ -383,9 +441,9 @@ export async function* replayNativeSession(connection, capture, params) {
|
|
|
383
441
|
// session id, so nothing would ever come back to take this one. `take()` is both halves at once
|
|
384
442
|
// (release the buffer, disarm), which is why the failure path calls the same function the
|
|
385
443
|
// success path does rather than a disarm of its own.
|
|
386
|
-
updates = capture.
|
|
444
|
+
updates = capture.takeScoped(params.nativeSessionId);
|
|
387
445
|
}
|
|
388
|
-
for (const event of
|
|
446
|
+
for (const event of foldScopedReplayEvents(updates, params.nativeSessionId)) {
|
|
389
447
|
const rejection = validateWireEvent(event);
|
|
390
448
|
if (rejection) {
|
|
391
449
|
console.error(`[acp] dropping a replayed event that fails validateWireEvent: ${rejection.reason} (session ${params.nativeSessionId})`);
|
|
@@ -50,3 +50,39 @@ export function sniffAuthUrl(line) {
|
|
|
50
50
|
return undefined;
|
|
51
51
|
return match[0].replace(TRAILING_DECORATION, '');
|
|
52
52
|
}
|
|
53
|
+
/** How many lines after an invitation a URL may still arrive on. Two covers the measured shape with one spare. */
|
|
54
|
+
const INVITATION_REACH = 2;
|
|
55
|
+
/**
|
|
56
|
+
* {@link sniffAuthUrl} with one line of memory, for a login process's own output.
|
|
57
|
+
*
|
|
58
|
+
* A device-code login measured on a real machine prints the invitation and the URL on **adjacent**
|
|
59
|
+
* lines (`Please visit the following URL…` / `Verification URL: https://…`), which the single-line
|
|
60
|
+
* rule misses entirely. This remembers an invitation for {@link INVITATION_REACH} lines.
|
|
61
|
+
*
|
|
62
|
+
* Deliberately **not** used by the stderr sniffer on login connections: that one covers an `npx`
|
|
63
|
+
* install's output too, where widening the match would pick up documentation links. This one is only
|
|
64
|
+
* ever pointed at a process whose whole job is to sign in.
|
|
65
|
+
*/
|
|
66
|
+
export function createAuthUrlScanner() {
|
|
67
|
+
let reach = 0;
|
|
68
|
+
return (line) => {
|
|
69
|
+
const direct = sniffAuthUrl(line);
|
|
70
|
+
if (direct) {
|
|
71
|
+
reach = 0;
|
|
72
|
+
return direct;
|
|
73
|
+
}
|
|
74
|
+
const clean = stripAnsi(line);
|
|
75
|
+
if (INVITATION_WORDS.test(clean)) {
|
|
76
|
+
reach = INVITATION_REACH;
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
if (reach === 0)
|
|
80
|
+
return undefined;
|
|
81
|
+
reach -= 1;
|
|
82
|
+
const match = ABSOLUTE_URL.exec(clean);
|
|
83
|
+
if (!match)
|
|
84
|
+
return undefined;
|
|
85
|
+
reach = 0;
|
|
86
|
+
return match[0].replace(TRAILING_DECORATION, '');
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -355,12 +355,17 @@ export const acpContractChecks = [
|
|
|
355
355
|
// `AcpSession.prompt`'s own doc comment lists an overlapping call as an expected case, and
|
|
356
356
|
// ACP itself allows it — the SDK's `ActiveSession.prompt` enqueues a `stop` message per call,
|
|
357
357
|
// so two prompts really do produce two completions on the read queue. Everything downstream
|
|
358
|
-
// is built on one result per prompt: `
|
|
359
|
-
// `
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
// the
|
|
358
|
+
// is built on one result per prompt: each `prompt()` call pushes its own entry onto
|
|
359
|
+
// `AcpSession.inFlightTurns`, `finishTurn` pops exactly one entry per completion it sees (not
|
|
360
|
+
// "the" entry, "an" entry — see its own doc), and `agentSessions.ts`'s `onAgentTurnDone`
|
|
361
|
+
// fires once per `finishTurn` call and emits one `agent_turn_done` frame each time (2026-09-21
|
|
362
|
+
// rewrite; there is no longer a `tasks.agentTurnStarted`/`agentTurnFinished` bracket in the
|
|
363
|
+
// middle of that path, but the one-result-per-prompt pairing itself is unchanged — it was
|
|
364
|
+
// always `inFlightTurns`' push/pop symmetry, not the bracket, that guaranteed it). The
|
|
365
|
+
// server's run stays parked until a `turn_done` arrives, and `agent_message` — the event that
|
|
366
|
+
// flushes the server's delta buffer and attaches the turn's usage figures — is synthesized
|
|
367
|
+
// only as a turn ends. A dropped second result therefore does not merely lose a status line:
|
|
368
|
+
// it strands the second turn's text and parks the run indefinitely.
|
|
364
369
|
//
|
|
365
370
|
// Driven through `driveOneTurn` twice concurrently rather than two bare `prompt()` calls so a
|
|
366
371
|
// real adapter's hook (which may need to say something specific to get output) is what decides
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export function buildSessionMcpServers(userServers = [], lease) {
|
|
2
|
+
if (!lease)
|
|
3
|
+
return [...userServers];
|
|
4
|
+
const fail = () => { throw new Error('Delegation MCP configuration is unavailable'); };
|
|
5
|
+
let url;
|
|
6
|
+
try {
|
|
7
|
+
url = new URL(lease.url);
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return fail();
|
|
11
|
+
}
|
|
12
|
+
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash ||
|
|
13
|
+
url.pathname !== '/mcp/delegation' || !Number.isSafeInteger(lease.expiresAtEpochMs) ||
|
|
14
|
+
lease.expiresAtEpochMs <= Date.now() || userServers.some((server) => server.name === 'axom-delegation'))
|
|
15
|
+
return fail();
|
|
16
|
+
const headers = Object.entries(lease.headers);
|
|
17
|
+
if (headers.length !== 1 || headers[0][0].toLowerCase() !== 'authorization' ||
|
|
18
|
+
!/^Bearer [A-Za-z0-9_-]+$/.test(headers[0][1]))
|
|
19
|
+
return fail();
|
|
20
|
+
return [...userServers, {
|
|
21
|
+
type: 'http', name: 'axom-delegation', url: url.toString(),
|
|
22
|
+
headers: headers.map(([name, value]) => ({ name, value })),
|
|
23
|
+
}];
|
|
24
|
+
}
|
|
@@ -104,6 +104,15 @@ export function interceptDraftUpdates(source, options) {
|
|
|
104
104
|
enqueue(encoder.encode(`${line}\n`));
|
|
105
105
|
return;
|
|
106
106
|
}
|
|
107
|
+
if (options.captureUpdate && typeof parsed === 'object' && parsed !== null) {
|
|
108
|
+
const message = parsed;
|
|
109
|
+
const params = message.params;
|
|
110
|
+
if (message.method === SESSION_UPDATE_METHOD && !('id' in message) &&
|
|
111
|
+
params && typeof params.sessionId === 'string' && params.sessionId.length > 0 &&
|
|
112
|
+
typeof params.update === 'object' && params.update !== null &&
|
|
113
|
+
options.captureUpdate(params))
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
107
116
|
const diverted = diversionReasonFor(parsed, options.isOwnSession);
|
|
108
117
|
if (!diverted) {
|
|
109
118
|
// The original text, not a re-serialisation of the parse — see the doc comment.
|