@platless/axom-cli 0.1.0 → 0.5.9
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 +132 -0
- package/dist/axom-patch/cli/account-limits/README.md +27 -0
- package/dist/axom-patch/cli/account-limits/antigravity-proxy.mjs +47 -0
- package/dist/axom-patch/cli/account-limits/antigravity-read.mjs +90 -0
- package/dist/axom-patch/cli/account-limits/antigravity.mjs +46 -0
- package/dist/axom-patch/cli/account-limits/generate.py +120 -0
- package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +64 -0
- package/dist/axom-patch/cli/account-limits/normalize.mjs +151 -0
- package/dist/axom-patch/cli/claude-account-limits.json +37 -0
- package/dist/axom-patch/cli/codex-account-limits.json +37 -0
- package/dist/axom-patch/cli/commandcode-account-limits.json +30 -0
- package/dist/axom-patch/cli/nightly-task.md +152 -0
- package/dist/axom-patch/cli/pi-steering.json +37 -0
- package/dist/axom-patch/cli/pi-subagent-subwork.json +23 -0
- package/dist/axom-patch/cli/probes/claude-late-async-terminal.mjs +38 -0
- package/dist/axom-patch/cli/probes/pi-steering.mjs +80 -0
- package/dist/axom-patch/cli/probes/pi-subagent-subwork.mjs +211 -0
- package/dist/axom-patch/cli/probes/qoder-steering.mjs +89 -0
- package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +61 -0
- package/dist/axom-patch/cli/probes/zcode-subagent-subwork.mjs +257 -0
- package/dist/axom-patch/cli/qoder-background-terminal.md +82 -0
- package/dist/axom-patch/cli/qoder-foreground-output.md +42 -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 +63 -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/axom-patch/cli/zcode-account-limits.json +31 -0
- package/dist/axom-patch/cli/zcode-models.json +38 -0
- package/dist/axom-patch/cli/zcode-subagent-subwork.json +73 -0
- package/dist/cli.js +36 -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/keepers/acp/keeper.mjs +3083 -0
- package/dist/keepers/acp/manifest.json +9 -0
- package/dist/keepers/terminal/keeper.mjs +8469 -0
- package/dist/keepers/terminal/manifest.json +9 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/conpty_console_list_agent.js +25 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/eventEmitter2.js +47 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/index.js +52 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/interfaces.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/shared/conout.js +10 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/terminal.js +190 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/types.js +7 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/unixTerminal.js +351 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/utils.js +38 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsConoutConnection.js +131 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsPtyAgent.js +344 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/windowsTerminal.js +209 -0
- package/dist/keepers/terminal/node_modules/node-pty/lib/worker/conoutSocketWorker.js +22 -0
- package/dist/keepers/terminal/node_modules/node-pty/package.json +66 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-arm64/pty.node +0 -0
- package/dist/keepers/terminal/node_modules/node-pty/prebuilds/linux-x64/pty.node +0 -0
- package/dist/mcp.js +32 -14
- package/dist/runtime-deps/qoder-sdk/package-lock.json +1189 -0
- package/dist/runtime-deps/qoder-sdk/package.json +8 -0
- package/dist/test/keeperKit.js +67 -0
- package/dist/workstation/acp/acpAgents.js +2514 -681
- package/dist/workstation/acp/acpClient.js +95 -318
- package/dist/workstation/acp/activityBusy.js +43 -0
- package/dist/workstation/acp/activityReporting.js +54 -0
- package/dist/workstation/acp/adoption.js +28 -356
- package/dist/workstation/acp/authUrlSniffer.js +36 -0
- package/dist/workstation/acp/binaryInstall.js +100 -27
- package/dist/workstation/acp/builds/gc.js +245 -0
- package/dist/workstation/acp/builds/install/binary.js +28 -0
- package/dist/workstation/acp/builds/install/npm.js +235 -0
- package/dist/workstation/acp/builds/install/uv.js +254 -0
- package/dist/workstation/acp/builds/migrate.js +195 -0
- package/dist/workstation/acp/builds/model.js +128 -0
- package/dist/workstation/acp/builds/nodeGate.js +100 -0
- package/dist/workstation/acp/builds/store.js +352 -0
- package/dist/workstation/acp/commandTableProbe.js +68 -0
- package/dist/workstation/acp/configOptions.js +103 -1
- package/dist/workstation/acp/delegationMcp.js +24 -0
- package/dist/workstation/acp/envSecrets.js +27 -0
- package/dist/workstation/acp/initializeResponse.js +285 -0
- package/dist/workstation/acp/installErrors.js +12 -2
- package/dist/workstation/acp/installProbe.js +1 -1
- package/dist/workstation/acp/managedPatches.js +144 -0
- package/dist/workstation/acp/outbox.js +330 -0
- package/dist/workstation/acp/patchAssets.js +80 -0
- package/dist/workstation/acp/permission.js +335 -37
- package/dist/workstation/acp/processor/checkpoint.js +87 -0
- package/dist/workstation/acp/processor/commands.js +35 -0
- package/dist/workstation/acp/processor/control.js +355 -0
- package/dist/workstation/acp/processor/emit.js +26 -0
- package/dist/workstation/acp/processor/jsonrpc.js +172 -0
- package/dist/workstation/acp/processor/memoryLog.js +49 -0
- package/dist/workstation/acp/processor/origins.js +48 -0
- package/dist/workstation/acp/processor/output.js +67 -0
- package/dist/workstation/acp/processor/projection.js +42 -0
- package/dist/workstation/acp/processor/record.js +128 -0
- package/dist/workstation/acp/processor/runtime.js +181 -0
- package/dist/workstation/acp/processor/sdkSchemas.js +61 -0
- package/dist/workstation/acp/processor/session.js +525 -0
- package/dist/workstation/acp/processor/sideChannel.js +80 -0
- package/dist/workstation/acp/processor/state.js +74 -0
- package/dist/workstation/acp/processor/step.js +459 -0
- package/dist/workstation/acp/processor/test/fixtureLog.js +65 -0
- package/dist/workstation/acp/processor/test/harness.js +120 -0
- package/dist/workstation/acp/processor/test/largeIdEcho.js +50 -0
- package/dist/workstation/acp/processor/test/rawAgent.js +70 -0
- package/dist/workstation/acp/processor/transport/generations.js +76 -0
- package/dist/workstation/acp/processor/transport/keeper.js +130 -0
- package/dist/workstation/acp/processor/transport/local.js +269 -0
- package/dist/workstation/acp/processor/transport/types.js +78 -0
- package/dist/workstation/acp/processor/updates.js +364 -0
- package/dist/workstation/acp/processorSession.js +1512 -0
- package/dist/workstation/acp/qoderStartup.js +45 -0
- package/dist/workstation/acp/registry/catalog.js +385 -4
- package/dist/workstation/acp/registry/detect.js +55 -0
- package/dist/workstation/acp/registry/launch.js +61 -119
- package/dist/workstation/acp/sessionUpdateMapper.js +56 -27
- package/dist/workstation/acp/steering.js +58 -0
- package/dist/workstation/acp/switching.js +114 -0
- package/dist/workstation/acp/terminalLogin.js +171 -0
- package/dist/workstation/acp/terminalLoginSpec.js +41 -0
- package/dist/workstation/acp/test/adapterFixtures.js +256 -0
- package/dist/workstation/acp/test/daemonLife.js +106 -0
- package/dist/workstation/acp/test/fakeAgent.js +185 -9
- package/dist/workstation/acp/test/keeperWorld.js +267 -0
- package/dist/workstation/acp/test/memoryOutbox.js +55 -0
- package/dist/workstation/acp/test/raw/fixtures.js +196 -0
- package/dist/workstation/acp/test/raw/qoderCaptures.js +125 -0
- package/dist/workstation/acp/test/raw/sanitize.js +344 -0
- package/dist/workstation/acp/test/raw/sources.js +249 -0
- package/dist/workstation/acp/test/realAdapter.js +377 -0
- package/dist/workstation/acp/test/scriptAgent.js +206 -0
- package/dist/workstation/acp/test/sessionFrames.js +42 -0
- package/dist/workstation/acp/timedCommand.js +4 -2
- package/dist/workstation/acp/uvxInstall.js +72 -122
- package/dist/workstation/acp/validate.js +33 -5
- package/dist/workstation/agentFlow.js +103 -0
- package/dist/workstation/agentSessions.js +1686 -348
- package/dist/workstation/channels/agent.js +14 -6
- package/dist/workstation/channels/computer.js +203 -7
- package/dist/workstation/channels/computerExecs.js +200 -0
- package/dist/workstation/channels/management.js +86 -6
- package/dist/workstation/config.js +3 -1
- package/dist/workstation/connection/encryptedChannel.js +88 -21
- package/dist/workstation/daemon.js +309 -6
- package/dist/workstation/daemonctl.js +28 -2
- 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/exec/attach.js +126 -0
- package/dist/workstation/exec/command.js +40 -0
- package/dist/workstation/exec/durable.js +517 -0
- package/dist/workstation/exec/fallback.js +106 -0
- package/dist/workstation/exec/policy.js +837 -0
- package/dist/workstation/exec/shaping.js +211 -0
- package/dist/workstation/exec.js +3 -112
- package/dist/workstation/files/adapters/computerFileFrames.js +51 -0
- package/dist/workstation/files/adapters/toolRequests.js +121 -0
- package/dist/workstation/files/client.js +455 -0
- package/dist/workstation/files/errors.js +28 -0
- package/dist/workstation/files/fence.js +350 -0
- package/dist/workstation/files/find/fileIndex.js +153 -0
- package/dist/workstation/files/find/fuzzy.js +155 -0
- package/dist/workstation/files/host.js +122 -0
- package/dist/workstation/files/hostProtocol.js +2 -0
- package/dist/workstation/files/media/cache.js +620 -0
- package/dist/workstation/files/media/encoding.js +308 -0
- package/dist/workstation/files/media/estimator.js +100 -0
- package/dist/workstation/files/media/media.js +325 -0
- package/dist/workstation/files/media/packedAudio.js +23 -0
- package/dist/workstation/files/media/scheduler.js +551 -0
- package/dist/workstation/files/media/types.js +5 -0
- package/dist/workstation/files/scope.js +45 -0
- package/dist/workstation/files/structured.js +284 -0
- package/dist/workstation/files/text.js +356 -0
- package/dist/workstation/files/uploads.js +351 -0
- package/dist/workstation/journal.js +65 -236
- package/dist/workstation/keepers/acp/client.js +101 -0
- package/dist/workstation/keepers/acp/envelope.js +62 -0
- package/dist/workstation/keepers/acp/host.js +419 -0
- package/dist/workstation/keepers/acp/main.js +18 -0
- package/dist/workstation/keepers/acp/memoryStream.js +121 -0
- package/dist/workstation/keepers/acp/params.js +137 -0
- package/dist/workstation/keepers/acp/protocol.js +66 -0
- package/dist/workstation/keepers/acp/recover.js +120 -0
- package/dist/workstation/keepers/acp/session.js +584 -0
- package/dist/workstation/keepers/acp/stderr.js +154 -0
- package/dist/workstation/keepers/kit/activity.js +103 -0
- package/dist/workstation/keepers/kit/bootstrap.js +164 -0
- package/dist/workstation/keepers/kit/buildVersion.js +51 -0
- package/dist/workstation/keepers/kit/bundle.js +120 -0
- package/dist/workstation/keepers/kit/client.js +432 -0
- package/dist/workstation/keepers/kit/identity.js +21 -0
- package/dist/workstation/keepers/kit/ipc.js +112 -0
- package/dist/workstation/keepers/kit/launchConfig.js +8 -0
- package/dist/workstation/keepers/kit/launcher.js +249 -0
- package/dist/workstation/keepers/kit/lock.js +123 -0
- package/dist/workstation/keepers/kit/orphanSweep.js +86 -0
- package/dist/workstation/keepers/kit/paths.js +75 -0
- package/dist/workstation/keepers/kit/protocol.js +43 -0
- package/dist/workstation/keepers/kit/server.js +518 -0
- package/dist/workstation/keepers/kit/streamLog.js +697 -0
- package/dist/workstation/keepers/legacy.js +296 -0
- package/dist/workstation/keepers/policy.js +576 -0
- package/dist/workstation/keepers/stop.js +34 -0
- package/dist/workstation/keepers/terminal/client.js +213 -0
- package/dist/workstation/keepers/terminal/exec.js +981 -0
- package/dist/workstation/keepers/terminal/generations.js +129 -0
- package/dist/workstation/keepers/terminal/keeper.js +62 -0
- package/dist/workstation/keepers/terminal/main.js +43 -0
- package/dist/workstation/keepers/terminal/orphanSweep.js +3 -0
- package/dist/workstation/keepers/terminal/params.js +27 -0
- package/dist/workstation/keepers/terminal/protocol.js +56 -0
- package/dist/workstation/keepers/terminal/pty.js +330 -0
- package/dist/workstation/oauth/deviceAuthorization.js +21 -12
- package/dist/workstation/protocol/accountLimits.js +47 -0
- package/dist/workstation/protocol/agent.js +201 -8
- package/dist/workstation/protocol/common.js +36 -0
- package/dist/workstation/protocol/computer.js +107 -7
- package/dist/workstation/protocol/management.js +50 -2
- package/dist/workstation/qoderAdapter/backgroundProcess.js +118 -0
- package/dist/workstation/qoderAdapter/backgroundTerminal.js +193 -0
- package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +372 -0
- package/dist/workstation/qoderAdapter/callbackContext.js +76 -0
- package/dist/workstation/qoderAdapter/completedContinuation.js +139 -0
- package/dist/workstation/qoderAdapter/contract.js +78 -0
- package/dist/workstation/qoderAdapter/foregroundOutput.js +36 -0
- package/dist/workstation/qoderAdapter/history.js +85 -0
- package/dist/workstation/qoderAdapter/identityIndex.js +191 -0
- package/dist/workstation/qoderAdapter/inputQueue.js +42 -0
- package/dist/workstation/qoderAdapter/main.js +95 -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 +48 -0
- package/dist/workstation/qoderAdapter/sdkRuntime.js +93 -0
- package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +145 -0
- package/dist/workstation/qoderAdapter/session.js +319 -0
- package/dist/workstation/qoderAdapter/subagentMeta.js +28 -0
- package/dist/workstation/qoderAdapter/subwork.js +85 -0
- package/dist/workstation/qoderAdapter/taskControl.js +38 -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/systemMetrics.js +110 -1
- package/dist/workstation/tools/browser.js +394 -0
- package/dist/workstation/tools/client.js +277 -0
- package/dist/workstation/tools/info.js +50 -0
- package/dist/workstation/tools/keeperProcess.js +124 -0
- package/dist/workstation/tools/router.js +55 -0
- package/dist/workstation/tools/terminalRouter.js +222 -0
- package/dist/workstation/tools/validation.js +86 -0
- package/dist/workstation/update/coordinator.js +32 -0
- package/dist/workstation/update/npmInstaller.js +2 -0
- package/dist/workstation/update/ptyPreflight.js +45 -0
- package/dist/workstation/update/taskRegistry.js +141 -23
- package/dist/workstation/update/watchdog.js +66 -0
- package/package.json +20 -7
- package/skills/axom/SKILL.md +55 -17
- package/skills/axom-definition/SKILL.md +32 -32
- package/skills/axom-platform/SKILL.md +39 -39
- package/dist/workstation/acp/acpSession.js +0 -1047
- package/dist/workstation/acp/contract.js +0 -494
- package/dist/workstation/acp/draftInterceptor.js +0 -154
|
@@ -1,18 +1,78 @@
|
|
|
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/`) and a processor-driven session (`processorSession.ts`, over keeper-acp or the
|
|
14
|
+
* local transport); `connectAgent` (`acpClient.ts`) serves the short-lived connections (list, delete, verify, sign-in).
|
|
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 { RequestError } from '@agentclientprotocol/sdk';
|
|
35
|
+
import { certifiedRuntime } from '../qoderAdapter/foregroundOutput.js';
|
|
36
|
+
import { existsSync } from 'node:fs';
|
|
37
|
+
import { readdir } from 'node:fs/promises';
|
|
1
38
|
import path from 'node:path';
|
|
39
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
40
|
import semver from 'semver';
|
|
3
|
-
import { connectAgent,
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
41
|
+
import { connectAgent, jsonRpcErrorCode, stderrShowsMissingPackageVersion } from './acpClient.js';
|
|
42
|
+
import { launchForTerminalLogin, runTerminalLogin, terminalInvocationFor } from './terminalLogin.js';
|
|
43
|
+
import { createCommandTableProbe } from './commandTableProbe.js';
|
|
44
|
+
import { deleteNativeSessions, listNativeSessions } from './adoption.js';
|
|
6
45
|
import { AcpAuth } from './auth.js';
|
|
7
|
-
import { binaryTargetFor, installBinary,
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
46
|
+
import { binaryTargetFor, installBinary, platformTriple } from './binaryInstall.js';
|
|
47
|
+
import { binaryBuild } from './builds/install/binary.js';
|
|
48
|
+
import { defaultNpmCommand, ensurePristine, npmBuild } from './builds/install/npm.js';
|
|
49
|
+
import { installUvBuild } from './builds/install/uv.js';
|
|
50
|
+
import { adoptNpxCopy, findNpxCopy, legacyBinaryVersions, moveLegacyBinary, RESERVED_ROOT_NAMES } from './builds/migrate.js';
|
|
51
|
+
import { AdapterNotInstalledError, AdapterStillInstallingError, binaryBuildDir, npmPristineDir, inside } from './builds/model.js';
|
|
52
|
+
import { AdapterNodeVersionError } from './builds/nodeGate.js';
|
|
53
|
+
import { buildKey as buildKeyOf, collectBuilds, collectLegacy, processCommandLines } from './builds/gc.js';
|
|
54
|
+
import { keyGenOf, refOfSession, resolveSuperseded, restoreConfigOptions, sessionKeyFor, SWITCH_ABANDONED_NOTE, SWITCH_SUSPEND_TIMEOUT_MS, } from './switching.js';
|
|
55
|
+
import { decodeCheckpoint } from './processor/checkpoint.js';
|
|
56
|
+
import { BuildStore } from './builds/store.js';
|
|
57
|
+
import { selectPackagePatches, patchDigest } from './managedPatches.js';
|
|
58
|
+
import { nativeExtensionFor, nativeAccountLimitsVersionFor } from './patchAssets.js';
|
|
59
|
+
import { ACCOUNT_LIMITS_READ_TIMEOUT_MS } from '../protocol/accountLimits.js';
|
|
60
|
+
import { buildSessionMcpServers } from './delegationMcp.js';
|
|
61
|
+
import { overlayEnvSecrets } from './envSecrets.js';
|
|
62
|
+
import { AdoptionRefusedError, ProcessorSession } from './processorSession.js';
|
|
63
|
+
import { hostsRunningAdapter } from './processor/transport/generations.js';
|
|
64
|
+
import { isOutboxTail } from './outbox.js';
|
|
65
|
+
import { LocalTransport } from './processor/transport/local.js';
|
|
66
|
+
import { AcpKeeperUnavailableError } from '../keepers/acp/client.js';
|
|
67
|
+
import { prepareQoderStartup, QODER_MANAGED_VERSION } from './qoderStartup.js';
|
|
68
|
+
import { friendlyOrdinaryInstallError, friendlyPinnedVersionUnavailableAndFallbackFailed, friendlyUvMissingError, logInstallFailureDetail, } from './installErrors.js';
|
|
69
|
+
import { ADAPTER_INSTALL_TIMEOUT_MS, npmCacheDir, npmPackageName, npmPackageVersion, pickInstalledVersion, findOnPath, probeEnvironment, runDependencyInstall, } from './installProbe.js';
|
|
11
70
|
import { createDeferredPermissionSink, registerPermissionHandlers } from './permission.js';
|
|
12
|
-
import { catalogEntryFor } from './registry/catalog.js';
|
|
13
|
-
import {
|
|
71
|
+
import { catalogEntryFor, catalogLaunchEnv, effectiveEntries, RECOMMENDED_RANK, withCatalogOverrides } from './registry/catalog.js';
|
|
72
|
+
import { detected } from './registry/detect.js';
|
|
73
|
+
import { launchForBuild, withPathPrefix } from './registry/launch.js';
|
|
14
74
|
import { RegistryClient } from './registry/registryClient.js';
|
|
15
|
-
import {
|
|
75
|
+
import { defaultRunUv, parseUvxSpec, UV_TOOL_INSTALL_TIMEOUT_MS } from './uvxInstall.js';
|
|
16
76
|
function errorMessage(error) {
|
|
17
77
|
return error instanceof Error ? error.message : String(error);
|
|
18
78
|
}
|
|
@@ -44,8 +104,8 @@ function errorMessage(error) {
|
|
|
44
104
|
*/
|
|
45
105
|
export class AgentRefusedSessionError extends Error {
|
|
46
106
|
errorCode;
|
|
47
|
-
constructor(cause) {
|
|
48
|
-
super(errorMessage(cause), { cause });
|
|
107
|
+
constructor(cause, publicMessage) {
|
|
108
|
+
super(publicMessage ?? errorMessage(cause), publicMessage === undefined ? { cause } : undefined);
|
|
49
109
|
this.name = 'AgentRefusedSessionError';
|
|
50
110
|
this.errorCode = jsonRpcErrorCode(cause);
|
|
51
111
|
}
|
|
@@ -184,9 +244,11 @@ export function createLoginUrlSink(login) {
|
|
|
184
244
|
/** A verification or authentication ran past {@link AGENT_PROBE_TIMEOUT_MS}. */
|
|
185
245
|
export class AgentProbeTimeoutError extends Error {
|
|
186
246
|
}
|
|
247
|
+
/** A real session stopped answering after initialize; closing its transport reaps the child. */
|
|
248
|
+
export class AgentSessionStartTimeoutError extends Error {
|
|
249
|
+
}
|
|
187
250
|
/**
|
|
188
|
-
* Reject with
|
|
189
|
-
* doing.
|
|
251
|
+
* Reject with the specified timeout error once `timeoutMs` has passed, whatever `work` is still doing.
|
|
190
252
|
*
|
|
191
253
|
* `work` is deliberately *not* cancelled here — nothing in ACP can cancel an `initialize` or a
|
|
192
254
|
* `session/new` in flight. What actually stops it is the caller's `connection.close()`, which kills
|
|
@@ -194,10 +256,10 @@ export class AgentProbeTimeoutError extends Error {
|
|
|
194
256
|
* it will usually reject (the child died under it) long after nobody is listening, and an unhandled
|
|
195
257
|
* rejection would take the daemon down.
|
|
196
258
|
*/
|
|
197
|
-
async function withDeadline(work, timeoutMs, what) {
|
|
259
|
+
async function withDeadline(work, timeoutMs, what, timeoutError = AgentProbeTimeoutError) {
|
|
198
260
|
let timer;
|
|
199
261
|
const deadline = new Promise((_, reject) => {
|
|
200
|
-
timer = setTimeout(() => reject(new
|
|
262
|
+
timer = setTimeout(() => reject(new timeoutError(`${what} did not finish within ${timeoutMs}ms.`)), timeoutMs);
|
|
201
263
|
});
|
|
202
264
|
deadline.catch(() => { });
|
|
203
265
|
try {
|
|
@@ -212,7 +274,7 @@ async function withDeadline(work, timeoutMs, what) {
|
|
|
212
274
|
* 等**第一条** `available_commands_update` 的窗口。一条都没等到就收工,`observed` 保持 undefined
|
|
213
275
|
* ——按「缺席 ≠ 空表」的约定,服务端据此不写命令表那一半(见 {@link verify} 的 KDoc)。
|
|
214
276
|
*
|
|
215
|
-
* 数值沿用原来那个固定 settle 窗口(`docs/superpowers/notes/2026-08-31-codex-command-table-race.md`)。
|
|
277
|
+
* 数值沿用原来那个固定 settle 窗口(`archived/docs/superpowers/notes/2026-08-31-codex-command-table-race.md`)。
|
|
216
278
|
* **没有证据支持把它调大**:真机上量到同一个 agent 在一台机器上回 72 条、在另一台回 0 条,但那两次
|
|
217
279
|
* 的耗时差(8.4s vs 2.1s)完全可能全部落在 spawn/connect 上,而不是广播来晚了。要区分这两件事,
|
|
218
280
|
* 得先有 {@link verify} 里那条「没等到表」的日志,不是先改这个数字。
|
|
@@ -232,6 +294,13 @@ const COMMAND_QUIET_MS = 750;
|
|
|
232
294
|
* 必须显著小于 {@link AGENT_PROBE_TIMEOUT_MS} —— 连接和 `session/new` 还要在这之前各花一段。
|
|
233
295
|
*/
|
|
234
296
|
const COMMAND_SETTLE_CEILING_MS = 5_000;
|
|
297
|
+
/**
|
|
298
|
+
* p3-fix7 N9: a verification whose first opening saw no command table opens one more session and waits this long for
|
|
299
|
+
* the first broadcast. "Nothing seen" is not an observation: the dispatch planner reads "offers
|
|
300
|
+
* superpowers:brainstorming" off this table, and a missed table can flip a project to native. Kept well inside
|
|
301
|
+
* {@link AGENT_PROBE_TIMEOUT_MS} with the first attempt's window and two session openings.
|
|
302
|
+
*/
|
|
303
|
+
const COMMAND_RETRY_FIRST_BROADCAST_MS = 4_000;
|
|
235
304
|
/** ACP 的 `AvailableCommand` → 隧道形状:`input.hint` 拍平成 `inputHint`。 */
|
|
236
305
|
function toAvailableCommandWire(raw) {
|
|
237
306
|
const command = raw;
|
|
@@ -250,42 +319,49 @@ function toAvailableCommandWire(raw) {
|
|
|
250
319
|
* others — `verify` and `listNativeSessions` are exactly the paths a settings page depends on, and
|
|
251
320
|
* they are the ones nobody remembers.
|
|
252
321
|
*/
|
|
322
|
+
/** The same observation, off the processor's `initialize` reading (a live session's). */
|
|
323
|
+
function observationOfFacts(facts) {
|
|
324
|
+
return {
|
|
325
|
+
authMethods: facts.authMethods,
|
|
326
|
+
supportsAccountLimits: facts.supportsAccountLimits,
|
|
327
|
+
supportsSteering: facts.supportsSteering,
|
|
328
|
+
terminalLogins: new Map(facts.terminalLogins),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/** keeper-acp's bound on a spawn spec's lists (`params.ts` MAX_LIST). */
|
|
332
|
+
const ACP_STDERR_SECRETS_MAX = 256;
|
|
333
|
+
/** A spawn spec's environment is complete (keeper-acp never reads its own): `process.env`'s unset entries dropped. */
|
|
334
|
+
function completeEnvironment(env) {
|
|
335
|
+
const out = {};
|
|
336
|
+
for (const [key, value] of Object.entries(env))
|
|
337
|
+
if (typeof value === 'string')
|
|
338
|
+
out[key] = value;
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
/** `initialize` on a session open (`connectAgent`'s default, which the old path used). */
|
|
342
|
+
const DEFAULT_SESSION_INITIALIZE_TIMEOUT_MS = 30_000;
|
|
253
343
|
function observationOf(connection) {
|
|
254
|
-
return {
|
|
344
|
+
return {
|
|
345
|
+
authMethods: connection.authMethods,
|
|
346
|
+
supportsAccountLimits: connection.supportsAccountLimits,
|
|
347
|
+
supportsSteering: connection.supportsSteering,
|
|
348
|
+
terminalLogins: connection.terminalLogins,
|
|
349
|
+
};
|
|
255
350
|
}
|
|
256
351
|
/**
|
|
257
|
-
* The version of
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
* answered for npx, this covers all three, and `undefined` here is a genuine "not installed" rather
|
|
261
|
-
* than "did not look" — every branch is a cheap local read this daemon always performs.
|
|
262
|
-
*
|
|
263
|
-
* An agent with more than one distribution (uncommon on the live registry, but not ruled out by the
|
|
264
|
-
* types) is checked npx-first, then uvx, then binary — the same preference order `resolveLaunch`
|
|
265
|
-
* uses to pick which one actually launches.
|
|
266
|
-
*
|
|
267
|
-
* `uvVersions` is a pre-read `uv tool list` snapshot ({@link listUvTools}), not a live call: `uv` is
|
|
268
|
-
* a real subprocess, and this function runs once per registry entry inside {@link AcpAgents.roster}'s
|
|
269
|
-
* map — spawning it per uvx agent there is exactly the per-agent-subprocess defect the roster's own
|
|
270
|
-
* doc comment rules out (39 entries, on every scan). The caller reads `uv tool list` exactly once and
|
|
271
|
-
* hands the parsed result in here for a plain map lookup.
|
|
352
|
+
* The version a new session of an agent would run on this machine: its **current build**
|
|
353
|
+
* (`builds.json`, spec §6.4 — `adapterVersion` reports current), for every distribution, uv included
|
|
354
|
+
* (Task 15b). `undefined` is a genuine "not installed".
|
|
272
355
|
*/
|
|
273
|
-
function adapterVersionFor(
|
|
274
|
-
|
|
275
|
-
if (npx)
|
|
276
|
-
return npxCacheVersion(npx.package, npxCache);
|
|
277
|
-
if (uvx)
|
|
278
|
-
return uvVersions.get(uvPackageName(uvx.package));
|
|
279
|
-
if (binary)
|
|
280
|
-
return installedBinaryVersion(agent.id, adapterRoot, agent.version);
|
|
281
|
-
return undefined;
|
|
356
|
+
function adapterVersionFor(current) {
|
|
357
|
+
return current?.version;
|
|
282
358
|
}
|
|
283
359
|
/**
|
|
284
360
|
* Whether `agent` cannot be launched on this machine **at all** — not "its `binary` archive is
|
|
285
361
|
* missing for this platform", which is what this used to check on its own, independent of whatever
|
|
286
362
|
* else the agent offers.
|
|
287
363
|
*
|
|
288
|
-
*
|
|
364
|
+
* Install dispatches npx first, then uvx, then binary, and an agent with
|
|
289
365
|
* either of the first two is launchable through it regardless of what its `binary` block does or
|
|
290
366
|
* does not cover — `kilo` and `sigit` on the live registry both carry `npx` *and* `binary`, and a
|
|
291
367
|
* platform missing their binary archive is still fully served by npx. So this derives its answer
|
|
@@ -304,34 +380,215 @@ function isUnsupportedPlatform(agent, platform, arch) {
|
|
|
304
380
|
const triple = platformTriple(platform, arch);
|
|
305
381
|
return !triple || !binaryTargetFor(agent, triple);
|
|
306
382
|
}
|
|
383
|
+
/** The version an entry asks npm for: its npx specifier's pin, else the entry's version. */
|
|
384
|
+
function requestedVersionOf(entry) {
|
|
385
|
+
const npx = entry.agent.distribution.npx;
|
|
386
|
+
return (npx && npmPackageVersion(npx.package)) || entry.agent.version;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* How long an open waits for an install it had to start (spec §6.2) before failing with
|
|
390
|
+
* {@link AdapterStillInstallingError}. The server gives a whole open `AGENT_CHANNEL_OPEN_TIMEOUT_MS`
|
|
391
|
+
* = 120 s (`AgentChannelSession.kt`); this leaves ~30 s for the spawn, `initialize` and
|
|
392
|
+
* `session/new|load` that follow, so the reader gets this actionable error instead of a generic
|
|
393
|
+
* "open timed out". The install itself is not bounded by this — it keeps running under the agent lock
|
|
394
|
+
* with its own install budget, and a retried open waits on that lock.
|
|
395
|
+
*/
|
|
396
|
+
export const OPEN_INSTALL_BUDGET_MS = 90_000;
|
|
397
|
+
/** How long the resume offer waits for keeper-acp's session list (the offer goes out regardless). */
|
|
398
|
+
const LEFTOVER_LIST_TIMEOUT_MS = 5_000;
|
|
399
|
+
/** keeper-acp sessions rebuilt at once by the start-time adoption. */
|
|
400
|
+
const ADOPT_CONCURRENCY = 8;
|
|
401
|
+
/** The version installing `entry` asks for — what an open that has to install shows the user. */
|
|
402
|
+
function installingVersionOf(entry) {
|
|
403
|
+
const { npx, uvx } = entry.agent.distribution;
|
|
404
|
+
if (npx)
|
|
405
|
+
return requestedVersionOf(entry);
|
|
406
|
+
return (uvx && parseUvxSpec(uvx.package).version) || entry.agent.version;
|
|
407
|
+
}
|
|
408
|
+
/** The registry could not be read for a migration: tried again by the next launch rather than given up on. */
|
|
409
|
+
class RegistryUnavailableForMigration extends Error {
|
|
410
|
+
}
|
|
411
|
+
/** Pre-build binary agent directories directly under the `acp-adapters` root (`builds/migrate.ts`). */
|
|
412
|
+
async function legacyAgentDirs(root) {
|
|
413
|
+
let entries;
|
|
414
|
+
try {
|
|
415
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return [];
|
|
419
|
+
}
|
|
420
|
+
return entries.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.') && !RESERVED_ROOT_NAMES.has(entry.name)).map((entry) => entry.name);
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* {@link BridgeInfo.currentBuild}/`builds`/`switchPending` for one agent (spec §6.4): the current build (with its sessions, possibly
|
|
424
|
+
* none) and every build a live session runs. `listed` false (keeper-acp could not be listed): only `currentBuild`.
|
|
425
|
+
*/
|
|
426
|
+
export function rosterBuildFields(current, sessions, listed) {
|
|
427
|
+
const patchedBuild = (build) => build.kind === 'npm-patched' || build.patchDigest !== undefined;
|
|
428
|
+
const fields = {};
|
|
429
|
+
if (current)
|
|
430
|
+
fields.currentBuild = { id: current.buildId, version: current.version, ...(patchedBuild(current) ? { patched: true } : {}) };
|
|
431
|
+
if (!listed)
|
|
432
|
+
return fields;
|
|
433
|
+
const tallies = new Map();
|
|
434
|
+
const tally = (id, version, patched) => {
|
|
435
|
+
let entry = tallies.get(id);
|
|
436
|
+
if (!entry) {
|
|
437
|
+
entry = { id, version, patched, sessions: 0, idle: 0, busy: 0, unmovable: 0, busyReasons: new Set(), unmovableReasons: new Set() };
|
|
438
|
+
tallies.set(id, entry);
|
|
439
|
+
}
|
|
440
|
+
return entry;
|
|
441
|
+
};
|
|
442
|
+
if (current)
|
|
443
|
+
tally(current.buildId, current.version, patchedBuild(current));
|
|
444
|
+
const pendingTargets = [];
|
|
445
|
+
for (const session of sessions ?? []) {
|
|
446
|
+
const entry = tally(session.buildId, session.version, session.patched);
|
|
447
|
+
entry.sessions += 1;
|
|
448
|
+
entry[session.state] += 1;
|
|
449
|
+
const reasons = session.state === 'busy' ? entry.busyReasons : session.state === 'unmovable' ? entry.unmovableReasons : undefined;
|
|
450
|
+
for (const reason of session.reasons ?? [])
|
|
451
|
+
if (reasons && reasons.size < ROSTER_REASONS_MAX)
|
|
452
|
+
reasons.add(reason);
|
|
453
|
+
if (session.pendingTo !== undefined)
|
|
454
|
+
pendingTargets.push(session.pendingTo);
|
|
455
|
+
}
|
|
456
|
+
if (tallies.size === 0)
|
|
457
|
+
return fields;
|
|
458
|
+
// The current build first, then the rest by version (newest first), so a card reads the same on every scan.
|
|
459
|
+
const ordered = [...tallies.values()].sort((a, b) => (a.id === current?.buildId ? -1 : b.id === current?.buildId ? 1 : 0) || compareVersionsDesc(a.version, b.version) || a.id.localeCompare(b.id));
|
|
460
|
+
fields.builds = ordered.map((entry) => ({
|
|
461
|
+
id: entry.id, version: entry.version, ...(entry.patched ? { patched: true } : {}),
|
|
462
|
+
...(entry.id === current?.buildId ? { current: true } : {}),
|
|
463
|
+
sessions: entry.sessions, idle: entry.idle, busy: entry.busy, unmovable: entry.unmovable,
|
|
464
|
+
...(entry.busyReasons.size > 0 ? { busyReasons: [...entry.busyReasons] } : {}),
|
|
465
|
+
...(entry.unmovableReasons.size > 0 ? { unmovableReasons: [...entry.unmovableReasons] } : {}),
|
|
466
|
+
}));
|
|
467
|
+
if (pendingTargets.length > 0) {
|
|
468
|
+
const first = pendingTargets[0];
|
|
469
|
+
fields.switchPending = { remaining: pendingTargets.length, ...(pendingTargets.every((target) => target === first) ? { toBuild: first } : {}) };
|
|
470
|
+
}
|
|
471
|
+
return fields;
|
|
472
|
+
}
|
|
473
|
+
function compareVersionsDesc(a, b) {
|
|
474
|
+
const va = semver.parse(a, { loose: true });
|
|
475
|
+
const vb = semver.parse(b, { loose: true });
|
|
476
|
+
return va && vb ? semver.rcompare(va, vb) : 0;
|
|
477
|
+
}
|
|
478
|
+
/** Why a local-fallback session is never moved (spec §6.3 "当前限制"): it does not outlive the daemon, so its next reopen uses current. */
|
|
479
|
+
export const LOCAL_SESSION_UNMOVABLE = 'it is running without the session keeper; it will use the current build when reopened';
|
|
480
|
+
/** At most this many distinct reasons per build entry (the card explains, it does not list every session). */
|
|
481
|
+
const ROSTER_REASONS_MAX = 4;
|
|
307
482
|
export class AcpAgents {
|
|
483
|
+
sessionStartTimeoutMs;
|
|
308
484
|
registry;
|
|
309
485
|
platform;
|
|
310
486
|
arch;
|
|
311
487
|
probe;
|
|
312
488
|
runCommand;
|
|
313
489
|
/**
|
|
314
|
-
*
|
|
490
|
+
* The adapter builds root (`builds/model.ts` layout) —
|
|
315
491
|
* `<workstation config dir>/acp-adapters`, derived from the same `cacheDir` the registry cache
|
|
316
|
-
* itself lives in directly
|
|
317
|
-
* branch needs this root and nothing else in this class's options carries it).
|
|
492
|
+
* itself lives in directly.
|
|
318
493
|
*/
|
|
319
494
|
adapterRoot;
|
|
495
|
+
commandRetryFirstBroadcastMs;
|
|
320
496
|
/** Last observation per registry agent id; in-process only, by design — see {@link AuthObservation}. */
|
|
321
497
|
observed = new Map();
|
|
322
|
-
|
|
498
|
+
quotaReaders = new Map();
|
|
499
|
+
/** The `uv` uv builds are installed with; see {@link AcpAgentsOptions.runUv}. */
|
|
323
500
|
runUv;
|
|
324
|
-
/** Injectable `
|
|
325
|
-
|
|
501
|
+
/** Injectable `npm` for adapter builds; see {@link AcpAgentsOptions.npmCommand}. */
|
|
502
|
+
npmCommand;
|
|
503
|
+
nodePath;
|
|
504
|
+
binaryFetch;
|
|
505
|
+
patchRecipes;
|
|
506
|
+
/** `builds.json` and the per-agent install/switch/GC lock (spec §6.1). */
|
|
507
|
+
builds;
|
|
508
|
+
/** The registry with catalog supplements but without overrides applied — {@link effectiveEntries} applies them, keeping launchEnv apart. */
|
|
509
|
+
baseRegistry;
|
|
510
|
+
/** {@link OPEN_INSTALL_BUDGET_MS}, overridable for tests. */
|
|
511
|
+
openInstallBudgetMs;
|
|
512
|
+
cacheDir;
|
|
513
|
+
transports;
|
|
514
|
+
generations;
|
|
515
|
+
/** The once-per-daemon adoption's discovery ({@link adoptKeeperSessions}). */
|
|
516
|
+
adoption;
|
|
517
|
+
/**
|
|
518
|
+
* keeper-acp generations whose listing failed (timed out, not answering) at the adoption or a retry since (final review finding 3):
|
|
519
|
+
* their sessions are neither adopted nor orphans yet — {@link retryKeeperAdoption} lists them again, no open spawns while one
|
|
520
|
+
* remains (a spawn into a session of it would stop its running adapter), and they are never retired meanwhile.
|
|
521
|
+
*/
|
|
522
|
+
unlistedGens = new Set();
|
|
523
|
+
/** The retry in flight ({@link retryKeeperAdoption}). */
|
|
524
|
+
retrying;
|
|
525
|
+
leftoverListTimeoutMs;
|
|
526
|
+
/** Which keeper-acp generation holds a session key (discovery and leftover listings): its IPC goes there. */
|
|
527
|
+
keeperOwner = new Map();
|
|
528
|
+
/** The highest seq a keeper-acp session's outbox held when it was listed (the local fallback numbers above it, Task 21). */
|
|
529
|
+
keeperHeads = new Map();
|
|
530
|
+
/** Sessions this daemon holds (or is spawning) on an older generation, by key: that generation is never stopped meanwhile. */
|
|
531
|
+
heldOnOlder = new Map();
|
|
532
|
+
/** Refs whose session the manager is retiring to respawn it ({@link holdForReopen}): their older generation stays held. */
|
|
533
|
+
reopenHolds = new Set();
|
|
534
|
+
/** Keys this daemon spawned on keeper-acp: never adopted (they are its own). */
|
|
535
|
+
ownKeeperKeys = new Set();
|
|
536
|
+
initializeTimeoutMs;
|
|
537
|
+
/** One legacy migration per agent per process ({@link migrateAgent}); the daemon's start and any open share it. */
|
|
538
|
+
migrations = new Map();
|
|
539
|
+
/**
|
|
540
|
+
* Each ref's current keeper-acp key generation (Task 24): key `<ref>` (0) until a switch moves it to `<ref>#s<n>`. Learned from
|
|
541
|
+
* every listing (the spawn label's `keyGen` is the persisted record) and set by every switch; a ref absent here uses itself.
|
|
542
|
+
*/
|
|
543
|
+
refKeys = new Map();
|
|
544
|
+
/**
|
|
545
|
+
* keeper-acp sessions whose adapter runs but which cannot be continued (spec §5.5.6: no readable checkpoint and raw truncated, or
|
|
546
|
+
* its open never completed), by key: left running, marked degraded, and switched at the ref's next `agent_open` — the process is
|
|
547
|
+
* closed and a new one loads the native session (never `session/new`).
|
|
548
|
+
*/
|
|
549
|
+
unrecoverable = new Map();
|
|
550
|
+
/** Daemon leases on builds (opens resolving → spawning, switches, short-lived connections), `agentId\0buildId` → count (GC). */
|
|
551
|
+
leases = new Map();
|
|
552
|
+
/** When each build was last seen referenced (GC's 10-minute grace); unknown builds count from this daemon's start. */
|
|
553
|
+
buildSeen = new Map();
|
|
554
|
+
startedAt = Date.now();
|
|
555
|
+
/** Refs whose switch cannot happen (spec §6.4 `unmovable`), with why — until the session goes or its target changes. */
|
|
556
|
+
unmovableRefs = new Map();
|
|
557
|
+
/** The persistent intents (`builds.json` `switchPending`), read once and kept in step with every write. */
|
|
558
|
+
intents;
|
|
559
|
+
/** One GC at a time (a later one runs after it). */
|
|
560
|
+
collecting;
|
|
561
|
+
/** One {@link retireIdleGenerations} pass at a time, and whether another was asked for meanwhile. */
|
|
562
|
+
retiringGenerations;
|
|
563
|
+
retireAgain = false;
|
|
326
564
|
constructor(options) {
|
|
327
|
-
this.
|
|
565
|
+
this.sessionStartTimeoutMs = options.sessionStartTimeoutMs ?? 60_000;
|
|
566
|
+
if (!(this.sessionStartTimeoutMs > 0) || !Number.isFinite(this.sessionStartTimeoutMs)) {
|
|
567
|
+
throw new RangeError('sessionStartTimeoutMs must be finite and positive');
|
|
568
|
+
}
|
|
569
|
+
// Through the catalog, always: an injected registry too, so tests exercise the same overlay.
|
|
570
|
+
this.baseRegistry = options.registry ?? new RegistryClient({ cacheDir: options.cacheDir });
|
|
571
|
+
this.registry = withCatalogOverrides(this.baseRegistry);
|
|
328
572
|
this.platform = options.platform ?? process.platform;
|
|
329
573
|
this.arch = options.arch ?? process.arch;
|
|
330
574
|
this.probe = options.probe ?? probeEnvironment();
|
|
331
575
|
this.runCommand = options.runCommand ?? runDependencyInstall;
|
|
332
|
-
this.runUv = options.runUv;
|
|
333
|
-
this.
|
|
576
|
+
this.runUv = options.runUv ?? defaultRunUv;
|
|
577
|
+
this.nodePath = options.nodePath ?? process.execPath;
|
|
578
|
+
this.npmCommand = options.npmCommand ?? defaultNpmCommand(this.nodePath, this.platform);
|
|
579
|
+
this.binaryFetch = options.binaryFetch;
|
|
580
|
+
this.patchRecipes = options.patchRecipes;
|
|
581
|
+
this.commandRetryFirstBroadcastMs = options.commandRetryFirstBroadcastMs ?? COMMAND_RETRY_FIRST_BROADCAST_MS;
|
|
582
|
+
this.openInstallBudgetMs = options.openInstallBudgetMs ?? OPEN_INSTALL_BUDGET_MS;
|
|
334
583
|
this.adapterRoot = path.join(options.cacheDir, 'acp-adapters');
|
|
584
|
+
this.builds = new BuildStore(this.adapterRoot);
|
|
585
|
+
this.cacheDir = options.cacheDir;
|
|
586
|
+
this.transports = { ...options.transports };
|
|
587
|
+
this.generations = options.keeperGenerations;
|
|
588
|
+
this.initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_SESSION_INITIALIZE_TIMEOUT_MS;
|
|
589
|
+
this.leftoverListTimeoutMs = options.leftoverListTimeoutMs ?? LEFTOVER_LIST_TIMEOUT_MS;
|
|
590
|
+
// Read once, up front: an `agent_open` asks for a ref's intent on its way to the reply (Task 24).
|
|
591
|
+
void this.loadIntents().catch(() => undefined);
|
|
335
592
|
}
|
|
336
593
|
/**
|
|
337
594
|
* The roster reported in the `hello` frame and answered to an external-agents scan, one entry per
|
|
@@ -349,9 +606,15 @@ export class AcpAgents {
|
|
|
349
606
|
* is the one field that does describe this machine (has the ACP adapter already been downloaded
|
|
350
607
|
* here), spread in only when it was genuinely established.
|
|
351
608
|
*
|
|
352
|
-
* `
|
|
609
|
+
* `curated` / `defaultName` / `defaultLabel` come from `registry/catalog.ts` — the curated list
|
|
353
610
|
* of agents Axom has actually verified, which replaced the old "official CLI on PATH" table. They
|
|
354
611
|
* are spread in only for a catalog hit; an agent outside the catalog is not claimed either way.
|
|
612
|
+
* `recommended` is the catalog's own static flag (claude, codex, pi) and nothing else — it used to
|
|
613
|
+
* be spread for every catalog hit, which is what `curated` now says. `detected` is the catalog's
|
|
614
|
+
* local detection (`registry/detect.ts`): the agent itself, or its prerequisite, is on this machine
|
|
615
|
+
* the way the user installed it — a reason to recommend, never a claim that Axom installed it.
|
|
616
|
+
* `missingPrerequisite` names a prerequisite that is absent *and* that `install` cannot fetch, so
|
|
617
|
+
* the card can say what to do instead of offering an `Install` that can only refuse.
|
|
355
618
|
*
|
|
356
619
|
* Never throws, for the same reason the deleted `rosterFor` swallowed a failing probe: a registry
|
|
357
620
|
* that is unreachable on a cold-start machine must not be able to stop the daemon from connecting.
|
|
@@ -362,7 +625,7 @@ export class AcpAgents {
|
|
|
362
625
|
* started daemon reports neither field for anything — that absence is the truthful answer and the
|
|
363
626
|
* server contract spells out that it means "unknown", not "not authenticated".
|
|
364
627
|
*/
|
|
365
|
-
async roster() {
|
|
628
|
+
async roster(options = {}) {
|
|
366
629
|
let agents;
|
|
367
630
|
try {
|
|
368
631
|
agents = await this.registry.load();
|
|
@@ -371,37 +634,39 @@ export class AcpAgents {
|
|
|
371
634
|
console.warn(`[acp] agent registry unavailable, reporting an empty roster: ${errorMessage(error)}`);
|
|
372
635
|
return [];
|
|
373
636
|
}
|
|
374
|
-
// Read once for the whole roster, not once per agent:
|
|
375
|
-
//
|
|
376
|
-
const
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
// this once per roster build, not once per agent.
|
|
383
|
-
const uvTools = await listUvTools(this.runUv);
|
|
384
|
-
const uvVersions = new Map(uvTools.map((entry) => [entry.name, entry.version]));
|
|
385
|
-
// Hoisted for the same reason as `npxCache`/`uvTools`: it is the same value for every entry in
|
|
637
|
+
// Read once for the whole roster, not once per agent: every npm/binary agent's installed version
|
|
638
|
+
// is its current build (spec §6.4).
|
|
639
|
+
const buildsDocument = await this.builds.read().catch((error) => {
|
|
640
|
+
console.warn(`[acp] adapter builds unreadable, reporting none installed: ${errorMessage(error)}`);
|
|
641
|
+
return { schema: 1, agents: {} };
|
|
642
|
+
});
|
|
643
|
+
// Since Task 15b uv agents are builds too: building the roster spawns no `uv` (and nothing else).
|
|
644
|
+
// Hoisted for the same reason as `buildsDocument`: it is the same value for every entry in
|
|
386
645
|
// this roster (a fact about this machine, not this agent), so it is computed once rather than
|
|
387
646
|
// once per agent inside the `.map` below.
|
|
388
647
|
const triple = platformTriple(this.platform, this.arch);
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
return Promise.all(agents.map(async (agent) => {
|
|
648
|
+
// Spec §6.4: the build data is joined only for a scan (`options.sessions`) — never the hello — and only when every keeper-acp
|
|
649
|
+
// generation could be listed (`undefined`: absent, "not looked at").
|
|
650
|
+
const running = options.sessions ? await this.classifiedRunningSessions(options.sessions) : undefined;
|
|
651
|
+
return agents.map((agent) => {
|
|
394
652
|
const observation = this.observed.get(agent.id);
|
|
395
653
|
const catalog = catalogEntryFor(agent.id);
|
|
396
654
|
// Computed once and used by both `adapterInstalled` and `adapterVersion` below: they are two
|
|
397
655
|
// halves of one disk read, and deriving them separately is exactly how they drifted apart.
|
|
398
|
-
const
|
|
656
|
+
const current = this.builds.currentIn(buildsDocument, agent.id);
|
|
657
|
+
const adapterVersion = adapterVersionFor(current);
|
|
658
|
+
// One detection pass per agent, shared by `adapterInstalled`, `detected` and
|
|
659
|
+
// `missingPrerequisite`: three readings of the same look at the disk must not disagree.
|
|
660
|
+
const prerequisite = catalog?.prerequisite;
|
|
661
|
+
const prerequisiteMet = !prerequisite || detected(prerequisite.detect, this.probe, triple);
|
|
662
|
+
const inUse = detected(catalog?.detect, this.probe, triple) || (prerequisite !== undefined && prerequisiteMet);
|
|
663
|
+
// A `pathLaunch` agent has no installable artifact: its `available` is exactly "the body is on
|
|
664
|
+
// this machine" (see `registry/catalog.ts`). No build will ever exist for it, so the build
|
|
665
|
+
// halves below stay honestly absent.
|
|
666
|
+
const pathLaunchAvailable = catalog?.pathLaunch !== undefined && inUse;
|
|
399
667
|
return {
|
|
400
668
|
type: agent.id,
|
|
401
|
-
|
|
402
|
-
// (`uvVersions`) — handed through so this doesn't make `resolveLaunch` spawn `uv` again for
|
|
403
|
-
// every uvx-distributed agent (see `resolvable`'s own doc comment).
|
|
404
|
-
available: await this.resolvable(agent, uvTools),
|
|
669
|
+
available: this.resolvable(agent, current) || pathLaunchAvailable,
|
|
405
670
|
version: agent.version,
|
|
406
671
|
// Always present: the registry document itself always carries a version for every entry,
|
|
407
672
|
// unlike `adapterVersion` below, which depends on what this particular machine has done.
|
|
@@ -415,19 +680,18 @@ export class AcpAgents {
|
|
|
415
680
|
// which meant the one reader who most needed an `Update` button was the one reader who
|
|
416
681
|
// never got offered one (design §11). Which version is on disk is `adapterVersion`'s job
|
|
417
682
|
// below; whether it is behind the pin is the card's `updatable` comparison. Neither belongs
|
|
418
|
-
// here.
|
|
419
|
-
// *launch* decision in `registry/launch.ts` — not in this presence check.
|
|
683
|
+
// here.
|
|
420
684
|
//
|
|
421
|
-
//
|
|
422
|
-
//
|
|
423
|
-
//
|
|
685
|
+
// Since spec 2026-10-11 §6.4 "installed" means the agent has a **current build** (npm, binary
|
|
686
|
+
// and — since Task 15b — uv), and `adapterVersion`
|
|
687
|
+
// is that build's version — the version a new session would actually run.
|
|
424
688
|
//
|
|
425
|
-
// `
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
429
|
-
//
|
|
430
|
-
adapterInstalled: adapterVersion !== undefined &&
|
|
689
|
+
// `prerequisiteMet` folds in the catalog's prerequisite (design §4.4): `pi-acp`'s adapter
|
|
690
|
+
// installed with no `pi` on `PATH` is not an agent this machine can add —
|
|
691
|
+
// the card would offer `Add`, the try-run would fail, and the reader would be told nothing
|
|
692
|
+
// about the missing CLI. Reporting it not-installed is what makes `Install` fetch the
|
|
693
|
+
// prerequisite too (or, with nothing to fetch, `missingPrerequisite` below explain it).
|
|
694
|
+
adapterInstalled: adapterVersion !== undefined && prerequisiteMet,
|
|
431
695
|
// The version half of the same answer — see `adapterVersionFor`'s doc comment. Absent
|
|
432
696
|
// rather than null when nothing is installed, on this file's usual "never write down what
|
|
433
697
|
// nobody looked at" rule.
|
|
@@ -442,7 +706,11 @@ export class AcpAgents {
|
|
|
442
706
|
// machine can launch has no use for it. Absent when the machine's own platform/arch falls
|
|
443
707
|
// outside the registry's six triples (see `binaryInstall.ts`'s `platformTriple`) — `true`
|
|
444
708
|
// with no triple to quote is still the correct, honest signal in that case.
|
|
445
|
-
|
|
709
|
+
// A `pathLaunch` agent has no distribution to be "unsupported" about: its launch path is the
|
|
710
|
+
// machine's own installation (detect-driven, above), not a downloadable artifact's platform
|
|
711
|
+
// coverage — the empty-distribution supplement would otherwise read as "nothing left to
|
|
712
|
+
// launch through".
|
|
713
|
+
...(!pathLaunchAvailable && isUnsupportedPlatform(agent, this.platform, this.arch)
|
|
446
714
|
? { unsupportedPlatform: true, ...(triple ? { platformTriple: triple } : {}) }
|
|
447
715
|
: {}),
|
|
448
716
|
// The registry publishes a human-facing name for every agent; carrying it is what stops the
|
|
@@ -452,10 +720,30 @@ export class AcpAgents {
|
|
|
452
720
|
// its id-derived fallback would have said something.
|
|
453
721
|
...(agent.name ? { displayName: agent.name } : {}),
|
|
454
722
|
...(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
|
-
|
|
723
|
+
// Spread from `registry/catalog.ts` only for a catalog hit — `curated`, `recommended` and
|
|
724
|
+
// `detected` have no `false`, the same "absent means unknown/no" rule as `supportsSteering`.
|
|
725
|
+
// An `unverified` entry keeps its defaults but is not one Axom vouches for (see `catalog.ts`).
|
|
726
|
+
...(catalog ? { ...(catalog.unverified ? {} : { curated: true }), defaultName: catalog.defaultName } : {}),
|
|
458
727
|
...(catalog?.defaultLabel ? { defaultLabel: catalog.defaultLabel } : {}),
|
|
728
|
+
...(catalog?.recommended ? { recommended: true } : {}),
|
|
729
|
+
// 静态推荐条目的展示序(目录数组序):推荐层按它排,使目录能表达 "pi 第一、hermes 第二"
|
|
730
|
+
// 这类顺序意图 —— 名册序做不到(supplement 追加在后),字母序与本意相反。
|
|
731
|
+
...(RECOMMENDED_RANK.has(agent.id) ? { recommendedRank: RECOMMENDED_RANK.get(agent.id) } : {}),
|
|
732
|
+
...(catalog?.pathLaunch
|
|
733
|
+
? {
|
|
734
|
+
pathLaunch: {
|
|
735
|
+
command: catalog.pathLaunch.command,
|
|
736
|
+
...(catalog.pathLaunch.args ? { args: [...catalog.pathLaunch.args] } : {}),
|
|
737
|
+
...(catalog.pathLaunch.hint ? { hint: catalog.pathLaunch.hint } : {}),
|
|
738
|
+
},
|
|
739
|
+
}
|
|
740
|
+
: {}),
|
|
741
|
+
...(inUse ? { detected: true } : {}),
|
|
742
|
+
// Only when nothing here can fix it: a prerequisite with an `install` argv (Pi) is fetched by
|
|
743
|
+
// `Install`, which the card already offers because `adapterInstalled` is false.
|
|
744
|
+
...(prerequisite && !prerequisiteMet && !prerequisite.install
|
|
745
|
+
? { missingPrerequisite: { label: prerequisite.label, ...(prerequisite.hint ? { hint: prerequisite.hint } : {}) } }
|
|
746
|
+
: {}),
|
|
459
747
|
// Spread rather than assign: an explicit `authMethods: undefined` would still be an own
|
|
460
748
|
// property, and "the key is here but empty" is exactly the ambiguity these fields exist to
|
|
461
749
|
// avoid on the roster's other end.
|
|
@@ -465,8 +753,121 @@ export class AcpAgents {
|
|
|
465
753
|
// has connected to and found *not* steering-capable publishes nothing, because absent
|
|
466
754
|
// already means "do not offer it". See `BridgeInfo.supportsSteering`.
|
|
467
755
|
...(observation?.supportsSteering === true ? { supportsSteering: true } : {}),
|
|
756
|
+
// The installed native extension is known without spawning a process or reading quota.
|
|
757
|
+
// A pre-upgrade live session's initialize observation must not hide its refresh entry.
|
|
758
|
+
...(observation?.supportsAccountLimits || (current && current.launch.runtime !== 'node' && nativeExtensionFor(current.agentId, current.version)) ? { supportsAccountLimits: true } : {}),
|
|
759
|
+
...(options.sessions ? rosterBuildFields(current, running?.get(agent.id), running !== undefined) : {}),
|
|
468
760
|
};
|
|
469
|
-
})
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* The live sessions' builds joined with the manager's view of each (Task 25), grouped by agent; `undefined` when a keeper-acp
|
|
765
|
+
* generation could not be listed (the counts would be wrong, so none are reported).
|
|
766
|
+
*/
|
|
767
|
+
async classifiedRunningSessions(classify) {
|
|
768
|
+
const running = await this.runningSessions();
|
|
769
|
+
if (!running)
|
|
770
|
+
return undefined;
|
|
771
|
+
const byAgent = new Map();
|
|
772
|
+
for (const session of running) {
|
|
773
|
+
const state = session.local
|
|
774
|
+
? { state: 'unmovable', reasons: [LOCAL_SESSION_UNMOVABLE] }
|
|
775
|
+
: await classify(session).catch((error) => {
|
|
776
|
+
console.warn(`[acp] roster: the state of ${session.sessionRef} could not be read: ${errorMessage(error)}`);
|
|
777
|
+
return { state: 'busy', reasons: ['its state could not be read right now'] };
|
|
778
|
+
});
|
|
779
|
+
const list = byAgent.get(session.agentId) ?? [];
|
|
780
|
+
list.push({ ...session, ...state });
|
|
781
|
+
byAgent.set(session.agentId, list);
|
|
782
|
+
}
|
|
783
|
+
return byAgent;
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* Every live session's adapter process with its build (spec §6.4, Task 25): keeper-acp's process labels across every live
|
|
787
|
+
* generation, plus the local fallback's. One entry per ref (a ref a crash left with two keys is counted once, by its newest key).
|
|
788
|
+
* `undefined` when keeper-acp — any generation — cannot be listed.
|
|
789
|
+
*/
|
|
790
|
+
async runningSessions() {
|
|
791
|
+
const listed = await this.listRunningAdapters();
|
|
792
|
+
if (!listed)
|
|
793
|
+
return undefined;
|
|
794
|
+
const byRef = new Map();
|
|
795
|
+
for (const { summary, local } of listed) {
|
|
796
|
+
const ref = refOfSession(summary);
|
|
797
|
+
const { agentId, buildId, version, kind } = summary.label;
|
|
798
|
+
if (ref === undefined || typeof agentId !== 'string' || typeof buildId !== 'string')
|
|
799
|
+
continue;
|
|
800
|
+
const keyGen = keyGenOf(summary.label);
|
|
801
|
+
const previous = byRef.get(ref);
|
|
802
|
+
if (previous && previous.keyGen >= keyGen)
|
|
803
|
+
continue;
|
|
804
|
+
byRef.set(ref, {
|
|
805
|
+
keyGen,
|
|
806
|
+
session: { sessionRef: ref, agentId, buildId, version: typeof version === 'string' ? version : buildId, patched: kind === 'npm-patched', local },
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
return [...byRef.values()].map(({ session }) => session);
|
|
810
|
+
}
|
|
811
|
+
/** The adapter processes running now, in every live keeper-acp generation and the local fallback; `undefined` if any cannot be listed. */
|
|
812
|
+
async listRunningAdapters() {
|
|
813
|
+
const found = [];
|
|
814
|
+
if (this.transports.keeper) {
|
|
815
|
+
let transports;
|
|
816
|
+
if (this.generations) {
|
|
817
|
+
try {
|
|
818
|
+
const live = await this.generations.live();
|
|
819
|
+
transports = await Promise.all(live.map((gen) => this.generations.transportFor(gen)));
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
return undefined;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
else {
|
|
826
|
+
transports = [this.transports.keeper];
|
|
827
|
+
}
|
|
828
|
+
for (const transport of transports) {
|
|
829
|
+
try {
|
|
830
|
+
for (const summary of await withDeadline(transport.list(), this.leftoverListTimeoutMs, 'keeper-acp list', Error)) {
|
|
831
|
+
if (hostsRunningAdapter(summary))
|
|
832
|
+
found.push({ summary, local: false });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
return undefined;
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (this.transports.local) {
|
|
841
|
+
try {
|
|
842
|
+
for (const summary of await this.transports.local.list())
|
|
843
|
+
if (hostsRunningAdapter(summary))
|
|
844
|
+
found.push({ summary, local: true });
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
return undefined;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return found;
|
|
851
|
+
}
|
|
852
|
+
/** The agent's current build id and every installed build id (`builds.json`), for `agent_switch_idle` (Task 25). */
|
|
853
|
+
async buildIdsOf(agentId) {
|
|
854
|
+
const entry = await this.builds.agent(agentId);
|
|
855
|
+
return { ...(entry?.current ? { current: entry.current } : {}), installed: Object.keys(entry?.builds ?? {}) };
|
|
856
|
+
}
|
|
857
|
+
/** Roster-visible change listeners (see {@link onRosterChanged}). */
|
|
858
|
+
rosterChangedListeners = [];
|
|
859
|
+
/**
|
|
860
|
+
* Subscribe to roster-visible observation changes.
|
|
861
|
+
*
|
|
862
|
+
* Fired when an agent's quota capability (`supportsAccountLimits`) is first observed by this
|
|
863
|
+
* process — the one roster field that can change mid-flight without a build changing, hence
|
|
864
|
+
* without the build-upkeep push that carries every other roster update. The daemon answers by
|
|
865
|
+
* re-scanning and pushing the roster; offline, the next hello carries it. Observations are
|
|
866
|
+
* in-memory, so a restart forgets the capability and this firing together — the next spawn
|
|
867
|
+
* re-learns both.
|
|
868
|
+
*/
|
|
869
|
+
onRosterChanged(listener) {
|
|
870
|
+
this.rosterChangedListeners.push(listener);
|
|
470
871
|
}
|
|
471
872
|
/**
|
|
472
873
|
* Record what a just-established connection revealed about one agent. Called on every connection,
|
|
@@ -478,7 +879,21 @@ export class AcpAgents {
|
|
|
478
879
|
*/
|
|
479
880
|
observe(agentId, observation) {
|
|
480
881
|
const previous = this.observed.get(agentId);
|
|
481
|
-
|
|
882
|
+
const merged = { ...previous, ...observation };
|
|
883
|
+
this.observed.set(agentId, merged);
|
|
884
|
+
if (merged.supportsAccountLimits === true && previous?.supportsAccountLimits !== true) {
|
|
885
|
+
// The quota capability was just learned mid-flight. Without a push here the server's roster
|
|
886
|
+
// copy stays at the hello/build-change snapshot, and the account UI it gates never renders
|
|
887
|
+
// (observed 2026-10-13: this daemon's first zcode spawn advertised the capability, the
|
|
888
|
+
// server's roster remained at that morning's build-change push, no scan happened, and no
|
|
889
|
+
// account UI ever appeared for the agent).
|
|
890
|
+
for (const listener of this.rosterChangedListeners) {
|
|
891
|
+
try {
|
|
892
|
+
listener();
|
|
893
|
+
}
|
|
894
|
+
catch { /* a listener must not break the observe path */ }
|
|
895
|
+
}
|
|
896
|
+
}
|
|
482
897
|
}
|
|
483
898
|
/**
|
|
484
899
|
* What one agent was last seen to advertise as ways of signing in, or `undefined` when this
|
|
@@ -504,267 +919,1527 @@ export class AcpAgents {
|
|
|
504
919
|
return this.observed.get(agentId)?.authMethods;
|
|
505
920
|
}
|
|
506
921
|
/**
|
|
507
|
-
* Start one session:
|
|
508
|
-
*
|
|
922
|
+
* Start one session (Task 19, spec 2026-10-11 §5.3/§5.4): build the spawn spec, spawn the adapter in keeper-acp (the local
|
|
923
|
+
* fallback when keeper-acp is unavailable), let the processor negotiate `initialize`, then either continue in
|
|
924
|
+
* {@link AcpOpenSpec.nativeSessionId} or open a fresh one — every step a note or a write in the session's raw log.
|
|
509
925
|
*
|
|
510
|
-
* The
|
|
511
|
-
*
|
|
512
|
-
* exists to prevent one layer up.
|
|
926
|
+
* The adapter is stopped on every failure path after it was spawned — an agent left running for a session that never
|
|
927
|
+
* registered is exactly the orphan the manager's own tombstone machinery exists to prevent one layer up.
|
|
513
928
|
*/
|
|
514
929
|
async open(spec) {
|
|
515
|
-
const
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
930
|
+
const requestId = spec.openRequestId ?? randomUUID();
|
|
931
|
+
const openStarted = performance.now();
|
|
932
|
+
// Log identifiers and timing only: commands, environment, cwd and lease headers can hold secrets.
|
|
933
|
+
const phase = async (name, work) => {
|
|
934
|
+
const started = performance.now();
|
|
935
|
+
const log = (status) => console.info(`${new Date().toISOString()} [acp-open] request=${JSON.stringify(requestId)} agent=${JSON.stringify(spec.agentId)}` +
|
|
936
|
+
` phase=${name} status=${status} durationMs=${Math.round(performance.now() - started)}` +
|
|
937
|
+
` totalMs=${Math.round(performance.now() - openStarted)}`);
|
|
938
|
+
log('started');
|
|
939
|
+
try {
|
|
940
|
+
const result = await work();
|
|
941
|
+
log('completed');
|
|
942
|
+
return result;
|
|
943
|
+
}
|
|
944
|
+
catch (error) {
|
|
945
|
+
log('failed');
|
|
946
|
+
throw error;
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
// A successful open-time install becomes the session's own `agent_install_outcome` note (Task 20: numbered by the processor,
|
|
950
|
+
// recorded in the outbox); the caller still hears every outcome (a failure has no session to go on).
|
|
951
|
+
let installed;
|
|
952
|
+
if (!spec.command) {
|
|
953
|
+
await phase('ensure-installed', () => this.installForOpen(spec.agentId, (outcome) => {
|
|
954
|
+
if (outcome.ok)
|
|
955
|
+
installed = outcome;
|
|
956
|
+
spec.onInstallOutcome?.(outcome);
|
|
957
|
+
}));
|
|
958
|
+
}
|
|
959
|
+
let resolved = await phase('prepare-launch', () => this.resolveLaunchFor(spec.agentId, spec.command, spec.args));
|
|
960
|
+
// Spec §5.5.6 (Task 24): a session the adoption could not continue is switched by this open — its process is closed and a new
|
|
961
|
+
// one loads the native session, never falling back to `session/new`.
|
|
962
|
+
if (this.transports.keeper && spec.sessionRef !== undefined && spec.nativeSessionId) {
|
|
963
|
+
await this.adoptKeeperSessions();
|
|
964
|
+
if (this.unrecoverable.has(this.keyFor(spec.sessionRef)))
|
|
965
|
+
spec = { ...spec, requireSessionLoad: true };
|
|
966
|
+
}
|
|
967
|
+
// GC never takes the build between resolving it and the spawn (whose keeper-acp label references it from then on).
|
|
968
|
+
const releaseLease = this.leaseBuild(spec.agentId, resolved.launch.label?.buildId);
|
|
969
|
+
const startup = spec.agentId === 'qoder' && spec.delegationMcp
|
|
970
|
+
? await phase('prepare-qoder', () => prepareQoderStartup(resolved.launch, spec.delegationMcp, spec.cwd, spec.env, spec.agentEnv))
|
|
971
|
+
: undefined;
|
|
972
|
+
if (startup)
|
|
973
|
+
resolved = { launch: startup.launch, source: resolved.source };
|
|
974
|
+
let session;
|
|
975
|
+
try {
|
|
976
|
+
session = await phase('initialize', () => this.startProcessorSession(resolved, spec, startup, installed));
|
|
977
|
+
}
|
|
978
|
+
catch (error) {
|
|
979
|
+
await startup?.dispose();
|
|
980
|
+
if (startup)
|
|
981
|
+
throw new Error('Managed Qoder startup could not initialize');
|
|
982
|
+
throw error;
|
|
983
|
+
}
|
|
984
|
+
finally {
|
|
985
|
+
releaseLease();
|
|
986
|
+
}
|
|
987
|
+
const facts = session.initializeFacts;
|
|
988
|
+
this.observe(spec.agentId, observationOfFacts(facts));
|
|
566
989
|
try {
|
|
567
|
-
|
|
990
|
+
if (startup && (session.agentInfo?.name !== 'qoder-cli' || session.agentInfo.version !== QODER_MANAGED_VERSION)) {
|
|
991
|
+
throw new Error('Managed Qoder runtime identity is not certified');
|
|
992
|
+
}
|
|
993
|
+
// Qoder 1.1.61 accepts HTTP MCP at startup, but rejects that entry in ACP new/load.
|
|
994
|
+
const sessionSpec = { ...spec, managedHistoryResumeRequired: !!spec.delegationMcp || spec.managedHistoryResumeRequired,
|
|
995
|
+
...(startup ? { delegationMcp: undefined, requireSessionLoad: spec.requireSessionLoad || !!spec.nativeSessionId } : {}) };
|
|
996
|
+
const operation = spec.nativeSessionId && facts.capabilities.loadSession ? 'session/load' : 'session/new';
|
|
997
|
+
const started = await phase(operation, () => withDeadline(this.startSession(session, facts, sessionSpec), this.sessionStartTimeoutMs, `Agent '${spec.agentId}' ${operation}`, AgentSessionStartTimeoutError));
|
|
568
998
|
// Only here, never before: the agent accepted a session, which is the one piece of evidence
|
|
569
999
|
// ACP offers that it is not waiting on credentials. Recording it up at `initialize` time
|
|
570
1000
|
// would claim authentication for an agent about to reject `session/new` with auth_required.
|
|
571
|
-
this.observe(spec.agentId, { ...
|
|
572
|
-
|
|
573
|
-
return {
|
|
574
|
-
session: hostedSession(started.session, connection, started.history, started.configOptions),
|
|
575
|
-
nativeSessionId: started.nativeSessionId,
|
|
576
|
-
};
|
|
1001
|
+
this.observe(spec.agentId, { ...observationOfFacts(facts), authenticated: true });
|
|
1002
|
+
return { session, nativeSessionId: started.nativeSessionId };
|
|
577
1003
|
}
|
|
578
1004
|
catch (error) {
|
|
579
|
-
await
|
|
1005
|
+
await session.close('auto').catch(() => undefined);
|
|
1006
|
+
await startup?.dispose();
|
|
580
1007
|
// Marked, not merely rethrown: everything above this `try` failed before the agent said
|
|
581
1008
|
// anything, and only a failure from here on can be an `auth_required`. See
|
|
582
1009
|
// {@link AgentRefusedSessionError} — `agentSessions.ts` is the reader.
|
|
583
|
-
throw new AgentRefusedSessionError(error);
|
|
1010
|
+
throw new AgentRefusedSessionError(error, startup ? 'Managed Qoder session could not open' : undefined);
|
|
584
1011
|
}
|
|
585
1012
|
}
|
|
586
1013
|
/**
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
591
|
-
* `sessionCapabilities.list` (that gate lives in `adoption.ts`); a *connection* failure still
|
|
592
|
-
* throws, because "this agent is not installed" and "this agent has no sessions" must not look
|
|
593
|
-
* alike to the user.
|
|
1014
|
+
* Spawn the adapter behind a {@link ProcessorSession} and wait for its `initialize` (spec §5.3: keeper-acp, or the local
|
|
1015
|
+
* fallback when keeper-acp cannot be reached — §5.7). The spawn spec carries the **complete** environment (keeper-acp never
|
|
1016
|
+
* reads its own), the daemon's working directory (where `connectAgent` started adapters), a managed startup's secrets for
|
|
1017
|
+
* stderr redaction and its temporary files to remove on exit; the label is opaque to the keeper.
|
|
594
1018
|
*/
|
|
595
|
-
async
|
|
596
|
-
const
|
|
597
|
-
const
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
1019
|
+
async startProcessorSession(resolved, spec, startup, installOutcome) {
|
|
1020
|
+
const { spawn, label: baseLabel, launch } = this.spawnSpecOf(resolved, spec, startup);
|
|
1021
|
+
const ref = spec.sessionRef;
|
|
1022
|
+
const sessionKey = ref ?? `open-${randomUUID()}`;
|
|
1023
|
+
// A ref whose earlier life ran in the daemon (the local fallback, or a migrated legacy journal) and left frames the server has
|
|
1024
|
+
// not applied continues there for this life, so they are resent from where they are; a drained one is released, its watermark
|
|
1025
|
+
// kept as the floor. Either way the processor numbers after everything that ref ever held (spec §5.5).
|
|
1026
|
+
const resumeFrom = spec.seqBase?.() ?? 0;
|
|
1027
|
+
const local = await this.localLeftover(sessionKey);
|
|
1028
|
+
let seqBase = Math.max(resumeFrom, local?.lastSeq ?? 0);
|
|
1029
|
+
const stayLocal = local !== undefined && local.lastSeq > resumeFrom && local.unacknowledged;
|
|
1030
|
+
if (local && !stayLocal)
|
|
1031
|
+
await this.localTransport().release(sessionKey).catch(() => undefined);
|
|
1032
|
+
// Sessions an earlier daemon left in keeper-acp are discovered (and adopted) before keeper-acp spawns anything for this one.
|
|
1033
|
+
if (this.transports.keeper)
|
|
1034
|
+
await this.adoptKeeperSessions();
|
|
1035
|
+
// keeper-acp's key for the ref: the ref itself, or the key its last switch moved it to (Task 24).
|
|
1036
|
+
const keyGen = ref !== undefined ? this.refKeys.get(ref) ?? 0 : 0;
|
|
1037
|
+
const keeperKey = ref !== undefined ? sessionKeyFor(ref, keyGen) : sessionKey;
|
|
1038
|
+
// A generation that could not be listed may run THIS ref's adapter — only possible when the ref already has a keeper-acp session
|
|
1039
|
+
// directory (keys are deterministic, the data dir is shared): reopening it into a new spawn would stop that adapter, so such an
|
|
1040
|
+
// open is refused until the generation is listed (the manager retried right before this frame). A brand-new ref spawns as usual:
|
|
1041
|
+
// keeper-acp itself refuses (`foreign`) a directory another live keeper holds. (final review finding 3, correction)
|
|
1042
|
+
if (this.transports.keeper && !stayLocal && ref !== undefined && this.keeperAdoptionPending()) {
|
|
1043
|
+
const candidates = [...new Set([ref, keeperKey])];
|
|
1044
|
+
let existing = true;
|
|
1045
|
+
try {
|
|
1046
|
+
existing = (await Promise.all(candidates.map((key) => this.generations?.hasSessionDir?.(key) ?? Promise.resolve(true)))).some(Boolean);
|
|
1047
|
+
}
|
|
1048
|
+
catch {
|
|
1049
|
+
existing = true;
|
|
1050
|
+
}
|
|
1051
|
+
if (existing) {
|
|
1052
|
+
throw new Error(`keeper-acp (generation ${[...this.unlistedGens].join(', ')}) is not answering; this session may still be running there. Try again shortly.`);
|
|
1053
|
+
}
|
|
602
1054
|
}
|
|
603
|
-
|
|
604
|
-
|
|
1055
|
+
const options = {
|
|
1056
|
+
spawn, seqBase, backfill: spec.backfill === true,
|
|
1057
|
+
...(installOutcome ? { installOutcome } : {}),
|
|
1058
|
+
launch,
|
|
1059
|
+
...(startup ? { onClosed: () => startup.dispose() } : {}),
|
|
1060
|
+
};
|
|
1061
|
+
let session;
|
|
1062
|
+
if (this.transports.keeper && !stayLocal) {
|
|
1063
|
+
try {
|
|
1064
|
+
// A kept session of this ref in an older generation that still runs (it hosts other adapters): the new spawn goes into it,
|
|
1065
|
+
// there — its raw log and outbox continue as any reused session's; nothing is moved or released. Moving it to the current
|
|
1066
|
+
// generation is §6.3 switching (Task 24). Every other open goes to the current generation (fix round 1, I-2/I-3).
|
|
1067
|
+
const target = await this.spawnTargetFor(keeperKey);
|
|
1068
|
+
// Spec §5.5.6 (Task 24): a session the adoption could not continue still runs its adapter — this open is its switch: the
|
|
1069
|
+
// process is closed first (keeper-acp refuses a second spawn into a running session), then the new one loads.
|
|
1070
|
+
const unrecoverable = this.unrecoverable.get(keeperKey);
|
|
1071
|
+
if (unrecoverable) {
|
|
1072
|
+
await target.transport.terminate(keeperKey).catch((error) => {
|
|
1073
|
+
console.warn(`[acp] stopping the unrecoverable session ${keeperKey} before its reopen failed: ${errorMessage(error)}`);
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
this.ownKeeperKeys.add(keeperKey);
|
|
1077
|
+
if (target.gen !== undefined)
|
|
1078
|
+
this.heldOnOlder.set(keeperKey, target.gen);
|
|
1079
|
+
else
|
|
1080
|
+
this.heldOnOlder.delete(keeperKey); // a reopen hold on an older generation that is gone now
|
|
1081
|
+
try {
|
|
1082
|
+
session = await ProcessorSession.start({ ...options, sessionKey: keeperKey, transport: target.transport,
|
|
1083
|
+
label: { ...baseLabel, ...(keyGen > 0 ? { keyGen } : {}) },
|
|
1084
|
+
onDegrade: this.recordDegraded(keeperKey, target.gen ?? 'current'),
|
|
1085
|
+
...(target.gen !== undefined ? { onClosed: () => this.olderSessionClosed(keeperKey, options.onClosed) } : {}) });
|
|
1086
|
+
}
|
|
1087
|
+
catch (error) {
|
|
1088
|
+
this.heldOnOlder.delete(keeperKey);
|
|
1089
|
+
throw error;
|
|
1090
|
+
}
|
|
1091
|
+
this.unrecoverable.delete(keeperKey);
|
|
1092
|
+
if (target.gen === undefined)
|
|
1093
|
+
this.keeperOwner.delete(keeperKey);
|
|
1094
|
+
this.keeperHeads.delete(keeperKey);
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
if (!(error instanceof AcpKeeperUnavailableError))
|
|
1098
|
+
throw error;
|
|
1099
|
+
console.warn(`[acp] keeper-acp is unavailable (${errorMessage(error)}); running agent '${spec.agentId}' in the daemon`);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (!session) {
|
|
1103
|
+
// keeper-acp holds an earlier life of this ref this daemon cannot read now: its unacknowledged frames may never reach the
|
|
1104
|
+
// server. Number above everything it held (as far as the last listing knew), and say that output may be missing (Task 21).
|
|
1105
|
+
const keeperHead = stayLocal ? undefined : this.keeperHeads.get(keeperKey);
|
|
1106
|
+
const gap = keeperHead !== undefined && this.keeperOwner.has(keeperKey);
|
|
1107
|
+
if (gap)
|
|
1108
|
+
seqBase = Math.max(seqBase, keeperHead);
|
|
1109
|
+
session = await ProcessorSession.start({ ...options, sessionKey, label: baseLabel, seqBase, transport: this.localTransport(),
|
|
1110
|
+
...(gap ? { outputGap: `keeper-acp, which holds this session's earlier output (through seq ${keeperHead}), cannot be reached` } : {}) });
|
|
605
1111
|
}
|
|
606
|
-
}
|
|
607
|
-
/**
|
|
608
|
-
* Verify one agent on demand: spawn it, negotiate `initialize`, try to open a session, then throw
|
|
609
|
-
* the whole thing away. Nothing is kept running — a verification is a question, not a resource.
|
|
610
|
-
*
|
|
611
|
-
* This is the only way the roster's auth fields can ever be populated for an agent nobody has
|
|
612
|
-
* started a session with, which is the case a settings page is looking at: {@link roster} reads
|
|
613
|
-
* {@link AuthObservation} and building the roster spawns nothing, so before the first verification
|
|
614
|
-
* (or the first real session) every agent reports "unknown" and stays that way. What is learned
|
|
615
|
-
* here goes through {@link observe}, so the next `hello`/scan carries it.
|
|
616
|
-
*
|
|
617
|
-
* ### Two outcomes, and neither of them is "not authenticated"
|
|
618
|
-
*
|
|
619
|
-
* A session that opened proves the agent is not waiting on credentials — that is `authenticated:
|
|
620
|
-
* true`. A session that did not open proves nothing at all: the agent may need signing in, or its
|
|
621
|
-
* cwd may not exist, or it may have crashed. That case comes back as {@link
|
|
622
|
-
* AcpVerification.sessionError} — the observed facts, with no verdict attached — and the
|
|
623
|
-
* observation is *not* updated, so an earlier proof is not erased by a later inconclusive attempt.
|
|
624
|
-
*
|
|
625
|
-
* Failing to connect at all throws, because "this agent is not installed" and "this agent is
|
|
626
|
-
* installed and would not start a session" are different answers to the user.
|
|
627
|
-
*/
|
|
628
|
-
async verify(spec, timeoutMs = AGENT_PROBE_TIMEOUT_MS) {
|
|
629
|
-
const connection = await this.connectFor(spec, timeoutMs);
|
|
630
|
-
const authMethods = connection.authMethods;
|
|
631
|
-
this.observe(spec.agentId, observationOf(connection));
|
|
632
1112
|
try {
|
|
633
|
-
|
|
634
|
-
let observedConfig;
|
|
635
|
-
await withDeadline((async () => {
|
|
636
|
-
const opened = await AcpSession.open(connection, { cwd: spec.cwd });
|
|
637
|
-
// Captured off `session/new`'s own response, not off the settle wait below: the snapshot
|
|
638
|
-
// exists the moment `AcpSession.open` resolves, unlike the command table, which the agent
|
|
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);
|
|
696
|
-
})(), timeoutMs, `Opening a session on agent '${spec.agentId}'`);
|
|
697
|
-
this.observe(spec.agentId, { ...observationOf(connection), authenticated: true });
|
|
698
|
-
return {
|
|
699
|
-
authMethods,
|
|
700
|
-
authenticated: true,
|
|
701
|
-
...(observed !== undefined ? { availableCommands: observed } : {}),
|
|
702
|
-
...(observedConfig !== undefined ? { configOptions: observedConfig } : {}),
|
|
703
|
-
};
|
|
1113
|
+
await session.initialized(this.initializeTimeoutMs);
|
|
704
1114
|
}
|
|
705
1115
|
catch (error) {
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
// or what was being attempted.
|
|
709
|
-
return { authMethods, sessionError: `Agent '${spec.agentId}' did not open a session: ${errorMessage(error)}` };
|
|
1116
|
+
await session.close('auto').catch(() => undefined);
|
|
1117
|
+
throw error;
|
|
710
1118
|
}
|
|
711
|
-
|
|
712
|
-
|
|
1119
|
+
return session;
|
|
1120
|
+
}
|
|
1121
|
+
/**
|
|
1122
|
+
* The spawn spec, label and launch diagnostics of a launch (spec §5.3): the **complete** environment (keeper-acp never reads its
|
|
1123
|
+
* own), the daemon's working directory (where `connectAgent` started adapters), a managed startup's secrets for stderr redaction and
|
|
1124
|
+
* its temporary files to remove on exit; the label is opaque to the keeper.
|
|
1125
|
+
*/
|
|
1126
|
+
spawnSpecOf(resolved, spec, startup) {
|
|
1127
|
+
const { launch } = resolved;
|
|
1128
|
+
const env = completeEnvironment(withPathPrefix({ ...process.env, ...launch.env, ...spec.env, ...spec.agentEnv }, launch.pathPrefix));
|
|
1129
|
+
const cwd = process.cwd();
|
|
1130
|
+
// keeper-acp takes at most ACP_STDERR_SECRETS_MAX; a managed startup's own secrets come first.
|
|
1131
|
+
const allSecrets = [...(startup?.secrets ?? []), ...overlayEnvSecrets(spec)];
|
|
1132
|
+
if (allSecrets.length > ACP_STDERR_SECRETS_MAX) {
|
|
1133
|
+
console.warn(`[acp] agent '${spec.agentId}': ${allSecrets.length - ACP_STDERR_SECRETS_MAX} of ${allSecrets.length} env values ` +
|
|
1134
|
+
`are not redacted from its stderr (keeper-acp takes at most ${ACP_STDERR_SECRETS_MAX})`);
|
|
713
1135
|
}
|
|
1136
|
+
const stderrSecrets = allSecrets.slice(0, ACP_STDERR_SECRETS_MAX);
|
|
1137
|
+
const spawn = {
|
|
1138
|
+
command: launch.command, args: [...launch.args], env, cwd,
|
|
1139
|
+
...(stderrSecrets.length > 0 ? { stderrSecrets } : {}),
|
|
1140
|
+
...(startup ? { removeOnExit: [startup.dir] } : {}),
|
|
1141
|
+
};
|
|
1142
|
+
const label = {
|
|
1143
|
+
sessionRef: spec.sessionRef ?? null, agentId: spec.agentId, agentKey: spec.agentKey ?? null,
|
|
1144
|
+
buildId: launch.label?.buildId ?? null, version: launch.label?.version ?? null, kind: launch.label?.kind ?? null,
|
|
1145
|
+
specDigest: createHash('sha256').update(JSON.stringify([spawn.command, spawn.args, spawn.cwd, Object.keys(env).sort()])).digest('hex').slice(0, 16),
|
|
1146
|
+
};
|
|
1147
|
+
return {
|
|
1148
|
+
spawn, label,
|
|
1149
|
+
launch: {
|
|
1150
|
+
source: resolved.source, platform: process.platform, architecture: process.arch, transport: 'stdio',
|
|
1151
|
+
executable: (findOnPath(launch.command, probeEnvironment(env)) ?? launch.command).slice(0, 4096),
|
|
1152
|
+
},
|
|
1153
|
+
};
|
|
714
1154
|
}
|
|
715
1155
|
/**
|
|
716
|
-
*
|
|
717
|
-
*
|
|
718
|
-
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
* Deliberately does not report an auth state. `authenticate` resolving means the agent accepted
|
|
723
|
-
* the call, which is not the same as a session it will now open; the caller asks {@link verify}
|
|
724
|
-
* for that, and gets an observation made the only way ACP allows one to be made. What is recorded
|
|
725
|
-
* here is `authMethods`, because `initialize` happened.
|
|
1156
|
+
* The daemon's once-per-process adoption (Task 21, spec §5.5): every keeper-acp generation that runs (and the current one when
|
|
1157
|
+
* session directories wait on disk for it) is listed; each session whose adapter still runs is adopted where it is
|
|
1158
|
+
* ({@link ProcessorSession.adopt}) — its {@link KeeperAdoption.session} resolves with the rebuilt session, or `undefined` when it
|
|
1159
|
+
* could not be continued (its adapter is stopped then, its outbox kept). Sessions whose adapter is gone stay leftovers (offered as
|
|
1160
|
+
* orphans, resent on a reopen). Resolves once the listing is done; rebuilding goes on behind it. Never rejects.
|
|
726
1161
|
*/
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
1162
|
+
adoptKeeperSessions() {
|
|
1163
|
+
this.adoption ??= this.discoverKeeperSessions().catch((error) => {
|
|
1164
|
+
console.warn(`[acp] adopting keeper-acp sessions failed: ${errorMessage(error)}`);
|
|
1165
|
+
return [];
|
|
1166
|
+
});
|
|
1167
|
+
return this.adoption;
|
|
1168
|
+
}
|
|
1169
|
+
async discoverKeeperSessions() {
|
|
1170
|
+
const generations = this.generations;
|
|
1171
|
+
if (!generations || !this.transports.keeper)
|
|
1172
|
+
return [];
|
|
1173
|
+
const current = await generations.current();
|
|
1174
|
+
const live = await generations.live();
|
|
1175
|
+
const gens = live.includes(current) || !await generations.hasSessionData() ? live : [...live, current];
|
|
1176
|
+
return this.adoptFrom(gens, current);
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Whether a keeper-acp generation could not be listed yet (see {@link unlistedGens}): the manager then retries the adoption
|
|
1180
|
+
* ({@link retryKeeperAdoption}) before it handles a frame, offers its inventory or polls. Synchronous.
|
|
1181
|
+
*/
|
|
1182
|
+
keeperAdoptionPending() { return this.unlistedGens.size > 0; }
|
|
1183
|
+
/**
|
|
1184
|
+
* Final review finding 3: lists again the keeper-acp generations whose listing failed (at the daemon's start or a retry since) and
|
|
1185
|
+
* adopts their sessions exactly as the start-time adoption does; each adoption is returned once, here. A generation that stopped
|
|
1186
|
+
* meanwhile is dropped (the current generation recovers its directories). One retry at a time; never rejects.
|
|
1187
|
+
*/
|
|
1188
|
+
retryKeeperAdoption() {
|
|
1189
|
+
if (!this.adoption || this.unlistedGens.size === 0)
|
|
1190
|
+
return Promise.resolve([]);
|
|
1191
|
+
this.retrying ??= (async () => {
|
|
1192
|
+
await this.adoption;
|
|
1193
|
+
const generations = this.generations;
|
|
1194
|
+
if (!generations || this.unlistedGens.size === 0)
|
|
1195
|
+
return [];
|
|
1196
|
+
const current = await generations.current();
|
|
1197
|
+
const live = new Set(await generations.live());
|
|
1198
|
+
for (const gen of [...this.unlistedGens])
|
|
1199
|
+
if (gen !== current && !live.has(gen))
|
|
1200
|
+
this.unlistedGens.delete(gen);
|
|
1201
|
+
return this.adoptFrom([...this.unlistedGens], current);
|
|
1202
|
+
})().catch((error) => {
|
|
1203
|
+
console.warn(`[acp] retrying the adoption of keeper-acp sessions failed: ${errorMessage(error)}`);
|
|
1204
|
+
return [];
|
|
1205
|
+
}).finally(() => { this.retrying = undefined; });
|
|
1206
|
+
return this.retrying;
|
|
1207
|
+
}
|
|
1208
|
+
/** Lists `gens` and adopts what they hold (see {@link adoptKeeperSessions}); a generation that cannot be listed is remembered. */
|
|
1209
|
+
async adoptFrom(gens, current) {
|
|
1210
|
+
const generations = this.generations;
|
|
1211
|
+
const listed = [];
|
|
1212
|
+
for (const gen of gens) {
|
|
1213
|
+
let transport;
|
|
1214
|
+
let sessions;
|
|
743
1215
|
try {
|
|
744
|
-
|
|
745
|
-
await withDeadline(
|
|
1216
|
+
transport = await generations.transportFor(gen);
|
|
1217
|
+
sessions = await withDeadline(transport.list(), this.leftoverListTimeoutMs, `keeper-acp ${gen} list`, Error);
|
|
746
1218
|
}
|
|
747
|
-
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
|
|
1219
|
+
catch (error) {
|
|
1220
|
+
// keeper-acp of the current build could not be launched or reached: the local fallback serves this daemon (spec §5.7) — it is
|
|
1221
|
+
// not a generation whose running sessions wait to be adopted (final review finding 3, correction).
|
|
1222
|
+
if (gen === current && error instanceof AcpKeeperUnavailableError) {
|
|
1223
|
+
console.warn(`[acp] keeper-acp (generation ${gen}) is unavailable; its kept sessions are not adopted: ${errorMessage(error)}`);
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
this.unlistedGens.add(gen);
|
|
1227
|
+
console.warn(`[acp] keeper-acp generation ${gen} could not be listed; its sessions are adopted once it answers: ${errorMessage(error)}`);
|
|
1228
|
+
continue;
|
|
751
1229
|
}
|
|
1230
|
+
this.unlistedGens.delete(gen);
|
|
1231
|
+
for (const summary of sessions)
|
|
1232
|
+
if (!this.ownKeeperKeys.has(summary.sessionKey))
|
|
1233
|
+
listed.push({ gen, transport, summary });
|
|
752
1234
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1235
|
+
const found = [];
|
|
1236
|
+
const queue = [];
|
|
1237
|
+
for (const { gen, transport, summary } of await this.withoutSuperseded(listed)) {
|
|
1238
|
+
const key = summary.sessionKey;
|
|
1239
|
+
this.noteKeeperSession(gen, summary);
|
|
1240
|
+
const running = summary.process.state === 'running' || summary.process.state === 'starting';
|
|
1241
|
+
// An exited adapter's session is looked at too (Task 23, spec §5.3): its raw tail may hold a running turn's real end, which is
|
|
1242
|
+
// folded and delivered before the session is reported dead — when there is nothing to finish, `adoptOne` leaves it alone.
|
|
1243
|
+
const exitedWithRaw = summary.process.state === 'exited' && summary.raw !== null;
|
|
1244
|
+
if (!running && !exitedWithRaw)
|
|
1245
|
+
continue;
|
|
1246
|
+
const ref = refOfSession(summary);
|
|
1247
|
+
if (!running && ref === undefined)
|
|
1248
|
+
continue;
|
|
1249
|
+
if (ref === undefined) {
|
|
1250
|
+
// No ref the server could reopen it under: nobody will ever drive it.
|
|
1251
|
+
console.warn(`[acp] keeper-acp session ${key} names no session ref; stopping its adapter (its outbox is kept)`);
|
|
1252
|
+
void transport.terminate(key).catch(() => null);
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
let settle;
|
|
1256
|
+
const session = new Promise((resolve) => { settle = resolve; });
|
|
1257
|
+
queue.push(async () => { settle(await this.adoptOne(transport, gen, gen === current, summary, !running)); });
|
|
1258
|
+
found.push({
|
|
1259
|
+
sessionRef: ref, agentId: String(summary.label.agentId ?? ''), agentKey: String(summary.label.agentKey ?? ''),
|
|
1260
|
+
journalMaxSeq: this.keeperHeads.get(key) ?? 0, ...(running ? {} : { agentAlive: false }), session,
|
|
1261
|
+
});
|
|
757
1262
|
}
|
|
1263
|
+
// A few at a time: each rebuild reads a checkpoint and up to one cadence of raw.
|
|
1264
|
+
const worker = async () => { for (let next = queue.shift(); next; next = queue.shift())
|
|
1265
|
+
await next(); };
|
|
1266
|
+
void Promise.all(Array.from({ length: ADOPT_CONCURRENCY }, worker)).then(() => this.retireIdleGenerations());
|
|
1267
|
+
return found;
|
|
758
1268
|
}
|
|
759
1269
|
/**
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
1270
|
+
* Task 24: one keeper-acp session per ref. Two exist only when a switch was interrupted by a crash (the successor `<ref>#s<n>` is
|
|
1271
|
+
* spawned, and loaded, while the predecessor still exists; the predecessor is released last): `resolveSuperseded` keeps the newest
|
|
1272
|
+
* key whose open completed — its import of the predecessor's outbox had landed — or, when none did, the oldest. The others are
|
|
1273
|
+
* stopped and released. Every listed ref's current key generation is recorded.
|
|
1274
|
+
*/
|
|
1275
|
+
async withoutSuperseded(listed) {
|
|
1276
|
+
const byRef = new Map();
|
|
1277
|
+
const kept = [];
|
|
1278
|
+
for (const entry of listed) {
|
|
1279
|
+
const ref = refOfSession(entry.summary);
|
|
1280
|
+
if (ref === undefined) {
|
|
1281
|
+
kept.push(entry);
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
byRef.set(ref, [...(byRef.get(ref) ?? []), entry]);
|
|
1285
|
+
}
|
|
1286
|
+
for (const [ref, entries] of byRef) {
|
|
1287
|
+
let winner = entries[0];
|
|
1288
|
+
if (entries.length > 1) {
|
|
1289
|
+
const resolved = await resolveSuperseded(entries.map((entry) => ({ ...entry, sessionKey: entry.summary.sessionKey, label: entry.summary.label })), (entry) => this.openCompleted(entry.transport, entry.summary.sessionKey));
|
|
1290
|
+
winner = entries.find((entry) => entry.summary.sessionKey === resolved.winner.sessionKey);
|
|
1291
|
+
for (const gone of resolved.superseded) {
|
|
1292
|
+
console.warn(`[acp] ${ref}: keeper-acp session ${gone.sessionKey} (generation ${gone.gen}) was superseded by ${winner.summary.sessionKey} ` +
|
|
1293
|
+
'(a switch a restart interrupted); stopping and releasing it');
|
|
1294
|
+
await gone.transport.terminate(gone.sessionKey).catch(() => null);
|
|
1295
|
+
await gone.transport.release(gone.sessionKey).catch((error) => {
|
|
1296
|
+
console.warn(`[acp] releasing the superseded session ${gone.sessionKey} failed: ${errorMessage(error)}`);
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
this.refKeys.set(ref, keyGenOf(winner.summary.label));
|
|
1301
|
+
kept.push(winner);
|
|
1302
|
+
}
|
|
1303
|
+
return kept;
|
|
1304
|
+
}
|
|
1305
|
+
/** Whether a keeper-acp session's head checkpoint says its open had completed (no load history open). */
|
|
1306
|
+
async openCompleted(transport, sessionKey) {
|
|
1307
|
+
try {
|
|
1308
|
+
const attached = await transport.attach(sessionKey);
|
|
1309
|
+
if (!attached.checkpoint)
|
|
1310
|
+
return false;
|
|
1311
|
+
// A successor the switch gave up on (its rollback took the session back) never wins, whatever its checkpoint says.
|
|
1312
|
+
if (await this.switchAbandoned(transport, sessionKey, attached))
|
|
1313
|
+
return false;
|
|
1314
|
+
const checkpoint = decodeCheckpoint(Buffer.from(attached.checkpoint.blob, 'base64').toString('utf8'));
|
|
1315
|
+
return checkpoint.state.open.status === 'open' && checkpoint.state.history === null;
|
|
1316
|
+
}
|
|
1317
|
+
catch {
|
|
1318
|
+
return false;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
noteKeeperSession(gen, summary) {
|
|
1322
|
+
this.keeperOwner.set(summary.sessionKey, gen);
|
|
1323
|
+
const tail = isOutboxTail(summary.outbox.committedTail) ? summary.outbox.committedTail.seq : 0;
|
|
1324
|
+
this.keeperHeads.set(summary.sessionKey, Math.max(summary.outbox.lastSeq ?? 0, tail));
|
|
1325
|
+
const ref = refOfSession(summary);
|
|
1326
|
+
if (ref !== undefined && keyGenOf(summary.label) >= (this.refKeys.get(ref) ?? 0))
|
|
1327
|
+
this.refKeys.set(ref, keyGenOf(summary.label));
|
|
1328
|
+
}
|
|
1329
|
+
async adoptOne(transport, gen, current, summary, exited = false) {
|
|
1330
|
+
const key = summary.sessionKey;
|
|
1331
|
+
try {
|
|
1332
|
+
if (!current)
|
|
1333
|
+
this.heldOnOlder.set(key, gen);
|
|
1334
|
+
const options = { transport, sessionKey: key, label: summary.label, onDegrade: this.recordDegraded(key, gen),
|
|
1335
|
+
// An older generation that no longer hosts a session of this daemon (or a running adapter) is stopped once this one is gone.
|
|
1336
|
+
...(current ? {} : { onClosed: () => this.olderSessionClosed(key) }) };
|
|
1337
|
+
const session = exited ? await ProcessorSession.adopt({ ...options, allowExited: true }) : await ProcessorSession.adopt(options);
|
|
1338
|
+
if (!session) {
|
|
1339
|
+
// An exited adapter with no turn left to finish: nothing to hold; it stays a leftover for its ref's next open.
|
|
1340
|
+
this.heldOnOlder.delete(key);
|
|
1341
|
+
return undefined;
|
|
1342
|
+
}
|
|
1343
|
+
// The owner of an older generation's session is kept: a reopen after its adapter exits spawns there (fix round 1, I-2).
|
|
1344
|
+
if (current)
|
|
1345
|
+
this.keeperOwner.delete(key);
|
|
1346
|
+
return session;
|
|
1347
|
+
}
|
|
1348
|
+
catch (error) {
|
|
1349
|
+
this.heldOnOlder.delete(key);
|
|
1350
|
+
if (exited) {
|
|
1351
|
+
// Its adapter had already exited: there is nothing to stop. Its outbox stays for its ref's next open (which loads it).
|
|
1352
|
+
console.warn(`[acp] keeper-acp session ${key} (generation ${gen}, adapter exited) could not be adopted` +
|
|
1353
|
+
`${error instanceof AdoptionRefusedError ? '' : ' (keeper-acp failed)'}: ${errorMessage(error)}; keeping its outbox`);
|
|
1354
|
+
return undefined;
|
|
1355
|
+
}
|
|
1356
|
+
// Spec §5.5.6 (Task 24 replaces Task 21's interim stop): a session this daemon cannot continue — its open never completed, or
|
|
1357
|
+
// its checkpoint is unreadable and raw truncated — is marked degraded and left running; the ref's next `agent_open` switches it
|
|
1358
|
+
// (closes the process, then a new one loads the native session). Until then it is offered as an orphan; its outbox is kept.
|
|
1359
|
+
const reason = errorMessage(error);
|
|
1360
|
+
console.warn(`[acp] keeper-acp session ${key} (generation ${gen}) could not be adopted` +
|
|
1361
|
+
`${error instanceof AdoptionRefusedError ? '' : ' (keeper-acp failed)'}: ${reason}; marked degraded — its next open switches it`);
|
|
1362
|
+
this.unrecoverable.set(key, { gen, reason });
|
|
1363
|
+
this.keeperOwner.set(key, gen);
|
|
1364
|
+
return undefined;
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
/** Where a keeper spawn of `sessionKey` goes: the older generation that still holds a kept session of it, else the current one. */
|
|
1368
|
+
async spawnTargetFor(sessionKey) {
|
|
1369
|
+
const generations = this.generations;
|
|
1370
|
+
const owner = this.keeperOwner.get(sessionKey);
|
|
1371
|
+
const current = this.transports.keeper;
|
|
1372
|
+
if (!generations || owner === undefined || owner === await generations.current())
|
|
1373
|
+
return { transport: current };
|
|
1374
|
+
if (!(await generations.live()).includes(owner))
|
|
1375
|
+
return { transport: current }; // gone: the current one adopts the directory
|
|
1376
|
+
return { transport: await generations.transportFor(owner), gen: owner };
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
1379
|
+
* The manager retires a session to respawn it at once (an `agent_open` of a keeper session whose adapter is gone, Task 21 fix
|
|
1380
|
+
* round 2): until the returned release, the older generation that holds it is not stopped — the retire's close no longer releases
|
|
1381
|
+
* the hold, the respawn's spawn takes it over (or drops it when it goes elsewhere or fails). Release after the attempt, whatever
|
|
1382
|
+
* it did.
|
|
1383
|
+
*/
|
|
1384
|
+
holdForReopen(sessionRef) {
|
|
1385
|
+
return this.holdKey(this.keyFor(sessionRef));
|
|
1386
|
+
}
|
|
1387
|
+
/** {@link holdForReopen} by key (a switch holds its predecessor's generation until it is released or rolled back to). */
|
|
1388
|
+
holdKey(sessionKey) {
|
|
1389
|
+
this.reopenHolds.add(sessionKey);
|
|
1390
|
+
let released = false;
|
|
1391
|
+
return () => {
|
|
1392
|
+
if (released)
|
|
1393
|
+
return;
|
|
1394
|
+
released = true;
|
|
1395
|
+
this.reopenHolds.delete(sessionKey);
|
|
1396
|
+
void this.retireIdleGenerations();
|
|
1397
|
+
};
|
|
1398
|
+
}
|
|
1399
|
+
// ---- switching (Task 24, spec §6.3) ---------------------------------------------------------------------------------------
|
|
1400
|
+
/** A ref's current keeper-acp key: `<ref>`, or `<ref>#s<n>` once a switch moved it (`switching.ts`). */
|
|
1401
|
+
keyFor(sessionRef) {
|
|
1402
|
+
return sessionKeyFor(sessionRef, this.refKeys.get(sessionRef) ?? 0);
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Where a held session runs (Task 24/25): its transport key, the keeper-acp generation hosting it (`undefined`: the current one, or
|
|
1406
|
+
* the local fallback), its build (`undefined`: a command override) and whether keeper-acp hosts it at all.
|
|
1407
|
+
*/
|
|
1408
|
+
placementOf(session) {
|
|
1409
|
+
if (!(session instanceof ProcessorSession))
|
|
1410
|
+
return undefined;
|
|
1411
|
+
const label = session.label;
|
|
1412
|
+
const gen = this.heldOnOlder.get(session.sessionKey);
|
|
1413
|
+
return {
|
|
1414
|
+
sessionKey: session.sessionKey, keeper: session.transportMode === 'keeper',
|
|
1415
|
+
...(gen !== undefined ? { gen } : {}),
|
|
1416
|
+
...(typeof label.agentId === 'string' ? { agentId: label.agentId } : {}),
|
|
1417
|
+
...(typeof label.buildId === 'string' ? { buildId: label.buildId } : {}),
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Move a live keeper-hosted session onto a new adapter process (spec §6.3): the target build (`toBuild`, else the agent's current
|
|
1422
|
+
* one — a command override keeps its command) on the **current** keeper-acp generation, under a new key, build before teardown.
|
|
1423
|
+
* Called only at an `agent_open` (its `spec` carries the session's fresh spawn environment) and only for a strictly idle session
|
|
1424
|
+
* (the caller checks; this only holds the loop at a record boundary). Steps:
|
|
1425
|
+
*
|
|
1426
|
+
* 1. lease both builds; hold the predecessor's generation; hold the predecessor's loop ({@link ProcessorSession.suspend}) — its
|
|
1427
|
+
* checkpoint is the successor's initial state;
|
|
1428
|
+
* 2. spawn the successor `<ref>#s<n>` with `baseOffset` past the predecessor's raw log, the predecessor's unacknowledged outbox
|
|
1429
|
+
* entries imported first, and `initialize` it — it must advertise `loadSession`, or it is stopped and released and the
|
|
1430
|
+
* predecessor goes on untouched (`unmovable`);
|
|
1431
|
+
* 3. only now stop the predecessor's adapter (its transport session — raw, outbox — stays), then `session/load` on the successor
|
|
1432
|
+
* with `requireSessionLoad` **forced** whatever the `agent_open` said (never `session/new`), and set back every config option the
|
|
1433
|
+
* load did not restore;
|
|
1434
|
+
* 4. success: release the predecessor (the import landed in step 2) — `switched`; failure: stop and release the successor, spawn
|
|
1435
|
+
* the predecessor's build again in the predecessor's key and load there (`rolledBack`), or, when that fails too, `lost`.
|
|
1436
|
+
*
|
|
1437
|
+
* Runs under the agent's build lock (install / switch / GC are serialised per agent). Never rejects.
|
|
1438
|
+
*/
|
|
1439
|
+
async switchSession(from, spec, request) {
|
|
1440
|
+
const ref = spec.sessionRef;
|
|
1441
|
+
const nativeSessionId = (from instanceof ProcessorSession ? from.nativeSessionId : null) ?? spec.nativeSessionId;
|
|
1442
|
+
const refuse = (reason, permanent = true) => ({ kind: 'unmovable', reason, permanent });
|
|
1443
|
+
let outcome;
|
|
1444
|
+
if (!(from instanceof ProcessorSession) || from.transportMode !== 'keeper' || !this.transports.keeper) {
|
|
1445
|
+
outcome = refuse('only a keeper-hosted session can be moved');
|
|
1446
|
+
}
|
|
1447
|
+
else if (ref === undefined) {
|
|
1448
|
+
outcome = refuse('the session has no ref');
|
|
1449
|
+
}
|
|
1450
|
+
else if (!nativeSessionId) {
|
|
1451
|
+
outcome = refuse('the session has no native session to load');
|
|
1452
|
+
}
|
|
1453
|
+
else if (spec.delegationMcp || from.delegation) {
|
|
1454
|
+
outcome = refuse('a managed delegation session is not moved');
|
|
1455
|
+
}
|
|
1456
|
+
else {
|
|
1457
|
+
outcome = await this.builds.withAgentLock(spec.agentId, () => this.switchLocked(from, { ...spec, sessionRef: ref, nativeSessionId }, request))
|
|
1458
|
+
.catch((error) => refuse(`the switch failed before anything was stopped: ${errorMessage(error)}`, false));
|
|
1459
|
+
}
|
|
1460
|
+
if (ref !== undefined) {
|
|
1461
|
+
// The build the refusal was about, resolved (Task 25): a later "move" to a different build may try again.
|
|
1462
|
+
const refusedFor = async () => request.toBuild ?? (await this.builds.agent(spec.agentId).catch(() => undefined))?.current;
|
|
1463
|
+
if (outcome.kind === 'unmovable' && outcome.permanent) {
|
|
1464
|
+
const toBuild = await refusedFor();
|
|
1465
|
+
this.unmovableRefs.set(ref, { reason: outcome.reason, ...(toBuild ? { toBuild } : {}) });
|
|
1466
|
+
}
|
|
1467
|
+
else if (outcome.kind === 'rolledBack' && !outcome.retry) {
|
|
1468
|
+
const toBuild = await refusedFor();
|
|
1469
|
+
this.unmovableRefs.set(ref, { reason: outcome.error, ...(toBuild ? { toBuild } : {}) });
|
|
1470
|
+
}
|
|
1471
|
+
else if (outcome.kind === 'switched')
|
|
1472
|
+
this.unmovableRefs.delete(ref);
|
|
1473
|
+
// Fulfilled, or never possible: the intent goes. A transient refusal keeps it for the next idle moment.
|
|
1474
|
+
if (!(outcome.kind === 'unmovable' && !outcome.permanent) && !(outcome.kind === 'rolledBack' && outcome.retry)) {
|
|
1475
|
+
await this.cancelSwitch({ sessionRef: ref }).catch(() => false);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
const what = outcome.kind === 'switched' ? `moved to ${outcome.toKey}` : outcome.kind === 'unmovable' ? `not moved: ${outcome.reason}`
|
|
1479
|
+
: outcome.kind === 'rolledBack' ? `rolled back: ${outcome.error}` : `lost: ${outcome.error}`;
|
|
1480
|
+
console.info(`[acp] switch of ${ref ?? '?'} (agent '${spec.agentId}', ${request.reason}): ${what}`);
|
|
1481
|
+
if (outcome.kind === 'switched')
|
|
1482
|
+
void this.collectBuilds().catch(() => undefined);
|
|
1483
|
+
return outcome;
|
|
1484
|
+
}
|
|
1485
|
+
async switchLocked(from, spec, request) {
|
|
1486
|
+
const ref = spec.sessionRef;
|
|
1487
|
+
const agentId = spec.agentId;
|
|
1488
|
+
const oldKey = from.sessionKey;
|
|
1489
|
+
const oldGen = this.heldOnOlder.get(oldKey);
|
|
1490
|
+
const oldTransport = oldGen === undefined ? this.transports.keeper : await this.generations.transportFor(oldGen);
|
|
1491
|
+
const current = this.transports.keeper;
|
|
1492
|
+
const fromBuildId = typeof from.label.buildId === 'string' ? from.label.buildId : undefined;
|
|
1493
|
+
let resolved;
|
|
1494
|
+
let toBuildId;
|
|
1495
|
+
try {
|
|
1496
|
+
if (spec.command) {
|
|
1497
|
+
resolved = { launch: { command: spec.command, args: spec.args, env: {} }, source: 'command-override' };
|
|
1498
|
+
}
|
|
1499
|
+
else {
|
|
1500
|
+
toBuildId = request.toBuild ?? (await this.builds.agent(agentId))?.current;
|
|
1501
|
+
if (toBuildId === undefined)
|
|
1502
|
+
return { kind: 'unmovable', reason: `agent '${agentId}' has no current build`, permanent: true };
|
|
1503
|
+
resolved = await this.launchOfBuild(agentId, toBuildId);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
catch (error) {
|
|
1507
|
+
return { kind: 'unmovable', reason: errorMessage(error), permanent: true };
|
|
1508
|
+
}
|
|
1509
|
+
const releases = [this.leaseBuild(agentId, fromBuildId), this.leaseBuild(agentId, toBuildId), this.holdKey(oldKey)];
|
|
1510
|
+
try {
|
|
1511
|
+
const configBefore = from.configOptions.map((option) => ({ ...option }));
|
|
1512
|
+
let suspendTimer;
|
|
1513
|
+
const checkpoint = await Promise.race([
|
|
1514
|
+
from.suspend(),
|
|
1515
|
+
new Promise((resolve) => { suspendTimer = setTimeout(() => resolve(undefined), SWITCH_SUSPEND_TIMEOUT_MS); }),
|
|
1516
|
+
]).finally(() => clearTimeout(suspendTimer));
|
|
1517
|
+
if (!checkpoint) {
|
|
1518
|
+
from.resume();
|
|
1519
|
+
return { kind: 'unmovable', reason: 'its record loop could not be held at a boundary', permanent: false };
|
|
1520
|
+
}
|
|
1521
|
+
// 2. The successor, under a fresh key on the current generation.
|
|
1522
|
+
const taken = new Set((await current.list()).map((summary) => summary.sessionKey));
|
|
1523
|
+
let keyGen = Math.max(keyGenOf(from.label), this.refKeys.get(ref) ?? 0) + 1;
|
|
1524
|
+
while (taken.has(sessionKeyFor(ref, keyGen)))
|
|
1525
|
+
keyGen += 1;
|
|
1526
|
+
const newKey = sessionKeyFor(ref, keyGen);
|
|
1527
|
+
const oldRaw = (await oldTransport.list()).find((summary) => summary.sessionKey === oldKey)?.raw;
|
|
1528
|
+
const baseOffset = Math.max(checkpoint.offset + 1, oldRaw?.next ?? 0);
|
|
1529
|
+
const imports = from.outbox.pending.map((item) => ({ seq: item.seq, frame: item.frame }));
|
|
1530
|
+
let next;
|
|
1531
|
+
try {
|
|
1532
|
+
next = await this.startPlaced(resolved, spec, { transport: current, sessionKey: newKey, keyGen, seqBase: from.headSeq, switchedFrom: oldKey,
|
|
1533
|
+
seed: { checkpoint, baseOffset, imports } });
|
|
1534
|
+
}
|
|
1535
|
+
catch (error) {
|
|
1536
|
+
await current.terminate(newKey).catch(() => null);
|
|
1537
|
+
await current.release(newKey).catch(() => undefined);
|
|
1538
|
+
from.resume();
|
|
1539
|
+
return { kind: 'unmovable', reason: `the new adapter process did not start: ${errorMessage(error)}`, permanent: false };
|
|
1540
|
+
}
|
|
1541
|
+
const facts = next.initializeFacts;
|
|
1542
|
+
if (!facts.capabilities.loadSession) {
|
|
1543
|
+
// Build before teardown: the predecessor was never touched.
|
|
1544
|
+
await next.close('release').catch(() => undefined);
|
|
1545
|
+
from.resume();
|
|
1546
|
+
return { kind: 'unmovable', reason: `${facts.agentInfo?.name ?? agentId} ${facts.agentInfo?.version ?? ''} does not support session/load`.replace(/ +/g, ' '), permanent: true };
|
|
1547
|
+
}
|
|
1548
|
+
if (request.proceed && !request.proceed()) {
|
|
1549
|
+
await next.close('release').catch(() => undefined);
|
|
1550
|
+
from.resume();
|
|
1551
|
+
return { kind: 'unmovable', reason: 'the workstation is stopping', permanent: false };
|
|
1552
|
+
}
|
|
1553
|
+
// The predecessor's adapter wrote something while its loop was held (fix round 1): only the predecessor can fold it, and its
|
|
1554
|
+
// log goes with it. Not now — the predecessor folds it, the intent stays, the next idle moment tries again. Also looked up in
|
|
1555
|
+
// keeper-acp itself: an output record already appended but not yet delivered counts the same. Only adapter output (`up`)
|
|
1556
|
+
// counts (fix round 2); stderr does not hold a switch back.
|
|
1557
|
+
if (from.arrivedWhileSuspended > 0 || await this.outputPast(oldTransport, oldKey, checkpoint.offset)) {
|
|
1558
|
+
await next.close('release').catch(() => undefined);
|
|
1559
|
+
from.resume();
|
|
1560
|
+
return { kind: 'unmovable', reason: 'its adapter produced output while the switch was being prepared', permanent: false };
|
|
1561
|
+
}
|
|
1562
|
+
// 3. Only now: the predecessor's adapter stops (its raw and outbox stay, for a rollback) — with its loop still held.
|
|
1563
|
+
await from.stopAdapter();
|
|
1564
|
+
// Fix round 2: keeper-acp records what the adapter still wrote on its way out (after the check above, or on SIGTERM) into the
|
|
1565
|
+
// predecessor's raw. Only the predecessor can fold it — under its own seqs, which the successor has not used yet (it numbered
|
|
1566
|
+
// nothing it recorded: it is not registered) — so the switch is given up here: the successor is abandoned, the predecessor
|
|
1567
|
+
// folds its whole tail (output, then its exit) on its loop, and is restarted and loaded by the rollback. Nothing is lost and the
|
|
1568
|
+
// intent stays for a later idle moment.
|
|
1569
|
+
if (await this.outputPast(oldTransport, oldKey, checkpoint.offset)) {
|
|
1570
|
+
await this.abandonSuccessor(current, newKey, next, 'the previous adapter wrote output while it was being stopped');
|
|
1571
|
+
const tailEnd = ((await oldTransport.list()).find((summary) => summary.sessionKey === oldKey)?.raw?.next ?? checkpoint.offset + 1) - 1;
|
|
1572
|
+
from.resume();
|
|
1573
|
+
await from.foldedThrough(tailEnd).catch((error) => {
|
|
1574
|
+
console.warn(`[acp] ${oldKey}: folding the stopped adapter's last output failed: ${errorMessage(error)}`);
|
|
1575
|
+
});
|
|
1576
|
+
await from.close('keep');
|
|
1577
|
+
const back = await this.rollBack(from, spec, { oldKey, oldGen, oldTransport, fromBuildId, configBefore,
|
|
1578
|
+
error: 'the previous adapter wrote output while it was being stopped' });
|
|
1579
|
+
return back.kind === 'rolledBack' ? { ...back, retry: true } : back;
|
|
1580
|
+
}
|
|
1581
|
+
await from.close('keep');
|
|
1582
|
+
try {
|
|
1583
|
+
await this.loadInto(next, spec, configBefore);
|
|
1584
|
+
}
|
|
1585
|
+
catch (error) {
|
|
1586
|
+
await this.abandonSuccessor(current, newKey, next, errorMessage(error));
|
|
1587
|
+
return this.rollBack(from, spec, { oldKey, oldGen, oldTransport, fromBuildId, configBefore, error: errorMessage(error) });
|
|
1588
|
+
}
|
|
1589
|
+
// 4. The successor holds the session and the predecessor's unacknowledged frames (imported before the load): release it.
|
|
1590
|
+
this.refKeys.set(ref, keyGen);
|
|
1591
|
+
this.heldOnOlder.delete(oldKey);
|
|
1592
|
+
this.keeperOwner.delete(oldKey);
|
|
1593
|
+
this.keeperHeads.delete(oldKey);
|
|
1594
|
+
this.unrecoverable.delete(oldKey);
|
|
1595
|
+
await oldTransport.release(oldKey).catch((error) => {
|
|
1596
|
+
console.warn(`[acp] releasing ${oldKey} after its switch to ${newKey} failed (the next daemon drops it as superseded): ${errorMessage(error)}`);
|
|
1597
|
+
});
|
|
1598
|
+
return { kind: 'switched', session: next, nativeSessionId: spec.nativeSessionId, fromKey: oldKey, toKey: newKey };
|
|
1599
|
+
}
|
|
1600
|
+
finally {
|
|
1601
|
+
for (const release of releases)
|
|
1602
|
+
release();
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* A successor that will not hold the session (its load failed, or it failed after loading — fix round 1): marked abandoned in its
|
|
1607
|
+
* own log FIRST (`switch_abandoned` note — a crash before the release must not let the next daemon's discovery pick it over the
|
|
1608
|
+
* rolled-back predecessor, which then holds every newer frame), then stopped and released, retried, and loudly reported when it
|
|
1609
|
+
* stays.
|
|
1610
|
+
*/
|
|
1611
|
+
async abandonSuccessor(transport, key, session, reason) {
|
|
1612
|
+
await transport.note(key, { type: SWITCH_ABANDONED_NOTE, reason: reason.slice(0, 1024) }).catch((error) => {
|
|
1613
|
+
console.error(`[acp] marking the abandoned switch successor ${key} failed: ${errorMessage(error)}`);
|
|
1614
|
+
});
|
|
1615
|
+
await session.close('release').catch(() => undefined);
|
|
1616
|
+
let last;
|
|
1617
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1618
|
+
let still;
|
|
1619
|
+
try {
|
|
1620
|
+
still = (await transport.list()).some((summary) => summary.sessionKey === key);
|
|
1621
|
+
}
|
|
1622
|
+
catch (error) {
|
|
1623
|
+
last = error;
|
|
1624
|
+
still = true;
|
|
1625
|
+
}
|
|
1626
|
+
if (!still)
|
|
1627
|
+
return;
|
|
1628
|
+
if (attempt > 0)
|
|
1629
|
+
await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
|
|
1630
|
+
await transport.terminate(key).catch(() => null);
|
|
1631
|
+
await transport.release(key).catch((error) => { last = error; });
|
|
1632
|
+
}
|
|
1633
|
+
console.error(`[acp] the abandoned switch successor ${key} could not be released (${last instanceof Error ? last.message : String(last)}); ` +
|
|
1634
|
+
'it is marked abandoned, so the next daemon drops it in favour of its predecessor');
|
|
1635
|
+
}
|
|
1636
|
+
/** Whether raw holds adapter output (`up`) past `offset` (the predecessor's checkpoint): output only it can fold (fix round 2). */
|
|
1637
|
+
async outputPast(transport, sessionKey, offset) {
|
|
1638
|
+
for (let at = offset + 1;;) {
|
|
1639
|
+
let page;
|
|
1640
|
+
try {
|
|
1641
|
+
page = await transport.readRaw(sessionKey, at);
|
|
1642
|
+
}
|
|
1643
|
+
catch {
|
|
1644
|
+
return true;
|
|
1645
|
+
} // cannot tell: do not risk it
|
|
1646
|
+
if (page.length === 0)
|
|
1647
|
+
return false;
|
|
1648
|
+
if (page.some((record) => record.kind === 'up'))
|
|
1649
|
+
return true;
|
|
1650
|
+
at = page[page.length - 1].o + 1;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
/** Whether a switch successor was marked abandoned (fix round 1): its log holds a `switch_abandoned` note. */
|
|
1654
|
+
async switchAbandoned(transport, sessionKey, attached) {
|
|
1655
|
+
for (let at = attached.raw?.first ?? 0; at <= attached.head;) {
|
|
1656
|
+
let page;
|
|
1657
|
+
try {
|
|
1658
|
+
page = await transport.readRaw(sessionKey, at);
|
|
1659
|
+
}
|
|
1660
|
+
catch {
|
|
1661
|
+
return false;
|
|
1662
|
+
}
|
|
1663
|
+
if (page.length === 0)
|
|
1664
|
+
return false;
|
|
1665
|
+
if (page.some((record) => record.kind === 'note' && record.note.type === SWITCH_ABANDONED_NOTE))
|
|
1666
|
+
return true;
|
|
1667
|
+
at = page[page.length - 1].o + 1;
|
|
1668
|
+
}
|
|
1669
|
+
return false;
|
|
1670
|
+
}
|
|
1671
|
+
/** Step 6: the predecessor's build in the predecessor's key, loaded again; `lost` when that fails too (never `session/new`). */
|
|
1672
|
+
async rollBack(from, spec, old) {
|
|
1673
|
+
let back;
|
|
1674
|
+
try {
|
|
1675
|
+
const resolved = spec.command
|
|
1676
|
+
? { launch: { command: spec.command, args: spec.args, env: {} }, source: 'command-override' }
|
|
1677
|
+
: old.fromBuildId !== undefined ? await this.launchOfBuild(spec.agentId, old.fromBuildId)
|
|
1678
|
+
: (() => { throw new Error('the session\'s build is unknown'); })();
|
|
1679
|
+
back = await this.startPlaced(resolved, spec, { transport: old.oldTransport, sessionKey: old.oldKey, keyGen: keyGenOf(from.label),
|
|
1680
|
+
seqBase: from.headSeq, ...(old.oldGen !== undefined ? { gen: old.oldGen } : {}) });
|
|
1681
|
+
if (!back.initializeFacts.capabilities.loadSession)
|
|
1682
|
+
throw new Error('the previous build no longer advertises session/load');
|
|
1683
|
+
await this.loadInto(back, spec, old.configBefore);
|
|
1684
|
+
return { kind: 'rolledBack', session: back, nativeSessionId: spec.nativeSessionId, error: old.error };
|
|
1685
|
+
}
|
|
1686
|
+
catch (error) {
|
|
1687
|
+
await back?.close('keep').catch(() => undefined);
|
|
1688
|
+
if (old.oldGen !== undefined)
|
|
1689
|
+
this.heldOnOlder.delete(old.oldKey);
|
|
1690
|
+
return { kind: 'lost', error: `the new adapter could not load the session (${old.error}), and neither could the previous one (${errorMessage(error)})` };
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
/** `session/load` with `requireSessionLoad` forced (spec §5.5.4/§6.3: never `session/new`), then the config options set back. */
|
|
1694
|
+
async loadInto(session, spec, configBefore) {
|
|
1695
|
+
const facts = session.initializeFacts;
|
|
1696
|
+
await withDeadline(this.startSession(session, facts, { ...spec, requireSessionLoad: true, backfill: false }), this.sessionStartTimeoutMs, `Agent '${spec.agentId}' session/load`, AgentSessionStartTimeoutError);
|
|
1697
|
+
if (session.nativeSessionId !== spec.nativeSessionId)
|
|
1698
|
+
throw new Error(`session/load bound '${session.nativeSessionId}', not '${spec.nativeSessionId}'`);
|
|
1699
|
+
await restoreConfigOptions(session, configBefore, () => `switch-${randomUUID()}`, (line) => console.warn(line));
|
|
1700
|
+
}
|
|
1701
|
+
/** A session spawned where the caller says (a switch's successor, or its rollback), `initialize`d. */
|
|
1702
|
+
async startPlaced(resolved, spec, placement) {
|
|
1703
|
+
const { spawn, label, launch } = this.spawnSpecOf(resolved, spec);
|
|
1704
|
+
const key = placement.sessionKey;
|
|
1705
|
+
this.ownKeeperKeys.add(key);
|
|
1706
|
+
if (placement.gen !== undefined)
|
|
1707
|
+
this.heldOnOlder.set(key, placement.gen);
|
|
1708
|
+
const session = await ProcessorSession.start({
|
|
1709
|
+
spawn, launch, seqBase: placement.seqBase, backfill: false, transport: placement.transport, sessionKey: key,
|
|
1710
|
+
label: { ...label, ...(placement.keyGen > 0 ? { keyGen: placement.keyGen } : {}), ...(placement.switchedFrom ? { switchedFrom: placement.switchedFrom } : {}) },
|
|
1711
|
+
...(placement.seed ? { seed: placement.seed } : {}),
|
|
1712
|
+
onDegrade: this.recordDegraded(key, placement.gen ?? 'current'),
|
|
1713
|
+
...(placement.gen !== undefined ? { onClosed: () => this.olderSessionClosed(key) } : {}),
|
|
1714
|
+
});
|
|
1715
|
+
try {
|
|
1716
|
+
await session.initialized(this.initializeTimeoutMs);
|
|
1717
|
+
}
|
|
1718
|
+
catch (error) {
|
|
1719
|
+
await session.close(placement.seed ? 'release' : 'auto').catch(() => undefined);
|
|
1720
|
+
throw error;
|
|
1721
|
+
}
|
|
1722
|
+
return session;
|
|
1723
|
+
}
|
|
1724
|
+
/** The launch of one recorded build (not necessarily the current one), through the same Node gate and launch env as any open. */
|
|
1725
|
+
async launchOfBuild(agentId, buildId) {
|
|
1726
|
+
const build = (await this.builds.agent(agentId))?.builds[buildId];
|
|
1727
|
+
if (!build || !existsSync(inside(this.adapterRoot, build.dir)))
|
|
1728
|
+
throw new AdapterNotInstalledError(agentId, `build ${buildId} is not installed`);
|
|
1729
|
+
this.touchBuild(agentId, buildId);
|
|
1730
|
+
return {
|
|
1731
|
+
source: 'registry',
|
|
1732
|
+
launch: await launchForBuild(build, this.adapterRoot, {
|
|
1733
|
+
nodePath: this.nodePath, launchEnv: catalogLaunchEnv(agentId, build.registryVersion), basePath: this.probe.path,
|
|
1734
|
+
}),
|
|
1735
|
+
};
|
|
1736
|
+
}
|
|
1737
|
+
// ---- switch intents (spec §6.3 step 7) ------------------------------------------------------------------------------------
|
|
1738
|
+
/** The intents, read from `builds.json` once; every write below updates this copy too. */
|
|
1739
|
+
loadIntents() {
|
|
1740
|
+
this.intents ??= this.builds.switchIntents().then((all) => new Map(all.map(({ agentId, sessionRef, intent }) => [sessionRef, { agentId, intent }])), (error) => { this.intents = undefined; throw error; });
|
|
1741
|
+
return this.intents;
|
|
1742
|
+
}
|
|
1743
|
+
/** Every persistent switch intent (`builds.json` `switchPending`). */
|
|
1744
|
+
async switchIntents() {
|
|
1745
|
+
return [...(await this.loadIntents())].map(([sessionRef, { agentId, intent }]) => ({ agentId, sessionRef, intent }));
|
|
1746
|
+
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Record that `sessionRef` (a session of `agentId`) should move — to `toBuild`, else the agent's current build at the time, and to
|
|
1749
|
+
* the current keeper-acp generation (`keeperGen: 'current'` alone: only off an older generation). Persistent: survives a restart.
|
|
1750
|
+
*/
|
|
1751
|
+
async requestSwitch(agentId, sessionRef, intent) {
|
|
1752
|
+
const record = {
|
|
1753
|
+
...(intent.toBuild !== undefined ? { toBuild: intent.toBuild } : {}), ...(intent.keeperGen ? { keeperGen: intent.keeperGen } : {}),
|
|
1754
|
+
reason: intent.reason, requestedBy: intent.requestedBy ?? 'user', requestedAt: new Date().toISOString(),
|
|
1755
|
+
};
|
|
1756
|
+
const intents = await this.loadIntents();
|
|
1757
|
+
if (!await this.builds.setSwitchIntent(agentId, sessionRef, record))
|
|
1758
|
+
throw new Error(`${this.builds.file} could not be written`);
|
|
1759
|
+
intents.set(sessionRef, { agentId, intent: record });
|
|
1760
|
+
this.unmovableRefs.delete(sessionRef);
|
|
1761
|
+
}
|
|
1762
|
+
/** Cancel the intent of one ref, or every intent of one agent. `true` when one existed. */
|
|
1763
|
+
async cancelSwitch(target) {
|
|
1764
|
+
const intents = await this.loadIntents();
|
|
1765
|
+
const refs = 'sessionRef' in target ? [target.sessionRef]
|
|
1766
|
+
: [...intents].filter(([, entry]) => entry.agentId === target.agentId).map(([ref]) => ref);
|
|
1767
|
+
if (!refs.some((ref) => intents.has(ref)))
|
|
1768
|
+
return false;
|
|
1769
|
+
const removed = await this.builds.cancelSwitchIntents(target);
|
|
1770
|
+
for (const ref of refs)
|
|
1771
|
+
intents.delete(ref);
|
|
1772
|
+
return removed;
|
|
1773
|
+
}
|
|
1774
|
+
/** Why `sessionRef` cannot be moved (spec §6.4 `unmovable`), if a switch of it found so. */
|
|
1775
|
+
unmovableReason(sessionRef) {
|
|
1776
|
+
return this.unmovableRefs.get(sessionRef)?.reason;
|
|
1777
|
+
}
|
|
1778
|
+
/** {@link unmovableReason} with the build the refusal was about (`toBuild`; absent when it could not be resolved). */
|
|
1779
|
+
unmovableFor(sessionRef) {
|
|
1780
|
+
return this.unmovableRefs.get(sessionRef);
|
|
1781
|
+
}
|
|
1782
|
+
/**
|
|
1783
|
+
* The switch a held session still owes its intent, or `undefined` (no intent, or it is fulfilled — then the intent is dropped).
|
|
1784
|
+
* A build intent is fulfilled once the session runs the target build; a keeper-generation intent once it runs on the current
|
|
1785
|
+
* generation. A command-override session only ever moves generations.
|
|
1786
|
+
*/
|
|
1787
|
+
async pendingSwitchFor(sessionRef, session) {
|
|
1788
|
+
const found = (await this.loadIntents()).get(sessionRef);
|
|
1789
|
+
if (!found)
|
|
1790
|
+
return undefined;
|
|
1791
|
+
const placement = this.placementOf(session);
|
|
1792
|
+
if (!placement?.keeper)
|
|
1793
|
+
return undefined;
|
|
1794
|
+
const { intent, agentId } = found;
|
|
1795
|
+
const onOlder = placement.gen !== undefined;
|
|
1796
|
+
const target = intent.keeperGen === 'current' && intent.toBuild === undefined ? undefined
|
|
1797
|
+
: intent.toBuild ?? (await this.builds.agent(agentId))?.current;
|
|
1798
|
+
const needsBuild = target !== undefined && placement.buildId !== undefined && placement.buildId !== target;
|
|
1799
|
+
const needsGen = intent.keeperGen === 'current' && onOlder;
|
|
1800
|
+
if (!needsBuild && !needsGen) {
|
|
1801
|
+
await this.cancelSwitch({ sessionRef }).catch(() => false);
|
|
1802
|
+
return undefined;
|
|
1803
|
+
}
|
|
1804
|
+
return { ...(needsBuild ? { toBuild: target } : {}), reason: intent.reason };
|
|
1805
|
+
}
|
|
1806
|
+
// ---- build leases and GC (spec §6.3 "GC") ----------------------------------------------------------------------------------
|
|
1807
|
+
/** A daemon lease on a build (GC never takes it while held); the release also marks it seen now. */
|
|
1808
|
+
leaseBuild(agentId, buildId) {
|
|
1809
|
+
if (buildId === undefined)
|
|
1810
|
+
return () => { };
|
|
1811
|
+
const key = buildKeyOf(agentId, buildId);
|
|
1812
|
+
this.leases.set(key, (this.leases.get(key) ?? 0) + 1);
|
|
1813
|
+
this.touchBuild(agentId, buildId);
|
|
1814
|
+
let released = false;
|
|
1815
|
+
return () => {
|
|
1816
|
+
if (released)
|
|
1817
|
+
return;
|
|
1818
|
+
released = true;
|
|
1819
|
+
const left = (this.leases.get(key) ?? 1) - 1;
|
|
1820
|
+
if (left > 0)
|
|
1821
|
+
this.leases.set(key, left);
|
|
1822
|
+
else
|
|
1823
|
+
this.leases.delete(key);
|
|
1824
|
+
this.touchBuild(agentId, buildId, Date.now());
|
|
1825
|
+
};
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1828
|
+
* A build was just resolved for a launch: protected until any connection started from it has necessarily ended (a login's ceiling)
|
|
1829
|
+
* — the lease of the short-lived connections (verify, list, delete, login), which end on their own.
|
|
1830
|
+
*/
|
|
1831
|
+
touchBuild(agentId, buildId, until = Date.now() + AGENT_LOGIN_CEILING_MS) {
|
|
1832
|
+
const key = buildKeyOf(agentId, buildId);
|
|
1833
|
+
this.buildSeen.set(key, Math.max(this.buildSeen.get(key) ?? 0, until));
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* The builds adapter processes run right now: every keeper-acp generation that runs, listed (a generation that cannot be listed →
|
|
1837
|
+
* `undefined`: nothing may be collected), plus the local fallback's processes.
|
|
1838
|
+
*/
|
|
1839
|
+
async runningBuildReferences() {
|
|
1840
|
+
const listed = await this.listRunningAdapters();
|
|
1841
|
+
if (!listed)
|
|
1842
|
+
return undefined;
|
|
1843
|
+
const refs = [];
|
|
1844
|
+
for (const { summary } of listed) {
|
|
1845
|
+
const { agentId, buildId } = summary.label;
|
|
1846
|
+
if (typeof agentId === 'string' && typeof buildId === 'string')
|
|
1847
|
+
refs.push({ agentId, buildId });
|
|
1848
|
+
}
|
|
1849
|
+
return refs;
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* Collect unused adapter builds (spec §6.3; `builds/gc.ts` has the rules) and what an older CLI left. Never while keeper-acp cannot
|
|
1853
|
+
* be listed. One at a time; never rejects (failures are logged).
|
|
1854
|
+
*/
|
|
1855
|
+
collectBuilds(options = {}) {
|
|
1856
|
+
const previous = this.collecting;
|
|
1857
|
+
let run;
|
|
1858
|
+
run = (async () => {
|
|
1859
|
+
await previous?.catch(() => undefined);
|
|
1860
|
+
try {
|
|
1861
|
+
const running = await this.runningBuildReferences();
|
|
1862
|
+
const leased = [...this.leases.keys()].map((key) => { const [agentId, buildId] = key.split('\u0000'); return { agentId: agentId, buildId: buildId }; });
|
|
1863
|
+
// A pending switch's target build is leased until the switch ran (fix round 1): GC must not take it first.
|
|
1864
|
+
for (const { agentId, intent } of await this.switchIntents().catch(() => [])) {
|
|
1865
|
+
if (intent.toBuild !== undefined)
|
|
1866
|
+
leased.push({ agentId, buildId: intent.toBuild });
|
|
1867
|
+
}
|
|
1868
|
+
const report = await collectBuilds({ store: this.builds, running, leased, lastSeen: this.buildSeen, seenByDefault: this.startedAt,
|
|
1869
|
+
now: options.now ?? Date.now(), ...(options.graceMs !== undefined ? { graceMs: options.graceMs } : {}) });
|
|
1870
|
+
if (report.skipped) {
|
|
1871
|
+
console.info(`[acp-builds] GC skipped: ${report.skipped}`);
|
|
1872
|
+
return report;
|
|
1873
|
+
}
|
|
1874
|
+
const legacyRemoved = await collectLegacy({ store: this.builds, commandLines: await processCommandLines() });
|
|
1875
|
+
return { ...report, legacyRemoved };
|
|
1876
|
+
}
|
|
1877
|
+
catch (error) {
|
|
1878
|
+
console.warn(`[acp-builds] GC failed: ${errorMessage(error)}`);
|
|
1879
|
+
return undefined;
|
|
1880
|
+
}
|
|
1881
|
+
finally {
|
|
1882
|
+
if (this.collecting === run)
|
|
1883
|
+
this.collecting = undefined;
|
|
1884
|
+
}
|
|
1885
|
+
})();
|
|
1886
|
+
this.collecting = run;
|
|
1887
|
+
return run;
|
|
1888
|
+
}
|
|
1889
|
+
/** Whether this daemon holds (or is about to respawn) a session on older generation `gen`. */
|
|
1890
|
+
holds(gen) {
|
|
1891
|
+
// A generation that could not be listed may host running adapters nobody adopted yet (final review finding 3).
|
|
1892
|
+
return this.unlistedGens.has(gen) || [...this.heldOnOlder.values()].includes(gen);
|
|
1893
|
+
}
|
|
1894
|
+
/** A session of this daemon on an older generation ended: that generation may be idle now. */
|
|
1895
|
+
olderSessionClosed(sessionKey, also) {
|
|
1896
|
+
// A reopen's retire keeps the hold: the respawn goes into the same generation right behind it (fix round 2).
|
|
1897
|
+
if (!this.reopenHolds.has(sessionKey))
|
|
1898
|
+
this.heldOnOlder.delete(sessionKey);
|
|
1899
|
+
void this.retireIdleGenerations();
|
|
1900
|
+
return also?.();
|
|
1901
|
+
}
|
|
1902
|
+
/**
|
|
1903
|
+
* A keeper-hosted session degraded (keeper-acp gone, or poisoned): its outbox head is remembered, so a later open of the ref that
|
|
1904
|
+
* has to run in the daemon numbers above it and reports the gap (fix round 1, minor 4).
|
|
1905
|
+
*/
|
|
1906
|
+
recordDegraded(sessionKey, gen) {
|
|
1907
|
+
return (headSeq) => {
|
|
1908
|
+
this.keeperHeads.set(sessionKey, Math.max(this.keeperHeads.get(sessionKey) ?? 0, headSeq));
|
|
1909
|
+
if (!this.keeperOwner.has(sessionKey))
|
|
1910
|
+
this.keeperOwner.set(sessionKey, gen);
|
|
1911
|
+
};
|
|
1912
|
+
}
|
|
1913
|
+
/**
|
|
1914
|
+
* Older keeper-acp generations that host no running adapter any more are stopped (the current one adopts what they kept). One pass
|
|
1915
|
+
* at a time (Task 24): two concurrent passes would both stop the same generation, and the second one's listing reconnects to it
|
|
1916
|
+
* while the first one asks it to stop — a pass asked for meanwhile runs once more afterwards.
|
|
1917
|
+
*/
|
|
1918
|
+
retireIdleGenerations() {
|
|
1919
|
+
if (this.retiringGenerations) {
|
|
1920
|
+
this.retireAgain = true;
|
|
1921
|
+
return this.retiringGenerations;
|
|
1922
|
+
}
|
|
1923
|
+
this.retiringGenerations = (async () => {
|
|
1924
|
+
do {
|
|
1925
|
+
this.retireAgain = false;
|
|
1926
|
+
await this.retireIdleGenerationsOnce();
|
|
1927
|
+
} while (this.retireAgain);
|
|
1928
|
+
})().finally(() => { this.retiringGenerations = undefined; });
|
|
1929
|
+
return this.retiringGenerations;
|
|
1930
|
+
}
|
|
1931
|
+
async retireIdleGenerationsOnce() {
|
|
1932
|
+
const generations = this.generations;
|
|
1933
|
+
if (!generations)
|
|
1934
|
+
return;
|
|
1935
|
+
try {
|
|
1936
|
+
const current = await generations.current();
|
|
1937
|
+
let stopped = false;
|
|
1938
|
+
for (const gen of await generations.live()) {
|
|
1939
|
+
if (gen === current)
|
|
1940
|
+
continue;
|
|
1941
|
+
// Never while a session this daemon holds (or is spawning) lives there, its adapter exited or not (fix round 1, minor 7).
|
|
1942
|
+
if (this.holds(gen))
|
|
1943
|
+
continue;
|
|
1944
|
+
// Asked again after keeper-acp's own listing: a reopen may have taken a hold meanwhile (fix round 2).
|
|
1945
|
+
if (await generations.stopIfIdle(gen, () => this.holds(gen))) {
|
|
1946
|
+
stopped = true;
|
|
1947
|
+
for (const [key, owner] of this.keeperOwner)
|
|
1948
|
+
if (owner === gen)
|
|
1949
|
+
this.keeperOwner.delete(key);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
// The current generation adopts the kept sessions now (keeper-acp's `acp.list` adopts directories whose keeper is gone), so the
|
|
1953
|
+
// next offer lists them without waiting for its periodic adoption (fix round 1, minor 5).
|
|
1954
|
+
if (stopped && this.transports.keeper) {
|
|
1955
|
+
for (const summary of await withDeadline(this.transports.keeper.list(), this.leftoverListTimeoutMs, 'keeper-acp list', Error)) {
|
|
1956
|
+
if (!hostsRunningAdapter(summary))
|
|
1957
|
+
this.noteKeeperSession(current, summary);
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
catch (error) {
|
|
1962
|
+
console.warn(`[acp] retiring idle keeper-acp generations failed: ${errorMessage(error)}`);
|
|
1963
|
+
}
|
|
1964
|
+
}
|
|
1965
|
+
/**
|
|
1966
|
+
* What the local transport holds under `sessionKey` from an earlier life (a fallback session's outbox, a migrated legacy journal):
|
|
1967
|
+
* the highest seq it ever held and whether frames past its watermark remain. `undefined`: nothing.
|
|
1968
|
+
*/
|
|
1969
|
+
async localLeftover(sessionKey) {
|
|
1970
|
+
let summary;
|
|
1971
|
+
try {
|
|
1972
|
+
summary = (await this.localTransport().list()).find((s) => s.sessionKey === sessionKey);
|
|
1973
|
+
}
|
|
1974
|
+
catch {
|
|
1975
|
+
return undefined;
|
|
1976
|
+
}
|
|
1977
|
+
if (!summary)
|
|
1978
|
+
return undefined;
|
|
1979
|
+
const tail = summary.outbox.committedTail;
|
|
1980
|
+
const tailSeq = typeof tail?.seq === 'number' ? tail.seq : 0;
|
|
1981
|
+
return {
|
|
1982
|
+
lastSeq: Math.max(summary.outbox.lastSeq ?? 0, tailSeq),
|
|
1983
|
+
unacknowledged: summary.outbox.next > Math.max(summary.outbox.first, summary.outbox.committed + 1),
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
/**
|
|
1987
|
+
* Sessions an earlier life left in either transport that no live session holds (spec §5.5.2: the resume offer lists them as
|
|
1988
|
+
* orphans): local outboxes, and keeper-acp's sessions once this daemon may use keeper-acp (its sweep succeeded). The caller
|
|
1989
|
+
* removes the refs it holds. keeper-acp that does not answer in time contributes nothing (the offer must not wait on it).
|
|
1990
|
+
*/
|
|
1991
|
+
async leftoverSessions() {
|
|
1992
|
+
const keys = new Set();
|
|
1993
|
+
try {
|
|
1994
|
+
for (const summary of await this.localTransport().list())
|
|
1995
|
+
keys.add(summary.sessionKey);
|
|
1996
|
+
}
|
|
1997
|
+
catch (error) {
|
|
1998
|
+
console.warn(`[acp] listing local agent outboxes failed: ${errorMessage(error)}`);
|
|
1999
|
+
}
|
|
2000
|
+
if (this.transports.keeper && (this.adoption || this.ownKeeperKeys.size > 0)) {
|
|
2001
|
+
await this.adoptKeeperSessions();
|
|
2002
|
+
for (const [gen, transport] of await this.keeperTransports()) {
|
|
2003
|
+
try {
|
|
2004
|
+
for (const summary of await withDeadline(transport.list(), this.leftoverListTimeoutMs, 'keeper-acp list', Error)) {
|
|
2005
|
+
// A running adapter in a generation the adoption has not listed yet is not an orphan: it is adopted on the next retry
|
|
2006
|
+
// (final review finding 3) — offered as an orphan, the server would end its turn and reopen it into a new spawn.
|
|
2007
|
+
if (gen !== undefined && this.unlistedGens.has(gen) && hostsRunningAdapter(summary))
|
|
2008
|
+
continue;
|
|
2009
|
+
// The ref, not the key: a switched session lives under `<ref>#s<n>` (Task 24).
|
|
2010
|
+
keys.add(refOfSession(summary) ?? summary.sessionKey);
|
|
2011
|
+
if (gen !== undefined && !hostsRunningAdapter(summary))
|
|
2012
|
+
this.noteKeeperSession(gen, summary);
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
catch (error) {
|
|
2016
|
+
console.warn(`[acp] listing keeper-acp sessions${gen ? ` (generation ${gen})` : ''} for the resume offer failed: ${errorMessage(error)}`);
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
return [...keys].sort();
|
|
2021
|
+
}
|
|
2022
|
+
/**
|
|
2023
|
+
* keeper-acp's transports by generation: every running one when the generations are known (the current one also when this daemon
|
|
2024
|
+
* used it, or session directories wait for it — nothing is launched just to be listed), else the current one alone.
|
|
2025
|
+
*/
|
|
2026
|
+
async keeperTransports() {
|
|
2027
|
+
if (!this.transports.keeper)
|
|
2028
|
+
return [];
|
|
2029
|
+
const generations = this.generations;
|
|
2030
|
+
if (!generations)
|
|
2031
|
+
return [[undefined, this.transports.keeper]];
|
|
2032
|
+
try {
|
|
2033
|
+
const current = await generations.current();
|
|
2034
|
+
const gens = new Set(await generations.live());
|
|
2035
|
+
if (!gens.has(current) && (this.ownKeeperKeys.size > 0 || await generations.hasSessionData()))
|
|
2036
|
+
gens.add(current);
|
|
2037
|
+
return Promise.all([...gens].map(async (gen) => [gen, await generations.transportFor(gen)]));
|
|
2038
|
+
}
|
|
2039
|
+
catch {
|
|
2040
|
+
return [[undefined, this.transports.keeper]];
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
/** `agent_close` for a ref no live session holds: whatever an earlier life left under it goes (its adapter stopped first). */
|
|
2044
|
+
async releaseLeftover(sessionRef) {
|
|
2045
|
+
const keeper = this.transports.keeper && (this.adoption || this.ownKeeperKeys.size > 0) ? (await this.keeperTransports()).map(([, t]) => t) : [];
|
|
2046
|
+
for (const transport of [this.localTransport(), ...keeper]) {
|
|
2047
|
+
try {
|
|
2048
|
+
// Every key of the ref (`<ref>`, and `<ref>#s<n>` after a switch — Task 24).
|
|
2049
|
+
for (const summary of (await transport.list()).filter((s) => s.sessionKey === sessionRef || refOfSession(s) === sessionRef)) {
|
|
2050
|
+
const key = summary.sessionKey;
|
|
2051
|
+
this.keeperOwner.delete(key);
|
|
2052
|
+
this.keeperHeads.delete(key);
|
|
2053
|
+
this.unrecoverable.delete(key);
|
|
2054
|
+
if (summary.process.state !== 'none' && summary.process.state !== 'exited')
|
|
2055
|
+
await transport.terminate(key);
|
|
2056
|
+
await transport.release(key);
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
catch (error) {
|
|
2060
|
+
console.warn(`[acp] releasing what an earlier life left for ${sessionRef} failed: ${errorMessage(error)}`);
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
2063
|
+
this.refKeys.delete(sessionRef);
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* A legacy `agents/sessions/<ref>.jsonl` (Task 20 removed the daemon journal): its entries become a local outbox under the ref,
|
|
2067
|
+
* which the resume offer lists and a reopen resends, exactly as the journal was. Done once per file; idempotent. `false`: a local
|
|
2068
|
+
* outbox for the ref already existed and was left as it is.
|
|
2069
|
+
*/
|
|
2070
|
+
async importLegacyOutbox(sessionKey, records) {
|
|
2071
|
+
const local = this.localTransport();
|
|
2072
|
+
if (!(local instanceof LocalTransport))
|
|
2073
|
+
throw new Error('The local transport cannot import an outbox');
|
|
2074
|
+
return local.importOutbox(sessionKey, records);
|
|
2075
|
+
}
|
|
2076
|
+
/** The fallback transport (spec §5.7), made on first use. */
|
|
2077
|
+
localTransport() {
|
|
2078
|
+
this.transports.local ??= new LocalTransport({ configDir: this.cacheDir });
|
|
2079
|
+
return this.transports.local;
|
|
2080
|
+
}
|
|
2081
|
+
/** Daemon shutdown: keeper-acp's connection is dropped (its adapters keep running); local adapters are stopped. */
|
|
2082
|
+
closeTransports() {
|
|
2083
|
+
this.clearQuotaReaders();
|
|
2084
|
+
this.transports.keeper?.close();
|
|
2085
|
+
this.generations?.close();
|
|
2086
|
+
this.transports.local?.close();
|
|
2087
|
+
}
|
|
2088
|
+
clearQuotaReaders(agentId) {
|
|
2089
|
+
for (const [key, reader] of this.quotaReaders)
|
|
2090
|
+
if (!agentId || reader.agentId === agentId) {
|
|
2091
|
+
this.quotaReaders.delete(key);
|
|
2092
|
+
void reader.pending.then(connection => connection.close()).catch(() => undefined);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
/**
|
|
2096
|
+
* Continue in an already-existing native session when we have an id for one and the agent can load
|
|
2097
|
+
* it; otherwise open a new session and report the new id truthfully.
|
|
2098
|
+
*
|
|
2099
|
+
* Two things are deliberately *not* failures here. An agent that does not advertise
|
|
2100
|
+
* `loadSession` still gets a session — the manager already tolerates the id it asked for coming
|
|
2101
|
+
* back different (it warns and re-points), so refusing to open would be strictly worse than
|
|
2102
|
+
* continuing in a fresh one. A `session/load` that is advertised but rejected (a stale id the
|
|
2103
|
+
* agent has since deleted, most likely) is treated the same way, for the same reason.
|
|
2104
|
+
* Manual configuration refresh opts out of both fallbacks with `requireSessionLoad`: a failed
|
|
2105
|
+
* load must not silently replace the conversation whose settings the reader wanted to refresh.
|
|
2106
|
+
*
|
|
2107
|
+
* Every decision is the daemon's, made on the processor's `initialize` reading; each attempt is one `agent_session_open` note
|
|
2108
|
+
* and the processor's result. A load's history window is closed (`historyEnd`) as soon as its result is in — before the
|
|
2109
|
+
* manager can admit a prompt (interface-contracts: `historyEnd` before any admitted prompt). The Qoder repair goes on where
|
|
2110
|
+
* `enableBackgroundTerminalRepair` was called: after the `agent_session_open` note of a load (before its history is folded),
|
|
2111
|
+
* after the result of a new or resumed session — a resume with `restoreCalls`.
|
|
2112
|
+
*/
|
|
2113
|
+
async startSession(session, facts, spec) {
|
|
2114
|
+
const nativeSessionId = spec.nativeSessionId;
|
|
2115
|
+
const capabilities = facts.capabilities;
|
|
2116
|
+
if (nativeSessionId && spec.managedHistoryResumeRequired) {
|
|
2117
|
+
throw new Error('Managed native history replay has no certified boundary');
|
|
2118
|
+
}
|
|
2119
|
+
if (spec.delegationMcp && !capabilities.mcpHttp) {
|
|
2120
|
+
throw new Error(`Agent '${spec.agentId}' does not advertise HTTP MCP support`);
|
|
2121
|
+
}
|
|
2122
|
+
if (spec.requireSessionLoad && !nativeSessionId) {
|
|
2123
|
+
throw new Error('Refreshing settings requires an existing native session ID');
|
|
2124
|
+
}
|
|
2125
|
+
const historyHome = spec.agentEnv.HOME ?? spec.env.HOME ?? homedir();
|
|
2126
|
+
// The finished MCP list (the lease checked here, against the clock, before any note is written); the bearer is a secretRef
|
|
2127
|
+
// in the note and the down record, its value only in this daemon's memory and the adapter's stdin (spec §13).
|
|
2128
|
+
const servers = buildSessionMcpServers(spec.mcpServers, spec.delegationMcp);
|
|
2129
|
+
const redact = [];
|
|
2130
|
+
const mcpServers = servers.map((server) => {
|
|
2131
|
+
if (!('headers' in server) || !spec.delegationMcp || server.name !== 'axom-delegation')
|
|
2132
|
+
return server;
|
|
2133
|
+
return { ...server, headers: server.headers.map((header) => {
|
|
2134
|
+
const ref = session.secret(header.value);
|
|
2135
|
+
redact.push(ref);
|
|
2136
|
+
return { ...header, value: ref };
|
|
2137
|
+
}) };
|
|
2138
|
+
});
|
|
2139
|
+
const delegation = spec.delegationMcp && spec.delegation ? {
|
|
2140
|
+
expiresAtEpochMs: spec.delegationMcp.expiresAtEpochMs, leaseId: spec.delegation.leaseId,
|
|
2141
|
+
...(spec.delegation.bindingId !== undefined && spec.delegation.instanceId !== undefined
|
|
2142
|
+
? { bindingId: spec.delegation.bindingId, instanceId: spec.delegation.instanceId } : {}),
|
|
2143
|
+
} : undefined;
|
|
2144
|
+
const request = { cwd: spec.cwd, mcpServers, redact, ...(delegation ? { delegation } : {}) };
|
|
2145
|
+
const refused = (result) => (result.code !== undefined ? new RequestError(result.code, result.error) : new Error(result.error));
|
|
2146
|
+
// Qoder's load may emit old chunks after its response. Cached reconnects need context, not
|
|
2147
|
+
// another history import. Actual adoption/backfill and explicit settings loads still use load.
|
|
2148
|
+
if (nativeSessionId && certifiedRuntime(facts.agentInfo) && capabilities.resume &&
|
|
2149
|
+
!spec.backfill && !spec.requireSessionLoad && !spec.delegationMcp) {
|
|
2150
|
+
const result = await session.openSession({ ...request, mode: 'resume', sessionId: nativeSessionId });
|
|
2151
|
+
if (!result.ok)
|
|
2152
|
+
throw refused(result); // Never silently replace this cached conversation with a fresh native root.
|
|
2153
|
+
await session.enableRepair(nativeSessionId, spec.cwd, historyHome, true);
|
|
2154
|
+
return { nativeSessionId };
|
|
2155
|
+
}
|
|
2156
|
+
if (nativeSessionId) {
|
|
2157
|
+
if (!capabilities.loadSession) {
|
|
2158
|
+
if (spec.requireSessionLoad || spec.delegationMcp)
|
|
2159
|
+
throw new Error(`Agent '${spec.agentId}' does not support session/load`);
|
|
2160
|
+
console.warn(`[acp] agent '${spec.agentId}' does not support session/load; starting a new session ` +
|
|
2161
|
+
`instead of continuing '${nativeSessionId}'`);
|
|
2162
|
+
}
|
|
2163
|
+
else {
|
|
2164
|
+
const result = await session.openSession({ ...request, mode: 'load', sessionId: nativeSessionId, repair: { historyHome } });
|
|
2165
|
+
if (result.ok) {
|
|
2166
|
+
await session.endHistory();
|
|
2167
|
+
return { nativeSessionId };
|
|
2168
|
+
}
|
|
2169
|
+
if (spec.requireSessionLoad || spec.delegationMcp)
|
|
2170
|
+
throw refused(result);
|
|
2171
|
+
console.warn(`[acp] session/load for '${nativeSessionId}' on agent '${spec.agentId}' failed ` +
|
|
2172
|
+
`(${result.error}); starting a new session instead`);
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
const result = await session.openSession({ ...request, mode: 'new' });
|
|
2176
|
+
if (!result.ok)
|
|
2177
|
+
throw refused(result);
|
|
2178
|
+
if (result.nativeSessionId)
|
|
2179
|
+
await session.enableRepair(result.nativeSessionId, spec.cwd, historyHome);
|
|
2180
|
+
return { nativeSessionId: result.nativeSessionId };
|
|
2181
|
+
}
|
|
2182
|
+
/**
|
|
2183
|
+
* List one agent's native sessions for adoption, via a throwaway connection — listing happens
|
|
2184
|
+
* before any session exists, so there is nothing running to ask.
|
|
2185
|
+
*
|
|
2186
|
+
* Degrades to an empty list rather than an error when the agent does not advertise
|
|
2187
|
+
* `sessionCapabilities.list` (that gate lives in `adoption.ts`); a *connection* failure still
|
|
2188
|
+
* throws, because "this agent is not installed" and "this agent has no sessions" must not look
|
|
2189
|
+
* alike to the user.
|
|
2190
|
+
*/
|
|
2191
|
+
async listNativeSessions(spec) {
|
|
2192
|
+
const resolved = await this.resolveLaunchFor(spec.agentId, spec.command, spec.args);
|
|
2193
|
+
const connection = await this.connectResolved(resolved, spec.env, spec.agentEnv);
|
|
2194
|
+
this.observe(spec.agentId, observationOf(connection));
|
|
2195
|
+
try {
|
|
2196
|
+
const result = await listNativeSessions(connection, { cwd: spec.filterCwd });
|
|
2197
|
+
return result.sessions;
|
|
2198
|
+
}
|
|
2199
|
+
finally {
|
|
2200
|
+
await connection.close().catch(() => undefined);
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
/**
|
|
2204
|
+
* Delete native sessions over `session/delete`, via a throwaway connection. Per-id outcomes; an
|
|
2205
|
+
* agent that does not advertise `sessionCapabilities.delete` yields `unsupported` for every id
|
|
2206
|
+
* without the method being called. A *connection* failure still throws.
|
|
2207
|
+
*/
|
|
2208
|
+
async deleteNativeSessions(spec, ids) {
|
|
2209
|
+
const resolved = await this.resolveLaunchFor(spec.agentId, spec.command, spec.args);
|
|
2210
|
+
const connection = await this.connectResolved(resolved, spec.env, spec.agentEnv);
|
|
2211
|
+
this.observe(spec.agentId, observationOf(connection));
|
|
2212
|
+
try {
|
|
2213
|
+
return await deleteNativeSessions(connection, ids);
|
|
2214
|
+
}
|
|
2215
|
+
finally {
|
|
2216
|
+
await connection.close().catch(() => undefined);
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
/** Explicit quota read, called only from an admitted footer refresh. */
|
|
2220
|
+
async minimumAccountLimitsVersion(spec) {
|
|
2221
|
+
if (spec.command)
|
|
2222
|
+
return 2;
|
|
2223
|
+
const current = await this.builds.current(spec.agentId);
|
|
2224
|
+
return current ? nativeAccountLimitsVersionFor(current.agentId, current.version) : 2;
|
|
2225
|
+
}
|
|
2226
|
+
async readAccountLimits(spec) {
|
|
2227
|
+
// Readers that need interactive credential authorization retain their process-local cache.
|
|
2228
|
+
// Keeping this control connection alive performs no model calls or periodic quota queries.
|
|
2229
|
+
const cached = (await this.minimumAccountLimitsVersion(spec)) >= 3;
|
|
2230
|
+
const key = createHash('sha256').update(JSON.stringify(spec)).digest('hex');
|
|
2231
|
+
let pending = this.quotaReaders.get(key)?.pending;
|
|
2232
|
+
if (!cached || !pending) {
|
|
2233
|
+
pending = this.connectFor(spec, 30_000);
|
|
2234
|
+
if (cached) {
|
|
2235
|
+
this.quotaReaders.set(key, { agentId: spec.agentId, pending });
|
|
2236
|
+
void pending.catch(() => { if (this.quotaReaders.get(key)?.pending === pending)
|
|
2237
|
+
this.quotaReaders.delete(key); });
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
const connection = await pending;
|
|
2241
|
+
try {
|
|
2242
|
+
const raw = await withDeadline(connection.connection.agent.request("_axom/account/rateLimits/read", { cwd: spec.cwd }), ACCOUNT_LIMITS_READ_TIMEOUT_MS, 'Refreshing account usage');
|
|
2243
|
+
const { accountLimitsSchema } = await import('../protocol/accountLimits.js');
|
|
2244
|
+
return accountLimitsSchema.parse(raw);
|
|
2245
|
+
}
|
|
2246
|
+
catch (error) {
|
|
2247
|
+
// A provider's RPC error can be retried with the same credential cache. A dead transport cannot.
|
|
2248
|
+
if (cached && typeof error?.code !== 'number') {
|
|
2249
|
+
this.quotaReaders.delete(key);
|
|
2250
|
+
await connection.close().catch(() => undefined);
|
|
2251
|
+
}
|
|
2252
|
+
throw error;
|
|
2253
|
+
}
|
|
2254
|
+
finally {
|
|
2255
|
+
if (!cached)
|
|
2256
|
+
await connection.close().catch(() => undefined);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
/**
|
|
2260
|
+
* Verify one agent on demand: spawn it, negotiate `initialize`, try to open a session, then throw
|
|
2261
|
+
* the whole thing away. Nothing is kept running — a verification is a question, not a resource.
|
|
2262
|
+
*
|
|
2263
|
+
* This is the only way the roster's auth fields can ever be populated for an agent nobody has
|
|
2264
|
+
* started a session with, which is the case a settings page is looking at: {@link roster} reads
|
|
2265
|
+
* {@link AuthObservation} and building the roster spawns nothing, so before the first verification
|
|
2266
|
+
* (or the first real session) every agent reports "unknown" and stays that way. What is learned
|
|
2267
|
+
* here goes through {@link observe}, so the next `hello`/scan carries it.
|
|
2268
|
+
*
|
|
2269
|
+
* ### Two outcomes, and neither of them is "not authenticated"
|
|
2270
|
+
*
|
|
2271
|
+
* A session that opened proves the agent is not waiting on credentials — that is `authenticated:
|
|
2272
|
+
* true`. A session that did not open proves nothing at all: the agent may need signing in, or its
|
|
2273
|
+
* cwd may not exist, or it may have crashed. That case comes back as {@link
|
|
2274
|
+
* AcpVerification.sessionError} — the observed facts, with no verdict attached — and the
|
|
2275
|
+
* observation is *not* updated, so an earlier proof is not erased by a later inconclusive attempt.
|
|
2276
|
+
*
|
|
2277
|
+
* Failing to connect at all throws, because "this agent is not installed" and "this agent is
|
|
2278
|
+
* installed and would not start a session" are different answers to the user.
|
|
2279
|
+
*/
|
|
2280
|
+
async verify(spec, timeoutMs = AGENT_PROBE_TIMEOUT_MS) {
|
|
2281
|
+
const probe = createCommandTableProbe();
|
|
2282
|
+
const connection = await this.connectFor(spec, timeoutMs, undefined, probe);
|
|
2283
|
+
const authMethods = connection.authMethods;
|
|
2284
|
+
this.observe(spec.agentId, observationOf(connection));
|
|
2285
|
+
try {
|
|
2286
|
+
let observed;
|
|
2287
|
+
let observedConfig;
|
|
2288
|
+
const started = Date.now();
|
|
2289
|
+
await withDeadline((async () => {
|
|
2290
|
+
const first = await observeCommands(probe, connection, spec.cwd, COMMAND_FIRST_BROADCAST_MS);
|
|
2291
|
+
observedConfig = first.configOptions;
|
|
2292
|
+
observed = first.commands;
|
|
2293
|
+
})(), timeoutMs, `Opening a session on agent '${spec.agentId}'`);
|
|
2294
|
+
if (observed === undefined) {
|
|
2295
|
+
// p3-fix7 N9: "no table within the window" is unknown, not "this agent has no commands": look once more, with a
|
|
2296
|
+
// longer window. p3-fix7b M-4: on a fresh connection (this one is bound to the first session, so a table pushed
|
|
2297
|
+
// before the retry's session/new answer would read as a stranger's). p3-fix7c 3: the retry is only a second
|
|
2298
|
+
// look for the table — its failure, or the deadline running out during it, leaves the table unknown and never
|
|
2299
|
+
// turns a session that opened into a sessionError.
|
|
2300
|
+
observed = await this.retryCommandTable(spec, timeoutMs - (Date.now() - started));
|
|
2301
|
+
}
|
|
2302
|
+
if (observed === undefined) {
|
|
2303
|
+
// A 那条日志的 daemon 侧对应物。没有它,"这个 agent 在这个目录下没有命令" 与
|
|
2304
|
+
// "表来晚了、窗口没接住" 在任何一层都分不开 —— 而这两件事的修法完全不同。
|
|
2305
|
+
console.warn(`[acp] agent '${spec.agentId}' broadcast no command table within ` +
|
|
2306
|
+
`${COMMAND_FIRST_BROADCAST_MS}ms (and ${this.commandRetryFirstBroadcastMs}ms on a second session) ` +
|
|
2307
|
+
`of opening a session in ${spec.cwd}`);
|
|
2308
|
+
}
|
|
2309
|
+
this.observe(spec.agentId, { ...observationOf(connection), authenticated: true });
|
|
2310
|
+
return {
|
|
2311
|
+
authMethods,
|
|
2312
|
+
authenticated: true,
|
|
2313
|
+
...(observed !== undefined ? { availableCommands: observed } : {}),
|
|
2314
|
+
...(observedConfig !== undefined ? { configOptions: observedConfig } : {}),
|
|
2315
|
+
};
|
|
2316
|
+
}
|
|
2317
|
+
catch (error) {
|
|
2318
|
+
// Prefixed rather than relayed raw: the SDK masks an agent-thrown error as "Internal error",
|
|
2319
|
+
// and a settings page showing that alone tells the user nothing about which agent it came from
|
|
2320
|
+
// or what was being attempted.
|
|
2321
|
+
return { authMethods, sessionError: `Agent '${spec.agentId}' did not open a session: ${errorMessage(error)}` };
|
|
2322
|
+
}
|
|
2323
|
+
finally {
|
|
2324
|
+
await connection.close().catch(() => undefined);
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
/** p3-fix7c 3: one more look for the command table on a fresh connection; undefined on any failure or timeout. */
|
|
2328
|
+
async retryCommandTable(spec, remainingMs) {
|
|
2329
|
+
if (remainingMs <= 0)
|
|
2330
|
+
return undefined;
|
|
2331
|
+
let retryConnection;
|
|
2332
|
+
let abandoned = false;
|
|
2333
|
+
try {
|
|
2334
|
+
return await withDeadline((async () => {
|
|
2335
|
+
const probe = createCommandTableProbe();
|
|
2336
|
+
const connection = await this.connectFor(spec, remainingMs, undefined, probe);
|
|
2337
|
+
retryConnection = connection;
|
|
2338
|
+
// The deadline may already have fired while connecting: nobody will close it after this point but us.
|
|
2339
|
+
if (abandoned) {
|
|
2340
|
+
await connection.close().catch(() => undefined);
|
|
2341
|
+
return undefined;
|
|
2342
|
+
}
|
|
2343
|
+
return (await observeCommands(probe, connection, spec.cwd, this.commandRetryFirstBroadcastMs)).commands;
|
|
2344
|
+
})(), remainingMs, `Looking again for agent '${spec.agentId}''s command table`);
|
|
2345
|
+
}
|
|
2346
|
+
catch (error) {
|
|
2347
|
+
abandoned = true;
|
|
2348
|
+
console.warn(`[acp] retry for agent '${spec.agentId}''s command table failed: ${errorMessage(error)}`);
|
|
2349
|
+
return undefined;
|
|
2350
|
+
}
|
|
2351
|
+
finally {
|
|
2352
|
+
await retryConnection?.close().catch(() => undefined);
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
/**
|
|
2356
|
+
* Run one agent's `authenticate` for a `methodId` **it advertised**, over a throwaway connection —
|
|
2357
|
+
* or, for a terminal-type method, run the sign-in itself ({@link hostTerminalLogin}): ACP forbids
|
|
2358
|
+
* passing a terminal method to `authenticate`, and the workstation is the terminal's host (spec
|
|
2359
|
+
* 2026-09-24 D6/D12).
|
|
2360
|
+
*
|
|
2361
|
+
* The refusal for an unadvertised non-terminal `methodId` is {@link AcpAuth}'s and happens before
|
|
2362
|
+
* anything is written to the agent — which is why this hands `AcpAuth` the connection rather than
|
|
2363
|
+
* re-deriving the check: there is exactly one place that rule lives.
|
|
2364
|
+
*
|
|
2365
|
+
* Deliberately does not report an auth state. `authenticate` resolving means the agent accepted
|
|
2366
|
+
* the call, which is not the same as a session it will now open; the caller asks {@link verify}
|
|
2367
|
+
* for that, and gets an observation made the only way ACP allows one to be made. What is recorded
|
|
2368
|
+
* here is `authMethods`, because `initialize` happened.
|
|
2369
|
+
*/
|
|
2370
|
+
async authenticate(spec, methodId, login = {}) {
|
|
2371
|
+
this.clearQuotaReaders(spec.agentId);
|
|
2372
|
+
const urls = createLoginUrlSink(login);
|
|
2373
|
+
// **The retraction has to cover the connect, not just the login.** The sniffer is armed inside
|
|
2374
|
+
// `connectAgent` at `spawn()`, before `initialize` — so a URL can be presented while this is
|
|
2375
|
+
// still awaiting, and every way connecting fails (initialize timeout, spawn error, an
|
|
2376
|
+
// unsupported protocol version, the unpinned retry failing too) would otherwise leave the card
|
|
2377
|
+
// up with nothing left running that could ever take it down. Measured, not reasoned: a probe of
|
|
2378
|
+
// the first version reported `PRESENTED [ 'stderr-auth-url-…' ] RETRACTED []`. And the trigger
|
|
2379
|
+
// is ordinary — the scan covers the `npx` install, whose own output can carry a "visit this
|
|
2380
|
+
// URL" line, and a login whose agent then fails to install is exactly when that appears.
|
|
2381
|
+
try {
|
|
2382
|
+
// Spec 2026-09-24 D10: a sign-in almost always follows the connection that hit
|
|
2383
|
+
// `auth_required`, which already read this method. Reuse it rather than cold-start the agent.
|
|
2384
|
+
const remembered = this.observed.get(spec.agentId)?.terminalLogins?.get(methodId);
|
|
2385
|
+
if (remembered) {
|
|
2386
|
+
await this.hostTerminalLogin(spec, remembered, urls.present, login);
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
const connection = await this.connectFor(spec, AGENT_PROBE_TIMEOUT_MS, {
|
|
2390
|
+
...(urls.present ? { present: urls.present } : {}),
|
|
2391
|
+
dismiss: urls.dismiss,
|
|
2392
|
+
});
|
|
2393
|
+
this.observe(spec.agentId, observationOf(connection));
|
|
2394
|
+
const terminal = connection.terminalLogins.get(methodId);
|
|
2395
|
+
if (terminal) {
|
|
2396
|
+
// ACP: a terminal method is never passed to `authenticate`. This connection has nothing
|
|
2397
|
+
// left to do, and a login can take minutes — do not hold an agent process open for it.
|
|
2398
|
+
await connection.close().catch(() => undefined);
|
|
2399
|
+
await this.hostTerminalLogin(spec, terminal, urls.present, login);
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
try {
|
|
2403
|
+
const what = `Signing in to agent '${spec.agentId}' with method '${methodId}'`;
|
|
2404
|
+
await withDeadline(cancellable(new AcpAuth(connection).authenticate(methodId), login.signal, what), loginDeadlineMs(login.ceilingMs), what);
|
|
2405
|
+
}
|
|
2406
|
+
finally {
|
|
2407
|
+
// The connection dies here and not a moment earlier — this `finally` runs when the login
|
|
2408
|
+
// *settles*, never on a probe-sized deadline part-way through one.
|
|
2409
|
+
await connection.close().catch(() => undefined);
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
finally {
|
|
2413
|
+
// The retraction trigger a stderr-sourced URL can never get from the protocol — see
|
|
2414
|
+
// {@link createLoginUrlSink}. This runs on every exit, including every way connecting failed.
|
|
2415
|
+
urls.finish();
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
/**
|
|
2419
|
+
* Run a terminal sign-in on this machine (spec 2026-09-24 D7, D9). The launch is resolved the same
|
|
2420
|
+
* way a session's is, because the standard shape appends to it; the `_meta` shape ignores it.
|
|
2421
|
+
*/
|
|
2422
|
+
async hostTerminalLogin(spec, terminal, present, login) {
|
|
2423
|
+
// The launch is resolved for both shapes, and for the same reason a session's is: its env is
|
|
2424
|
+
// the login child's environment. The `_meta` shape keeps the invocation's own command and args
|
|
2425
|
+
// (see `launchForTerminalLogin`) but rides the resolved launch's env — a login that seeded a
|
|
2426
|
+
// different credential slot than the sessions it was logging in for would be a no-op for them.
|
|
2427
|
+
const launch = launchForTerminalLogin(terminal, (await this.resolveLaunchFor(spec.agentId, spec.command, spec.args)).launch);
|
|
2428
|
+
await runTerminalLogin(terminalInvocationFor(terminal, launch, { ...spec.env, ...spec.agentEnv }), {
|
|
2429
|
+
...(present ? { present } : {}),
|
|
2430
|
+
...(login.signal ? { signal: login.signal } : {}),
|
|
2431
|
+
deadlineMs: loginDeadlineMs(login.ceilingMs),
|
|
2432
|
+
});
|
|
2433
|
+
}
|
|
2434
|
+
/**
|
|
2435
|
+
* The spawn half shared by {@link verify} and {@link authenticate}. No session either way — but
|
|
2436
|
+
* **not "no handlers" any more**, and the difference is the whole of Task 4b: `authenticate` is a
|
|
2437
|
+
* round trip through a human, so its connection carries the elicitation/permission handlers and
|
|
2438
|
+
* the stderr sniffer that a login URL reaches the reader through. `verify` passes no `login` and
|
|
2439
|
+
* so gets exactly what it always got: a bare connection nobody can raise a card on.
|
|
2440
|
+
*
|
|
2441
|
+
* **This is the only method in this class that builds a `present`**, and that is the mechanism, not
|
|
2442
|
+
* a convention: {@link connectResolved} — the route every other connection takes, including
|
|
768
2443
|
* {@link open}'s long-lived session — accepts only {@link ConnectAgentOptions}, whose `present` is
|
|
769
2444
|
* typed `never`. Arming the stderr sniffer anywhere else would mean writing a fresh `connectAgent`
|
|
770
2445
|
* call by hand, which is a visibly deliberate act rather than one more argument. What that buys is
|
|
@@ -782,21 +2457,24 @@ export class AcpAgents {
|
|
|
782
2457
|
* `connectAgent`'s `initialize` deadline, whose expiry kills the child. A login's own budget is
|
|
783
2458
|
* {@link AGENT_LOGIN_CEILING_MS} and is applied around `authenticate` alone.
|
|
784
2459
|
*/
|
|
785
|
-
async connectFor(spec, timeoutMs, login) {
|
|
2460
|
+
async connectFor(spec, timeoutMs, login, probe) {
|
|
786
2461
|
const resolved = await this.resolveLaunchFor(spec.agentId, spec.command, spec.args);
|
|
787
2462
|
// The same budget as the caller's, not the 30s default: `connectAgent` kills the child when it
|
|
788
2463
|
// fires, which is the only thing that stops an agent that opened stdio and never spoke.
|
|
789
|
-
if (!login)
|
|
790
|
-
return this.connectResolved(
|
|
2464
|
+
if (!login) {
|
|
2465
|
+
return this.connectResolved(resolved, spec.env, spec.agentEnv, {
|
|
2466
|
+
initializeTimeoutMs: timeoutMs, ...(probe ? { configure: (app) => probe.configure(app) } : {}),
|
|
2467
|
+
});
|
|
2468
|
+
}
|
|
791
2469
|
const options = {
|
|
792
2470
|
initializeTimeoutMs: timeoutMs,
|
|
793
2471
|
...(login.present ? { present: login.present } : {}),
|
|
794
|
-
configure: (app) => {
|
|
2472
|
+
configure: (app, adapter) => {
|
|
795
2473
|
// The same registration the session connection makes (see `open`), with the two
|
|
796
2474
|
// elicitation callbacks actually wired — this is the only connection they can be reached
|
|
797
2475
|
// on, because `authenticate` does not run on a session's connection.
|
|
798
2476
|
//
|
|
799
|
-
// The sink is deferred and never bound, deliberately: there is no
|
|
2477
|
+
// The sink is deferred and never bound, deliberately: there is no session here, so an
|
|
800
2478
|
// agent that sent `session/request_permission` on a login connection would be referring to
|
|
801
2479
|
// a session that cannot exist. `createDeferredPermissionSink` answers that by throwing a
|
|
802
2480
|
// named error, which the SDK turns into a JSON-RPC error for the agent — loud and
|
|
@@ -804,117 +2482,53 @@ export class AcpAgents {
|
|
|
804
2482
|
registerPermissionHandlers(app, createDeferredPermissionSink().sink, {
|
|
805
2483
|
present: login.present,
|
|
806
2484
|
dismiss: login.dismiss ?? (() => { }),
|
|
807
|
-
});
|
|
2485
|
+
}, adapter);
|
|
808
2486
|
},
|
|
809
2487
|
};
|
|
810
|
-
return
|
|
811
|
-
}
|
|
812
|
-
/**
|
|
813
|
-
* Continue in an already-existing native session when we have an id for one and the agent can load
|
|
814
|
-
* it; otherwise open a new session and report the new id truthfully.
|
|
815
|
-
*
|
|
816
|
-
* Two things are deliberately *not* failures here. An agent that does not advertise
|
|
817
|
-
* `loadSession` still gets a session — the manager already tolerates the id it asked for coming
|
|
818
|
-
* back different (it warns and re-points), so refusing to open would be strictly worse than
|
|
819
|
-
* continuing in a fresh one. A `session/load` that is advertised but rejected (a stale id the
|
|
820
|
-
* agent has since deleted, most likely) is treated the same way, for the same reason.
|
|
821
|
-
*
|
|
822
|
-
* `AcpSession.attach` runs **before** the replay, not after: it subscribes to the connection's live
|
|
823
|
-
* `session/update` stream, and `ReplayCapture` withholds updates from that subscription for exactly
|
|
824
|
-
* as long as the id is armed — i.e. for the whole `session/load`. Subscribing afterwards would
|
|
825
|
-
* leave a gap between the replay's `take()` and the subscription in which a live update is dropped.
|
|
826
|
-
*/
|
|
827
|
-
async startSession(connection, capture, spec) {
|
|
828
|
-
const nativeSessionId = spec.nativeSessionId;
|
|
829
|
-
if (nativeSessionId) {
|
|
830
|
-
if (!connection.capabilities.loadSession) {
|
|
831
|
-
console.warn(`[acp] agent '${spec.agentId}' does not support session/load; starting a new session ` +
|
|
832
|
-
`instead of continuing '${nativeSessionId}'`);
|
|
833
|
-
}
|
|
834
|
-
else {
|
|
835
|
-
// `AcpSession.attach` reads `initial.configOptions` lazily (see its own doc comment) —
|
|
836
|
-
// exactly so this can hand it a mutable object *before* the snapshot exists, and fill it in
|
|
837
|
-
// below once `session/load`'s response arrives. Snapshotting eagerly here (e.g. passing
|
|
838
|
-
// `{ configOptions: [] }` outright) would leave the attached session's `configOptions`
|
|
839
|
-
// empty forever, because nothing would ever revisit it.
|
|
840
|
-
const initial = { configOptions: [] };
|
|
841
|
-
const session = AcpSession.attach(connection, nativeSessionId, capture, initial);
|
|
842
|
-
try {
|
|
843
|
-
const history = [];
|
|
844
|
-
for await (const event of replayNativeSession(connection, capture, {
|
|
845
|
-
cwd: spec.cwd,
|
|
846
|
-
nativeSessionId,
|
|
847
|
-
onLoaded: (response) => {
|
|
848
|
-
initial.configOptions = normalizeConfigOptions(response.configOptions, response.modes);
|
|
849
|
-
},
|
|
850
|
-
})) {
|
|
851
|
-
// Folded and validated either way (one code path, no second copy of the replay rules);
|
|
852
|
-
// retained only when someone is going to ask for it, so a long history is not held for
|
|
853
|
-
// the life of a session that will never replay it.
|
|
854
|
-
if (spec.backfill)
|
|
855
|
-
history.push(event);
|
|
856
|
-
}
|
|
857
|
-
return { session, nativeSessionId, history, configOptions: initial.configOptions };
|
|
858
|
-
}
|
|
859
|
-
catch (error) {
|
|
860
|
-
console.warn(`[acp] session/load for '${nativeSessionId}' on agent '${spec.agentId}' failed ` +
|
|
861
|
-
`(${errorMessage(error)}); starting a new session instead`);
|
|
862
|
-
await session.close().catch(() => undefined);
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
const opened = await AcpSession.open(connection, { cwd: spec.cwd });
|
|
867
|
-
return { session: opened.session, nativeSessionId: opened.nativeSessionId, history: [], configOptions: opened.configOptions };
|
|
2488
|
+
return connectAgent(resolved.launch, spec.env, spec.agentEnv, options);
|
|
868
2489
|
}
|
|
869
2490
|
/**
|
|
870
|
-
*
|
|
871
|
-
*
|
|
2491
|
+
* Install (or Update) one agent's ACP adapter and make it the agent's **current build** — the only
|
|
2492
|
+
* thing, besides a CLI-certified change ({@link reconcileCertifiedBuilds}), that moves the pointer
|
|
2493
|
+
* new sessions launch from (spec §6.1). What the settings page's Install / Update / Config does.
|
|
872
2494
|
*
|
|
873
|
-
* ##
|
|
2495
|
+
* ## Builds, not caches
|
|
874
2496
|
*
|
|
875
|
-
*
|
|
876
|
-
*
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
*
|
|
881
|
-
*
|
|
2497
|
+
* npm and binary adapters are installed into daemon-owned, immutable, versioned directories under
|
|
2498
|
+
* `<configDir>/acp-adapters/` (`builds/model.ts`), staged and renamed into place whole, then
|
|
2499
|
+
* recorded in `builds.json`. A new install never touches a directory a running adapter is loading
|
|
2500
|
+
* from, and `open` never downloads anything: it launches the current build or fails with
|
|
2501
|
+
* {@link AdapterNotInstalledError}. A registry version bump only makes the card offer Update.
|
|
2502
|
+
* Install, switch and GC of one agent are serialised ({@link BuildStore.withAgentLock}), so two
|
|
2503
|
+
* concurrent Updates download once. The uvx path still installs into `uv tool dir` until Task 15b.
|
|
882
2504
|
*
|
|
883
2505
|
* ## Three answers, and the third one is not a failure
|
|
884
2506
|
*
|
|
885
|
-
* `ok` with `alreadyInstalled` means there was nothing to
|
|
2507
|
+
* `ok` with `alreadyInstalled` means there was nothing to download. `ok` without it means this call
|
|
886
2508
|
* downloaded it. `unsupported` means there is nothing for *this machine* to fetch — the entry
|
|
887
2509
|
* declares no launchable distribution, or it is binary-only with no archive for this platform
|
|
888
2510
|
* triple. Reported as its own outcome rather than as an error so the page can grey the card out
|
|
889
2511
|
* instead of offering a retry that cannot succeed.
|
|
890
2512
|
*
|
|
891
|
-
*
|
|
892
|
-
* npx → uvx → binary in `resolveLaunch`'s own preference order.
|
|
893
|
-
*
|
|
894
|
-
* ## The catalog's `dependency` runs first, and can refuse to run anything at all
|
|
2513
|
+
* ## The catalog's `prerequisite` runs first, and can refuse to run anything at all
|
|
895
2514
|
*
|
|
896
|
-
* `registry/catalog.ts` may curate one
|
|
897
|
-
*
|
|
898
|
-
* adapter step, in
|
|
2515
|
+
* `registry/catalog.ts` may curate one prerequisite for an agent (`pi-acp` needs the `pi` CLI,
|
|
2516
|
+
* `zcode-acp` the ZCode desktop app) that the adapter fetch above does nothing to provide. That
|
|
2517
|
+
* block is handled *before* the adapter step, in up to three gates:
|
|
899
2518
|
*
|
|
900
2519
|
* 1. `requiresNode`, checked against this daemon's own `process.version` (or the injected
|
|
901
2520
|
* `nodeVersion`, for tests) — because npm only *warns*, never refuses, on an `engines`
|
|
902
|
-
* mismatch.
|
|
903
|
-
*
|
|
904
|
-
* satisfied right before the agent fails at run time with an engines error nobody could trace
|
|
905
|
-
* back to install. Failing this gate returns `nodeTooOld` and runs no command at all — not
|
|
906
|
-
* even the `findOnPath` probe below, since there would be nothing useful to install anyway.
|
|
907
|
-
* 2. `check` on `PATH` (via `findOnPath`) — only when that comes back empty does this call the
|
|
2521
|
+
* mismatch. Failing this gate returns `nodeTooOld` and runs no command at all.
|
|
2522
|
+
* 2. its `detect` (`registry/detect.ts`) — only when that finds nothing does this call the
|
|
908
2523
|
* catalog's literal `install` argv through {@link AcpAgents.runCommand}, which is
|
|
909
2524
|
* `execFile`-based and never a shell (see `catalog.ts`'s security note on that array).
|
|
2525
|
+
* 3. a prerequisite with no `install` argv cannot be fetched by anyone but the reader, so a missing
|
|
2526
|
+
* one refuses with the catalog's `hint` as the error and fetches no adapter either.
|
|
910
2527
|
*
|
|
911
|
-
*
|
|
912
|
-
* step below — nothing here changes for `claude-acp`, `codex-acp`, or any other uncurated agent.
|
|
913
|
-
*
|
|
914
|
-
* Never starts the agent: see {@link installNpxPackage}.
|
|
2528
|
+
* Never starts the agent.
|
|
915
2529
|
*/
|
|
916
2530
|
async install(agentId, options = {}) {
|
|
917
|
-
const dependency = catalogEntryFor(agentId)?.
|
|
2531
|
+
const dependency = catalogEntryFor(agentId)?.prerequisite;
|
|
918
2532
|
if (dependency) {
|
|
919
2533
|
const nodeVersion = options.nodeVersion ?? process.version;
|
|
920
2534
|
if (dependency.requiresNode && !semver.satisfies(nodeVersion, dependency.requiresNode)) {
|
|
@@ -927,7 +2541,13 @@ export class AcpAgents {
|
|
|
927
2541
|
}
|
|
928
2542
|
}
|
|
929
2543
|
let dependencyInstalled;
|
|
930
|
-
if (dependency &&
|
|
2544
|
+
if (dependency && !this.prerequisiteSatisfied(agentId)) {
|
|
2545
|
+
if (!dependency.install) {
|
|
2546
|
+
return {
|
|
2547
|
+
ok: false,
|
|
2548
|
+
error: dependency.hint ?? `Agent '${agentId}' needs ${dependency.label}, which is not installed on this machine.`,
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
931
2551
|
const [command, ...args] = dependency.install;
|
|
932
2552
|
const outcome = await this.runCommand(command, args, { timeoutMs: options.timeoutMs });
|
|
933
2553
|
if (!outcome.ok) {
|
|
@@ -935,12 +2555,11 @@ export class AcpAgents {
|
|
|
935
2555
|
}
|
|
936
2556
|
dependencyInstalled = true;
|
|
937
2557
|
}
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
if (!agent) {
|
|
2558
|
+
const entry = (await this.effective()).find((candidate) => candidate.agent.id === agentId);
|
|
2559
|
+
if (!entry) {
|
|
941
2560
|
return { ok: false, error: `Agent '${agentId}' is not in the ACP agent registry.` };
|
|
942
2561
|
}
|
|
943
|
-
const outcome = await this.installAdapter(
|
|
2562
|
+
const outcome = await this.builds.withAgentLock(agentId, () => this.installAdapter(entry, 'user', options.timeoutMs));
|
|
944
2563
|
if (!outcome.ok)
|
|
945
2564
|
return outcome;
|
|
946
2565
|
return {
|
|
@@ -951,60 +2570,86 @@ export class AcpAgents {
|
|
|
951
2570
|
...(outcome.pinnedVersionUnavailable ? { pinnedVersionUnavailable: outcome.pinnedVersionUnavailable } : {}),
|
|
952
2571
|
};
|
|
953
2572
|
}
|
|
2573
|
+
/** The registry as Axom installs it (catalog supplements, pins, launchEnv kept apart) — see `registry/catalog.ts`. */
|
|
2574
|
+
async effective() {
|
|
2575
|
+
return effectiveEntries(await this.baseRegistry.load());
|
|
2576
|
+
}
|
|
954
2577
|
/**
|
|
955
|
-
*
|
|
956
|
-
* npx → uvx → binary
|
|
957
|
-
* launched, so `Install` always fetches the thing that will later be started.
|
|
958
|
-
*
|
|
959
|
-
* ## Why this exists
|
|
960
|
-
*
|
|
961
|
-
* {@link install} used to refuse anything without an `npx` block outright, which made
|
|
962
|
-
* `installBinary` (`binaryInstall.ts`) and `installUvTool` (`uvxInstall.ts`) unreachable from
|
|
963
|
-
* production: they were written, tested, and called by nothing. On the live registry snapshot that
|
|
964
|
-
* is 18 of 39 agents — 16 binary-only and 2 uvx-only — every one of them rendering an `Install`
|
|
965
|
-
* button whose task could only ever fail. Design §4.1's "three distributions, one model" is this
|
|
966
|
-
* function.
|
|
967
|
-
*
|
|
968
|
-
* ## `unsupported` still exists, and now means something narrower
|
|
2578
|
+
* Install one agent's adapter through **whichever distribution it actually ships**, dispatching
|
|
2579
|
+
* npx → uvx → binary, then commit the build and make it current. The caller holds the agent's lock.
|
|
969
2580
|
*
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
*
|
|
977
|
-
*
|
|
978
|
-
* Every other failure is an ordinary `error` the reader can retry.
|
|
979
|
-
*
|
|
980
|
-
* `checksumVerified` from the binary path is deliberately not propagated into
|
|
981
|
-
* {@link AcpInstallResult}: a registry entry with no `sha256` (`antigravity-acp`, `cursor`)
|
|
982
|
-
* installs successfully and this result must not imply either that it was verified or that
|
|
983
|
-
* anything went wrong. `binaryInstall.ts` logs the fact where it is established.
|
|
2581
|
+
* `unsupported` means one of two things: the entry declares no launchable distribution at all, or
|
|
2582
|
+
* it is binary-only and publishes no archive for **this machine's** platform triple. Every other
|
|
2583
|
+
* failure is an ordinary `error` the reader can retry, and leaves the current build untouched.
|
|
2584
|
+
*/
|
|
2585
|
+
async installAdapter(entry, setBy, timeoutMs,
|
|
2586
|
+
/**
|
|
2587
|
+
* The certified reconcile's current build: if the install lands on that same build, its record is
|
|
2588
|
+
* refreshed but the pointer (and `currentSetBy`) is left alone — that is not a move.
|
|
984
2589
|
*/
|
|
985
|
-
|
|
2590
|
+
currentBuildId) {
|
|
2591
|
+
const agent = entry.agent;
|
|
986
2592
|
const { npx, uvx, binary } = agent.distribution;
|
|
2593
|
+
const source = entry.pinnedBy ?? 'registry';
|
|
2594
|
+
const commit = (build) => this.builds.commit(build, { makeCurrent: build.buildId !== currentBuildId, setBy });
|
|
987
2595
|
if (npx) {
|
|
988
|
-
|
|
2596
|
+
const pristine = await this.installPristineWithFallback(agent, npx.package, timeoutMs);
|
|
2597
|
+
if (!pristine.ok)
|
|
2598
|
+
return pristine;
|
|
2599
|
+
try {
|
|
2600
|
+
const build = await npmBuild({
|
|
2601
|
+
store: this.builds, agentId: agent.id, name: npmPackageName(npx.package), version: pristine.version,
|
|
2602
|
+
args: npx.args ?? [], env: npx.env ?? {}, registryVersion: entry.registryVersion,
|
|
2603
|
+
source: pristine.pinnedVersionUnavailable ? 'unpinned-fallback' : source, installedBy: setBy,
|
|
2604
|
+
nodePath: this.nodePath,
|
|
2605
|
+
...(this.patchRecipes ? { recipes: this.patchRecipes } : {}),
|
|
2606
|
+
});
|
|
2607
|
+
const recorded = pristine.pinnedVersionUnavailable
|
|
2608
|
+
? { ...build, requestedVersion: requestedVersionOf(entry) }
|
|
2609
|
+
: build;
|
|
2610
|
+
await commit(recorded);
|
|
2611
|
+
return {
|
|
2612
|
+
ok: true, alreadyInstalled: !pristine.downloaded, build: recorded,
|
|
2613
|
+
...(pristine.pinnedVersionUnavailable ? { pinnedVersionUnavailable: pristine.pinnedVersionUnavailable } : {}),
|
|
2614
|
+
};
|
|
2615
|
+
}
|
|
2616
|
+
catch (error) {
|
|
2617
|
+
logInstallFailureDetail(agent.id, 'npx', npx.package, String(error));
|
|
2618
|
+
// The Node gate: the package's own engines.node refuses the Node it would run on. Not current.
|
|
2619
|
+
if (error instanceof AdapterNodeVersionError) {
|
|
2620
|
+
return { ok: false, error: error.message, nodeTooOld: { required: error.required, found: error.found } };
|
|
2621
|
+
}
|
|
2622
|
+
return { ok: false, error: `Adapter build preparation failed: ${errorMessage(error)}` };
|
|
2623
|
+
}
|
|
989
2624
|
}
|
|
990
2625
|
if (uvx) {
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
const outcome = await (
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
2626
|
+
// A per-version build under `uv/<pkg>/<version>/` (`builds/install/uv.ts`): its own UV_TOOL_DIR
|
|
2627
|
+
// and UV_TOOL_BIN_DIR, never the user's uv tools or `~/.local/bin`; launch never runs `uv`.
|
|
2628
|
+
const outcome = await installUvBuild({
|
|
2629
|
+
store: this.builds, agentId: agent.id, spec: uvx.package, args: uvx.args ?? [], env: uvx.env ?? {},
|
|
2630
|
+
registryVersion: entry.registryVersion, source, installedBy: setBy, runUv: this.runUv,
|
|
2631
|
+
timeoutMs: timeoutMs ?? UV_TOOL_INSTALL_TIMEOUT_MS,
|
|
2632
|
+
}).catch((error) => ({ ok: false, error: errorMessage(error), uvMissing: undefined }));
|
|
2633
|
+
if (!outcome.ok) {
|
|
2634
|
+
const existingVersion = (await this.builds.current(agent.id))?.version;
|
|
2635
|
+
logInstallFailureDetail(agent.id, 'uvx', uvx.package, outcome.error);
|
|
2636
|
+
// `uv` itself is absent: retrying will not help, installing uv will.
|
|
2637
|
+
if (outcome.uvMissing)
|
|
2638
|
+
return { ok: false, error: friendlyUvMissingError(agent.id, existingVersion) };
|
|
2639
|
+
return { ok: false, error: friendlyOrdinaryInstallError(agent.id, outcome.error, existingVersion) };
|
|
2640
|
+
}
|
|
2641
|
+
try {
|
|
2642
|
+
await commit(outcome.build);
|
|
2643
|
+
return { ok: true, alreadyInstalled: !outcome.downloaded, build: outcome.build };
|
|
2644
|
+
}
|
|
2645
|
+
catch (error) {
|
|
2646
|
+
return { ok: false, error: errorMessage(error) };
|
|
2647
|
+
}
|
|
1004
2648
|
}
|
|
1005
2649
|
if (binary) {
|
|
1006
2650
|
const triple = platformTriple(this.platform, this.arch);
|
|
1007
|
-
|
|
2651
|
+
const target = triple ? binaryTargetFor(agent, triple) : undefined;
|
|
2652
|
+
if (!triple || !target) {
|
|
1008
2653
|
return {
|
|
1009
2654
|
ok: false,
|
|
1010
2655
|
unsupported: true,
|
|
@@ -1015,16 +2660,26 @@ export class AcpAgents {
|
|
|
1015
2660
|
root: this.adapterRoot,
|
|
1016
2661
|
platform: this.platform,
|
|
1017
2662
|
arch: this.arch,
|
|
2663
|
+
...(this.binaryFetch ? { fetch: this.binaryFetch } : {}),
|
|
1018
2664
|
});
|
|
1019
|
-
if (outcome.ok)
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
2665
|
+
if (!outcome.ok) {
|
|
2666
|
+
// `installBinary` never touches another version's tree (it stages, then renames), so what
|
|
2667
|
+
// survives the attempt is exactly the current build.
|
|
2668
|
+
const existingVersion = (await this.builds.current(agent.id))?.version;
|
|
2669
|
+
logInstallFailureDetail(agent.id, 'binary', agent.version, outcome.error);
|
|
2670
|
+
return { ok: false, error: friendlyOrdinaryInstallError(agent.id, outcome.error, existingVersion) };
|
|
2671
|
+
}
|
|
2672
|
+
try {
|
|
2673
|
+
const build = binaryBuild({
|
|
2674
|
+
root: this.adapterRoot, agentId: agent.id, version: outcome.version, target, env: target.env ?? {},
|
|
2675
|
+
registryVersion: entry.registryVersion, source, installedBy: setBy,
|
|
2676
|
+
});
|
|
2677
|
+
await commit(build);
|
|
2678
|
+
return { ok: true, alreadyInstalled: outcome.alreadyInstalled, build };
|
|
2679
|
+
}
|
|
2680
|
+
catch (error) {
|
|
2681
|
+
return { ok: false, error: errorMessage(error) };
|
|
2682
|
+
}
|
|
1028
2683
|
}
|
|
1029
2684
|
return {
|
|
1030
2685
|
ok: false,
|
|
@@ -1033,255 +2688,433 @@ export class AcpAgents {
|
|
|
1033
2688
|
};
|
|
1034
2689
|
}
|
|
1035
2690
|
/**
|
|
1036
|
-
*
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
*
|
|
1041
|
-
* ## Why `Install`/`Update` needed this and `connectResolved` alone was not enough
|
|
1042
|
-
*
|
|
1043
|
-
* The launch-time fallback only ever fires *after* an `Install` has already put something usable
|
|
1044
|
-
* on disk — it exists to recover a session that is about to open, not to get the adapter onto the
|
|
1045
|
-
* machine in the first place. Before this method, a registry entry pinned to an npm version that
|
|
1046
|
-
* does not exist failed here, at `Install`/`Update`, every single time, and never got anywhere
|
|
1047
|
-
* near `connectResolved`: the card offered a button whose task could only ever fail, worse than
|
|
1048
|
-
* the launch-time defect it was fixed for.
|
|
2691
|
+
* The npm half of {@link installAdapter}: the pinned version into `npm/<sha16(name)>/<version>/pristine`
|
|
2692
|
+
* and, when — and only when — npm answers `ETARGET` (the registry pinned a version npm does not
|
|
2693
|
+
* have), one retry unpinned, recording the version that actually landed. This is the **only** place
|
|
2694
|
+
* that fallback exists now: a launch never resolves a version (spec §6.2 "ETARGET 回退移到安装期").
|
|
1049
2695
|
*/
|
|
1050
|
-
async
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
2696
|
+
async installPristineWithFallback(agent, pinnedSpec, timeoutMs) {
|
|
2697
|
+
const name = npmPackageName(pinnedSpec);
|
|
2698
|
+
const version = npmPackageVersion(pinnedSpec);
|
|
2699
|
+
const budget = timeoutMs ?? ADAPTER_INSTALL_TIMEOUT_MS;
|
|
2700
|
+
const install = (pinned) => ensurePristine({
|
|
2701
|
+
store: this.builds, name, ...(pinned !== undefined ? { version: pinned } : {}), npmCommand: this.npmCommand, timeoutMs: budget,
|
|
2702
|
+
}).catch((error) => ({ ok: false, error: errorMessage(error), stderr: '' }));
|
|
2703
|
+
const outcome = await install(version);
|
|
1056
2704
|
if (outcome.ok)
|
|
1057
|
-
return
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
// exec --package=` only ever adds to the cache), so this is a fact about what survives this
|
|
1061
|
-
// attempt no matter which branch below is taken.
|
|
1062
|
-
const existingVersion = npxCacheVersion(npmPackageName(pinnedSpec), readNpxCache(this.probe));
|
|
1063
|
-
const fallback = unpinnedFallbackFor(agent, this.platform);
|
|
1064
|
-
if (!fallback || !stderrShowsMissingPackageVersion(outcome.stderr)) {
|
|
2705
|
+
return outcome;
|
|
2706
|
+
const existingVersion = (await this.builds.current(agent.id))?.version;
|
|
2707
|
+
if (version === undefined || !stderrShowsMissingPackageVersion(outcome.stderr)) {
|
|
1065
2708
|
logInstallFailureDetail(agent.id, 'npx', pinnedSpec, outcome.stderr || outcome.error);
|
|
1066
|
-
return {
|
|
1067
|
-
ok: false,
|
|
1068
|
-
error: friendlyOrdinaryInstallError(agent.id, outcome.stderr || outcome.error, existingVersion),
|
|
1069
|
-
};
|
|
2709
|
+
return { ok: false, error: friendlyOrdinaryInstallError(agent.id, outcome.stderr || outcome.error, existingVersion) };
|
|
1070
2710
|
}
|
|
1071
2711
|
// 两条都是 warn,而且都要能被一个月后读日志的人看懂——他不知道今晚发生了什么,只看得见这两行。
|
|
1072
|
-
// 措辞跟 `connectResolved` 里那两条故意保持一致,只把"启动"换成"安装"。
|
|
1073
2712
|
console.warn(`[acp] agent '${agent.id}' could not be installed at the version the ACP registry pins ` +
|
|
1074
|
-
`('${
|
|
2713
|
+
`('${pinnedSpec}'): npm has no published version matching it (npm error code ` +
|
|
1075
2714
|
'ETARGET). This is a bad entry published upstream, not a broken install on this machine.');
|
|
1076
|
-
console.warn(`[acp] retrying the install for agent '${agent.id}' once as '${
|
|
2715
|
+
console.warn(`[acp] retrying the install for agent '${agent.id}' once as '${name}', with the ` +
|
|
1077
2716
|
"version pin removed, so npm resolves whatever it currently calls 'latest'. That is " +
|
|
1078
2717
|
'deliberately NOT the version the registry asked for: if this agent then misbehaves, ' +
|
|
1079
2718
|
'suspect the version before suspecting the agent.');
|
|
1080
|
-
const
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
npxCommand: this.npxCommand,
|
|
1084
|
-
});
|
|
1085
|
-
if (!retryOutcome.ok) {
|
|
1086
|
-
// 不钉版本那次再失败,抛出去的是把两件事都说清楚的一句话:registry 钉的版本不存在,而且
|
|
1087
|
-
// 绕过去的那次尝试自己也没成 —— 而不是原来那条已经绕过去的 ETARGET。
|
|
1088
|
-
logInstallFailureDetail(agent.id, 'npx', fallback.unpinned, retryOutcome.stderr || retryOutcome.error);
|
|
2719
|
+
const retry = await install(undefined);
|
|
2720
|
+
if (!retry.ok) {
|
|
2721
|
+
logInstallFailureDetail(agent.id, 'npx', name, retry.stderr || retry.error);
|
|
1089
2722
|
return {
|
|
1090
2723
|
ok: false,
|
|
1091
|
-
error: friendlyPinnedVersionUnavailableAndFallbackFailed(agent.id,
|
|
2724
|
+
error: friendlyPinnedVersionUnavailableAndFallbackFailed(agent.id, pinnedSpec, retry.stderr || retry.error, existingVersion),
|
|
1092
2725
|
};
|
|
1093
2726
|
}
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
2727
|
+
return { ...retry, pinnedVersionUnavailable: { pinned: pinnedSpec, installedInstead: retry.version } };
|
|
2728
|
+
}
|
|
2729
|
+
/**
|
|
2730
|
+
* Move installed agents forward to what **this CLI** certifies, without anyone pressing Update
|
|
2731
|
+
* (spec §6.1 "由 Axom 更新"): an agent whose effective version is pinned by this CLI (runtime.json
|
|
2732
|
+
* override, catalog pin or supplement) and differs from its current build, or whose current
|
|
2733
|
+
* version would now be patched differently (a recipe added, changed or retired). The new build is
|
|
2734
|
+
* installed and made current with `currentSetBy: 'axom'`; running sessions keep the build they
|
|
2735
|
+
* were spawned from. Agents with no current build are left alone (nothing is installed to move),
|
|
2736
|
+
* as is every registry-only version change — that is the user's Update to make.
|
|
2737
|
+
*
|
|
2738
|
+
* Never throws: failures are logged per agent and the old current stays. Reads no registry at all
|
|
2739
|
+
* when no agent is installed, so a fresh machine starts without touching the network for this.
|
|
2740
|
+
*/
|
|
2741
|
+
async reconcileCertifiedBuilds(options = {}) {
|
|
2742
|
+
const moves = [];
|
|
2743
|
+
let document;
|
|
2744
|
+
try {
|
|
2745
|
+
document = await this.builds.read();
|
|
2746
|
+
}
|
|
2747
|
+
catch (error) {
|
|
2748
|
+
console.warn(`[acp-builds] cannot read builds: ${errorMessage(error)}`);
|
|
2749
|
+
return moves;
|
|
2750
|
+
}
|
|
2751
|
+
const installed = Object.keys(document.agents).filter((agentId) => this.builds.currentIn(document, agentId));
|
|
2752
|
+
if (!installed.length)
|
|
2753
|
+
return moves;
|
|
2754
|
+
let entries;
|
|
2755
|
+
try {
|
|
2756
|
+
entries = await this.effective();
|
|
2757
|
+
}
|
|
2758
|
+
catch (error) {
|
|
2759
|
+
console.warn(`[acp-builds] registry unavailable, skipping certified build reconcile: ${errorMessage(error)}`);
|
|
2760
|
+
return moves;
|
|
2761
|
+
}
|
|
2762
|
+
for (const agentId of installed) {
|
|
2763
|
+
const entry = entries.find((candidate) => candidate.agent.id === agentId);
|
|
2764
|
+
if (!entry)
|
|
2765
|
+
continue;
|
|
2766
|
+
try {
|
|
2767
|
+
const move = await this.builds.withAgentLock(agentId, () => this.reconcileOne(entry, options.timeoutMs));
|
|
2768
|
+
if (move)
|
|
2769
|
+
moves.push(move);
|
|
2770
|
+
}
|
|
2771
|
+
catch (error) {
|
|
2772
|
+
console.warn(`[acp-builds] agent '${agentId}': certified build reconcile failed: ${errorMessage(error)}`);
|
|
2773
|
+
}
|
|
2774
|
+
}
|
|
2775
|
+
return moves;
|
|
2776
|
+
}
|
|
2777
|
+
async reconcileOne(entry, timeoutMs) {
|
|
2778
|
+
const agentId = entry.agent.id;
|
|
2779
|
+
const current = await this.builds.current(agentId);
|
|
2780
|
+
if (!current)
|
|
2781
|
+
return undefined;
|
|
2782
|
+
// The pin this CLI certifies was already tried and npm did not have it: the current build is the
|
|
2783
|
+
// install-time fallback for exactly this pin. Trying again on every daemon start would only repeat
|
|
2784
|
+
// the same network round trip and land on the same build.
|
|
2785
|
+
const sameUnavailablePin = current.source === 'unpinned-fallback' && current.requestedVersion === requestedVersionOf(entry);
|
|
2786
|
+
if (entry.pinnedBy && entry.agent.version !== current.version && !sameUnavailablePin) {
|
|
2787
|
+
const outcome = await this.installAdapter(entry, 'axom', timeoutMs, current.buildId);
|
|
2788
|
+
if (!outcome.ok)
|
|
2789
|
+
throw new Error(outcome.error);
|
|
2790
|
+
if (!outcome.build || outcome.build.buildId === current.buildId)
|
|
2791
|
+
return undefined;
|
|
2792
|
+
console.info(`[acp-builds] agent '${agentId}': moved ${current.buildId} -> ${outcome.build.buildId} (pinned by ${entry.pinnedBy})`);
|
|
2793
|
+
return { agentId, from: current.buildId, to: outcome.build.buildId, reason: 'certified-pin' };
|
|
2794
|
+
}
|
|
2795
|
+
// Same version: does this CLI patch it differently from the build that is current?
|
|
2796
|
+
if ((current.kind !== 'npm' && current.kind !== 'npm-patched') || !current.package)
|
|
2797
|
+
return undefined;
|
|
2798
|
+
const pristine = inside(this.adapterRoot, npmPristineDir(current.package, current.version));
|
|
2799
|
+
const recipes = await selectPackagePatches(current.package, current.version, inside(path.join(pristine, 'node_modules'), current.package), this.patchRecipes);
|
|
2800
|
+
const wanted = recipes.length ? patchDigest(recipes) : undefined;
|
|
2801
|
+
if (wanted === current.patchDigest)
|
|
2802
|
+
return undefined;
|
|
2803
|
+
const build = await npmBuild({
|
|
2804
|
+
store: this.builds, agentId, name: current.package, version: current.version,
|
|
2805
|
+
args: current.launch.args, env: current.launch.env, registryVersion: current.registryVersion,
|
|
2806
|
+
source: current.source, installedBy: 'axom', nodePath: this.nodePath,
|
|
2807
|
+
...(this.patchRecipes ? { recipes: this.patchRecipes } : {}),
|
|
2808
|
+
});
|
|
2809
|
+
await this.builds.commit(build, { makeCurrent: true, setBy: 'axom' });
|
|
2810
|
+
console.info(`[acp-builds] agent '${agentId}': moved ${current.buildId} -> ${build.buildId} (patch set changed)`);
|
|
2811
|
+
return { agentId, from: current.buildId, to: build.buildId, reason: 'patch-set' };
|
|
2812
|
+
}
|
|
2813
|
+
/**
|
|
2814
|
+
* Take over what an older CLI left on this machine (spec §6.2 "迁移", `builds/migrate.ts`), for every
|
|
2815
|
+
* agent that has no current build: an `_npx` copy of exactly the effective version, or pre-build
|
|
2816
|
+
* binary directories. Run by the daemon at start, in the background; an open (or any launch) of an
|
|
2817
|
+
* agent whose share has not run yet waits on that agent's lock instead of reporting it not installed.
|
|
2818
|
+
*
|
|
2819
|
+
* Idempotent (an agent with a current build is never touched) and once per agent per process. Reads
|
|
2820
|
+
* no registry when there is nothing to migrate from (no `_npx`, no legacy directories). Never throws.
|
|
2821
|
+
*/
|
|
2822
|
+
async migrateLegacyAdapters() {
|
|
2823
|
+
const migrated = [];
|
|
2824
|
+
let document;
|
|
2825
|
+
try {
|
|
2826
|
+
document = await this.builds.read();
|
|
2827
|
+
}
|
|
2828
|
+
catch (error) {
|
|
2829
|
+
console.warn(`[acp-builds] cannot read builds, skipping migration: ${errorMessage(error)}`);
|
|
2830
|
+
return migrated;
|
|
2831
|
+
}
|
|
2832
|
+
const hasNpx = existsSync(path.join(npmCacheDir(this.probe), '_npx'));
|
|
2833
|
+
const legacy = new Set(await legacyAgentDirs(this.adapterRoot));
|
|
2834
|
+
if (!hasNpx && !legacy.size)
|
|
2835
|
+
return migrated;
|
|
2836
|
+
let entries;
|
|
2837
|
+
try {
|
|
2838
|
+
entries = await this.effective();
|
|
2839
|
+
}
|
|
2840
|
+
catch (error) {
|
|
2841
|
+
console.warn(`[acp-builds] registry unavailable, migration deferred to first open: ${errorMessage(error)}`);
|
|
2842
|
+
return migrated;
|
|
2843
|
+
}
|
|
2844
|
+
for (const entry of entries) {
|
|
2845
|
+
const agentId = entry.agent.id;
|
|
2846
|
+
if (this.builds.currentIn(document, agentId))
|
|
2847
|
+
continue;
|
|
2848
|
+
const { npx, uvx, binary } = entry.agent.distribution;
|
|
2849
|
+
if (npx ? !hasNpx : uvx || !binary || !legacy.has(agentId))
|
|
2850
|
+
continue;
|
|
2851
|
+
const result = await this.migrateAgent(agentId, entries);
|
|
2852
|
+
if (result)
|
|
2853
|
+
migrated.push(result);
|
|
2854
|
+
}
|
|
2855
|
+
return migrated;
|
|
2856
|
+
}
|
|
2857
|
+
/** {@link migrateLegacyAdapters} for one agent, at most once per process (shared by the daemon's sweep and every launch). */
|
|
2858
|
+
migrateAgent(agentId, entries) {
|
|
2859
|
+
let running = this.migrations.get(agentId);
|
|
2860
|
+
if (!running) {
|
|
2861
|
+
running = this.builds.withAgentLock(agentId, () => this.migrateOne(agentId, entries)).catch((error) => {
|
|
2862
|
+
console.warn(`[acp-builds] agent '${agentId}': migration of the previous install failed: ${errorMessage(error)}`);
|
|
2863
|
+
// A registry that could not be read is worth another try; anything else is not repeated.
|
|
2864
|
+
if (error instanceof RegistryUnavailableForMigration)
|
|
2865
|
+
this.migrations.delete(agentId);
|
|
2866
|
+
return undefined;
|
|
2867
|
+
});
|
|
2868
|
+
this.migrations.set(agentId, running);
|
|
2869
|
+
}
|
|
2870
|
+
return running;
|
|
2871
|
+
}
|
|
2872
|
+
async migrateOne(agentId, known) {
|
|
2873
|
+
if (await this.builds.current(agentId))
|
|
2874
|
+
return undefined;
|
|
2875
|
+
const npmCache = npmCacheDir(this.probe);
|
|
2876
|
+
const legacyVersions = await legacyBinaryVersions(this.adapterRoot, agentId);
|
|
2877
|
+
const unrecorded = await legacyBinaryVersions(path.join(this.adapterRoot, 'bin'), agentId);
|
|
2878
|
+
if (!legacyVersions.length && !unrecorded.length && !existsSync(path.join(npmCache, '_npx')))
|
|
2879
|
+
return undefined;
|
|
2880
|
+
let entries = known;
|
|
2881
|
+
if (!entries) {
|
|
2882
|
+
try {
|
|
2883
|
+
entries = await this.effective();
|
|
2884
|
+
}
|
|
2885
|
+
catch (error) {
|
|
2886
|
+
throw new RegistryUnavailableForMigration(errorMessage(error));
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
const entry = entries.find((candidate) => candidate.agent.id === agentId);
|
|
2890
|
+
if (!entry)
|
|
2891
|
+
return undefined;
|
|
2892
|
+
const { npx, uvx, binary } = entry.agent.distribution;
|
|
2893
|
+
if (npx)
|
|
2894
|
+
return this.migrateFromNpx(entry, npx.package, npmCache);
|
|
2895
|
+
// uv: the user's own uv tool dir is not ours to take over; the first open installs a build.
|
|
2896
|
+
if (uvx || !binary)
|
|
2897
|
+
return undefined;
|
|
2898
|
+
return this.migrateLegacyBinaries(entry, legacyVersions, unrecorded);
|
|
2899
|
+
}
|
|
2900
|
+
/** npm: the effective version's `_npx` copy, as this agent's current build (pristine or patched, Node gate applied). */
|
|
2901
|
+
async migrateFromNpx(entry, spec, npmCache) {
|
|
2902
|
+
const agentId = entry.agent.id;
|
|
2903
|
+
const name = npmPackageName(spec);
|
|
2904
|
+
// Exactly what the pre-build launch ran: `npx <name>@<effective version>`.
|
|
2905
|
+
const version = requestedVersionOf(entry);
|
|
2906
|
+
const copy = await findNpxCopy(npmCache, name, version);
|
|
2907
|
+
if (!copy)
|
|
2908
|
+
return undefined;
|
|
2909
|
+
const { copied } = await adoptNpxCopy({ store: this.builds, name, version, nodeModules: copy });
|
|
2910
|
+
const npx = entry.agent.distribution.npx;
|
|
2911
|
+
const build = {
|
|
2912
|
+
...await npmBuild({
|
|
2913
|
+
store: this.builds, agentId, name, version, args: npx.args ?? [], env: npx.env ?? {},
|
|
2914
|
+
registryVersion: entry.registryVersion, source: entry.pinnedBy ?? 'registry', installedBy: 'axom',
|
|
2915
|
+
nodePath: this.nodePath, ...(this.patchRecipes ? { recipes: this.patchRecipes } : {}),
|
|
2916
|
+
}),
|
|
2917
|
+
migratedFrom: 'npx-cache',
|
|
2918
|
+
};
|
|
2919
|
+
await this.builds.commit(build, { makeCurrent: true, setBy: 'axom' });
|
|
2920
|
+
console.info(`[acp-builds] agent '${agentId}': ${name}@${version} ${copied ? 'copied from' : 'already copied from'} ${copy} as build ${build.buildId}`);
|
|
2921
|
+
return { agentId, from: 'npx-cache', current: build.buildId, builds: [build.buildId] };
|
|
2922
|
+
}
|
|
2923
|
+
/**
|
|
2924
|
+
* binary: every pre-build `acp-adapters/<agent>/<version>` renamed in place into `bin/<agent>/<version>`
|
|
2925
|
+
* and recorded (plus any `bin/<agent>/<version>` an interrupted migration renamed but never recorded);
|
|
2926
|
+
* the one the pre-build launch picked — the pinned version if present, else the highest — is current.
|
|
2927
|
+
*/
|
|
2928
|
+
async migrateLegacyBinaries(entry, legacyVersions, unrecorded) {
|
|
2929
|
+
const agent = entry.agent;
|
|
2930
|
+
const triple = platformTriple(this.platform, this.arch);
|
|
2931
|
+
const target = triple ? binaryTargetFor(agent, triple) : undefined;
|
|
2932
|
+
if (!target)
|
|
2933
|
+
return undefined;
|
|
2934
|
+
const recorded = [];
|
|
2935
|
+
const record = (version) => {
|
|
2936
|
+
try {
|
|
2937
|
+
recorded.push({
|
|
2938
|
+
...binaryBuild({
|
|
2939
|
+
root: this.adapterRoot, agentId: agent.id, version, target, env: target.env ?? {},
|
|
2940
|
+
registryVersion: entry.registryVersion, source: entry.pinnedBy ?? 'registry', installedBy: 'axom',
|
|
2941
|
+
}),
|
|
2942
|
+
migratedFrom: 'legacy-binary',
|
|
2943
|
+
});
|
|
2944
|
+
}
|
|
2945
|
+
catch (error) {
|
|
2946
|
+
console.warn(`[acp-builds] agent '${agent.id}' ${version}: not taken over: ${errorMessage(error)}`);
|
|
2947
|
+
}
|
|
1104
2948
|
};
|
|
2949
|
+
for (const version of [...legacyVersions].sort()) {
|
|
2950
|
+
let destination;
|
|
2951
|
+
try {
|
|
2952
|
+
destination = inside(this.adapterRoot, binaryBuildDir(agent.id, version));
|
|
2953
|
+
}
|
|
2954
|
+
catch {
|
|
2955
|
+
continue;
|
|
2956
|
+
}
|
|
2957
|
+
// The old install unpacked in place: a directory without its executable is a killed unpack. Left alone.
|
|
2958
|
+
const command = path.posix.normalize(target.cmd.replace(/\\/g, '/'));
|
|
2959
|
+
if (!existsSync(path.join(this.adapterRoot, agent.id, version, ...command.split('/')))) {
|
|
2960
|
+
console.warn(`[acp-builds] agent '${agent.id}' ${version}: no '${target.cmd}' in the old install; left where it is.`);
|
|
2961
|
+
continue;
|
|
2962
|
+
}
|
|
2963
|
+
if (await moveLegacyBinary(this.adapterRoot, agent.id, version, destination))
|
|
2964
|
+
record(version);
|
|
2965
|
+
}
|
|
2966
|
+
for (const version of unrecorded)
|
|
2967
|
+
if (!recorded.some((build) => build.version === version))
|
|
2968
|
+
record(version);
|
|
2969
|
+
const pick = pickInstalledVersion(recorded.map((build) => build.version), agent.version);
|
|
2970
|
+
if (pick === undefined)
|
|
2971
|
+
return undefined;
|
|
2972
|
+
for (const build of recorded)
|
|
2973
|
+
await this.builds.commit(build, { makeCurrent: false, setBy: 'axom' });
|
|
2974
|
+
const current = recorded.find((build) => build.version === pick);
|
|
2975
|
+
await this.builds.commit(current, { makeCurrent: true, setBy: 'axom' });
|
|
2976
|
+
console.info(`[acp-builds] agent '${agent.id}': took over ${recorded.map((build) => build.buildId).join(', ')}; current ${current.buildId}`);
|
|
2977
|
+
return { agentId: agent.id, from: 'legacy-binary', current: current.buildId, builds: recorded.map((build) => build.buildId) };
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* Spec §6.2: an agent with no current build — after its migration, if any — is installed **once**,
|
|
2981
|
+
* by the first open that needs it ("Installing <version>" on the session). That is what the pre-build
|
|
2982
|
+
* npx launch did on every open; a uv tool the user had in their own uv dir is reinstalled the same
|
|
2983
|
+
* way. Only npm and uv distributions: an open never fetched a binary agent, which still fails as not
|
|
2984
|
+
* installed. Every later open launches the build and downloads nothing.
|
|
2985
|
+
*/
|
|
2986
|
+
async installForOpen(agentId, onOutcome) {
|
|
2987
|
+
if (await this.builds.current(agentId))
|
|
2988
|
+
return;
|
|
2989
|
+
await this.migrateAgent(agentId);
|
|
2990
|
+
if (await this.builds.current(agentId))
|
|
2991
|
+
return;
|
|
2992
|
+
let entry;
|
|
2993
|
+
try {
|
|
2994
|
+
entry = (await this.effective()).find((candidate) => candidate.agent.id === agentId);
|
|
2995
|
+
}
|
|
2996
|
+
catch {
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
// Not in the registry, or nothing an open may fetch: `launchSpecFor` says which.
|
|
3000
|
+
if (!entry || !(entry.agent.distribution.npx || entry.agent.distribution.uvx))
|
|
3001
|
+
return;
|
|
3002
|
+
const found = entry;
|
|
3003
|
+
const version = installingVersionOf(found);
|
|
3004
|
+
const agentName = found.agent.name || agentId;
|
|
3005
|
+
// Started once and never aborted: an open that gives up below leaves it running under the lock.
|
|
3006
|
+
const install = this.builds.withAgentLock(agentId, async () => {
|
|
3007
|
+
if (await this.builds.current(agentId))
|
|
3008
|
+
return undefined;
|
|
3009
|
+
const outcome = await this.installAdapter(found, 'user');
|
|
3010
|
+
return outcome.ok ? { ok: true, agentName, version } : { ok: false, agentName, version, error: outcome.error };
|
|
3011
|
+
}).catch((error) => ({ ok: false, agentName, version, error: errorMessage(error) }));
|
|
3012
|
+
let timer;
|
|
3013
|
+
const budget = new Promise((resolve) => { timer = setTimeout(() => resolve('budget'), this.openInstallBudgetMs); });
|
|
3014
|
+
const settled = await Promise.race([install, budget]).finally(() => clearTimeout(timer));
|
|
3015
|
+
if (settled === 'budget') {
|
|
3016
|
+
void install.then((late) => {
|
|
3017
|
+
if (late)
|
|
3018
|
+
console.info(`[acp-builds] agent '${agentId}': install of ${version} finished after its open gave up (${late.ok ? 'installed' : late.error})`);
|
|
3019
|
+
});
|
|
3020
|
+
throw new AdapterStillInstallingError(agentId, agentName, version);
|
|
3021
|
+
}
|
|
3022
|
+
if (!settled)
|
|
3023
|
+
return;
|
|
3024
|
+
onOutcome?.(settled);
|
|
3025
|
+
if (!settled.ok)
|
|
3026
|
+
throw new Error(settled.error);
|
|
1105
3027
|
}
|
|
1106
3028
|
/**
|
|
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
|
-
* `
|
|
3029
|
+
* Whether the catalog's prerequisite for this agent (`registry/catalog.ts`'s `prerequisite`) is
|
|
3030
|
+
* detected on this machine (`registry/detect.ts`) — never whether the adapter itself has been
|
|
3031
|
+
* fetched, and never whether this daemon's Node satisfies `requiresNode` (that gate only matters
|
|
3032
|
+
* at {@link install} time, not for reporting what is already on the machine). An agent outside the
|
|
3033
|
+
* catalog, or one with no `prerequisite` block, needs nothing extra and reads as satisfied.
|
|
1112
3034
|
*
|
|
1113
|
-
* {@link roster}
|
|
1114
|
-
* curated prerequisite is not reports **not installed**, so its card offers `Install`
|
|
1115
|
-
* fetches the
|
|
3035
|
+
* {@link roster} makes the same reading for `adapterInstalled`: an agent whose adapter is on disk
|
|
3036
|
+
* but whose curated prerequisite is not reports **not installed**, so its card offers `Install`
|
|
3037
|
+
* (which fetches the prerequisite) instead of an `Add` whose try-run was always going to fail on
|
|
1116
3038
|
* `Could not start pi: executable not found`. Design §4.4, point 2.
|
|
1117
3039
|
*/
|
|
1118
|
-
|
|
1119
|
-
const
|
|
1120
|
-
if (!
|
|
3040
|
+
prerequisiteSatisfied(agentId, probe = this.probe) {
|
|
3041
|
+
const prerequisite = catalogEntryFor(agentId)?.prerequisite;
|
|
3042
|
+
if (!prerequisite)
|
|
1121
3043
|
return true;
|
|
1122
|
-
return
|
|
3044
|
+
return detected(prerequisite.detect, probe, platformTriple(this.platform, this.arch));
|
|
1123
3045
|
}
|
|
1124
|
-
/**
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
3046
|
+
/**
|
|
3047
|
+
* The spawn spec a new session of `agentId` launches with: its **current build** and nothing else
|
|
3048
|
+
* — no npx, no download (spec §6.1/§6.2). The spec carries the build's `label`, and a JavaScript
|
|
3049
|
+
* build must pass the Node gate (`builds/nodeGate.ts`). An agent with no current build first waits
|
|
3050
|
+
* for its share of the legacy migration ({@link migrateLegacyAdapters}); still none → it fails with
|
|
3051
|
+
* {@link AdapterNotInstalledError} — whatever its distribution: a uv agent too launches only from its
|
|
3052
|
+
* build, never through `uv`. Only {@link open} goes on to install (once) — see `installForOpen`.
|
|
3053
|
+
*/
|
|
3054
|
+
async launchSpecFor(agentId) {
|
|
3055
|
+
let current = await this.builds.current(agentId);
|
|
3056
|
+
if (!current) {
|
|
3057
|
+
// What an older CLI left may still be on its way into the store (daemon start runs this in the
|
|
3058
|
+
// background); wait for this agent's share of it rather than report it not installed.
|
|
3059
|
+
await this.migrateAgent(agentId);
|
|
3060
|
+
current = await this.builds.current(agentId);
|
|
3061
|
+
}
|
|
3062
|
+
if (current) {
|
|
3063
|
+
this.touchBuild(agentId, current.buildId);
|
|
3064
|
+
return launchForBuild(current, this.adapterRoot, {
|
|
3065
|
+
nodePath: this.nodePath,
|
|
3066
|
+
launchEnv: catalogLaunchEnv(agentId, current.registryVersion),
|
|
3067
|
+
basePath: this.probe.path,
|
|
3068
|
+
});
|
|
3069
|
+
}
|
|
1130
3070
|
const agents = await this.registry.load();
|
|
1131
|
-
|
|
1132
|
-
if (!agent) {
|
|
3071
|
+
if (!agents.some((candidate) => candidate.id === agentId)) {
|
|
1133
3072
|
throw new Error(`Agent '${agentId}' is not in the ACP agent registry. Check the id, refresh the registry, ` +
|
|
1134
3073
|
'or give this agent an explicit command in the workstation config.');
|
|
1135
3074
|
}
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
3075
|
+
throw new AdapterNotInstalledError(agentId);
|
|
3076
|
+
}
|
|
3077
|
+
/** A config-supplied `command` is the whole launch spec (ruling R3); otherwise the current build decides. */
|
|
3078
|
+
async resolveLaunchFor(agentId, command, args) {
|
|
3079
|
+
if (command)
|
|
3080
|
+
return { launch: { command, args, env: {} }, source: 'command-override' };
|
|
3081
|
+
return { source: 'registry', launch: await this.launchSpecFor(agentId) };
|
|
1142
3082
|
}
|
|
1143
3083
|
/**
|
|
1144
3084
|
* Connect a launch that is not a login — every connection this class makes except
|
|
1145
|
-
* {@link connectFor}'s login one
|
|
3085
|
+
* {@link connectFor}'s login one.
|
|
1146
3086
|
*
|
|
1147
3087
|
* **`options` is {@link ConnectAgentOptions}, whose `present` is `never`**, and that is the whole
|
|
1148
3088
|
* point of this method still existing as a separate one: the stderr sniffer cannot be armed
|
|
1149
|
-
* through the route {@link open} and {@link listNativeSessions} take.
|
|
1150
|
-
* `login` parameter for `connectFor`'s benefit, which meant the same door stood open one argument
|
|
1151
|
-
* further along; `connectFor` builds its own options now.
|
|
3089
|
+
* through the route {@link open} and {@link listNativeSessions} take.
|
|
1152
3090
|
*/
|
|
1153
|
-
async connectResolved(
|
|
1154
|
-
return
|
|
3091
|
+
async connectResolved(resolved, env, agentEnv, options = {}) {
|
|
3092
|
+
return connectAgent(resolved.launch, env, agentEnv, options);
|
|
1155
3093
|
}
|
|
1156
3094
|
/**
|
|
1157
|
-
* Whether
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
* `resolveLaunch`'s uvx branch. Without it, `resolveLaunch` falls back to reading `uv tool list`
|
|
1161
|
-
* itself via its default runner — a real `uv` subprocess per uvx-distributed agent, called from
|
|
1162
|
-
* inside `roster()`'s per-agent map, which is exactly the per-agent-spawn defect `adapterVersionFor`
|
|
1163
|
-
* was hoisted out of. This is the second call site that same invariant covers: building the roster
|
|
1164
|
-
* must start nothing per agent, no matter how many code paths ask "is this agent launchable".
|
|
1165
|
-
*
|
|
1166
|
-
* `resolveLaunch` itself is `async` now (`uv` is a non-blocking call — see `uvxInstall.ts`'s
|
|
1167
|
-
* `RunUv`), so this awaits it; with `uvTools` always supplied here, that branch never actually
|
|
1168
|
-
* waits on anything; it resolves off the already-hoisted snapshot exactly as synchronously as
|
|
1169
|
-
* before, just wrapped in a settled `Promise`.
|
|
3095
|
+
* Whether this agent can be launched on this machine (see {@link roster}): with a current build it
|
|
3096
|
+
* can; an npm or uv agent can be once installed (Install fetches it); a binary-only agent only with a
|
|
3097
|
+
* current build. Reads nothing and spawns nothing.
|
|
1170
3098
|
*/
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
await resolveLaunch(agent, this.platform, this.arch, this.adapterRoot, undefined, uvTools);
|
|
3099
|
+
resolvable(agent, current) {
|
|
3100
|
+
if (current)
|
|
1174
3101
|
return true;
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
return false;
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
/**
|
|
1182
|
-
* Run `attempt` on the launch the registry pins, and — only when npm answers ETARGET — once more on
|
|
1183
|
-
* the unpinned fallback.
|
|
1184
|
-
*
|
|
1185
|
-
* Extracted from `connectResolved` so that the login path can reuse the retry **without** reusing
|
|
1186
|
-
* `connectResolved`'s options parameter, which is typed to forbid `present`. Both callers get the
|
|
1187
|
-
* same retry and the same two log lines; only one of them can arm the stderr sniffer, and it says so
|
|
1188
|
-
* in its own signature rather than by passing an extra argument the other caller could also pass.
|
|
1189
|
-
*
|
|
1190
|
-
* ## Only on ETARGET, and only once
|
|
1191
|
-
*
|
|
1192
|
-
* 只在 npm 自己说「这个版本不存在」时重试。一次冷启动要几十秒(npx 得先把适配器下下来),所以
|
|
1193
|
-
* 「失败就重试」等于把每一次真实故障的代价翻倍。这里认的是 npm 打在子进程 stderr 上的错误码,见
|
|
1194
|
-
* `acpClient.ts` 的 `isMissingPackageVersion` —— 那是证据;从一根断掉的管子倒推「大概是版本不对吧」
|
|
1195
|
-
* 是猜测,两者在日志里长得一样,后果不一样。
|
|
1196
|
-
*
|
|
1197
|
-
* 不钉版本那次再失败,抛出去的就是**它自己**的错误。把原来的 ETARGET 报上去会让读日志的人接着去
|
|
1198
|
-
* 追一个已经绕过去的问题。
|
|
1199
|
-
*/
|
|
1200
|
-
async function withUnpinnedFallback(agentId, resolved, attempt) {
|
|
1201
|
-
try {
|
|
1202
|
-
return await attempt(resolved.launch);
|
|
1203
|
-
}
|
|
1204
|
-
catch (error) {
|
|
1205
|
-
const fallback = resolved.fallback;
|
|
1206
|
-
if (!fallback || !isMissingPackageVersion(error))
|
|
1207
|
-
throw error;
|
|
1208
|
-
// 两条都是 warn,而且都要能被一个月后读日志的人看懂 —— 他不知道今晚发生了什么,只看得见这两行。
|
|
1209
|
-
console.warn(`[acp] agent '${agentId}' could not be launched at the version the ACP registry pins ` +
|
|
1210
|
-
`('${fallback.pinned}'): npm has no published version matching it (npm error code ` +
|
|
1211
|
-
'ETARGET). This is a bad entry published upstream, not a broken install on this machine.');
|
|
1212
|
-
console.warn(`[acp] retrying agent '${agentId}' once as '${fallback.unpinned}', with the version pin ` +
|
|
1213
|
-
"removed, so npm resolves whatever it currently calls 'latest'. That is deliberately NOT " +
|
|
1214
|
-
'the version the registry asked for: if this agent now misbehaves, suspect the version ' +
|
|
1215
|
-
'before suspecting the agent.');
|
|
1216
|
-
return await attempt(fallback.launch);
|
|
3102
|
+
const { npx, uvx } = agent.distribution;
|
|
3103
|
+
return Boolean(npx || uvx);
|
|
1217
3104
|
}
|
|
1218
3105
|
}
|
|
1219
3106
|
/**
|
|
1220
|
-
*
|
|
3107
|
+
* Opens one session on [connection] (a verification's own, short-lived) and collects the command table the agent broadcasts
|
|
3108
|
+
* after `session/new` ({@link createCommandTableProbe}). `commands` undefined = no table arrived within [firstWindowMs]
|
|
3109
|
+
* (unknown, not empty).
|
|
1221
3110
|
*
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
1224
|
-
*
|
|
1225
|
-
*
|
|
1226
|
-
*
|
|
1227
|
-
* opened with, so the cell below is what `setConfigOption` writes back into and what the getter
|
|
1228
|
-
* reads — see that field's own doc for what a stale answer costs on every reconnect.
|
|
3111
|
+
* The table is broadcast *after* `session/new` is answered (`acp-agent.js:752-757`), so a verification that closed right after
|
|
3112
|
+
* opening could never see it; and it can also arrive before this client has read that answer (p3-fix7 N9), which the probe
|
|
3113
|
+
* keeps. Settle rather than "first push wins": Claude broadcasts twice — once on `session/new` and again when the init frame
|
|
3114
|
+
* lets it filter out terminal-bound commands. 不能靠一个固定的等待:一条都没来 → 到 [firstWindowMs] 就收工;来了第一条 →
|
|
3115
|
+
* 改用安静期([COMMAND_QUIET_MS])收尾,并受 [COMMAND_SETTLE_CEILING_MS] 兜底。
|
|
1229
3116
|
*/
|
|
1230
|
-
function
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
// one cannot drift from it.
|
|
1234
|
-
let snapshot = configOptions;
|
|
1235
|
-
const hosted = {
|
|
1236
|
-
// Verbatim. This used to be the one place a string became a `ContentBlock[]`
|
|
1237
|
-
// (`session.prompt([{ type: 'text', text }])`) — a stopgap for as long as `agent_prompt` could
|
|
1238
|
-
// only carry text. That seam is gone: the frame now carries blocks, so wrapping here would be a
|
|
1239
|
-
// second, narrower opinion about the shape of a user message.
|
|
1240
|
-
prompt: (blocks) => session.prompt(blocks),
|
|
1241
|
-
// Both halves come off the live agent: the process's own exit state, and the session's own
|
|
1242
|
-
// outstanding-prompt list. Nothing here is remembered by this daemon.
|
|
1243
|
-
get liveness() {
|
|
1244
|
-
return {
|
|
1245
|
-
agentAlive: connection.agentProcessAlive() && session.readable,
|
|
1246
|
-
turnRunning: session.turnRunning,
|
|
1247
|
-
};
|
|
1248
|
-
},
|
|
1249
|
-
answer: (requestId, outcome) => session.answer(requestId, outcome),
|
|
1250
|
-
interrupt: () => session.interrupt(),
|
|
1251
|
-
setConfigOption: async (configId, value) => {
|
|
1252
|
-
snapshot = await session.setConfigOption(configId, value);
|
|
1253
|
-
return snapshot;
|
|
1254
|
-
},
|
|
1255
|
-
get configOptions() {
|
|
1256
|
-
return snapshot;
|
|
1257
|
-
},
|
|
1258
|
-
steer: (blocks, priority) => session.steer(blocks, priority),
|
|
1259
|
-
// Straight off the connection that opened this session — never inferred, and never read back
|
|
1260
|
-
// from the roster, whose entry for this agent may still be the pre-connection one.
|
|
1261
|
-
supportsSteering: connection.supportsSteering,
|
|
1262
|
-
// Same source, same rule, and likewise never defaulted: an empty list here is the agent saying
|
|
1263
|
-
// nothing about *when*, which ruling N4 reads as "the server may not name a timing".
|
|
1264
|
-
steeringTimings: connection.steeringTimings,
|
|
1265
|
-
agentInfo: connection.agentInfo,
|
|
1266
|
-
supportsImageInput: connection.capabilities.promptImage,
|
|
1267
|
-
onEvent: (cb) => session.onEvent(cb),
|
|
1268
|
-
onTurnDone: (cb) => session.onTurnDone(cb),
|
|
1269
|
-
async close() {
|
|
1270
|
-
try {
|
|
1271
|
-
await session.close();
|
|
1272
|
-
}
|
|
1273
|
-
finally {
|
|
1274
|
-
// Runs even if the session's own shutdown threw: the subprocess is the expensive thing to
|
|
1275
|
-
// leak, and nothing else in the daemon will come back for it.
|
|
1276
|
-
await connection.close().catch(() => undefined);
|
|
1277
|
-
}
|
|
1278
|
-
},
|
|
1279
|
-
};
|
|
1280
|
-
if (history.length > 0) {
|
|
1281
|
-
hosted.backfill = async function* backfill() {
|
|
1282
|
-
for (const event of history)
|
|
1283
|
-
yield event;
|
|
1284
|
-
};
|
|
1285
|
-
}
|
|
1286
|
-
return hosted;
|
|
3117
|
+
async function observeCommands(probe, connection, cwd, firstWindowMs) {
|
|
3118
|
+
const { raw, configOptions } = await probe.observe(connection, cwd, { firstWindowMs, quietMs: COMMAND_QUIET_MS, ceilingMs: COMMAND_SETTLE_CEILING_MS });
|
|
3119
|
+
return { commands: raw?.map(toAvailableCommandWire), configOptions };
|
|
1287
3120
|
}
|