@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
|
@@ -1,17 +1,57 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
/**
|
|
3
|
+
* The daemon's single entry point into ACP — what `agentSessions.ts` holds where it used to hold a
|
|
4
|
+
* `Map<string, AgentBridge>`.
|
|
5
|
+
*
|
|
6
|
+
* ## What replaced what
|
|
7
|
+
*
|
|
8
|
+
* The deleted bridge SPI dispatched on a *bridge type* (`claude-code` | `codex` | `acp`) to one of
|
|
9
|
+
* several implementations. There is only one implementation now, so there is nothing to dispatch to:
|
|
10
|
+
* ruling R1 deleted the interface rather than leaving an extension point with a single member. What
|
|
11
|
+
* varies per agent is no longer code but **data** — the launch spec — so the key is a **registry
|
|
12
|
+
* agent id** (`claude-acp`, `codex-acp`, …) and this class turns that id into a running subprocess
|
|
13
|
+
* by way of the ACP agent registry (`registry/`), `connectAgent` (`acpClient.ts`) and `AcpSession`
|
|
14
|
+
* (`acpSession.ts`).
|
|
15
|
+
*
|
|
16
|
+
* ## One connection per session, deliberately
|
|
17
|
+
*
|
|
18
|
+
* Every {@link AcpAgents.open} spawns its own agent subprocess and its own `AcpConnection`, and
|
|
19
|
+
* {@link AcpAgentSession.close} tears both down together. Pooling one connection across sessions
|
|
20
|
+
* would be cheaper and is exactly what `permission.ts`'s `PermissionSink` doc comment warns against:
|
|
21
|
+
* `registerPermissionHandlers` binds one sink per connection with no per-session routing, so two
|
|
22
|
+
* sessions sharing a connection would cross-deliver permission cards, and `createDeferredPermissionSink`
|
|
23
|
+
* would throw on the second `bind()`. The one-subprocess-per-session shape is also what the deleted
|
|
24
|
+
* bridges had, so nothing downstream had to change.
|
|
25
|
+
*
|
|
26
|
+
* ## Config overrides beat the registry (ruling R3)
|
|
27
|
+
*
|
|
28
|
+
* A workstation's agent config block may carry `command`/`args`. When it does, that is the whole
|
|
29
|
+
* launch spec — the registry is not consulted at all, and no registry-supplied `env` is merged in.
|
|
30
|
+
* This is the escape hatch for an agent that is not in the registry, a locally built adapter, or a
|
|
31
|
+
* wrapper script; it is also what lets this module's own tests drive a real ACP subprocess without
|
|
32
|
+
* reaching the network or running `npx`.
|
|
33
|
+
*/
|
|
34
|
+
import { methods } from '@agentclientprotocol/sdk';
|
|
35
|
+
import { certifiedRuntime } from '../qoderAdapter/foregroundOutput.js';
|
|
1
36
|
import path from 'node:path';
|
|
37
|
+
import { randomUUID } from 'node:crypto';
|
|
2
38
|
import semver from 'semver';
|
|
3
39
|
import { connectAgent, isMissingPackageVersion, jsonRpcErrorCode, stderrShowsMissingPackageVersion } from './acpClient.js';
|
|
40
|
+
import { runTerminalLogin, terminalInvocationFor } from './terminalLogin.js';
|
|
4
41
|
import { AcpSession } from './acpSession.js';
|
|
5
|
-
import { createReplayCapture, listNativeSessions, replayNativeSession } from './adoption.js';
|
|
42
|
+
import { createReplayCapture, deleteNativeSessions, listNativeSessions, replayNativeSession } from './adoption.js';
|
|
6
43
|
import { AcpAuth } from './auth.js';
|
|
7
44
|
import { binaryTargetFor, installBinary, installedBinaryVersion, platformTriple } from './binaryInstall.js';
|
|
8
45
|
import { normalizeConfigOptions } from './configOptions.js';
|
|
46
|
+
import { prepareQoderStartup, QODER_MANAGED_VERSION } from './qoderStartup.js';
|
|
9
47
|
import { friendlyOrdinaryInstallError, friendlyPinnedVersionUnavailableAndFallbackFailed, logInstallFailureDetail, } from './installErrors.js';
|
|
10
|
-
import { ADAPTER_INSTALL_TIMEOUT_MS,
|
|
48
|
+
import { ADAPTER_INSTALL_TIMEOUT_MS, installNpxPackage, npmPackageName, npxCacheVersion, probeEnvironment, readNpxCache, runDependencyInstall, } from './installProbe.js';
|
|
11
49
|
import { createDeferredPermissionSink, registerPermissionHandlers } from './permission.js';
|
|
12
|
-
import { catalogEntryFor } from './registry/catalog.js';
|
|
50
|
+
import { catalogEntryFor, withCatalogOverrides } from './registry/catalog.js';
|
|
51
|
+
import { detected } from './registry/detect.js';
|
|
13
52
|
import { resolveLaunch, unpinnedFallbackFor } from './registry/launch.js';
|
|
14
53
|
import { RegistryClient } from './registry/registryClient.js';
|
|
54
|
+
import { preparePatchedLaunch } from './managedPatches.js';
|
|
15
55
|
import { installUvTool, listUvTools, uvPackageName, uvToolVersion } from './uvxInstall.js';
|
|
16
56
|
function errorMessage(error) {
|
|
17
57
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -44,8 +84,8 @@ function errorMessage(error) {
|
|
|
44
84
|
*/
|
|
45
85
|
export class AgentRefusedSessionError extends Error {
|
|
46
86
|
errorCode;
|
|
47
|
-
constructor(cause) {
|
|
48
|
-
super(errorMessage(cause), { cause });
|
|
87
|
+
constructor(cause, publicMessage) {
|
|
88
|
+
super(publicMessage ?? errorMessage(cause), publicMessage === undefined ? { cause } : undefined);
|
|
49
89
|
this.name = 'AgentRefusedSessionError';
|
|
50
90
|
this.errorCode = jsonRpcErrorCode(cause);
|
|
51
91
|
}
|
|
@@ -184,9 +224,11 @@ export function createLoginUrlSink(login) {
|
|
|
184
224
|
/** A verification or authentication ran past {@link AGENT_PROBE_TIMEOUT_MS}. */
|
|
185
225
|
export class AgentProbeTimeoutError extends Error {
|
|
186
226
|
}
|
|
227
|
+
/** A real session stopped answering after initialize; closing its transport reaps the child. */
|
|
228
|
+
export class AgentSessionStartTimeoutError extends Error {
|
|
229
|
+
}
|
|
187
230
|
/**
|
|
188
|
-
* Reject with
|
|
189
|
-
* doing.
|
|
231
|
+
* Reject with the specified timeout error once `timeoutMs` has passed, whatever `work` is still doing.
|
|
190
232
|
*
|
|
191
233
|
* `work` is deliberately *not* cancelled here — nothing in ACP can cancel an `initialize` or a
|
|
192
234
|
* `session/new` in flight. What actually stops it is the caller's `connection.close()`, which kills
|
|
@@ -194,10 +236,10 @@ export class AgentProbeTimeoutError extends Error {
|
|
|
194
236
|
* it will usually reject (the child died under it) long after nobody is listening, and an unhandled
|
|
195
237
|
* rejection would take the daemon down.
|
|
196
238
|
*/
|
|
197
|
-
async function withDeadline(work, timeoutMs, what) {
|
|
239
|
+
async function withDeadline(work, timeoutMs, what, timeoutError = AgentProbeTimeoutError) {
|
|
198
240
|
let timer;
|
|
199
241
|
const deadline = new Promise((_, reject) => {
|
|
200
|
-
timer = setTimeout(() => reject(new
|
|
242
|
+
timer = setTimeout(() => reject(new timeoutError(`${what} did not finish within ${timeoutMs}ms.`)), timeoutMs);
|
|
201
243
|
});
|
|
202
244
|
deadline.catch(() => { });
|
|
203
245
|
try {
|
|
@@ -212,7 +254,7 @@ async function withDeadline(work, timeoutMs, what) {
|
|
|
212
254
|
* 等**第一条** `available_commands_update` 的窗口。一条都没等到就收工,`observed` 保持 undefined
|
|
213
255
|
* ——按「缺席 ≠ 空表」的约定,服务端据此不写命令表那一半(见 {@link verify} 的 KDoc)。
|
|
214
256
|
*
|
|
215
|
-
* 数值沿用原来那个固定 settle 窗口(`docs/superpowers/notes/2026-08-31-codex-command-table-race.md`)。
|
|
257
|
+
* 数值沿用原来那个固定 settle 窗口(`archived/docs/superpowers/notes/2026-08-31-codex-command-table-race.md`)。
|
|
216
258
|
* **没有证据支持把它调大**:真机上量到同一个 agent 在一台机器上回 72 条、在另一台回 0 条,但那两次
|
|
217
259
|
* 的耗时差(8.4s vs 2.1s)完全可能全部落在 spawn/connect 上,而不是广播来晚了。要区分这两件事,
|
|
218
260
|
* 得先有 {@link verify} 里那条「没等到表」的日志,不是先改这个数字。
|
|
@@ -232,6 +274,13 @@ const COMMAND_QUIET_MS = 750;
|
|
|
232
274
|
* 必须显著小于 {@link AGENT_PROBE_TIMEOUT_MS} —— 连接和 `session/new` 还要在这之前各花一段。
|
|
233
275
|
*/
|
|
234
276
|
const COMMAND_SETTLE_CEILING_MS = 5_000;
|
|
277
|
+
/**
|
|
278
|
+
* p3-fix7 N9: a verification whose first opening saw no command table opens one more session and waits this long for
|
|
279
|
+
* the first broadcast. "Nothing seen" is not an observation: the dispatch planner reads "offers
|
|
280
|
+
* superpowers:brainstorming" off this table, and a missed table can flip a project to native. Kept well inside
|
|
281
|
+
* {@link AGENT_PROBE_TIMEOUT_MS} with the first attempt's window and two session openings.
|
|
282
|
+
*/
|
|
283
|
+
const COMMAND_RETRY_FIRST_BROADCAST_MS = 4_000;
|
|
235
284
|
/** ACP 的 `AvailableCommand` → 隧道形状:`input.hint` 拍平成 `inputHint`。 */
|
|
236
285
|
function toAvailableCommandWire(raw) {
|
|
237
286
|
const command = raw;
|
|
@@ -251,7 +300,11 @@ function toAvailableCommandWire(raw) {
|
|
|
251
300
|
* they are the ones nobody remembers.
|
|
252
301
|
*/
|
|
253
302
|
function observationOf(connection) {
|
|
254
|
-
return {
|
|
303
|
+
return {
|
|
304
|
+
authMethods: connection.authMethods,
|
|
305
|
+
supportsSteering: connection.supportsSteering,
|
|
306
|
+
terminalLogins: connection.terminalLogins,
|
|
307
|
+
};
|
|
255
308
|
}
|
|
256
309
|
/**
|
|
257
310
|
* The version of `agent`'s ACP adapter actually found on this machine, dispatched by distribution
|
|
@@ -305,6 +358,7 @@ function isUnsupportedPlatform(agent, platform, arch) {
|
|
|
305
358
|
return !triple || !binaryTargetFor(agent, triple);
|
|
306
359
|
}
|
|
307
360
|
export class AcpAgents {
|
|
361
|
+
sessionStartTimeoutMs;
|
|
308
362
|
registry;
|
|
309
363
|
platform;
|
|
310
364
|
arch;
|
|
@@ -317,6 +371,7 @@ export class AcpAgents {
|
|
|
317
371
|
* branch needs this root and nothing else in this class's options carries it).
|
|
318
372
|
*/
|
|
319
373
|
adapterRoot;
|
|
374
|
+
commandRetryFirstBroadcastMs;
|
|
320
375
|
/** Last observation per registry agent id; in-process only, by design — see {@link AuthObservation}. */
|
|
321
376
|
observed = new Map();
|
|
322
377
|
/** Injectable `uv` runner for {@link roster}'s single hoisted `uv tool list` read; see {@link AcpAgentsOptions.runUv}. */
|
|
@@ -324,13 +379,19 @@ export class AcpAgents {
|
|
|
324
379
|
/** Injectable `npx` binary for {@link installAdapter}'s npx branch; see {@link AcpAgentsOptions.npxCommand}. */
|
|
325
380
|
npxCommand;
|
|
326
381
|
constructor(options) {
|
|
327
|
-
this.
|
|
382
|
+
this.sessionStartTimeoutMs = options.sessionStartTimeoutMs ?? 60_000;
|
|
383
|
+
if (!(this.sessionStartTimeoutMs > 0) || !Number.isFinite(this.sessionStartTimeoutMs)) {
|
|
384
|
+
throw new RangeError('sessionStartTimeoutMs must be finite and positive');
|
|
385
|
+
}
|
|
386
|
+
// Through the catalog, always: an injected registry too, so tests exercise the same overlay.
|
|
387
|
+
this.registry = withCatalogOverrides(options.registry ?? new RegistryClient({ cacheDir: options.cacheDir }));
|
|
328
388
|
this.platform = options.platform ?? process.platform;
|
|
329
389
|
this.arch = options.arch ?? process.arch;
|
|
330
390
|
this.probe = options.probe ?? probeEnvironment();
|
|
331
391
|
this.runCommand = options.runCommand ?? runDependencyInstall;
|
|
332
392
|
this.runUv = options.runUv;
|
|
333
393
|
this.npxCommand = options.npxCommand;
|
|
394
|
+
this.commandRetryFirstBroadcastMs = options.commandRetryFirstBroadcastMs ?? COMMAND_RETRY_FIRST_BROADCAST_MS;
|
|
334
395
|
this.adapterRoot = path.join(options.cacheDir, 'acp-adapters');
|
|
335
396
|
}
|
|
336
397
|
/**
|
|
@@ -349,9 +410,15 @@ export class AcpAgents {
|
|
|
349
410
|
* is the one field that does describe this machine (has the ACP adapter already been downloaded
|
|
350
411
|
* here), spread in only when it was genuinely established.
|
|
351
412
|
*
|
|
352
|
-
* `
|
|
413
|
+
* `curated` / `defaultName` / `defaultLabel` come from `registry/catalog.ts` — the curated list
|
|
353
414
|
* of agents Axom has actually verified, which replaced the old "official CLI on PATH" table. They
|
|
354
415
|
* are spread in only for a catalog hit; an agent outside the catalog is not claimed either way.
|
|
416
|
+
* `recommended` is the catalog's own static flag (claude, codex, pi) and nothing else — it used to
|
|
417
|
+
* be spread for every catalog hit, which is what `curated` now says. `detected` is the catalog's
|
|
418
|
+
* local detection (`registry/detect.ts`): the agent itself, or its prerequisite, is on this machine
|
|
419
|
+
* the way the user installed it — a reason to recommend, never a claim that Axom installed it.
|
|
420
|
+
* `missingPrerequisite` names a prerequisite that is absent *and* that `install` cannot fetch, so
|
|
421
|
+
* the card can say what to do instead of offering an `Install` that can only refuse.
|
|
355
422
|
*
|
|
356
423
|
* Never throws, for the same reason the deleted `rosterFor` swallowed a failing probe: a registry
|
|
357
424
|
* that is unreachable on a cold-start machine must not be able to stop the daemon from connecting.
|
|
@@ -396,6 +463,11 @@ export class AcpAgents {
|
|
|
396
463
|
// Computed once and used by both `adapterInstalled` and `adapterVersion` below: they are two
|
|
397
464
|
// halves of one disk read, and deriving them separately is exactly how they drifted apart.
|
|
398
465
|
const adapterVersion = adapterVersionFor(agent, npxCache, this.adapterRoot, uvVersions);
|
|
466
|
+
// One detection pass per agent, shared by `adapterInstalled`, `detected` and
|
|
467
|
+
// `missingPrerequisite`: three readings of the same look at the disk must not disagree.
|
|
468
|
+
const prerequisite = catalog?.prerequisite;
|
|
469
|
+
const prerequisiteMet = !prerequisite || detected(prerequisite.detect, this.probe, triple);
|
|
470
|
+
const inUse = detected(catalog?.detect, this.probe, triple) || (prerequisite !== undefined && prerequisiteMet);
|
|
399
471
|
return {
|
|
400
472
|
type: agent.id,
|
|
401
473
|
// `uvTools` is the same hoisted `uv tool list` snapshot `adapterVersion` below looks up in
|
|
@@ -422,12 +494,12 @@ export class AcpAgents {
|
|
|
422
494
|
// "did not look": `adapterVersionFor` reads the npx cache, the hoisted `uv tool list`
|
|
423
495
|
// snapshot and our own binary install root, and every one of them is a local read.
|
|
424
496
|
//
|
|
425
|
-
// `
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
adapterInstalled: adapterVersion !== undefined &&
|
|
497
|
+
// `prerequisiteMet` folds in the catalog's prerequisite (design §4.4): `pi-acp`'s adapter
|
|
498
|
+
// sitting in the npx cache with no `pi` on `PATH` is not an agent this machine can add —
|
|
499
|
+
// the card would offer `Add`, the try-run would fail, and the reader would be told nothing
|
|
500
|
+
// about the missing CLI. Reporting it not-installed is what makes `Install` fetch the
|
|
501
|
+
// prerequisite too (or, with nothing to fetch, `missingPrerequisite` below explain it).
|
|
502
|
+
adapterInstalled: adapterVersion !== undefined && prerequisiteMet,
|
|
431
503
|
// The version half of the same answer — see `adapterVersionFor`'s doc comment. Absent
|
|
432
504
|
// rather than null when nothing is installed, on this file's usual "never write down what
|
|
433
505
|
// nobody looked at" rule.
|
|
@@ -452,10 +524,18 @@ export class AcpAgents {
|
|
|
452
524
|
// its id-derived fallback would have said something.
|
|
453
525
|
...(agent.name ? { displayName: agent.name } : {}),
|
|
454
526
|
...(agent.description ? { description: agent.description } : {}),
|
|
455
|
-
// Spread from `registry/catalog.ts` only for a catalog hit — `recommended`
|
|
456
|
-
// the same "absent means unknown/no" rule as `supportsSteering
|
|
457
|
-
|
|
527
|
+
// Spread from `registry/catalog.ts` only for a catalog hit — `curated`, `recommended` and
|
|
528
|
+
// `detected` have no `false`, the same "absent means unknown/no" rule as `supportsSteering`.
|
|
529
|
+
// An `unverified` entry keeps its defaults but is not one Axom vouches for (see `catalog.ts`).
|
|
530
|
+
...(catalog ? { ...(catalog.unverified ? {} : { curated: true }), defaultName: catalog.defaultName } : {}),
|
|
458
531
|
...(catalog?.defaultLabel ? { defaultLabel: catalog.defaultLabel } : {}),
|
|
532
|
+
...(catalog?.recommended ? { recommended: true } : {}),
|
|
533
|
+
...(inUse ? { detected: true } : {}),
|
|
534
|
+
// Only when nothing here can fix it: a prerequisite with an `install` argv (Pi) is fetched by
|
|
535
|
+
// `Install`, which the card already offers because `adapterInstalled` is false.
|
|
536
|
+
...(prerequisite && !prerequisiteMet && !prerequisite.install
|
|
537
|
+
? { missingPrerequisite: { label: prerequisite.label, ...(prerequisite.hint ? { hint: prerequisite.hint } : {}) } }
|
|
538
|
+
: {}),
|
|
459
539
|
// Spread rather than assign: an explicit `authMethods: undefined` would still be an own
|
|
460
540
|
// property, and "the key is here but empty" is exactly the ambiguity these fields exist to
|
|
461
541
|
// avoid on the roster's other end.
|
|
@@ -512,75 +592,118 @@ export class AcpAgents {
|
|
|
512
592
|
* exists to prevent one layer up.
|
|
513
593
|
*/
|
|
514
594
|
async open(spec) {
|
|
515
|
-
const
|
|
595
|
+
const requestId = spec.openRequestId ?? randomUUID();
|
|
596
|
+
const openStarted = performance.now();
|
|
597
|
+
// Log identifiers and timing only: commands, environment, cwd and lease headers can hold secrets.
|
|
598
|
+
const phase = async (name, work) => {
|
|
599
|
+
const started = performance.now();
|
|
600
|
+
const log = (status) => console.info(`${new Date().toISOString()} [acp-open] request=${JSON.stringify(requestId)} agent=${JSON.stringify(spec.agentId)}` +
|
|
601
|
+
` phase=${name} status=${status} durationMs=${Math.round(performance.now() - started)}` +
|
|
602
|
+
` totalMs=${Math.round(performance.now() - openStarted)}`);
|
|
603
|
+
log('started');
|
|
604
|
+
try {
|
|
605
|
+
const result = await work();
|
|
606
|
+
log('completed');
|
|
607
|
+
return result;
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
log('failed');
|
|
611
|
+
throw error;
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
let resolved = await phase('prepare-launch', () => this.resolveLaunchFor(spec.agentId, spec.command, spec.args));
|
|
615
|
+
const startup = spec.agentId === 'qoder' && spec.delegationMcp
|
|
616
|
+
? await phase('prepare-qoder', () => prepareQoderStartup(resolved.launch, spec.delegationMcp, spec.cwd, spec.env, spec.agentEnv))
|
|
617
|
+
: undefined;
|
|
618
|
+
if (startup)
|
|
619
|
+
resolved = { launch: startup.launch, source: resolved.source };
|
|
516
620
|
const capture = createReplayCapture();
|
|
517
621
|
const permissions = createDeferredPermissionSink();
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
// the response that ends the same `session/load` / `session/prompt` is applied synchronously
|
|
527
|
-
// off the reader. Registering the capture behind the three permission handlers is what made
|
|
528
|
-
// that gap wide enough to observe (measured: a turn's trailing deltas arriving after its own
|
|
529
|
-
// `stop`). `settleUpdates` is the actual fix — this is defence in depth, and it is not a
|
|
530
|
-
// substitute: nothing here forces the capture to stay first.
|
|
531
|
-
//
|
|
532
|
-
// ## Prerequisite for anything added to this hook: **every handler registered here must be
|
|
533
|
-
// ## synchronous with respect to `session/update`**
|
|
534
|
-
//
|
|
535
|
-
// This is where that property is established, so this is where it is written down —
|
|
536
|
-
// `settleUpdates` (`adoption.ts`) depends on it and cannot check it. It waits one macrotask
|
|
537
|
-
// boundary, which drains a chain of *synchronous* handlers' microtask continuations and
|
|
538
|
-
// nothing more; a handler that awaits a real macrotask (a timer, a file read, a subprocess,
|
|
539
|
-
// an HTTP call) before returning `Handled.no` moves the rest of the chain past that boundary
|
|
540
|
-
// and the wait ends with **zero** updates delivered — measured, and silent apart from
|
|
541
|
-
// `settleUpdates`' own maxRounds diagnostic, which this failure does not even trip.
|
|
542
|
-
//
|
|
543
|
-
// Both current registrations satisfy it. `capture.register` is a plain synchronous
|
|
544
|
-
// `onNotification` body. The permission handlers do block on a human answer, but only for
|
|
545
|
-
// `session/request_permission` and `elicitation/*`; for a `session/update` they fall through
|
|
546
|
-
// without awaiting anything real. Anything added here later has to clear the same bar — if it
|
|
547
|
-
// genuinely needs to await, it must do that work *after* handing the message on, not before.
|
|
548
|
-
configure: (app) => {
|
|
549
|
-
capture.register(app);
|
|
550
|
-
// **No `present` here, and that is a decision rather than an omission.** A session
|
|
551
|
-
// connection lives for hours; a `url` elicitation raised on one is not a sign-in the reader
|
|
552
|
-
// asked for, and there is no login for a card on screen to belong to. So
|
|
553
|
-
// `handleCreateElicitation` declines it rather than accepting into a void, which is an
|
|
554
|
-
// answer the agent can act on. The one connection that *does* wire both callbacks is the
|
|
555
|
-
// throwaway one {@link authenticate} opens — see {@link connectFor}. The stderr half of the
|
|
556
|
-
// same rule is enforced by the compiler: `ConnectAgentOptions.present` is typed `never`, so
|
|
557
|
-
// this call's sibling option cannot arm the sniffer on a session either.
|
|
622
|
+
let connection;
|
|
623
|
+
try {
|
|
624
|
+
connection = await phase('initialize', () => this.connectResolved(spec.agentId, resolved, spec.env, spec.agentEnv, {
|
|
625
|
+
...(startup ? { stderrSecrets: startup.secrets,
|
|
626
|
+
onProcessExit: () => { void startup.dispose().catch(() => undefined); } } : {}),
|
|
627
|
+
// Both registrations go in one hook: `ClientApp.connect()` copies its handler table once, so
|
|
628
|
+
// this is the only moment either of them can be installed (see `acpClient.ts`'s
|
|
629
|
+
// `ConnectAgentOptions.configure`).
|
|
558
630
|
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
631
|
+
// The capture goes FIRST, and the order is load-bearing rather than stylistic. The SDK awaits
|
|
632
|
+
// every handler in registration order (`jsonrpc.js`'s `processIncomingMessage`), so each one
|
|
633
|
+
// ahead of the capture costs it a microtask hop before it ever sees a `session/update` — while
|
|
634
|
+
// the response that ends the same `session/load` / `session/prompt` is applied synchronously
|
|
635
|
+
// off the reader. Registering the capture behind the three permission handlers is what made
|
|
636
|
+
// that gap wide enough to observe (measured: a turn's trailing deltas arriving after its own
|
|
637
|
+
// `stop`). `settleUpdates` is the actual fix — this is defence in depth, and it is not a
|
|
638
|
+
// substitute: nothing here forces the capture to stay first.
|
|
639
|
+
//
|
|
640
|
+
// ## Prerequisite for anything added to this hook: **every handler registered here must be
|
|
641
|
+
// ## synchronous with respect to `session/update`**
|
|
642
|
+
//
|
|
643
|
+
// This is where that property is established, so this is where it is written down —
|
|
644
|
+
// `settleUpdates` (`adoption.ts`) depends on it and cannot check it. It waits one macrotask
|
|
645
|
+
// boundary, which drains a chain of *synchronous* handlers' microtask continuations and
|
|
646
|
+
// nothing more; a handler that awaits a real macrotask (a timer, a file read, a subprocess,
|
|
647
|
+
// an HTTP call) before returning `Handled.no` moves the rest of the chain past that boundary
|
|
648
|
+
// and the wait ends with **zero** updates delivered — measured, and silent apart from
|
|
649
|
+
// `settleUpdates`' own maxRounds diagnostic, which this failure does not even trip.
|
|
650
|
+
//
|
|
651
|
+
// Both current registrations satisfy it. `capture.register` is a plain synchronous
|
|
652
|
+
// `onNotification` body. The permission handlers do block on a human answer, but only for
|
|
653
|
+
// `session/request_permission` and `elicitation/*`; for a `session/update` they fall through
|
|
654
|
+
// without awaiting anything real. Anything added here later has to clear the same bar — if it
|
|
655
|
+
// genuinely needs to await, it must do that work *after* handing the message on, not before.
|
|
656
|
+
configure: (app) => {
|
|
657
|
+
capture.register(app);
|
|
658
|
+
// **No `present` here, and that is a decision rather than an omission.** A session
|
|
659
|
+
// connection lives for hours; a `url` elicitation raised on one is not a sign-in the reader
|
|
660
|
+
// asked for, and there is no login for a card on screen to belong to. So
|
|
661
|
+
// `handleCreateElicitation` declines it rather than accepting into a void, which is an
|
|
662
|
+
// answer the agent can act on. The one connection that *does* wire both callbacks is the
|
|
663
|
+
// throwaway one {@link authenticate} opens — see {@link connectFor}. The stderr half of the
|
|
664
|
+
// same rule is enforced by the compiler: `ConnectAgentOptions.present` is typed `never`, so
|
|
665
|
+
// this call's sibling option cannot arm the sniffer on a session either.
|
|
666
|
+
//
|
|
667
|
+
// `dismiss` is a no-op for the same reason: nothing was presented, so nothing can be
|
|
668
|
+
// retracted, and an agent that sends `elicitation/complete` anyway must not crash the
|
|
669
|
+
// connection over it.
|
|
670
|
+
registerPermissionHandlers(app, permissions.sink, { dismiss: () => { } });
|
|
671
|
+
},
|
|
672
|
+
}));
|
|
673
|
+
}
|
|
674
|
+
catch (error) {
|
|
675
|
+
await startup?.dispose();
|
|
676
|
+
if (startup)
|
|
677
|
+
throw new Error('Managed Qoder startup could not initialize');
|
|
678
|
+
throw error;
|
|
679
|
+
}
|
|
565
680
|
this.observe(spec.agentId, observationOf(connection));
|
|
566
681
|
try {
|
|
567
|
-
|
|
682
|
+
if (startup && (connection.agentInfo?.name !== 'qoder-cli' || connection.agentInfo.version !== QODER_MANAGED_VERSION)) {
|
|
683
|
+
throw new Error('Managed Qoder runtime identity is not certified');
|
|
684
|
+
}
|
|
685
|
+
// Qoder 1.1.61 accepts HTTP MCP at startup, but rejects that entry in ACP new/load.
|
|
686
|
+
const sessionSpec = { ...spec, managedHistoryResumeRequired: !!spec.delegationMcp || spec.managedHistoryResumeRequired,
|
|
687
|
+
...(startup ? { delegationMcp: undefined, requireSessionLoad: spec.requireSessionLoad || !!spec.nativeSessionId } : {}) };
|
|
688
|
+
const operation = spec.nativeSessionId && connection.capabilities.loadSession ? 'session/load' : 'session/new';
|
|
689
|
+
const started = await phase(operation, () => withDeadline(this.startSession(connection, capture, sessionSpec), this.sessionStartTimeoutMs, `Agent '${spec.agentId}' ${operation}`, AgentSessionStartTimeoutError));
|
|
568
690
|
// Only here, never before: the agent accepted a session, which is the one piece of evidence
|
|
569
691
|
// ACP offers that it is not waiting on credentials. Recording it up at `initialize` time
|
|
570
692
|
// would claim authentication for an agent about to reject `session/new` with auth_required.
|
|
571
693
|
this.observe(spec.agentId, { ...observationOf(connection), authenticated: true });
|
|
572
694
|
permissions.bind(started.session);
|
|
573
695
|
return {
|
|
574
|
-
session: hostedSession(started.session, connection, started.history, started.configOptions),
|
|
696
|
+
session: hostedSession(started.session, connection, started.history, started.configOptions, startup?.dispose),
|
|
575
697
|
nativeSessionId: started.nativeSessionId,
|
|
576
698
|
};
|
|
577
699
|
}
|
|
578
700
|
catch (error) {
|
|
579
701
|
await connection.close().catch(() => undefined);
|
|
702
|
+
await startup?.dispose();
|
|
580
703
|
// Marked, not merely rethrown: everything above this `try` failed before the agent said
|
|
581
704
|
// anything, and only a failure from here on can be an `auth_required`. See
|
|
582
705
|
// {@link AgentRefusedSessionError} — `agentSessions.ts` is the reader.
|
|
583
|
-
throw new AgentRefusedSessionError(error);
|
|
706
|
+
throw new AgentRefusedSessionError(error, startup ? 'Managed Qoder session could not open' : undefined);
|
|
584
707
|
}
|
|
585
708
|
}
|
|
586
709
|
/**
|
|
@@ -604,6 +727,22 @@ export class AcpAgents {
|
|
|
604
727
|
await connection.close().catch(() => undefined);
|
|
605
728
|
}
|
|
606
729
|
}
|
|
730
|
+
/**
|
|
731
|
+
* Delete native sessions over `session/delete`, via a throwaway connection. Per-id outcomes; an
|
|
732
|
+
* agent that does not advertise `sessionCapabilities.delete` yields `unsupported` for every id
|
|
733
|
+
* without the method being called. A *connection* failure still throws.
|
|
734
|
+
*/
|
|
735
|
+
async deleteNativeSessions(spec, ids) {
|
|
736
|
+
const resolved = await this.resolveLaunchFor(spec.agentId, spec.command, spec.args);
|
|
737
|
+
const connection = await this.connectResolved(spec.agentId, resolved, spec.env, spec.agentEnv);
|
|
738
|
+
this.observe(spec.agentId, observationOf(connection));
|
|
739
|
+
try {
|
|
740
|
+
return await deleteNativeSessions(connection, ids);
|
|
741
|
+
}
|
|
742
|
+
finally {
|
|
743
|
+
await connection.close().catch(() => undefined);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
607
746
|
/**
|
|
608
747
|
* Verify one agent on demand: spawn it, negotiate `initialize`, try to open a session, then throw
|
|
609
748
|
* the whole thing away. Nothing is kept running — a verification is a question, not a resource.
|
|
@@ -632,68 +771,27 @@ export class AcpAgents {
|
|
|
632
771
|
try {
|
|
633
772
|
let observed;
|
|
634
773
|
let observedConfig;
|
|
774
|
+
const started = Date.now();
|
|
635
775
|
await withDeadline((async () => {
|
|
636
|
-
const
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
// only broadcasts afterwards.
|
|
640
|
-
observedConfig = opened.configOptions;
|
|
641
|
-
// The table is broadcast *after* `session/new` is answered (`acp-agent.js:752-757`), so a
|
|
642
|
-
// verification that closed here — as this one used to — could never see it. The sink is
|
|
643
|
-
// attached after `open` on purpose and is still safe: `AcpSession` buffers events until
|
|
644
|
-
// one is attached (`onEvent`'s `drain()`), so nothing that arrived in between is lost.
|
|
645
|
-
// Settle rather than "first push wins": Claude broadcasts twice — once on `session/new`
|
|
646
|
-
// and again when the init frame lets it filter out terminal-bound commands. Taking the
|
|
647
|
-
// first would leave commands in the menu that do nothing over ACP.
|
|
648
|
-
//
|
|
649
|
-
// 但**不能靠一个固定的等待**。这里原本是 `setTimeout(1500)` 死等,于是这段代码在和
|
|
650
|
-
// agent 的广播赛跑,而且**机器越快越容易输**:`session/new` 秒回时,1.5 秒窗口内广播还
|
|
651
|
-
// 没到,`observed` 保持 undefined,整张命令表就此丢失。真机上量到过同一个 agent 在热机器
|
|
652
|
-
// 上 2.1 秒回来 0 条、在慢机器上 8.4 秒回来 72 条 —— 差别只是后者慢到"顺便"等到了。
|
|
653
|
-
//
|
|
654
|
-
// 原来是无条件 `setTimeout(1500)` 睡满。改成两段:一条都没来 → 到
|
|
655
|
-
// [COMMAND_FIRST_BROADCAST_MS] 就收工(与原实现逐字同样的时间与结果);来了第一条 →
|
|
656
|
-
// 改用安静期收尾,并受 [COMMAND_SETTLE_CEILING_MS] 兜底。**严格不劣于原实现**:
|
|
657
|
-
// 没有广播的路径一模一样,有广播的路径要么更快,要么多收到一条原本会被丢掉的。
|
|
658
|
-
let finish;
|
|
659
|
-
const settled = new Promise((resolve) => {
|
|
660
|
-
finish = resolve;
|
|
661
|
-
});
|
|
662
|
-
let quietTimer;
|
|
663
|
-
let ceilingTimer;
|
|
664
|
-
let firstTimer = setTimeout(() => finish?.(), COMMAND_FIRST_BROADCAST_MS);
|
|
665
|
-
opened.session.onEvent((event) => {
|
|
666
|
-
if (event.eventName !== 'available_commands_update')
|
|
667
|
-
return;
|
|
668
|
-
const commands = event.payload.availableCommands;
|
|
669
|
-
if (Array.isArray(commands))
|
|
670
|
-
observed = commands.map(toAvailableCommandWire);
|
|
671
|
-
// 第一条到手:等第一条的窗口作废,换成"安静就收工"+ 绝对上限。
|
|
672
|
-
if (firstTimer !== undefined) {
|
|
673
|
-
clearTimeout(firstTimer);
|
|
674
|
-
firstTimer = undefined;
|
|
675
|
-
ceilingTimer = setTimeout(() => finish?.(), COMMAND_SETTLE_CEILING_MS);
|
|
676
|
-
}
|
|
677
|
-
// 每再来一条就把安静计时重新起头 —— Claude 的第二条才是能用的那份。
|
|
678
|
-
if (quietTimer !== undefined)
|
|
679
|
-
clearTimeout(quietTimer);
|
|
680
|
-
quietTimer = setTimeout(() => finish?.(), COMMAND_QUIET_MS);
|
|
681
|
-
});
|
|
682
|
-
await settled;
|
|
683
|
-
if (firstTimer !== undefined)
|
|
684
|
-
clearTimeout(firstTimer);
|
|
685
|
-
if (quietTimer !== undefined)
|
|
686
|
-
clearTimeout(quietTimer);
|
|
687
|
-
if (ceilingTimer !== undefined)
|
|
688
|
-
clearTimeout(ceilingTimer);
|
|
689
|
-
if (observed === undefined) {
|
|
690
|
-
// A 那条日志的 daemon 侧对应物。没有它,"这个 agent 在这个目录下没有命令" 与
|
|
691
|
-
// "表来晚了、窗口没接住" 在任何一层都分不开 —— 而这两件事的修法完全不同。
|
|
692
|
-
console.warn(`[acp] agent '${spec.agentId}' broadcast no command table within ` +
|
|
693
|
-
`${COMMAND_FIRST_BROADCAST_MS}ms of opening a session in ${spec.cwd}`);
|
|
694
|
-
}
|
|
695
|
-
await opened.session.close().catch(() => undefined);
|
|
776
|
+
const first = await openAndObserveCommands(connection, spec.cwd, COMMAND_FIRST_BROADCAST_MS);
|
|
777
|
+
observedConfig = first.configOptions;
|
|
778
|
+
observed = first.commands;
|
|
696
779
|
})(), timeoutMs, `Opening a session on agent '${spec.agentId}'`);
|
|
780
|
+
if (observed === undefined) {
|
|
781
|
+
// p3-fix7 N9: "no table within the window" is unknown, not "this agent has no commands": look once more, with a
|
|
782
|
+
// longer window. p3-fix7b M-4: on a fresh connection (this one is bound to the first session, so a table pushed
|
|
783
|
+
// before the retry's session/new answer would read as a stranger's). p3-fix7c 3: the retry is only a second
|
|
784
|
+
// look for the table — its failure, or the deadline running out during it, leaves the table unknown and never
|
|
785
|
+
// turns a session that opened into a sessionError.
|
|
786
|
+
observed = await this.retryCommandTable(spec, timeoutMs - (Date.now() - started));
|
|
787
|
+
}
|
|
788
|
+
if (observed === undefined) {
|
|
789
|
+
// A 那条日志的 daemon 侧对应物。没有它,"这个 agent 在这个目录下没有命令" 与
|
|
790
|
+
// "表来晚了、窗口没接住" 在任何一层都分不开 —— 而这两件事的修法完全不同。
|
|
791
|
+
console.warn(`[acp] agent '${spec.agentId}' broadcast no command table within ` +
|
|
792
|
+
`${COMMAND_FIRST_BROADCAST_MS}ms (and ${this.commandRetryFirstBroadcastMs}ms on a second session) ` +
|
|
793
|
+
`of opening a session in ${spec.cwd}`);
|
|
794
|
+
}
|
|
697
795
|
this.observe(spec.agentId, { ...observationOf(connection), authenticated: true });
|
|
698
796
|
return {
|
|
699
797
|
authMethods,
|
|
@@ -712,10 +810,40 @@ export class AcpAgents {
|
|
|
712
810
|
await connection.close().catch(() => undefined);
|
|
713
811
|
}
|
|
714
812
|
}
|
|
813
|
+
/** p3-fix7c 3: one more look for the command table on a fresh connection; undefined on any failure or timeout. */
|
|
814
|
+
async retryCommandTable(spec, remainingMs) {
|
|
815
|
+
if (remainingMs <= 0)
|
|
816
|
+
return undefined;
|
|
817
|
+
let retryConnection;
|
|
818
|
+
let abandoned = false;
|
|
819
|
+
try {
|
|
820
|
+
return await withDeadline((async () => {
|
|
821
|
+
const connection = await this.connectFor(spec, remainingMs);
|
|
822
|
+
retryConnection = connection;
|
|
823
|
+
// The deadline may already have fired while connecting: nobody will close it after this point but us.
|
|
824
|
+
if (abandoned) {
|
|
825
|
+
await connection.close().catch(() => undefined);
|
|
826
|
+
return undefined;
|
|
827
|
+
}
|
|
828
|
+
return (await openAndObserveCommands(connection, spec.cwd, this.commandRetryFirstBroadcastMs)).commands;
|
|
829
|
+
})(), remainingMs, `Looking again for agent '${spec.agentId}''s command table`);
|
|
830
|
+
}
|
|
831
|
+
catch (error) {
|
|
832
|
+
abandoned = true;
|
|
833
|
+
console.warn(`[acp] retry for agent '${spec.agentId}''s command table failed: ${errorMessage(error)}`);
|
|
834
|
+
return undefined;
|
|
835
|
+
}
|
|
836
|
+
finally {
|
|
837
|
+
await retryConnection?.close().catch(() => undefined);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
715
840
|
/**
|
|
716
|
-
* Run one agent's `authenticate` for a `methodId` **it advertised**, over a throwaway connection
|
|
841
|
+
* Run one agent's `authenticate` for a `methodId` **it advertised**, over a throwaway connection —
|
|
842
|
+
* or, for a terminal-type method, run the sign-in itself ({@link hostTerminalLogin}): ACP forbids
|
|
843
|
+
* passing a terminal method to `authenticate`, and the workstation is the terminal's host (spec
|
|
844
|
+
* 2026-09-24 D6/D12).
|
|
717
845
|
*
|
|
718
|
-
* The refusal for an unadvertised
|
|
846
|
+
* The refusal for an unadvertised non-terminal `methodId` is {@link AcpAuth}'s and happens before
|
|
719
847
|
* anything is written to the agent — which is why this hands `AcpAuth` the connection rather than
|
|
720
848
|
* re-deriving the check: there is exactly one place that rule lives.
|
|
721
849
|
*
|
|
@@ -735,11 +863,26 @@ export class AcpAgents {
|
|
|
735
863
|
// is ordinary — the scan covers the `npx` install, whose own output can carry a "visit this
|
|
736
864
|
// URL" line, and a login whose agent then fails to install is exactly when that appears.
|
|
737
865
|
try {
|
|
866
|
+
// Spec 2026-09-24 D10: a sign-in almost always follows the connection that hit
|
|
867
|
+
// `auth_required`, which already read this method. Reuse it rather than cold-start the agent.
|
|
868
|
+
const remembered = this.observed.get(spec.agentId)?.terminalLogins?.get(methodId);
|
|
869
|
+
if (remembered) {
|
|
870
|
+
await this.hostTerminalLogin(spec, remembered, urls.present, login);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
738
873
|
const connection = await this.connectFor(spec, AGENT_PROBE_TIMEOUT_MS, {
|
|
739
874
|
...(urls.present ? { present: urls.present } : {}),
|
|
740
875
|
dismiss: urls.dismiss,
|
|
741
876
|
});
|
|
742
877
|
this.observe(spec.agentId, observationOf(connection));
|
|
878
|
+
const terminal = connection.terminalLogins.get(methodId);
|
|
879
|
+
if (terminal) {
|
|
880
|
+
// ACP: a terminal method is never passed to `authenticate`. This connection has nothing
|
|
881
|
+
// left to do, and a login can take minutes — do not hold an agent process open for it.
|
|
882
|
+
await connection.close().catch(() => undefined);
|
|
883
|
+
await this.hostTerminalLogin(spec, terminal, urls.present, login);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
743
886
|
try {
|
|
744
887
|
const what = `Signing in to agent '${spec.agentId}' with method '${methodId}'`;
|
|
745
888
|
await withDeadline(cancellable(new AcpAuth(connection).authenticate(methodId), login.signal, what), loginDeadlineMs(login.ceilingMs), what);
|
|
@@ -756,6 +899,20 @@ export class AcpAgents {
|
|
|
756
899
|
urls.finish();
|
|
757
900
|
}
|
|
758
901
|
}
|
|
902
|
+
/**
|
|
903
|
+
* Run a terminal sign-in on this machine (spec 2026-09-24 D7, D9). The launch is resolved the same
|
|
904
|
+
* way a session's is, because the standard shape appends to it; the `_meta` shape ignores it.
|
|
905
|
+
*/
|
|
906
|
+
async hostTerminalLogin(spec, terminal, present, login) {
|
|
907
|
+
const launch = terminal.kind === 'meta'
|
|
908
|
+
? { command: terminal.command, args: [], env: {} }
|
|
909
|
+
: (await this.resolveLaunchFor(spec.agentId, spec.command, spec.args)).launch;
|
|
910
|
+
await runTerminalLogin(terminalInvocationFor(terminal, launch, { ...spec.env, ...spec.agentEnv }), {
|
|
911
|
+
...(present ? { present } : {}),
|
|
912
|
+
...(login.signal ? { signal: login.signal } : {}),
|
|
913
|
+
deadlineMs: loginDeadlineMs(login.ceilingMs),
|
|
914
|
+
});
|
|
915
|
+
}
|
|
759
916
|
/**
|
|
760
917
|
* The spawn half shared by {@link verify} and {@link authenticate}. No session either way — but
|
|
761
918
|
* **not "no handlers" any more**, and the difference is the whole of Task 4b: `authenticate` is a
|
|
@@ -807,7 +964,7 @@ export class AcpAgents {
|
|
|
807
964
|
});
|
|
808
965
|
},
|
|
809
966
|
};
|
|
810
|
-
return withUnpinnedFallback(spec.agentId, resolved, (launch) => connectAgent(launch, spec.env, spec.agentEnv, options));
|
|
967
|
+
return withUnpinnedFallback(spec.agentId, resolved, (launch, source) => connectAgent(launch, spec.env, spec.agentEnv, { ...options, launchSource: source }));
|
|
811
968
|
}
|
|
812
969
|
/**
|
|
813
970
|
* Continue in an already-existing native session when we have an id for one and the agent can load
|
|
@@ -818,6 +975,8 @@ export class AcpAgents {
|
|
|
818
975
|
* back different (it warns and re-points), so refusing to open would be strictly worse than
|
|
819
976
|
* continuing in a fresh one. A `session/load` that is advertised but rejected (a stale id the
|
|
820
977
|
* agent has since deleted, most likely) is treated the same way, for the same reason.
|
|
978
|
+
* Manual configuration refresh opts out of both fallbacks with `requireSessionLoad`: a failed
|
|
979
|
+
* load must not silently replace the conversation whose settings the reader wanted to refresh.
|
|
821
980
|
*
|
|
822
981
|
* `AcpSession.attach` runs **before** the replay, not after: it subscribes to the connection's live
|
|
823
982
|
* `session/update` stream, and `ReplayCapture` withholds updates from that subscription for exactly
|
|
@@ -826,8 +985,38 @@ export class AcpAgents {
|
|
|
826
985
|
*/
|
|
827
986
|
async startSession(connection, capture, spec) {
|
|
828
987
|
const nativeSessionId = spec.nativeSessionId;
|
|
988
|
+
if (nativeSessionId && spec.managedHistoryResumeRequired) {
|
|
989
|
+
throw new Error('Managed native history replay has no certified boundary');
|
|
990
|
+
}
|
|
991
|
+
if (spec.delegationMcp && !connection.capabilities.mcpHttp) {
|
|
992
|
+
throw new Error(`Agent '${spec.agentId}' does not advertise HTTP MCP support`);
|
|
993
|
+
}
|
|
994
|
+
if (spec.requireSessionLoad && !nativeSessionId) {
|
|
995
|
+
throw new Error('Refreshing settings requires an existing native session ID');
|
|
996
|
+
}
|
|
997
|
+
// Qoder's load may emit old chunks after its response. Cached reconnects need context, not
|
|
998
|
+
// another history import. Actual adoption/backfill and explicit settings loads still use load.
|
|
999
|
+
if (nativeSessionId && certifiedRuntime(connection.agentInfo) && connection.capabilities.resume &&
|
|
1000
|
+
!spec.backfill && !spec.requireSessionLoad && !spec.delegationMcp) {
|
|
1001
|
+
const initial = { configOptions: [] };
|
|
1002
|
+
const session = AcpSession.attach(connection, nativeSessionId, capture, initial);
|
|
1003
|
+
try {
|
|
1004
|
+
const response = await connection.connection.agent.request(methods.agent.session.resume, {
|
|
1005
|
+
sessionId: nativeSessionId, cwd: spec.cwd, mcpServers: [...(spec.mcpServers ?? [])],
|
|
1006
|
+
});
|
|
1007
|
+
initial.configOptions = normalizeConfigOptions(response.configOptions, response.modes);
|
|
1008
|
+
session.enableBackgroundTerminalRepair(spec.cwd, spec.agentEnv.HOME ?? spec.env.HOME ?? homedir(), true);
|
|
1009
|
+
return { session, nativeSessionId, history: [], configOptions: initial.configOptions };
|
|
1010
|
+
}
|
|
1011
|
+
catch (error) {
|
|
1012
|
+
await session.close().catch(() => undefined);
|
|
1013
|
+
throw error; // Never silently replace this cached conversation with a fresh native root.
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
829
1016
|
if (nativeSessionId) {
|
|
830
1017
|
if (!connection.capabilities.loadSession) {
|
|
1018
|
+
if (spec.requireSessionLoad || spec.delegationMcp)
|
|
1019
|
+
throw new Error(`Agent '${spec.agentId}' does not support session/load`);
|
|
831
1020
|
console.warn(`[acp] agent '${spec.agentId}' does not support session/load; starting a new session ` +
|
|
832
1021
|
`instead of continuing '${nativeSessionId}'`);
|
|
833
1022
|
}
|
|
@@ -839,11 +1028,14 @@ export class AcpAgents {
|
|
|
839
1028
|
// empty forever, because nothing would ever revisit it.
|
|
840
1029
|
const initial = { configOptions: [] };
|
|
841
1030
|
const session = AcpSession.attach(connection, nativeSessionId, capture, initial);
|
|
1031
|
+
session.enableBackgroundTerminalRepair(spec.cwd, spec.agentEnv.HOME ?? spec.env.HOME ?? homedir());
|
|
842
1032
|
try {
|
|
843
1033
|
const history = [];
|
|
844
1034
|
for await (const event of replayNativeSession(connection, capture, {
|
|
845
1035
|
cwd: spec.cwd,
|
|
846
1036
|
nativeSessionId,
|
|
1037
|
+
mcpServers: spec.mcpServers,
|
|
1038
|
+
delegationMcp: spec.delegationMcp,
|
|
847
1039
|
onLoaded: (response) => {
|
|
848
1040
|
initial.configOptions = normalizeConfigOptions(response.configOptions, response.modes);
|
|
849
1041
|
},
|
|
@@ -851,19 +1043,27 @@ export class AcpAgents {
|
|
|
851
1043
|
// Folded and validated either way (one code path, no second copy of the replay rules);
|
|
852
1044
|
// retained only when someone is going to ask for it, so a long history is not held for
|
|
853
1045
|
// the life of a session that will never replay it.
|
|
1046
|
+
session.observeReplayEvent(event);
|
|
854
1047
|
if (spec.backfill)
|
|
855
1048
|
history.push(event);
|
|
856
1049
|
}
|
|
857
1050
|
return { session, nativeSessionId, history, configOptions: initial.configOptions };
|
|
858
1051
|
}
|
|
859
1052
|
catch (error) {
|
|
1053
|
+
if (spec.requireSessionLoad || spec.delegationMcp) {
|
|
1054
|
+
await session.close().catch(() => undefined);
|
|
1055
|
+
throw error;
|
|
1056
|
+
}
|
|
860
1057
|
console.warn(`[acp] session/load for '${nativeSessionId}' on agent '${spec.agentId}' failed ` +
|
|
861
1058
|
`(${errorMessage(error)}); starting a new session instead`);
|
|
862
1059
|
await session.close().catch(() => undefined);
|
|
863
1060
|
}
|
|
864
1061
|
}
|
|
865
1062
|
}
|
|
866
|
-
const opened = await AcpSession.open(connection, {
|
|
1063
|
+
const opened = await AcpSession.open(connection, {
|
|
1064
|
+
cwd: spec.cwd, mcpServers: spec.mcpServers, delegationMcp: spec.delegationMcp,
|
|
1065
|
+
});
|
|
1066
|
+
opened.session.enableBackgroundTerminalRepair(spec.cwd, spec.agentEnv.HOME ?? spec.env.HOME ?? homedir());
|
|
867
1067
|
return { session: opened.session, nativeSessionId: opened.nativeSessionId, history: [], configOptions: opened.configOptions };
|
|
868
1068
|
}
|
|
869
1069
|
/**
|
|
@@ -891,30 +1091,34 @@ export class AcpAgents {
|
|
|
891
1091
|
* All three distributions are fetched, not just npx — see {@link installAdapter}, which dispatches
|
|
892
1092
|
* npx → uvx → binary in `resolveLaunch`'s own preference order.
|
|
893
1093
|
*
|
|
894
|
-
* ## The catalog's `
|
|
1094
|
+
* ## The catalog's `prerequisite` runs first, and can refuse to run anything at all
|
|
895
1095
|
*
|
|
896
|
-
* `registry/catalog.ts` may curate one
|
|
897
|
-
*
|
|
898
|
-
* adapter step, in
|
|
1096
|
+
* `registry/catalog.ts` may curate one prerequisite for an agent (`pi-acp` needs the `pi` CLI,
|
|
1097
|
+
* `zcode-acp` the ZCode desktop app) that the adapter fetch above does nothing to provide. That
|
|
1098
|
+
* block is handled *before* the adapter step, in up to three gates:
|
|
899
1099
|
*
|
|
900
1100
|
* 1. `requiresNode`, checked against this daemon's own `process.version` (or the injected
|
|
901
1101
|
* `nodeVersion`, for tests) — because npm only *warns*, never refuses, on an `engines`
|
|
902
|
-
* mismatch. On a Node-20 machine `npm install -g` for `pi` "succeeds",
|
|
1102
|
+
* mismatch. On a Node-20 machine `npm install -g` for `pi` "succeeds", detection then finds
|
|
903
1103
|
* the binary, and a check that stopped at presence-on-`PATH` would report the dependency
|
|
904
1104
|
* satisfied right before the agent fails at run time with an engines error nobody could trace
|
|
905
1105
|
* back to install. Failing this gate returns `nodeTooOld` and runs no command at all — not
|
|
906
|
-
* even the
|
|
907
|
-
* 2.
|
|
1106
|
+
* even the detection below, since there would be nothing useful to install anyway.
|
|
1107
|
+
* 2. its `detect` (`registry/detect.ts`) — only when that finds nothing does this call the
|
|
908
1108
|
* catalog's literal `install` argv through {@link AcpAgents.runCommand}, which is
|
|
909
1109
|
* `execFile`-based and never a shell (see `catalog.ts`'s security note on that array).
|
|
1110
|
+
* 3. a prerequisite with no `install` argv cannot be fetched by anyone but the reader, so a missing
|
|
1111
|
+
* one refuses with the catalog's `hint` as the error — the same words the card already shows
|
|
1112
|
+
* from the roster's `missingPrerequisite` — and fetches no adapter either: an adapter on disk
|
|
1113
|
+
* whose engine is not would only turn the card into an `Add` whose try-run must fail.
|
|
910
1114
|
*
|
|
911
|
-
* An agent outside the catalog, or one with no `
|
|
1115
|
+
* An agent outside the catalog, or one with no `prerequisite` block, skips straight to the adapter
|
|
912
1116
|
* step below — nothing here changes for `claude-acp`, `codex-acp`, or any other uncurated agent.
|
|
913
1117
|
*
|
|
914
1118
|
* Never starts the agent: see {@link installNpxPackage}.
|
|
915
1119
|
*/
|
|
916
1120
|
async install(agentId, options = {}) {
|
|
917
|
-
const dependency = catalogEntryFor(agentId)?.
|
|
1121
|
+
const dependency = catalogEntryFor(agentId)?.prerequisite;
|
|
918
1122
|
if (dependency) {
|
|
919
1123
|
const nodeVersion = options.nodeVersion ?? process.version;
|
|
920
1124
|
if (dependency.requiresNode && !semver.satisfies(nodeVersion, dependency.requiresNode)) {
|
|
@@ -927,7 +1131,13 @@ export class AcpAgents {
|
|
|
927
1131
|
}
|
|
928
1132
|
}
|
|
929
1133
|
let dependencyInstalled;
|
|
930
|
-
if (dependency &&
|
|
1134
|
+
if (dependency && !this.prerequisiteSatisfied(agentId)) {
|
|
1135
|
+
if (!dependency.install) {
|
|
1136
|
+
return {
|
|
1137
|
+
ok: false,
|
|
1138
|
+
error: dependency.hint ?? `Agent '${agentId}' needs ${dependency.label}, which is not installed on this machine.`,
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
931
1141
|
const [command, ...args] = dependency.install;
|
|
932
1142
|
const outcome = await this.runCommand(command, args, { timeoutMs: options.timeoutMs });
|
|
933
1143
|
if (!outcome.ok) {
|
|
@@ -985,7 +1195,16 @@ export class AcpAgents {
|
|
|
985
1195
|
async installAdapter(agent, timeoutMs) {
|
|
986
1196
|
const { npx, uvx, binary } = agent.distribution;
|
|
987
1197
|
if (npx) {
|
|
988
|
-
|
|
1198
|
+
const installed = await this.installNpxWithFallback(agent, npx.package, timeoutMs);
|
|
1199
|
+
if (!installed.ok || installed.pinnedVersionUnavailable)
|
|
1200
|
+
return installed;
|
|
1201
|
+
try {
|
|
1202
|
+
await preparePatchedLaunch(agent, await resolveLaunch(agent, this.platform, this.arch, this.adapterRoot), this.adapterRoot, this.probe, { timeoutMs, npxCommand: this.npxCommand });
|
|
1203
|
+
return installed;
|
|
1204
|
+
}
|
|
1205
|
+
catch (error) {
|
|
1206
|
+
return { ok: false, error: `Adapter patch preparation failed: ${String(error)}` };
|
|
1207
|
+
}
|
|
989
1208
|
}
|
|
990
1209
|
if (uvx) {
|
|
991
1210
|
// `uv tool install <pkg>==<ver>`, not `uvx <pkg>`: the launch path reads the entry point out
|
|
@@ -1104,29 +1323,29 @@ export class AcpAgents {
|
|
|
1104
1323
|
};
|
|
1105
1324
|
}
|
|
1106
1325
|
/**
|
|
1107
|
-
* Whether the catalog's
|
|
1108
|
-
*
|
|
1109
|
-
* this daemon's Node satisfies `requiresNode` (that gate only matters
|
|
1110
|
-
* for reporting what is already on the machine). An agent outside the
|
|
1111
|
-
* `
|
|
1326
|
+
* Whether the catalog's prerequisite for this agent (`registry/catalog.ts`'s `prerequisite`) is
|
|
1327
|
+
* detected on this machine (`registry/detect.ts`) — never whether the adapter itself has been
|
|
1328
|
+
* fetched, and never whether this daemon's Node satisfies `requiresNode` (that gate only matters
|
|
1329
|
+
* at {@link install} time, not for reporting what is already on the machine). An agent outside the
|
|
1330
|
+
* catalog, or one with no `prerequisite` block, needs nothing extra and reads as satisfied.
|
|
1112
1331
|
*
|
|
1113
|
-
* {@link roster}
|
|
1114
|
-
* curated prerequisite is not reports **not installed**, so its card offers `Install`
|
|
1115
|
-
* fetches the
|
|
1332
|
+
* {@link roster} makes the same reading for `adapterInstalled`: an agent whose adapter is on disk
|
|
1333
|
+
* but whose curated prerequisite is not reports **not installed**, so its card offers `Install`
|
|
1334
|
+
* (which fetches the prerequisite) instead of an `Add` whose try-run was always going to fail on
|
|
1116
1335
|
* `Could not start pi: executable not found`. Design §4.4, point 2.
|
|
1117
1336
|
*/
|
|
1118
|
-
|
|
1119
|
-
const
|
|
1120
|
-
if (!
|
|
1337
|
+
prerequisiteSatisfied(agentId, probe = this.probe) {
|
|
1338
|
+
const prerequisite = catalogEntryFor(agentId)?.prerequisite;
|
|
1339
|
+
if (!prerequisite)
|
|
1121
1340
|
return true;
|
|
1122
|
-
return
|
|
1341
|
+
return detected(prerequisite.detect, probe, platformTriple(this.platform, this.arch));
|
|
1123
1342
|
}
|
|
1124
1343
|
/** A config-supplied `command` is the whole launch spec (ruling R3); otherwise the registry decides. */
|
|
1125
1344
|
async resolveLaunchFor(agentId, command, args) {
|
|
1126
1345
|
// 裁决 R3 的覆盖没有回退可言:用户自己写下的命令行就是**全部**,这里不该去猜他们的哪一段
|
|
1127
1346
|
// 参数是版本号。
|
|
1128
1347
|
if (command)
|
|
1129
|
-
return { launch: { command, args, env: {} } };
|
|
1348
|
+
return { launch: { command, args, env: {} }, source: 'command-override' };
|
|
1130
1349
|
const agents = await this.registry.load();
|
|
1131
1350
|
const agent = agents.find((candidate) => candidate.id === agentId);
|
|
1132
1351
|
if (!agent) {
|
|
@@ -1136,7 +1355,8 @@ export class AcpAgents {
|
|
|
1136
1355
|
// 每次启动都从刚读到的 registry 现算一遍,没有任何一处把回退记下来:上游把版本修好的那一刻,
|
|
1137
1356
|
// 钉版本的那条路自己就恢复了,不需要谁去清什么状态。
|
|
1138
1357
|
return {
|
|
1139
|
-
|
|
1358
|
+
source: 'registry',
|
|
1359
|
+
launch: await preparePatchedLaunch(agent, await resolveLaunch(agent, this.platform, this.arch, this.adapterRoot), this.adapterRoot, this.probe, { npxCommand: this.npxCommand }),
|
|
1140
1360
|
fallback: unpinnedFallbackFor(agent, this.platform),
|
|
1141
1361
|
};
|
|
1142
1362
|
}
|
|
@@ -1151,7 +1371,7 @@ export class AcpAgents {
|
|
|
1151
1371
|
* further along; `connectFor` builds its own options now.
|
|
1152
1372
|
*/
|
|
1153
1373
|
async connectResolved(agentId, resolved, env, agentEnv, options = {}) {
|
|
1154
|
-
return withUnpinnedFallback(agentId, resolved, (launch) => connectAgent(launch, env, agentEnv, options));
|
|
1374
|
+
return withUnpinnedFallback(agentId, resolved, (launch, source) => connectAgent(launch, env, agentEnv, { ...options, launchSource: source }));
|
|
1155
1375
|
}
|
|
1156
1376
|
/**
|
|
1157
1377
|
* Whether `resolveLaunch` can produce a spec for this agent on this machine (see {@link roster}).
|
|
@@ -1199,7 +1419,7 @@ export class AcpAgents {
|
|
|
1199
1419
|
*/
|
|
1200
1420
|
async function withUnpinnedFallback(agentId, resolved, attempt) {
|
|
1201
1421
|
try {
|
|
1202
|
-
return await attempt(resolved.launch);
|
|
1422
|
+
return await attempt(resolved.launch, resolved.source);
|
|
1203
1423
|
}
|
|
1204
1424
|
catch (error) {
|
|
1205
1425
|
const fallback = resolved.fallback;
|
|
@@ -1213,7 +1433,7 @@ async function withUnpinnedFallback(agentId, resolved, attempt) {
|
|
|
1213
1433
|
"removed, so npm resolves whatever it currently calls 'latest'. That is deliberately NOT " +
|
|
1214
1434
|
'the version the registry asked for: if this agent now misbehaves, suspect the version ' +
|
|
1215
1435
|
'before suspecting the agent.');
|
|
1216
|
-
return await attempt(fallback.launch);
|
|
1436
|
+
return await attempt(fallback.launch, 'registry-fallback');
|
|
1217
1437
|
}
|
|
1218
1438
|
}
|
|
1219
1439
|
/**
|
|
@@ -1227,7 +1447,7 @@ async function withUnpinnedFallback(agentId, resolved, attempt) {
|
|
|
1227
1447
|
* opened with, so the cell below is what `setConfigOption` writes back into and what the getter
|
|
1228
1448
|
* reads — see that field's own doc for what a stale answer costs on every reconnect.
|
|
1229
1449
|
*/
|
|
1230
|
-
function hostedSession(session, connection, history, configOptions) {
|
|
1450
|
+
function hostedSession(session, connection, history, configOptions, disposeStartup) {
|
|
1231
1451
|
// `AcpSession` keeps a cell of its own for the same value, but does not expose it — and the two
|
|
1232
1452
|
// are written from the same place (the wrapper below is the only caller of its setter), so this
|
|
1233
1453
|
// one cannot drift from it.
|
|
@@ -1237,7 +1457,7 @@ function hostedSession(session, connection, history, configOptions) {
|
|
|
1237
1457
|
// (`session.prompt([{ type: 'text', text }])`) — a stopgap for as long as `agent_prompt` could
|
|
1238
1458
|
// only carry text. That seam is gone: the frame now carries blocks, so wrapping here would be a
|
|
1239
1459
|
// second, narrower opinion about the shape of a user message.
|
|
1240
|
-
prompt: (blocks) => session.prompt(blocks),
|
|
1460
|
+
prompt: (blocks, platformTurnId) => session.prompt(blocks, platformTurnId),
|
|
1241
1461
|
// Both halves come off the live agent: the process's own exit state, and the session's own
|
|
1242
1462
|
// outstanding-prompt list. Nothing here is remembered by this daemon.
|
|
1243
1463
|
get liveness() {
|
|
@@ -1247,6 +1467,7 @@ function hostedSession(session, connection, history, configOptions) {
|
|
|
1247
1467
|
};
|
|
1248
1468
|
},
|
|
1249
1469
|
answer: (requestId, outcome) => session.answer(requestId, outcome),
|
|
1470
|
+
answerElicitation: (requestId, response) => session.answerElicitation(requestId, response),
|
|
1250
1471
|
interrupt: () => session.interrupt(),
|
|
1251
1472
|
setConfigOption: async (configId, value) => {
|
|
1252
1473
|
snapshot = await session.setConfigOption(configId, value);
|
|
@@ -1256,6 +1477,7 @@ function hostedSession(session, connection, history, configOptions) {
|
|
|
1256
1477
|
return snapshot;
|
|
1257
1478
|
},
|
|
1258
1479
|
steer: (blocks, priority) => session.steer(blocks, priority),
|
|
1480
|
+
stopAsyncTask: (asyncTaskId) => session.stopAsyncTask(asyncTaskId),
|
|
1259
1481
|
// Straight off the connection that opened this session — never inferred, and never read back
|
|
1260
1482
|
// from the roster, whose entry for this agent may still be the pre-connection one.
|
|
1261
1483
|
supportsSteering: connection.supportsSteering,
|
|
@@ -1263,9 +1485,15 @@ function hostedSession(session, connection, history, configOptions) {
|
|
|
1263
1485
|
// nothing about *when*, which ruling N4 reads as "the server may not name a timing".
|
|
1264
1486
|
steeringTimings: connection.steeringTimings,
|
|
1265
1487
|
agentInfo: connection.agentInfo,
|
|
1488
|
+
get runtimeDiagnostics() { return connection.runtimeDiagnostics; },
|
|
1489
|
+
providerTaskAdapterSelected: connection.providerTaskAdapterSelected,
|
|
1490
|
+
onProviderTaskEvent: (cb) => connection.onProviderTaskEvent(cb),
|
|
1491
|
+
providerTaskStop: (taskId, requestId) => session.providerTaskStop(taskId, requestId),
|
|
1492
|
+
onRuntimeDiagnostics: (cb) => connection.onRuntimeDiagnostics(cb),
|
|
1266
1493
|
supportsImageInput: connection.capabilities.promptImage,
|
|
1267
1494
|
onEvent: (cb) => session.onEvent(cb),
|
|
1268
1495
|
onTurnDone: (cb) => session.onTurnDone(cb),
|
|
1496
|
+
onActivity: (cb) => session.onActivity(cb),
|
|
1269
1497
|
async close() {
|
|
1270
1498
|
try {
|
|
1271
1499
|
await session.close();
|
|
@@ -1274,6 +1502,7 @@ function hostedSession(session, connection, history, configOptions) {
|
|
|
1274
1502
|
// Runs even if the session's own shutdown threw: the subprocess is the expensive thing to
|
|
1275
1503
|
// leak, and nothing else in the daemon will come back for it.
|
|
1276
1504
|
await connection.close().catch(() => undefined);
|
|
1505
|
+
await disposeStartup?.();
|
|
1277
1506
|
}
|
|
1278
1507
|
},
|
|
1279
1508
|
};
|
|
@@ -1285,3 +1514,55 @@ function hostedSession(session, connection, history, configOptions) {
|
|
|
1285
1514
|
}
|
|
1286
1515
|
return hosted;
|
|
1287
1516
|
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Opens one session on [connection] and collects the command table the agent broadcasts after `session/new`, then
|
|
1519
|
+
* closes it. `commands` undefined = no table arrived within [firstWindowMs] (unknown, not empty).
|
|
1520
|
+
*
|
|
1521
|
+
* The table is broadcast *after* `session/new` is answered (`acp-agent.js:752-757`), so a verification that closed
|
|
1522
|
+
* right after opening could never see it. The sink is attached after `open` on purpose and is still safe:
|
|
1523
|
+
* `AcpSession` buffers events until one is attached (`onEvent`'s `drain()`), and since p3-fix7 N9 the connection
|
|
1524
|
+
* also keeps the updates that arrive before it knows its own session id (`acpClient.ts`).
|
|
1525
|
+
*
|
|
1526
|
+
* Settle rather than "first push wins": Claude broadcasts twice — once on `session/new` and again when the init frame
|
|
1527
|
+
* lets it filter out terminal-bound commands. 不能靠一个固定的等待:一条都没来 → 到 [firstWindowMs] 就收工;来了第一条 →
|
|
1528
|
+
* 改用安静期([COMMAND_QUIET_MS])收尾,并受 [COMMAND_SETTLE_CEILING_MS] 兜底。
|
|
1529
|
+
*/
|
|
1530
|
+
async function openAndObserveCommands(connection, cwd, firstWindowMs) {
|
|
1531
|
+
const opened = await AcpSession.open(connection, { cwd });
|
|
1532
|
+
// Captured off `session/new`'s own response: the snapshot exists the moment `open` resolves.
|
|
1533
|
+
const configOptions = opened.configOptions;
|
|
1534
|
+
let commands;
|
|
1535
|
+
let finish;
|
|
1536
|
+
const settled = new Promise((resolve) => {
|
|
1537
|
+
finish = resolve;
|
|
1538
|
+
});
|
|
1539
|
+
let quietTimer;
|
|
1540
|
+
let ceilingTimer;
|
|
1541
|
+
let firstTimer = setTimeout(() => finish?.(), firstWindowMs);
|
|
1542
|
+
opened.session.onEvent((event) => {
|
|
1543
|
+
if (event.eventName !== 'available_commands_update')
|
|
1544
|
+
return;
|
|
1545
|
+
const available = event.payload.availableCommands;
|
|
1546
|
+
if (Array.isArray(available))
|
|
1547
|
+
commands = available.map(toAvailableCommandWire);
|
|
1548
|
+
// 第一条到手:等第一条的窗口作废,换成"安静就收工"+ 绝对上限。
|
|
1549
|
+
if (firstTimer !== undefined) {
|
|
1550
|
+
clearTimeout(firstTimer);
|
|
1551
|
+
firstTimer = undefined;
|
|
1552
|
+
ceilingTimer = setTimeout(() => finish?.(), COMMAND_SETTLE_CEILING_MS);
|
|
1553
|
+
}
|
|
1554
|
+
// 每再来一条就把安静计时重新起头 —— Claude 的第二条才是能用的那份。
|
|
1555
|
+
if (quietTimer !== undefined)
|
|
1556
|
+
clearTimeout(quietTimer);
|
|
1557
|
+
quietTimer = setTimeout(() => finish?.(), COMMAND_QUIET_MS);
|
|
1558
|
+
});
|
|
1559
|
+
await settled;
|
|
1560
|
+
if (firstTimer !== undefined)
|
|
1561
|
+
clearTimeout(firstTimer);
|
|
1562
|
+
if (quietTimer !== undefined)
|
|
1563
|
+
clearTimeout(quietTimer);
|
|
1564
|
+
if (ceilingTimer !== undefined)
|
|
1565
|
+
clearTimeout(ceilingTimer);
|
|
1566
|
+
await opened.session.close().catch(() => undefined);
|
|
1567
|
+
return { commands, configOptions };
|
|
1568
|
+
}
|