@ljwei-stak/dsh-model-route-bot 0.66.3 → 0.67.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dsh-plugin/bot-product.mjs +81 -5
- package/.dsh-plugin/client.js +17490 -1791
- package/README.i18n.yaml +4 -4
- package/README.md +409 -364
- package/README.zh.md +409 -364
- package/apps/integration-client/README.md +31 -7
- package/apps/integration-client/api.mjs +14 -3
- package/apps/integration-client/binding-editor.jsx +308 -109
- package/apps/integration-client/binding-model.mjs +262 -0
- package/apps/integration-client/binding.css +144 -0
- package/apps/integration-client/bot-model-status.mjs +18 -0
- package/apps/integration-client/bot-profile-identity.jsx +197 -0
- package/apps/integration-client/bot-profile.css +57 -0
- package/apps/integration-client/bot-profile.mjs +97 -0
- package/apps/integration-client/bot-settings-dialog.css +18 -0
- package/apps/integration-client/bot-settings-dialog.jsx +111 -0
- package/apps/integration-client/bot-thread-header-bridge.css +20 -0
- package/apps/integration-client/bot-thread-header.jsx +278 -0
- package/apps/integration-client/bot-threads-panel.jsx +378 -0
- package/apps/integration-client/bot-threads.css +169 -0
- package/apps/integration-client/chat-design.mjs +250 -0
- package/apps/integration-client/chat-ui.jsx +175 -0
- package/apps/integration-client/chat.css +477 -0
- package/apps/integration-client/composer-draft-binding.mjs +50 -0
- package/apps/integration-client/composer-draft-bound.jsx +14 -0
- package/apps/integration-client/composer-draft-host.jsx +142 -0
- package/apps/integration-client/composer-draft-session.jsx +287 -0
- package/apps/integration-client/composer-draft-witness.mjs +115 -0
- package/apps/integration-client/composer-draft.css +4 -0
- package/apps/integration-client/composer-draft.mjs +322 -0
- package/apps/integration-client/contact-attention.jsx +54 -0
- package/apps/integration-client/data-panel.jsx +439 -0
- package/apps/integration-client/data.css +69 -0
- package/apps/integration-client/desktop-bridge.mjs +1 -0
- package/apps/integration-client/desktop-http-socket.mjs +49 -0
- package/apps/integration-client/desktop-keyboard.mjs +32 -0
- package/apps/integration-client/desktop-pane.jsx +99 -30
- package/apps/integration-client/desktop-stream.mjs +13 -3
- package/apps/integration-client/desktop-tools.jsx +19 -0
- package/apps/integration-client/desktop-touch.mjs +27 -0
- package/apps/integration-client/desktop.css +41 -0
- package/apps/integration-client/independent-chat-controller.mjs +224 -0
- package/apps/integration-client/independent-chat-entry.jsx +61 -0
- package/apps/integration-client/independent-chat-navigation.mjs +21 -0
- package/apps/integration-client/independent-chat-page.jsx +148 -0
- package/apps/integration-client/independent-chat-rich.jsx +64 -0
- package/apps/integration-client/independent-chat-rich.mjs +30 -0
- package/apps/integration-client/independent-chat.css +73 -0
- package/apps/integration-client/index.jsx +24 -16
- package/apps/integration-client/library-panel.jsx +4 -1
- package/apps/integration-client/licenses/jsqr-1.4.0-LICENSE.txt +202 -0
- package/apps/integration-client/main-route-editor.jsx +94 -0
- package/apps/integration-client/main-route-model.mjs +100 -0
- package/apps/integration-client/model-picker.css +37 -0
- package/apps/integration-client/model-picker.jsx +83 -0
- package/apps/integration-client/native-activity.css +141 -0
- package/apps/integration-client/native-activity.jsx +276 -0
- package/apps/integration-client/native-activity.mjs +384 -0
- package/apps/integration-client/notifications-panel.jsx +186 -0
- package/apps/integration-client/notifications.css +126 -0
- package/apps/integration-client/pairing-qr.jsx +79 -0
- package/apps/integration-client/panels.jsx +126 -115
- package/apps/integration-client/product-slots.jsx +46 -0
- package/apps/integration-client/routine-recorder.mjs +23 -2
- package/apps/integration-client/secure-pairing.jsx +84 -0
- package/apps/integration-client/tasks-panel.jsx +32 -10
- package/apps/integration-client/ui-icons.jsx +17 -0
- package/apps/integration-client/usage-explorer.jsx +43 -0
- package/apps/integration-client/usage-report.mjs +71 -0
- package/apps/integration-client/usage.css +34 -0
- package/apps/integration-client/web-panel-model.mjs +140 -0
- package/apps/integration-client/web-panel-page.jsx +221 -0
- package/apps/integration-client/web-panel-pins-live.jsx +28 -0
- package/apps/integration-client/web-panel.css +139 -0
- package/apps/integration-client/workspace-model.mjs +67 -0
- package/apps/integration-client/workspace-navigation.mjs +56 -0
- package/apps/integration-client/workspace-ui.jsx +173 -0
- package/apps/integration-client/workspace.css +332 -0
- package/modules/bot-runtime/SOURCE.json +176 -50
- package/modules/bot-runtime/index.mjs +169 -13
- package/modules/bot-runtime/product-overlay/OVERLAY.json +128 -2
- package/modules/bot-runtime/product-overlay/base/src/client/header.js +244 -0
- package/modules/bot-runtime/product-overlay/base/src/client/learning-card.js +202 -0
- package/modules/bot-runtime/product-overlay/base/src/client/model-menu.js +371 -0
- package/modules/bot-runtime/product-overlay/base/src/client/secret-card.js +363 -0
- package/modules/bot-runtime/product-overlay/base/src/client/sources.js +68 -0
- package/modules/bot-runtime/product-overlay/base/src/client/turn-error.js +260 -0
- package/modules/bot-runtime/product-overlay/base/src/host/channel.js +647 -0
- package/modules/bot-runtime/product-overlay/base/src/host/memory.js +2050 -0
- package/modules/bot-runtime/product-overlay/base/src/host/models.js +314 -0
- package/modules/bot-runtime/product-overlay/base/src/host/parts.js +1473 -0
- package/modules/bot-runtime/product-overlay/base/src/host/privacy.js +368 -0
- package/modules/bot-runtime/product-overlay/base/src/host/roster.js +776 -0
- package/modules/bot-runtime/product-overlay/base/src/host/store.js +512 -0
- package/modules/bot-runtime/product-overlay/base/src/host/usage.js +653 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/details-drawer.js.patch +48 -2
- package/modules/bot-runtime/product-overlay/patches/src/client/header.js.patch +41 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/index.js.patch +33 -1
- package/modules/bot-runtime/product-overlay/patches/src/client/learning-card.js.patch +20 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/model-menu.js.patch +24 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/secret-card.js.patch +25 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/sources.js.patch +52 -0
- package/modules/bot-runtime/product-overlay/patches/src/client/turn-error.js.patch +23 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/channel.js.patch +46 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/memory.js.patch +11 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/models.js.patch +16 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/parts.js.patch +95 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/privacy.js.patch +11 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/roster.js.patch +145 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/store.js.patch +68 -0
- package/modules/bot-runtime/product-overlay/patches/src/host/usage.js.patch +11 -0
- package/modules/bot-runtime/read-model.mjs +19 -4
- package/modules/bot-runtime/vendor/ds-bot/src/client/details-drawer.js +15 -5
- package/modules/bot-runtime/vendor/ds-bot/src/client/header.js +9 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/index.js +9 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/learning-card.js +2 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/model-menu.js +5 -3
- package/modules/bot-runtime/vendor/ds-bot/src/client/secret-card.js +3 -3
- package/modules/bot-runtime/vendor/ds-bot/src/client/sources.js +25 -2
- package/modules/bot-runtime/vendor/ds-bot/src/client/turn-error.js +3 -2
- package/modules/bot-runtime/vendor/ds-bot/src/host/channel.js +14 -2
- package/modules/bot-runtime/vendor/ds-bot/src/host/memory.js +1 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/models.js +7 -0
- package/modules/bot-runtime/vendor/ds-bot/src/host/parts.js +26 -9
- package/modules/bot-runtime/vendor/ds-bot/src/host/privacy.js +1 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/roster.js +112 -3
- package/modules/bot-runtime/vendor/ds-bot/src/host/store.js +34 -1
- package/modules/bot-runtime/vendor/ds-bot/src/host/usage.js +1 -1
- package/modules/independent-runtime/index.mjs +152 -62
- package/modules/independent-runtime/remote-tools.mjs +8 -5
- package/modules/openmaus-protocol/NOTICE +10 -0
- package/modules/openmaus-protocol/README.md +15 -0
- package/modules/openmaus-protocol/SOURCE.json +68 -0
- package/modules/openmaus-protocol/dist/protocol.mjs +1203 -0
- package/modules/openmaus-protocol/index.mjs +1 -0
- package/modules/openmaus-protocol/transport.mjs +77 -0
- package/modules/openmaus-protocol/vendor/LICENSE +202 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/README.md +405 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/credentials.ts +128 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/descriptor.ts +84 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/noise.ts +456 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/phone.ts +104 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/session.ts +417 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.json +2565 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/vectors/v1.sha256 +1 -0
- package/modules/openmaus-protocol/vendor/companion/src/e2e/yamux.ts +335 -0
- package/package.json +20 -6
- package/packages/bot-acp/README.md +156 -0
- package/packages/bot-acp/licenses/LICENSE-OpenMausBot +202 -0
- package/packages/bot-acp/licenses/NOTICE.md +27 -0
- package/packages/bot-acp/src/acp-adapter.mjs +795 -0
- package/packages/bot-acp/src/acp-host.mjs +148 -0
- package/packages/bot-acp/src/native-launch.mjs +121 -0
- package/packages/bot-acp/src/permission-policy.mjs +283 -0
- package/packages/bot-acp/src/rpc-codec.mjs +265 -0
- package/packages/bot-acp/src/session-core.mjs +581 -0
- package/packages/bot-acp/src/stdio-rpc.mjs +329 -0
- package/packages/bot-engines/README.md +281 -0
- package/packages/bot-engines/licenses/LICENSE-OpenMausBot +202 -0
- package/packages/bot-engines/licenses/NOTICE.md +32 -0
- package/packages/bot-engines/src/claude-engine.mjs +772 -0
- package/packages/bot-engines/src/claude-llm-adapter.mjs +797 -0
- package/packages/bot-engines/src/claude-llm-turn.mjs +292 -0
- package/packages/bot-engines/src/claude-permission-broker.mjs +130 -0
- package/packages/bot-engines/src/claude-permission-proxy.mjs +87 -0
- package/packages/bot-engines/src/claude-protocol.mjs +177 -0
- package/packages/bot-engines/src/driver-catalog.mjs +334 -0
- package/packages/bot-engines/src/host-loop-request.mjs +95 -0
- package/packages/bot-engines/src/host-registrations.mjs +105 -0
- package/packages/capability-broker/src/cli-bootstrap.mjs +86 -16
- package/packages/capability-broker/src/cli-bridge-response.mjs +80 -0
- package/packages/capability-broker/src/cli-stdio-bridge.mjs +21 -8
- package/packages/cli-runtime/src/codex-session-core.mjs +4 -0
- package/packages/cli-runtime/src/native-cli-output.mjs +57 -0
- package/packages/cli-runtime/src/native-peer.ps1 +51 -3
- package/packages/cli-runtime/src/native-process.mjs +77 -14
- package/packages/model-router-plugin/README.md +3 -3
- package/packages/model-router-plugin/package.json +1 -1
- package/packages/orchestration/src/binding-product.mjs +19 -0
- package/packages/orchestration/src/bot-data-actions.mjs +45 -0
- package/packages/orchestration/src/bot-data-chart.mjs +109 -0
- package/packages/orchestration/src/bot-data-context.mjs +44 -0
- package/packages/orchestration/src/bot-data-engine.mjs +809 -0
- package/packages/orchestration/src/bot-data-io.mjs +186 -0
- package/packages/orchestration/src/bot-data-sql.mjs +195 -0
- package/packages/orchestration/src/bot-data-surface.mjs +144 -0
- package/packages/orchestration/src/bot-data-tools.mjs +68 -0
- package/packages/orchestration/src/bot-data-worker.mjs +228 -0
- package/packages/orchestration/src/bot-independent-execution.mjs +195 -0
- package/packages/orchestration/src/bot-independent-stream.mjs +74 -0
- package/packages/orchestration/src/bot-loop.mjs +152 -19
- package/packages/orchestration/src/bot-notifications.mjs +159 -0
- package/packages/orchestration/src/bot-policy.mjs +43 -4
- package/packages/orchestration/src/bot-profile.mjs +171 -0
- package/packages/orchestration/src/bot-routine.mjs +4 -4
- package/packages/orchestration/src/bot-runtime-assembly.mjs +255 -8
- package/packages/orchestration/src/bot-runtime-switch.mjs +13 -2
- package/packages/orchestration/src/bot-serving.mjs +164 -0
- package/packages/orchestration/src/bot-session-store.mjs +105 -9
- package/packages/orchestration/src/bot-settings.mjs +50 -11
- package/packages/orchestration/src/bot-thread-admission.mjs +84 -0
- package/packages/orchestration/src/bot-thread-host.mjs +187 -0
- package/packages/orchestration/src/bot-threads.mjs +790 -0
- package/packages/orchestration/src/data-product-access.mjs +37 -0
- package/packages/orchestration/src/data-tool-contract.mjs +291 -0
- package/packages/orchestration/src/desktop-http-carrier.mjs +120 -0
- package/packages/orchestration/src/desktop-proxy.mjs +311 -0
- package/packages/orchestration/src/desktop-sessions.mjs +43 -9
- package/packages/orchestration/src/ds-lifecycle.mjs +31 -4
- package/packages/orchestration/src/grant-refresh-queue.mjs +17 -0
- package/packages/orchestration/src/independent-chat-page.mjs +39 -0
- package/packages/orchestration/src/independent-chat-service.mjs +308 -0
- package/packages/orchestration/src/independent-context-state.mjs +44 -0
- package/packages/orchestration/src/independent-message-wire.mjs +84 -0
- package/packages/orchestration/src/independent-tool-catalog.mjs +175 -0
- package/packages/orchestration/src/independent-tool-families.mjs +73 -0
- package/packages/orchestration/src/independent-tool-host.mjs +344 -0
- package/packages/orchestration/src/main-bot-grant.mjs +15 -0
- package/packages/orchestration/src/main-model-routes.mjs +208 -0
- package/packages/orchestration/src/model-accounting.mjs +99 -35
- package/packages/orchestration/src/model-auxiliary-dispatch.mjs +187 -0
- package/packages/orchestration/src/model-loop-frame.mjs +179 -0
- package/packages/orchestration/src/model-prepared-generation.mjs +175 -0
- package/packages/orchestration/src/model-request-bounds.mjs +111 -0
- package/packages/orchestration/src/native-activity-contract.mjs +491 -0
- package/packages/orchestration/src/native-activity-service.mjs +102 -0
- package/packages/orchestration/src/native-activity-store.mjs +249 -0
- package/packages/orchestration/src/native-conversation-tools.mjs +78 -0
- package/packages/orchestration/src/native-data-tools.mjs +100 -0
- package/packages/orchestration/src/native-remote-actions.mjs +742 -0
- package/packages/orchestration/src/native-team-tools.mjs +73 -0
- package/packages/orchestration/src/pairing-invite.mjs +37 -0
- package/packages/orchestration/src/product-composition.mjs +1559 -1001
- package/packages/orchestration/src/remote-machines.mjs +82 -14
- package/packages/orchestration/src/routine-execution.mjs +5 -3
- package/packages/orchestration/src/routine-session.mjs +19 -13
- package/packages/orchestration/src/secure-pairing-host.mjs +112 -0
- package/packages/orchestration/src/task-journal.mjs +29 -0
- package/packages/orchestration/src/task-product.mjs +17 -0
- package/packages/orchestration/src/team-assignment-store.mjs +120 -0
- package/packages/orchestration/src/team-bridge.mjs +282 -0
- package/packages/orchestration/src/team-scheduler.mjs +83 -0
- package/packages/orchestration/src/team-sdk-tools.mjs +84 -0
- package/packages/orchestration/src/team-tool-contract.mjs +284 -0
- package/packages/orchestration/src/ui-host-namespace.mjs +23 -0
- package/packages/orchestration/src/web-panel-actions.mjs +103 -0
- package/packages/orchestration/src/web-panel-framing.mjs +227 -0
- package/packages/orchestration/src/web-panel-store.mjs +206 -0
- package/remote-computer-agent/README.md +39 -29
- package/remote-computer-agent/build.mjs +3 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_LICENSE +202 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_NOTICE +10 -0
- package/remote-computer-agent/dist/OPENMAUSBOT_SOURCE.json +68 -0
- package/remote-computer-agent/dist/cli-stdio-bridge.mjs +215 -25
- package/remote-computer-agent/dist/cli-stdio-bridge.mjs.map +4 -4
- package/remote-computer-agent/dist/cli.mjs +1688 -168
- package/remote-computer-agent/dist/cli.mjs.map +4 -4
- package/remote-computer-agent/dist/index.mjs +1579 -122
- package/remote-computer-agent/dist/index.mjs.map +4 -4
- package/remote-computer-agent/dist/native-peer.ps1 +51 -3
- package/remote-computer-agent/package-lock.json +308 -2
- package/remote-computer-agent/package.json +2 -1
- package/remote-computer-agent/pair-device.ps1 +20 -0
- package/remote-computer-agent/setup.ps1 +38 -16
- package/remote-computer-agent/src/cli.ts +24 -3
- package/remote-computer-agent/src/desktop-session.ts +11 -3
- package/remote-computer-agent/src/e2e-server.ts +81 -0
- package/remote-computer-agent/src/pairing-page.ts +35 -0
- package/remote-computer-agent/src/protocol.ts +25 -25
- package/remote-computer-agent/src/secure-pairing.ts +105 -0
- package/remote-computer-agent/src/store.ts +20 -5
- package/remote-computer-agent/src/transport.ts +39 -15
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
// The public LlmAdapter bridge for the Claude retained conversation engine.
|
|
2
|
+
//
|
|
3
|
+
// One Bot conversation keeps ONE native CLI session (packages/bot-engines/src/claude-engine.mjs)
|
|
4
|
+
// and speaks to the installed SDK through the public `LlmAdapter` surface, so the Host's own
|
|
5
|
+
// `llm/stream` waterfall — the same one an integrated turn goes through — applies the Bot
|
|
6
|
+
// policy, re-decides the route and records the ONE receipt. This bridge holds no money and
|
|
7
|
+
// issues no receipt of its own.
|
|
8
|
+
//
|
|
9
|
+
// Trust boundary. Only a Host constructs this adapter, and only a Host supplies the trusted
|
|
10
|
+
// state it dispatches on:
|
|
11
|
+
// - `entry` / `entryFor()` — the pinned installation reference (executable, digest, version,
|
|
12
|
+
// optional pinned interpreter script). Never read from a request.
|
|
13
|
+
// - `identityFor({provider, model, sessionId, purpose})` — the committed Bot identity:
|
|
14
|
+
// the authority subject, the committed route's authority object, the durable binding and
|
|
15
|
+
// the Host-confirmed persona. It reads committed Host state; a request can never name a
|
|
16
|
+
// Bot, a path, a command, a credential or an engine configuration, and a request carrying
|
|
17
|
+
// any field outside the accepted set is refused.
|
|
18
|
+
// - `funding` / `fundingFor()` — the committed funding of this route. Only an explicitly
|
|
19
|
+
// configured Claude **subscription** chat route is open: an API-funded CLI route cannot
|
|
20
|
+
// prove a strict per-call cost or output bound here, so it is refused rather than priced
|
|
21
|
+
// by guesswork.
|
|
22
|
+
// - `engineFactory` — the retained-session engine seam. The default is the real
|
|
23
|
+
// `createClaudeEngine`; a Host (or a test) may inject another, and the injection is the
|
|
24
|
+
// only way the engine configuration is ever chosen.
|
|
25
|
+
//
|
|
26
|
+
// What a turn is. `stream(options)` is one conversation turn: the new user text is projected
|
|
27
|
+
// out of the request history (the retained CLI already holds everything before it), dispatched
|
|
28
|
+
// on the one native session, and mapped onto the SDK chunk vocabulary. Turns are serial: the
|
|
29
|
+
// engine's own single-turn fence refuses a concurrent second turn, and that refusal is
|
|
30
|
+
// surfaced, never queued silently. Cancellation belongs to the turn that asked for it — the
|
|
31
|
+
// session is never bound to the first turn's short-lived signal — and it really stops the
|
|
32
|
+
// native tree and waits for the engine's own settlement before the stream finishes.
|
|
33
|
+
//
|
|
34
|
+
// What is never done here: no second chat surface, no second ledger, no fabricated
|
|
35
|
+
// contextWindow / defaultMaxTokens / price / response-backend proof, no blind replay of a
|
|
36
|
+
// turn whose outcome is unknown, and no re-execution of the engine's own internal tools as
|
|
37
|
+
// loop tool calls.
|
|
38
|
+
|
|
39
|
+
import { randomBytes } from 'node:crypto'
|
|
40
|
+
import { isAbsolute } from 'node:path'
|
|
41
|
+
|
|
42
|
+
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
|
43
|
+
|
|
44
|
+
import { createClaudeEngine } from './claude-engine.mjs'
|
|
45
|
+
import {
|
|
46
|
+
CLAUDE_LLM_CAPABILITIES, assertRouteRequest, emptyTurnState, finishReasonFromTurn,
|
|
47
|
+
projectRetainedTurn, seedTurnState, sessionKeyOf, turnFacts, usageChunkFromTurn,
|
|
48
|
+
} from './claude-llm-turn.mjs'
|
|
49
|
+
|
|
50
|
+
/** The one funding kind this route opens. Everything else is refused with a reason. */
|
|
51
|
+
export const CLAUDE_LLM_FUNDING = 'subscription'
|
|
52
|
+
|
|
53
|
+
const identifier = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)
|
|
54
|
+
const text = value => typeof value === 'string' && value.trim() ? value : null
|
|
55
|
+
const coded = (code, message) => Object.assign(new Error(message), { code })
|
|
56
|
+
/** A construction refusal carries its machine code beside the message, so a Host can assert on it. */
|
|
57
|
+
const badOptions = (message) => { const [code, ...rest] = String(message).split(': '); return Object.assign(new TypeError(message), { code, detail: rest.join(': ') }) }
|
|
58
|
+
|
|
59
|
+
/** Two pinned entries are the same installation only when every recorded fact agrees. */
|
|
60
|
+
const entryEquals = (left, right) => Boolean(left) && Boolean(right)
|
|
61
|
+
&& left.executable === right.executable && left.sha256 === right.sha256
|
|
62
|
+
&& left.version === right.version && (left.script ?? null) === (right.script ?? null)
|
|
63
|
+
&& (left.scriptSha256 ?? null) === (right.scriptSha256 ?? null)
|
|
64
|
+
|
|
65
|
+
/** Turn refusal → one terminal SDK failure, so no consumer has to guess an outcome. */
|
|
66
|
+
const failureOf = error => {
|
|
67
|
+
const code = typeof error?.code === 'string' && error.code ? error.code : codeOfMessage(error?.message)
|
|
68
|
+
return { code, message: String(error?.message ?? error ?? 'claude route failed') }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The engine refuses with plain Errors whose code leads the message; keep it machine-readable. */
|
|
72
|
+
const codeOfMessage = message => {
|
|
73
|
+
const match = /^([A-Z][A-Z0-9_]{2,})(?::[ \n]|$)/.exec(typeof message === 'string' ? message : '')
|
|
74
|
+
return match ? match[1] : 'CLAUDE_TURN_FAILED'
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const finishChunk = (kind, failure) => ({ type: 'finish', reason: { kind, failure } })
|
|
78
|
+
|
|
79
|
+
/** A native pid that no longer exists. */
|
|
80
|
+
const pidAlive = pid => { try { process.kill(pid, 0); return true } catch { return false } }
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Wait, bounded, for the engine to notice that its retained process is gone.
|
|
84
|
+
*
|
|
85
|
+
* A process that exits right after its `result` frame is only noticed when the supervisor
|
|
86
|
+
* reports the stop, which can land after the turn promise already resolved. Dispatching the
|
|
87
|
+
* next prompt into that dead pipe loses the turn without any outcome, so the bridge gives
|
|
88
|
+
* the engine a moment to retire it and relaunch with `--resume`. A live process, or no
|
|
89
|
+
* process at all, returns immediately; the wait never blocks a healthy turn.
|
|
90
|
+
*/
|
|
91
|
+
async function awaitRetiredProcessNotice(engine, timeoutMs = 5_000) {
|
|
92
|
+
const end = Date.now() + timeoutMs
|
|
93
|
+
while (Date.now() < end) {
|
|
94
|
+
const retained = engine.snapshot?.().process ?? null
|
|
95
|
+
if (!retained || !Number.isInteger(retained.pid) || retained.pid <= 0 || pidAlive(retained.pid)) return
|
|
96
|
+
await new Promise(resolve => setTimeout(resolve, 25))
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Native stop reasons after which the retained session cannot be continued: the upstream
|
|
101
|
+
* conversation is gone, the identity failed, the grant was revoked, the process died
|
|
102
|
+
* mid-turn or the turn timed out. Each of them ends the native session, so the bridge
|
|
103
|
+
* retires it and the Host is told to open a new one instead of silently starting a fresh
|
|
104
|
+
* session that has lost the conversation.
|
|
105
|
+
*/
|
|
106
|
+
const RETIRING_STOP_REASONS = Object.freeze([
|
|
107
|
+
'resume-refused', 'model-identity-mismatch', 'grant-revoked', 'engine-stopped',
|
|
108
|
+
'turn-timeout', 'exit-before-result',
|
|
109
|
+
])
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The Claude retained-session route as a public `LlmAdapter`.
|
|
113
|
+
*
|
|
114
|
+
* Register with the installed runtime exactly like any other adapter:
|
|
115
|
+
* `ctx.llm.registerAdapter([provider], createClaudeLlmAdapter(hostOptions))`.
|
|
116
|
+
*/
|
|
117
|
+
export class ClaudeLlmAdapter extends LlmAdapter {
|
|
118
|
+
#provider
|
|
119
|
+
#model
|
|
120
|
+
#name
|
|
121
|
+
#funding
|
|
122
|
+
#fundingFor
|
|
123
|
+
#entry
|
|
124
|
+
#entryFor
|
|
125
|
+
#runtimeRoot
|
|
126
|
+
#workspaceFor
|
|
127
|
+
#workspace
|
|
128
|
+
#identityFor
|
|
129
|
+
#requestFor
|
|
130
|
+
#persona
|
|
131
|
+
#hooks
|
|
132
|
+
#onPermissionRequest
|
|
133
|
+
#provisionCredentials
|
|
134
|
+
#tools
|
|
135
|
+
#handlers
|
|
136
|
+
#toolsFor
|
|
137
|
+
#onActivity
|
|
138
|
+
#activityObserverFailures = 0
|
|
139
|
+
#permissionMode
|
|
140
|
+
#deadlineMs
|
|
141
|
+
#launcher
|
|
142
|
+
#python
|
|
143
|
+
#engineFactory
|
|
144
|
+
#generation = 1
|
|
145
|
+
#closed = false
|
|
146
|
+
#sessions = new Map()
|
|
147
|
+
#creations = new Set()
|
|
148
|
+
#engines = new Set()
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {object} options every value is trusted Host state.
|
|
152
|
+
* @param {string} options.provider the one provider route id this adapter serves
|
|
153
|
+
* @param {string} options.model the one exact committed model id
|
|
154
|
+
* @param {string} [options.name] display name for selectors and diagnostics
|
|
155
|
+
* @param {string} [options.funding='subscription'] committed funding kind; only `subscription` opens the route
|
|
156
|
+
* @param {Function} [options.fundingFor] `({provider, model}) => funding` trusted committed-funding read, re-checked per prepared call
|
|
157
|
+
* @param {{executable:string, sha256:string, version:string, script?:string, scriptSha256?:string}} [options.entry] pinned installation reference
|
|
158
|
+
* @param {Function} [options.entryFor] `() => entry|Promise<entry>` trusted pinned-installation read; a changed pin refuses the captured generation
|
|
159
|
+
* @param {string} options.runtimeRoot isolated absolute runtime root the engine creates its private homes under
|
|
160
|
+
* @param {Function} [options.workspaceFor] `({botId, sessionId}) => absolute workspace` trusted per-conversation workspace
|
|
161
|
+
* @param {string} [options.workspace] default absolute conversation workspace
|
|
162
|
+
* @param {Function} options.identityFor `({provider, model, sessionId, purpose}) => identity` — see the module header
|
|
163
|
+
* @param {string} [options.persona] Host-confirmed persona, when it is not per-Bot identity state
|
|
164
|
+
* @param {{onBeforeRequest?:Function, onObserve?:Function, onSettle?:Function, onClose?:Function}} [options.hooks] engine hooks; the money authority stays the SDK waterfall
|
|
165
|
+
* @param {Function} [options.onPermissionRequest] Host permission callback; absent denies every ask
|
|
166
|
+
* @param {Function} [options.provisionCredentials] `(home, identity) => Promise` writes subscription credentials into the isolated home
|
|
167
|
+
* @param {Function} [options.requestFor] Host-only canonical request projection; never supplied by model arguments
|
|
168
|
+
* @param {string[]} [options.tools] committed-grant tool names bridged into the engine over MCP
|
|
169
|
+
* @param {Array<{definition:{name}, parse:Function, execute:Function}>} [options.handlers] bridge handlers for `tools`
|
|
170
|
+
* @param {string} [options.permissionMode='default'] engine permission mode (bypassPermissions is refused by the engine)
|
|
171
|
+
* @param {number} [options.deadlineMs=120000] bounded lifetime of one engine
|
|
172
|
+
* @param {{probe?:Function, protect?:Function, supervise?:Function}} [options.launcher] process-launcher seam
|
|
173
|
+
* @param {string} [options.python] native-peer interpreter selection
|
|
174
|
+
* @param {Function} [options.engineFactory] `(config) => Promise<engine>` retained-session seam; the default is the real `createClaudeEngine`
|
|
175
|
+
*/
|
|
176
|
+
constructor(options = {}) {
|
|
177
|
+
super()
|
|
178
|
+
const {
|
|
179
|
+
provider, model, name, funding = CLAUDE_LLM_FUNDING, fundingFor = null,
|
|
180
|
+
entry = null, entryFor = null, runtimeRoot, workspace = null, workspaceFor = null,
|
|
181
|
+
identityFor, requestFor = null, persona = null, hooks = {}, onPermissionRequest = null, provisionCredentials = null,
|
|
182
|
+
tools = [], handlers = [], toolsFor = null, onActivity = null, permissionMode = 'default', deadlineMs = 120_000,
|
|
183
|
+
launcher = null, python, engineFactory = null,
|
|
184
|
+
} = options ?? {}
|
|
185
|
+
if (!identifier(provider)) throw badOptions('CLAUDE_ROUTE_PROVIDER_REQUIRED')
|
|
186
|
+
if (typeof model !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/.test(model)) throw badOptions('CLAUDE_ROUTE_MODEL_REQUIRED')
|
|
187
|
+
if (funding !== CLAUDE_LLM_FUNDING) {
|
|
188
|
+
throw badOptions(`CLAUDE_ROUTE_FUNDING_REFUSED: this bridge only opens an explicitly configured Claude subscription chat route, not ${JSON.stringify(funding)}`)
|
|
189
|
+
}
|
|
190
|
+
if (fundingFor !== null && typeof fundingFor !== 'function') throw badOptions('CLAUDE_ROUTE_FUNDING_READER_REQUIRED_FUNCTION')
|
|
191
|
+
if ((entry === null) === (entryFor === null)) throw badOptions('CLAUDE_ROUTE_ENTRY_REQUIRED: provide exactly one of entry / entryFor')
|
|
192
|
+
if (entry !== null && (!entry.executable || !/^[a-f0-9]{64}$/.test(entry.sha256 ?? '') || typeof entry.version !== 'string')) throw badOptions('CLAUDE_ROUTE_ENTRY_INVALID')
|
|
193
|
+
if (entryFor !== null && typeof entryFor !== 'function') throw badOptions('CLAUDE_ROUTE_ENTRY_READER_REQUIRED_FUNCTION')
|
|
194
|
+
if (!isAbsolute(runtimeRoot ?? '')) throw badOptions('CLAUDE_ROUTE_RUNTIME_ROOT_REQUIRED')
|
|
195
|
+
if (workspace !== null && !isAbsolute(workspace)) throw badOptions('CLAUDE_ROUTE_WORKSPACE_REQUIRED_ABSOLUTE')
|
|
196
|
+
if (workspaceFor !== null && typeof workspaceFor !== 'function') throw badOptions('CLAUDE_ROUTE_WORKSPACE_READER_REQUIRED_FUNCTION')
|
|
197
|
+
if (workspace === null && workspaceFor === null) throw badOptions('CLAUDE_ROUTE_WORKSPACE_REQUIRED')
|
|
198
|
+
if (typeof identityFor !== 'function') throw badOptions('CLAUDE_ROUTE_IDENTITY_RESOLVER_REQUIRED')
|
|
199
|
+
if (requestFor !== null && typeof requestFor !== 'function') throw badOptions('CLAUDE_ROUTE_REQUEST_PROJECTOR_REQUIRED_FUNCTION')
|
|
200
|
+
if (persona !== null && typeof persona !== 'string') throw badOptions('CLAUDE_ROUTE_PERSONA_REQUIRED_STRING')
|
|
201
|
+
for (const key of ['onBeforeRequest', 'onObserve', 'onSettle', 'onClose']) {
|
|
202
|
+
if (hooks[key] !== undefined && typeof hooks[key] !== 'function') throw badOptions(`CLAUDE_ROUTE_HOOK_${key.toUpperCase()}_REQUIRED_FUNCTION`)
|
|
203
|
+
}
|
|
204
|
+
if (onPermissionRequest !== null && typeof onPermissionRequest !== 'function') throw badOptions('CLAUDE_ROUTE_PERMISSION_CALLBACK_REQUIRED_FUNCTION')
|
|
205
|
+
if (provisionCredentials !== null && typeof provisionCredentials !== 'function') throw badOptions('CLAUDE_ROUTE_CREDENTIAL_PROVIDER_REQUIRED_FUNCTION')
|
|
206
|
+
if (!Array.isArray(tools) || tools.some(tool => typeof tool !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(tool))) throw badOptions('CLAUDE_ROUTE_TOOLS_INVALID')
|
|
207
|
+
if (!Array.isArray(handlers) || handlers.some(handler => !handler?.definition?.name || typeof handler.parse !== 'function' || typeof handler.execute !== 'function')) throw badOptions('CLAUDE_ROUTE_HANDLERS_INVALID')
|
|
208
|
+
if (toolsFor !== null && typeof toolsFor !== 'function') throw badOptions('CLAUDE_ROUTE_TOOLS_PROVIDER_REQUIRED_FUNCTION')
|
|
209
|
+
if (onActivity !== null && typeof onActivity !== 'function') throw badOptions('CLAUDE_ROUTE_ACTIVITY_CALLBACK_REQUIRED_FUNCTION')
|
|
210
|
+
if (engineFactory !== null && typeof engineFactory !== 'function') throw badOptions('CLAUDE_ROUTE_ENGINE_FACTORY_REQUIRED_FUNCTION')
|
|
211
|
+
if (launcher !== null && (typeof launcher !== 'object' || typeof launcher.probe !== 'function' || typeof launcher.protect !== 'function' || typeof launcher.supervise !== 'function')) throw badOptions('CLAUDE_ROUTE_LAUNCHER_INVALID')
|
|
212
|
+
|
|
213
|
+
this.#provider = provider
|
|
214
|
+
this.#model = model
|
|
215
|
+
this.#name = typeof name === 'string' && name.trim() ? name.trim() : `${provider} (Claude session)`
|
|
216
|
+
this.#funding = funding
|
|
217
|
+
this.#fundingFor = fundingFor
|
|
218
|
+
this.#entry = entry === null ? null : Object.freeze({ ...entry })
|
|
219
|
+
this.#entryFor = entryFor
|
|
220
|
+
this.#runtimeRoot = runtimeRoot
|
|
221
|
+
this.#workspaceFor = workspaceFor
|
|
222
|
+
this.#workspace = workspace
|
|
223
|
+
this.#identityFor = identityFor
|
|
224
|
+
this.#requestFor = requestFor
|
|
225
|
+
this.#persona = persona
|
|
226
|
+
this.#hooks = hooks
|
|
227
|
+
this.#onPermissionRequest = onPermissionRequest
|
|
228
|
+
this.#provisionCredentials = provisionCredentials
|
|
229
|
+
this.#tools = [...tools]
|
|
230
|
+
this.#handlers = [...handlers]
|
|
231
|
+
this.#toolsFor = toolsFor
|
|
232
|
+
this.#onActivity = onActivity
|
|
233
|
+
this.#permissionMode = permissionMode
|
|
234
|
+
this.#deadlineMs = Number.isSafeInteger(deadlineMs) && deadlineMs >= 1000 && deadlineMs <= 7_200_000 ? deadlineMs : 120_000
|
|
235
|
+
this.#launcher = launcher
|
|
236
|
+
this.#python = python
|
|
237
|
+
this.#engineFactory = engineFactory ?? (config => createClaudeEngine(config))
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Display metadata for the one route this registration holds. */
|
|
241
|
+
providerInfo(provider) {
|
|
242
|
+
if (provider !== this.#provider) throw new LlmError(`this adapter does not own provider "${provider}"`, 'NO_ADAPTER')
|
|
243
|
+
return Object.freeze({ id: this.#provider, name: this.#name })
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The one committed model this route serves; nothing is advertised that is not committed. */
|
|
247
|
+
async listModels(provider) {
|
|
248
|
+
if (provider !== this.#provider) return []
|
|
249
|
+
return [Object.freeze({
|
|
250
|
+
provider: this.#provider, id: this.#model, name: this.#model,
|
|
251
|
+
description: 'Claude 订阅会话路由:一次一条新的用户文本,保留一个原生 CLI 会话。',
|
|
252
|
+
inputModalities: Object.freeze(['text']),
|
|
253
|
+
})]
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Exact-route metadata. Deliberately absent: `context` (no context window is known for a
|
|
258
|
+
* subscription CLI session, so none is invented), `defaultMaxTokens` (the route cannot
|
|
259
|
+
* bound output tokens, so it declares no cap to materialize), and `reasoning` (no
|
|
260
|
+
* selectable efforts are exposed).
|
|
261
|
+
*/
|
|
262
|
+
async resolveModel(provider, model, signal) {
|
|
263
|
+
if (provider !== this.#provider || model !== this.#model) {
|
|
264
|
+
throw new LlmError(`this route serves only ${this.#provider}/${this.#model}`, 'CLAUDE_MODEL_NOT_CONFIGURED')
|
|
265
|
+
}
|
|
266
|
+
signal?.throwIfAborted()
|
|
267
|
+
return Object.freeze({
|
|
268
|
+
provider: this.#provider, id: this.#model, name: this.#model,
|
|
269
|
+
description: 'Claude 订阅会话路由:仅文本,上下文窗口与输出上界未知(不伪造)。',
|
|
270
|
+
inputModalities: Object.freeze(['text']),
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Bind this call to one immutable route generation: the exact model metadata, the funding
|
|
276
|
+
* the Host has committed right now, and the pinned installation reference as it stands at
|
|
277
|
+
* preparation time. A later Host re-pin (`replaceRoute`) refuses the captured generation
|
|
278
|
+
* instead of mixing one generation's route with another's entry or funding.
|
|
279
|
+
*/
|
|
280
|
+
async prepareCall(provider, model, signal) {
|
|
281
|
+
const generation = await this.#capture(provider, model, signal)
|
|
282
|
+
return Object.freeze({
|
|
283
|
+
model: generation.resolved,
|
|
284
|
+
stream: options => this.#streamGeneration(generation, options),
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The unprepared entry point the SDK's `stream()` reaches through the waterfall. */
|
|
289
|
+
async *stream(options) {
|
|
290
|
+
const prepared = await this.prepareCall(options?.provider, options?.model, options?.signal)
|
|
291
|
+
yield* prepared.stream(options)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---- Host-only route control ------------------------------------------------
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Re-pin this registration's route: a changed committed model, funding, entry or persona.
|
|
298
|
+
* The swap is atomic and bumps the generation, so a call prepared against the previous
|
|
299
|
+
* generation is refused rather than dispatched on mixed state. Sessions bound to the old
|
|
300
|
+
* route are retired without creating any engine. Host-only: a request can never call this.
|
|
301
|
+
*
|
|
302
|
+
* @param {{model?:string, funding?:string, entry?:object, persona?:string|null, name?:string}} next
|
|
303
|
+
*/
|
|
304
|
+
replaceRoute(next = {}) {
|
|
305
|
+
if (this.#closed) throw new LlmError('this registration has been released', 'REGISTRATION_DISPOSED')
|
|
306
|
+
if (!next || typeof next !== 'object') throw badOptions('CLAUDE_ROUTE_REPLACEMENT_REQUIRED')
|
|
307
|
+
const model = next.model ?? this.#model
|
|
308
|
+
if (typeof model !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/.test(model)) throw badOptions('CLAUDE_ROUTE_MODEL_REQUIRED')
|
|
309
|
+
const funding = next.funding ?? this.#funding
|
|
310
|
+
if (funding !== CLAUDE_LLM_FUNDING) throw badOptions(`CLAUDE_ROUTE_FUNDING_REFUSED: ${JSON.stringify(funding)}`)
|
|
311
|
+
const entry = next.entry === undefined ? this.#entry : Object.freeze({ ...next.entry })
|
|
312
|
+
if (entry !== null && (!entry.executable || !/^[a-f0-9]{64}$/.test(entry.sha256 ?? '') || typeof entry.version !== 'string')) throw badOptions('CLAUDE_ROUTE_ENTRY_INVALID')
|
|
313
|
+
const persona = next.persona === undefined ? this.#persona : next.persona
|
|
314
|
+
if (persona !== null && typeof persona !== 'string') throw badOptions('CLAUDE_ROUTE_PERSONA_REQUIRED_STRING')
|
|
315
|
+
this.#model = model
|
|
316
|
+
this.#funding = funding
|
|
317
|
+
this.#entry = entry
|
|
318
|
+
this.#persona = persona
|
|
319
|
+
if (typeof next.name === 'string' && next.name.trim()) this.#name = next.name.trim()
|
|
320
|
+
this.#generation++
|
|
321
|
+
// Every session was bound to the previous route facts: retire them now, and create
|
|
322
|
+
// nothing. A later call on a retired session is refused, never silently re-bound.
|
|
323
|
+
for (const session of [...this.#sessions.values()]) void this.#retire(session, 'route-replaced', false)
|
|
324
|
+
return Object.freeze({ provider: this.#provider, model: this.#model, generation: this.#generation })
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Release the registration: refuse every further call, wait for an engine that is still
|
|
329
|
+
* being created, and stop every live native tree with real settlement. Creates nothing.
|
|
330
|
+
*/
|
|
331
|
+
async invalidateBot(botId, reason = 'host-state-changed') {
|
|
332
|
+
const sessions = [...this.#sessions.values()].filter(session => session.botId === botId)
|
|
333
|
+
const creating = sessions.map(session => session.creation).filter(Boolean)
|
|
334
|
+
const evidence = await Promise.all(sessions.map(session => this.#retire(session, reason, false)))
|
|
335
|
+
await Promise.allSettled(creating)
|
|
336
|
+
return Object.freeze({ retired: sessions.length, evidence: sessions.map((session, index) => session.evidence ?? evidence[index]) })
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async close() {
|
|
340
|
+
this.#closed = true
|
|
341
|
+
const creations = [...this.#creations]
|
|
342
|
+
this.#creations.clear()
|
|
343
|
+
// Wait for a creation that is still in flight before stopping anything: a tree that is
|
|
344
|
+
// being launched right now is exactly the one disposal must not orphan.
|
|
345
|
+
await Promise.allSettled(creations)
|
|
346
|
+
const stopped = []
|
|
347
|
+
for (const session of [...this.#sessions.values()]) {
|
|
348
|
+
if (session.engine) stopped.push(this.#retire(session, 'adapter-closed', false))
|
|
349
|
+
else session.retired = Object.freeze({ reason: 'adapter-closed', resumable: false, at: Date.now() })
|
|
350
|
+
}
|
|
351
|
+
this.#sessions.clear()
|
|
352
|
+
await Promise.allSettled(stopped)
|
|
353
|
+
return Object.freeze({ closed: true, sessions: stopped.length })
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Observable route state: no money, no credentials, no message content. */
|
|
357
|
+
snapshot() {
|
|
358
|
+
return Object.freeze({
|
|
359
|
+
schemaVersion: 1, driver: 'claude', provider: this.#provider, model: this.#model,
|
|
360
|
+
name: this.#name, funding: this.#funding, generation: this.#generation, closed: this.#closed,
|
|
361
|
+
activityObserverFailures: this.#activityObserverFailures,
|
|
362
|
+
capabilities: CLAUDE_LLM_CAPABILITIES,
|
|
363
|
+
sessions: Object.freeze([...this.#sessions.values()].map(session => Object.freeze({
|
|
364
|
+
key: session.key, botId: session.botId, sessionId: session.sessionId,
|
|
365
|
+
turns: session.turns, retired: session.retired, lastSessionId: session.lastSessionId,
|
|
366
|
+
engineClosed: session.engine ? session.engine.closed : null,
|
|
367
|
+
pid: session.engine?.snapshot?.().process?.pid ?? null,
|
|
368
|
+
usageStatus: session.usageStatus, stopEvidence: session.evidence,
|
|
369
|
+
}))),
|
|
370
|
+
})
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---- internals ---------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
/** One immutable route generation: model, funding and pinned entry as of preparation. */
|
|
376
|
+
async #capture(provider, model, signal) {
|
|
377
|
+
if (this.#closed) throw new LlmError('this registration has been released', 'REGISTRATION_DISPOSED')
|
|
378
|
+
if (provider !== this.#provider || model !== this.#model) {
|
|
379
|
+
throw new LlmError(`this route serves only ${this.#provider}/${this.#model}`, 'CLAUDE_MODEL_NOT_CONFIGURED')
|
|
380
|
+
}
|
|
381
|
+
signal?.throwIfAborted()
|
|
382
|
+
const funding = this.#fundingFor === null ? this.#funding : await this.#fundingFor({ provider, model })
|
|
383
|
+
if (funding !== CLAUDE_LLM_FUNDING) {
|
|
384
|
+
throw new LlmError(
|
|
385
|
+
`this bridge only opens an explicitly configured Claude subscription chat route; committed funding is ${JSON.stringify(funding)} and an API-funded CLI route cannot prove a strict per-call cost or output bound here`,
|
|
386
|
+
'CLAUDE_ROUTE_FUNDING_REFUSED')
|
|
387
|
+
}
|
|
388
|
+
const entry = this.#entryFor === null ? this.#entry : await this.#entryFor()
|
|
389
|
+
if (!entry || !entry.executable || !/^[a-f0-9]{64}$/.test(entry.sha256 ?? '')) {
|
|
390
|
+
throw new LlmError('the Host pinned installation reference is missing or invalid', 'CLAUDE_ROUTE_ENTRY_INVALID')
|
|
391
|
+
}
|
|
392
|
+
const resolved = await this.resolveModel(provider, model, signal)
|
|
393
|
+
return Object.freeze({
|
|
394
|
+
generation: this.#generation, provider, model, funding, entry: Object.freeze({ ...entry }), resolved,
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The stream of one captured generation. Every refusal here is a terminal chunk. */
|
|
399
|
+
async *#streamGeneration(generation, options) {
|
|
400
|
+
if (this.#closed) { yield finishChunk('error', { code: 'CLAUDE_ADAPTER_CLOSED', message: '本路由已释放。' }); return }
|
|
401
|
+
if (generation.generation !== this.#generation) {
|
|
402
|
+
yield finishChunk('error', {
|
|
403
|
+
code: 'CLAUDE_ROUTE_GENERATION_CHANGED',
|
|
404
|
+
message: '宿主在准备之后重新登记了本路由(模型/资金/入口/人格代际已变),这次调用被拒绝而不是混代投递。',
|
|
405
|
+
})
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
let gate
|
|
409
|
+
let bootstrap = null
|
|
410
|
+
let identity
|
|
411
|
+
try {
|
|
412
|
+
identity = await this.#identityFor({
|
|
413
|
+
provider: generation.provider, model: generation.model,
|
|
414
|
+
sessionId: options.sessionId ?? null, purpose: null,
|
|
415
|
+
})
|
|
416
|
+
} catch (error) { yield finishChunk('error', { code: 'CLAUDE_IDENTITY_UNRESOLVED', message: String(error?.message ?? error) }); return }
|
|
417
|
+
const invalid = this.#validateIdentity(identity)
|
|
418
|
+
if (invalid) { yield finishChunk('error', { code: invalid, message: '宿主未提供可信的已提交 Bot 身份。' }); return }
|
|
419
|
+
|
|
420
|
+
// The persona this call is measured against comes from committed Host identity state,
|
|
421
|
+
// so a request's own system message can only ever be compared with it — never trusted.
|
|
422
|
+
const persona = identity.persona ?? this.#persona ?? null
|
|
423
|
+
try {
|
|
424
|
+
const projected = this.#requestFor ? await this.#requestFor(options, identity) : null
|
|
425
|
+
if (projected?.request) bootstrap = projected.bootstrap ?? null
|
|
426
|
+
gate = assertRouteRequest(projected?.request ?? projected ?? options, { provider: generation.provider, model: generation.model, persona })
|
|
427
|
+
} catch (error) { yield finishChunk('error', failureOf(error)); return }
|
|
428
|
+
|
|
429
|
+
const key = sessionKeyOf(identity.botId, identity.conversationId ?? options.sessionId ?? null)
|
|
430
|
+
let session = this.#sessions.get(key) ?? null
|
|
431
|
+
let fingerprint
|
|
432
|
+
try {
|
|
433
|
+
fingerprint = this.#fingerprint(generation, identity)
|
|
434
|
+
} catch (error) { yield finishChunk('error', { code: 'CLAUDE_IDENTITY_UNRESOLVED', message: String(error?.message ?? error) }); return }
|
|
435
|
+
if (session !== null && session.fingerprint !== fingerprint) {
|
|
436
|
+
// A different Bot, model, persona or grant revision on one conversation key is a new
|
|
437
|
+
// binding, not a continuation: retire the old session and refuse this call.
|
|
438
|
+
await this.#retire(session, 'binding-changed', false)
|
|
439
|
+
yield finishChunk('error', { code: 'CLAUDE_SESSION_BINDING_CHANGED', message: '本次调用与已保留会话的 Bot/模型/人格/授权代际不一致,已退休旧会话并拒绝。' })
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
const newSession = (prompt = persona, state = emptyTurnState()) => ({
|
|
443
|
+
key, botId: identity.botId, sessionId: options.sessionId ?? null, fingerprint,
|
|
444
|
+
persona: prompt, state,
|
|
445
|
+
engine: null, creation: null, turns: 0, retired: null, lastSessionId: null,
|
|
446
|
+
evidence: null, usageStatus: 'unknown',
|
|
447
|
+
})
|
|
448
|
+
if (session === null) {
|
|
449
|
+
session = newSession()
|
|
450
|
+
this.#sessions.set(key, session)
|
|
451
|
+
} else if (session.retired) {
|
|
452
|
+
// A retired conversation key cannot be continued: the native session is gone and an
|
|
453
|
+
// unknown-outcome process is never replayed into a fresh one. The Host opens a new session.
|
|
454
|
+
yield finishChunk('error', {
|
|
455
|
+
code: 'CLAUDE_SESSION_RETIRED',
|
|
456
|
+
message: `本会话已退休(${session.retired.reason}):不为未知结果自动新开会话,请开启新会话。`,
|
|
457
|
+
})
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
let projection
|
|
461
|
+
try {
|
|
462
|
+
projection = projectRetainedTurn({
|
|
463
|
+
messages: gate.messages, provider: generation.provider, model: generation.model, state: session.state,
|
|
464
|
+
})
|
|
465
|
+
} catch (error) {
|
|
466
|
+
if (error.code === 'CLAUDE_SESSION_HISTORY_UNSEEN' && bootstrap && bootstrap.prefixLength >= session.state.sent.length) {
|
|
467
|
+
try {
|
|
468
|
+
if (session.creation || session.engine?.snapshot?.().turn) throw coded('CLAUDE_TURN_BUSY', '上一轮仍在处理,不能切换原生上下文。')
|
|
469
|
+
const state = seedTurnState(gate.messages, bootstrap.prefixLength)
|
|
470
|
+
projection = projectRetainedTurn({ messages: gate.messages, provider: generation.provider, model: generation.model, state })
|
|
471
|
+
const prompt = `${persona ?? ''}${bootstrap.context}`
|
|
472
|
+
if (Buffer.byteLength(prompt) > 32_000 || prompt.includes('\0')) throw coded('CLAUDE_HOST_ROUTE_CONTEXT_LIMIT', '完整文本上下文超过原生会话可接收的上限。')
|
|
473
|
+
if (session.engine) {
|
|
474
|
+
const evidence = await this.#retire(session, 'host-route-switch', false)
|
|
475
|
+
if (!evidence?.treeStopped || evidence.status === 'unknown' || evidence.possiblyApplied === true) throw coded('CLAUDE_HOST_ROUTE_STOP_UNCONFIRMED', '旧原生进程尚未确认停止。')
|
|
476
|
+
}
|
|
477
|
+
options.signal?.throwIfAborted()
|
|
478
|
+
session = newSession(prompt, state)
|
|
479
|
+
this.#sessions.set(key, session)
|
|
480
|
+
} catch (failure) { yield finishChunk('error', failureOf(failure)); return }
|
|
481
|
+
} else {
|
|
482
|
+
// Retire only a session that was actually used. A rejected first request
|
|
483
|
+
// leaves its key available for the Host to supply valid history.
|
|
484
|
+
if (['CLAUDE_SESSION_HISTORY_CHANGED', 'CLAUDE_SESSION_HISTORY_UNSEEN'].includes(error.code)
|
|
485
|
+
&& (session.engine !== null || session.turns > 0)) {
|
|
486
|
+
await this.#retire(session, 'history-changed', false)
|
|
487
|
+
}
|
|
488
|
+
yield finishChunk('error', failureOf(error))
|
|
489
|
+
return
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
let engine
|
|
494
|
+
try {
|
|
495
|
+
engine = await this.#ensureEngine(session, identity, generation)
|
|
496
|
+
} catch (error) {
|
|
497
|
+
yield finishChunk('error', failureOf(error))
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
yield* this.#runTurn(session, engine, generation, projection, options)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Trusted identity shape check; the values themselves are Host state, never request state. */
|
|
505
|
+
#validateIdentity(identity) {
|
|
506
|
+
if (!identity || typeof identity !== 'object') return 'CLAUDE_IDENTITY_UNRESOLVED'
|
|
507
|
+
if (typeof identity.botId !== 'string' || !/^[A-Za-z0-9_.-]{1,128}$/.test(identity.botId)) return 'CLAUDE_IDENTITY_BOT_REQUIRED'
|
|
508
|
+
if (!identity.subject || typeof identity.subject !== 'object') return 'CLAUDE_IDENTITY_SUBJECT_REQUIRED'
|
|
509
|
+
if (!identity.authority || typeof identity.authority.assertModel !== 'function' || typeof identity.authority.describe !== 'function') return 'CLAUDE_IDENTITY_AUTHORITY_REQUIRED'
|
|
510
|
+
const binding = identity.binding
|
|
511
|
+
if (!binding || typeof binding !== 'object' || !identifier(binding.taskId) || !identifier(binding.attemptId)
|
|
512
|
+
|| typeof binding.machineId !== 'string' || !binding.machineId || typeof binding.workspaceId !== 'string' || !binding.workspaceId
|
|
513
|
+
|| !['local', 'remote'].includes(binding.location)) return 'CLAUDE_IDENTITY_BINDING_REQUIRED'
|
|
514
|
+
if (identity.persona !== undefined && identity.persona !== null && typeof identity.persona !== 'string') return 'CLAUDE_IDENTITY_PERSONA_INVALID'
|
|
515
|
+
if (identity.conversationId !== undefined && (typeof identity.conversationId !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/.test(identity.conversationId))) return 'CLAUDE_IDENTITY_CONVERSATION_INVALID'
|
|
516
|
+
return null
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** The binding one conversation key may not change underneath a retained session. */
|
|
520
|
+
#fingerprint(generation, identity) {
|
|
521
|
+
const revision = identity.authority.describe(identity.subject).revision
|
|
522
|
+
return JSON.stringify([
|
|
523
|
+
generation.provider, generation.model, identity.botId,
|
|
524
|
+
identity.conversationId ?? null,
|
|
525
|
+
identity.persona ?? this.#persona ?? null, revision,
|
|
526
|
+
])
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* The one engine of this session, created lazily. A creation already in flight is shared,
|
|
530
|
+
* so a concurrent second turn meets the engine's own single-turn fence rather than a
|
|
531
|
+
* second process. Disposal waits on these creations; nothing is created after close.
|
|
532
|
+
*/
|
|
533
|
+
async #ensureEngine(session, identity, generation) {
|
|
534
|
+
if (this.#closed) throw coded('CLAUDE_ADAPTER_CLOSED', '本路由已释放。')
|
|
535
|
+
if (session.engine && !session.engine.closed) return session.engine
|
|
536
|
+
if (session.creation) return session.creation
|
|
537
|
+
if (session.retired) {
|
|
538
|
+
throw coded('CLAUDE_SESSION_RETIRED',
|
|
539
|
+
`本会话的原生引擎已退休(${session.retired.reason}):结果未知的进程不会自动开新会话重放,请开启新会话。`)
|
|
540
|
+
}
|
|
541
|
+
// The pinned entry is re-read at creation: a Host re-pin that did not go through
|
|
542
|
+
// replaceRoute still refuses this generation instead of launching a different binary.
|
|
543
|
+
const current = this.#entryFor === null ? this.#entry : await this.#entryFor()
|
|
544
|
+
if (!entryEquals(current, generation.entry)) {
|
|
545
|
+
throw coded('CLAUDE_ROUTE_ENTRY_CHANGED', '宿主固定的安装引用在准备之后发生变化,这次调用被拒绝。')
|
|
546
|
+
}
|
|
547
|
+
const workspace = this.#workspaceFor === null
|
|
548
|
+
? this.#workspace
|
|
549
|
+
: await this.#workspaceFor({ botId: identity.botId, sessionId: session.sessionId })
|
|
550
|
+
if (typeof workspace !== 'string' || !isAbsolute(workspace)) throw coded('CLAUDE_ROUTE_WORKSPACE_REQUIRED', '宿主未提供绝对的工作区路径。')
|
|
551
|
+
const nativeTools = this.#toolsFor ? await this.#toolsFor(identity) : { tools: this.#tools, handlers: this.#handlers }
|
|
552
|
+
const config = {
|
|
553
|
+
authority: identity.authority,
|
|
554
|
+
subject: identity.subject,
|
|
555
|
+
binding: identity.binding,
|
|
556
|
+
operationId: `op-${randomBytes(8).toString('hex')}`,
|
|
557
|
+
entry: generation.entry,
|
|
558
|
+
runtimeRoot: this.#runtimeRoot,
|
|
559
|
+
workspace,
|
|
560
|
+
model: { catalogProvider: generation.provider, catalogModel: generation.model },
|
|
561
|
+
permissionMode: this.#permissionMode,
|
|
562
|
+
billing: generation.funding,
|
|
563
|
+
tools: nativeTools.tools,
|
|
564
|
+
handlers: nativeTools.handlers,
|
|
565
|
+
deadlineMs: this.#deadlineMs,
|
|
566
|
+
hooks: this.#hooks,
|
|
567
|
+
...(this.#onPermissionRequest ? { onPermissionRequest: this.#onPermissionRequest } : {}),
|
|
568
|
+
...(this.#provisionCredentials ? { provisionCredentials: home => this.#provisionCredentials(home, identity) } : {}),
|
|
569
|
+
...(this.#launcher ? { launcher: this.#launcher } : {}),
|
|
570
|
+
...(this.#python ? { python: this.#python } : {}),
|
|
571
|
+
// The persona travels to the engine as a Host-confirmed value. The current engine
|
|
572
|
+
// ignores unknown options; the engine system-prompt seam is the parent's wiring
|
|
573
|
+
// decision (see the bridge report's wiring.patch). It is never re-sent as turn text.
|
|
574
|
+
...(session.persona ? { systemPrompt: session.persona } : {}),
|
|
575
|
+
}
|
|
576
|
+
const creation = (async () => {
|
|
577
|
+
const engine = await this.#engineFactory(config)
|
|
578
|
+
this.#engines.add(engine)
|
|
579
|
+
engine.events.on('session', value => { if (value?.sessionId) session.lastSessionId = value.sessionId })
|
|
580
|
+
engine.events.on('tree-stopped', value => { session.evidence = value })
|
|
581
|
+
let activeActivity = null
|
|
582
|
+
const observe = value => {
|
|
583
|
+
if (!this.#onActivity) return
|
|
584
|
+
try {
|
|
585
|
+
if (!value || !['turn-open', 'tool', 'turn-close', 'stop-confirmed', 'cancel-requested'].includes(value.kind)
|
|
586
|
+
|| typeof value.turnId !== 'string' || typeof value.processGeneration !== 'string'
|
|
587
|
+
|| typeof identity.conversationId !== 'string') throw new Error('CLAUDE_ACTIVITY_IDENTITY_MISSING')
|
|
588
|
+
const scope = Object.freeze({ botId: identity.botId, mode: identity.independent ? 'independent' : 'integration',
|
|
589
|
+
conversationId: identity.conversationId, contextId: identity.independent ? identity.conversationId : null,
|
|
590
|
+
processGeneration: value.processGeneration, turnId: value.turnId })
|
|
591
|
+
if (value.kind === 'turn-open') activeActivity = { turnId: value.turnId, processGeneration: value.processGeneration }
|
|
592
|
+
const result = this.#onActivity(Object.freeze({ kind: value.kind, identity: scope,
|
|
593
|
+
...(value.kind === 'tool' ? { tool: Object.freeze({ phase: value.tool?.phase, id: value.tool?.id,
|
|
594
|
+
...(value.tool?.phase === 'started' ? { name: value.tool?.name } : { ok: value.tool?.ok }) }) } : {}),
|
|
595
|
+
...(value.kind === 'turn-close' ? { outcome: value.outcome } : {}) }))
|
|
596
|
+
if (result?.then) { result.catch?.(() => {}); throw new Error('CLAUDE_ACTIVITY_CALLBACK_MUST_BE_SYNCHRONOUS') }
|
|
597
|
+
} catch { this.#activityObserverFailures++ }
|
|
598
|
+
}
|
|
599
|
+
engine.events.on('activity', observe)
|
|
600
|
+
// Only the adapter's own current request signal asks for cancellation.
|
|
601
|
+
// The actual engine supplies separate supervisor-confirmed stop evidence.
|
|
602
|
+
session.requestActivityCancel = () => {
|
|
603
|
+
if (activeActivity && engine.snapshot?.().turn?.turnId === activeActivity.turnId) observe({ kind: 'cancel-requested', ...activeActivity })
|
|
604
|
+
}
|
|
605
|
+
return engine
|
|
606
|
+
})()
|
|
607
|
+
const tracked = creation.then(engine => {
|
|
608
|
+
this.#creations.delete(tracked)
|
|
609
|
+
session.creation = null
|
|
610
|
+
if (session.retired) {
|
|
611
|
+
// The session was retired while this engine was being created (a route re-pin, a
|
|
612
|
+
// binding change or a disposal). Stop what was just launched instead of storing an
|
|
613
|
+
// engine nobody may dispatch into.
|
|
614
|
+
this.#engines.delete(engine)
|
|
615
|
+
return engine.stop(`bridge-retire:${session.retired.reason}`).then(() => engine, () => engine)
|
|
616
|
+
}
|
|
617
|
+
session.engine = engine
|
|
618
|
+
return engine
|
|
619
|
+
}, error => { this.#creations.delete(tracked); session.creation = null; throw error })
|
|
620
|
+
this.#creations.add(tracked)
|
|
621
|
+
session.creation = tracked
|
|
622
|
+
return tracked
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Stop one session's native tree and record why; the engine's own settlement is awaited. */
|
|
626
|
+
async #retire(session, reason, resumable) {
|
|
627
|
+
const engine = session.engine
|
|
628
|
+
session.retired = Object.freeze({ reason, resumable: resumable === true, at: Date.now() })
|
|
629
|
+
session.engine = null
|
|
630
|
+
session.creation = null
|
|
631
|
+
if (!engine) return session.evidence ?? null
|
|
632
|
+
this.#engines.delete(engine)
|
|
633
|
+
try {
|
|
634
|
+
const proof = await engine.stop(`bridge-retire:${reason}`)
|
|
635
|
+
session.evidence = proof ?? { status: 'unknown', treeStopped: false }
|
|
636
|
+
if (engine.snapshot?.().launches === 0 && proof?.status === 'no-process' && !(proof.interruptedLaunches > 0)) {
|
|
637
|
+
session.evidence = { ...proof, notStarted: true, possiblyApplied: false }
|
|
638
|
+
}
|
|
639
|
+
return session.evidence
|
|
640
|
+
} catch (error) {
|
|
641
|
+
session.evidence = { status: 'unknown', treeStopped: false, reason: 'native-stop-failed' }
|
|
642
|
+
throw error
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* One turn: dispatch the projected new user text, stream the native answer as SDK chunks,
|
|
648
|
+
* and finish exactly once — after the engine's own Host settlement has completed.
|
|
649
|
+
*/
|
|
650
|
+
async *#runTurn(session, engine, generation, projection, options) {
|
|
651
|
+
const queue = []
|
|
652
|
+
const started = { text: false, reasoning: false }
|
|
653
|
+
const facts = { text: '', reasoning: '', toolCalls: 0, assistantText: null }
|
|
654
|
+
let wake = null
|
|
655
|
+
const wakeUp = () => { if (wake) { const resolve = wake; wake = null; resolve() } }
|
|
656
|
+
const push = chunk => { queue.push(chunk); wakeUp() }
|
|
657
|
+
const onDelta = value => {
|
|
658
|
+
if (value?.streamKind === 'assistant_text' && typeof value.delta === 'string') {
|
|
659
|
+
if (!started.text) { started.text = true; push({ type: 'block-start', index: 0, blockType: 'text' }) }
|
|
660
|
+
facts.text += value.delta
|
|
661
|
+
push({ type: 'text-delta', index: 0, text: value.delta })
|
|
662
|
+
return
|
|
663
|
+
}
|
|
664
|
+
if (value?.streamKind === 'reasoning_text' && typeof value.delta === 'string') {
|
|
665
|
+
if (!started.reasoning) { started.reasoning = true; push({ type: 'block-start', index: 1, blockType: 'reasoning' }) }
|
|
666
|
+
facts.reasoning += value.delta
|
|
667
|
+
push({ type: 'reasoning-delta', index: 1, text: value.delta })
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
const onAssistant = value => {
|
|
671
|
+
// A CLI that emits no partial messages still publishes its whole assistant message:
|
|
672
|
+
// use it only when no delta arrived, so text is never counted twice.
|
|
673
|
+
if (facts.text === '' && typeof value?.text === 'string' && value.text) facts.assistantText = value.text
|
|
674
|
+
}
|
|
675
|
+
const onTool = value => { if (value?.phase === 'started') facts.toolCalls++ }
|
|
676
|
+
engine.events.on('delta', onDelta)
|
|
677
|
+
engine.events.on('assistant', onAssistant)
|
|
678
|
+
engine.events.on('tool', onTool)
|
|
679
|
+
|
|
680
|
+
// The retained process may have exited while the previous turn was settling, before the
|
|
681
|
+
// supervisor reported it. Writing the prompt into that pipe loses the turn silently, so
|
|
682
|
+
// wait (bounded) for the engine to notice before dispatching. A live process, or none at
|
|
683
|
+
// all, dispatches immediately.
|
|
684
|
+
await awaitRetiredProcessNotice(engine)
|
|
685
|
+
|
|
686
|
+
let outcome = null
|
|
687
|
+
const submission = engine.submitTurn({ text: projection.text }).then(
|
|
688
|
+
result => { outcome = { result }; wakeUp() },
|
|
689
|
+
error => { outcome = { error }; wakeUp() },
|
|
690
|
+
)
|
|
691
|
+
submission.catch(() => {})
|
|
692
|
+
// A retained process that exits after its result is only noticed when the supervisor
|
|
693
|
+
// reports the stop, which can land after the turn promise already resolved. If the
|
|
694
|
+
// engine never settles this dispatched turn because of that death, the prompt is lost
|
|
695
|
+
// without any outcome — so watch for it and stop the tree, which settles the turn as the
|
|
696
|
+
// unknown outcome it really is instead of waiting for the turn timer.
|
|
697
|
+
const launchMark = engine.snapshot?.().launches ?? null
|
|
698
|
+
let processLost = false
|
|
699
|
+
const watcher = setInterval(() => {
|
|
700
|
+
const snap = engine.snapshot?.()
|
|
701
|
+
if (!snap || processLost) return
|
|
702
|
+
if (snap.process !== null || snap.launches !== launchMark) return
|
|
703
|
+
const turn = snap.turn
|
|
704
|
+
if (!turn || turn.settled === true || turn.dispatched !== true) return
|
|
705
|
+
processLost = true
|
|
706
|
+
void engine.stop('bridge-process-lost')
|
|
707
|
+
}, 100)
|
|
708
|
+
watcher.unref?.()
|
|
709
|
+
const onAbort = () => { if (!outcome) { session.requestActivityCancel?.(); outcome = { aborted: true }; wakeUp() } }
|
|
710
|
+
if (options.signal) {
|
|
711
|
+
if (options.signal.aborted) onAbort()
|
|
712
|
+
else options.signal.addEventListener('abort', onAbort, { once: true })
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
try {
|
|
716
|
+
while (queue.length || outcome === null) {
|
|
717
|
+
while (queue.length) yield queue.shift()
|
|
718
|
+
if (outcome !== null) break
|
|
719
|
+
await new Promise(resolve => { wake = resolve })
|
|
720
|
+
}
|
|
721
|
+
while (queue.length) yield queue.shift()
|
|
722
|
+
|
|
723
|
+
if (outcome.aborted === true) {
|
|
724
|
+
// Cancellation stops the native tree first and waits for the engine's own
|
|
725
|
+
// settlement, so the turn's outcome is observed — never a guessed success.
|
|
726
|
+
const evidence = await engine.stop('turn-cancelled')
|
|
727
|
+
await submission
|
|
728
|
+
await this.#retire(session, 'turn-cancelled', false)
|
|
729
|
+
yield finishChunk('aborted', {
|
|
730
|
+
code: 'CLAUDE_TURN_CANCELLED',
|
|
731
|
+
message: `本轮已取消,原生进程树已停止(${evidence?.status ?? 'unknown'})。`,
|
|
732
|
+
})
|
|
733
|
+
return
|
|
734
|
+
}
|
|
735
|
+
if (outcome.error) {
|
|
736
|
+
// A submit refusal (busy, stopped) is surfaced as it is; only a stopped engine
|
|
737
|
+
// makes the session itself unusable.
|
|
738
|
+
if (engine.closed) await this.#retire(session, 'engine-stopped', false)
|
|
739
|
+
yield finishChunk('error', failureOf(outcome.error))
|
|
740
|
+
return
|
|
741
|
+
}
|
|
742
|
+
const result = outcome.result
|
|
743
|
+
// Only a turn the engine proves it wrote to the CLI's stdin advances the session's
|
|
744
|
+
// delivered history: a provably undelivered turn is offered again by the next call.
|
|
745
|
+
if (result?.dispatched === true) session.state = projection.state
|
|
746
|
+
session.turns++
|
|
747
|
+
session.usageStatus = result?.usageStatus ?? 'unknown'
|
|
748
|
+
if (result?.ok !== true && (engine.closed || RETIRING_STOP_REASONS.includes(result.stopReason))) {
|
|
749
|
+
// The native session cannot continue after this outcome; retiring it here keeps the
|
|
750
|
+
// next call from silently opening a fresh session that lost the conversation.
|
|
751
|
+
await this.#retire(session, `native-${result.stopReason ?? 'failed'}`, false)
|
|
752
|
+
}
|
|
753
|
+
if (started.reasoning) {
|
|
754
|
+
yield { type: 'block-end', index: 1, block: { type: 'reasoning', text: facts.reasoning } }
|
|
755
|
+
}
|
|
756
|
+
if (!started.text && typeof facts.assistantText === 'string') {
|
|
757
|
+
// No partial messages from this CLI: publish the whole assistant message as one
|
|
758
|
+
// block instead of dropping the answer.
|
|
759
|
+
yield { type: 'block-start', index: 0, blockType: 'text' }
|
|
760
|
+
yield { type: 'text-delta', index: 0, text: facts.assistantText }
|
|
761
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text: facts.assistantText } }
|
|
762
|
+
facts.text = facts.assistantText
|
|
763
|
+
} else if (started.text) {
|
|
764
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text: facts.text } }
|
|
765
|
+
}
|
|
766
|
+
const usage = usageChunkFromTurn(result)
|
|
767
|
+
if (usage) yield { type: 'usage', usage }
|
|
768
|
+
const reason = finishReasonFromTurn(result, { textChars: facts.text.length })
|
|
769
|
+
if (processLost && reason.kind !== 'stop') {
|
|
770
|
+
reason.failure = {
|
|
771
|
+
code: 'CLAUDE_SESSION_PROCESS_LOST',
|
|
772
|
+
message: '保留会话的原生进程在本轮投递之后消失且没有结算:本轮结果未知,进程树已停止、会话已退休。',
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
yield {
|
|
776
|
+
type: 'finish',
|
|
777
|
+
reason,
|
|
778
|
+
replayState: { response: Object.freeze({
|
|
779
|
+
...turnFacts(result), sessionId: session.lastSessionId, provider: generation.provider, model: generation.model,
|
|
780
|
+
toolCallsObserved: facts.toolCalls, reasoningChars: facts.reasoning.length,
|
|
781
|
+
provenance: 'the CLI init frame names the configured model; the protocol does not prove the response backend',
|
|
782
|
+
}) },
|
|
783
|
+
}
|
|
784
|
+
} finally {
|
|
785
|
+
clearInterval(watcher)
|
|
786
|
+
engine.events.off('delta', onDelta)
|
|
787
|
+
engine.events.off('assistant', onAssistant)
|
|
788
|
+
engine.events.off('tool', onTool)
|
|
789
|
+
if (options.signal) options.signal.removeEventListener('abort', onAbort)
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Host-facing constructor. See {@link ClaudeLlmAdapter} for the trusted option set. */
|
|
795
|
+
export function createClaudeLlmAdapter(options) {
|
|
796
|
+
return new ClaudeLlmAdapter(options)
|
|
797
|
+
}
|