@ljwei-stak/dsh-model-route-bot 0.66.4 → 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 +18 -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/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,772 @@
|
|
|
1
|
+
// Claude Code conversation-session engine adapter.
|
|
2
|
+
//
|
|
3
|
+
// Ported and re-grounded from OpenMausBot server/drivers/claude.ts at pin
|
|
4
|
+
// 1bc4749aa8307d7d6998bf86de4ef6478460dd93 (Apache-2.0); see licenses/NOTICE.md.
|
|
5
|
+
// Upstream behavior kept: stream-json both ways, one retained CLI process per session,
|
|
6
|
+
// each turn settled by the CLI's own `result` frame, `init` confirming session id /
|
|
7
|
+
// model / permission mode, permission asks over a local ask/answer broker with fail-closed
|
|
8
|
+
// semantics, total_cost_usd treated as a running total, --resume on relaunch, and an error
|
|
9
|
+
// result before init on a resumed process meaning the session was refused.
|
|
10
|
+
//
|
|
11
|
+
// Target seams used (all read-only, same repository):
|
|
12
|
+
// - nativeProcess(helper,'supervise',…) private launcher with Windows job-object tree-stop evidence
|
|
13
|
+
// - privateEnvironment isolated home; protectProfile ACL
|
|
14
|
+
// - createCliBootstrap committed-grant MCP tool bridge
|
|
15
|
+
// - createAuthority grant revision/model assertions (guard interval, like codex-session-core)
|
|
16
|
+
//
|
|
17
|
+
// Policy: every dependency is trusted Host state. Caller-submitted JSON may only carry a
|
|
18
|
+
// message text (submitTurn) or a permission decision (respondToRequest). Authority,
|
|
19
|
+
// environment, executable paths, arguments and auto-approval can never arrive through
|
|
20
|
+
// those payloads. Usage/cost figures that the CLI does not report stay null; money flows
|
|
21
|
+
// only to the unique Host hooks (onSettle/onClose) — this adapter keeps no second ledger.
|
|
22
|
+
//
|
|
23
|
+
// Turn lifecycle fence (this port's hardening beyond upstream):
|
|
24
|
+
// - submitTurn reserves the single turn slot synchronously, before its first await:
|
|
25
|
+
// a same-tick second submit is refused busy with zero admissions and zero launches.
|
|
26
|
+
// The slot is held across admission, launch, dispatch, result and Host settlement;
|
|
27
|
+
// only a completed settlement (or an admission refusal, which proves nothing was
|
|
28
|
+
// dispatched) frees it for the next turn.
|
|
29
|
+
// - The public turn promise and the `turn` event complete only after the unique Host
|
|
30
|
+
// settlement callback (onSettle) ran. A settlement failure rejects with the native
|
|
31
|
+
// evidence attached, funding stays unknown, and the engine stops: no next turn, no
|
|
32
|
+
// fresh process pretending success, never a replay.
|
|
33
|
+
// - Post-admission failures settle exactly once. Failures provably before the prompt
|
|
34
|
+
// was written to the CLI's stdin settle `not-run`; a failed stdin write is `unknown`
|
|
35
|
+
// (may-dispatch). Every receipt binds its turnId and operationId; no second ledger.
|
|
36
|
+
// - stop() ends the native tree first, independent of any Host hook; real tree-stop
|
|
37
|
+
// evidence comes only from the supervisor's accounting. stop() completes only after
|
|
38
|
+
// settlement ran — `tree-stopped` carries the supervisor evidence before that wait —
|
|
39
|
+
// and a pending settlement stays observable in snapshot().
|
|
40
|
+
// - hooks.onObserve is an observation hook, not the ledger: its failures are counted
|
|
41
|
+
// and emitted (`observe-failed`), never silently swallowed, and are never themselves
|
|
42
|
+
// a turn or financial outcome.
|
|
43
|
+
|
|
44
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
45
|
+
import { spawn } from 'node:child_process'
|
|
46
|
+
import { EventEmitter } from 'node:events'
|
|
47
|
+
import { mkdir, readFile, realpath, stat, writeFile } from 'node:fs/promises'
|
|
48
|
+
import { isAbsolute, join } from 'node:path'
|
|
49
|
+
import { fileURLToPath } from 'node:url'
|
|
50
|
+
import { capabilityProbe, nativeProcess, privateEnvironment, protectProfile } from '../../cli-runtime/src/native-process.mjs'
|
|
51
|
+
import { createCliBootstrap } from '../../capability-broker/src/cli-bootstrap.mjs'
|
|
52
|
+
import {
|
|
53
|
+
brokerAnswer, classifyClaudeFrame, claudeResultError, claudeUserMessage,
|
|
54
|
+
sumNativeTurnResults, turnCostFromRunningTotal, validTurnText,
|
|
55
|
+
} from './claude-protocol.mjs'
|
|
56
|
+
import { brokerPipeCandidates, createPermissionBroker } from './claude-permission-broker.mjs'
|
|
57
|
+
|
|
58
|
+
const PROXY_PATH = fileURLToPath(new URL('./claude-permission-proxy.mjs', import.meta.url))
|
|
59
|
+
const digest = value => createHash('sha256').update(value).digest('hex')
|
|
60
|
+
const identifier = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value)
|
|
61
|
+
const DEFAULT_LAUNCHER = Object.freeze({ probe: capabilityProbe, protect: protectProfile, supervise: nativeProcess })
|
|
62
|
+
|
|
63
|
+
/** Defense in depth; --tools '' is the closed built-in tool set, including future tools. */
|
|
64
|
+
export const DISALLOWED_NATIVE_TOOLS = Object.freeze(['Bash', 'PowerShell', 'Read', 'Glob', 'Grep', 'Write', 'Edit', 'WebFetch', 'WebSearch', 'BashOutput', 'KillShell', 'KillBash', 'NotebookEdit', 'Task', 'Agent', 'Skill'])
|
|
65
|
+
export const ENGINE_PERMISSION_MODES = Object.freeze(['default', 'acceptEdits', 'auto'])
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Caller supplies an installation-attested absolute native entry and immutable digest.
|
|
69
|
+
* `script` (optional, also absolute and digest-pinned) covers interpreter-plus-script
|
|
70
|
+
* installs — e.g. a node-based CLI pinned as `node … cli.js` — mirroring upstream's
|
|
71
|
+
* configured executable. Both halves are Host state: a turn payload can never name them.
|
|
72
|
+
*/
|
|
73
|
+
export async function pinClaudeEntry({ executable, sha256, version, script, scriptSha256 }, home) {
|
|
74
|
+
if (!isAbsolute(executable ?? '') || !/^[a-f0-9]{64}$/.test(sha256 ?? '') || typeof version !== 'string' || !/^[0-9][A-Za-z0-9.+_-]{0,31}$/.test(version)) throw new TypeError('PINNED_ENGINE_ENTRY_REQUIRED')
|
|
75
|
+
if (script !== undefined && (!isAbsolute(script) || !/^[a-f0-9]{64}$/.test(scriptSha256 ?? ''))) throw new TypeError('PINNED_ENGINE_SCRIPT_REQUIRED')
|
|
76
|
+
const path = await realpath(executable)
|
|
77
|
+
if (!(await stat(path)).isFile() || (process.platform === 'win32' && !path.toLowerCase().endsWith('.exe'))) throw new Error('ENGINE_ENTRY_REQUIRED')
|
|
78
|
+
if (digest(await readFile(path)) !== sha256) throw new Error('ENGINE_ENTRY_CHANGED')
|
|
79
|
+
let scriptPath = null
|
|
80
|
+
if (script !== undefined) {
|
|
81
|
+
scriptPath = await realpath(script)
|
|
82
|
+
if (!(await stat(scriptPath)).isFile() || digest(await readFile(scriptPath)) !== scriptSha256) throw new Error('ENGINE_ENTRY_SCRIPT_CHANGED')
|
|
83
|
+
}
|
|
84
|
+
const reported = await new Promise((resolve, reject) => {
|
|
85
|
+
const child = spawn(path, scriptPath ? [scriptPath, '--version'] : ['--version'], { cwd: home, env: privateEnvironment(home), windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
86
|
+
let output = '', error = ''
|
|
87
|
+
const timer = setTimeout(() => { child.kill(); reject(new Error('ENGINE_VERSION_TIMEOUT')) }, 15_000)
|
|
88
|
+
child.stdout.on('data', chunk => { output += chunk.toString('utf8'); if (output.length > 1024) child.kill() })
|
|
89
|
+
child.stderr.on('data', chunk => { error = (error + chunk.toString('utf8')).slice(-1024) })
|
|
90
|
+
child.once('error', rejectError => { clearTimeout(timer); reject(rejectError) })
|
|
91
|
+
child.once('close', code => { clearTimeout(timer); if (code === 0) resolve(output.trim()); else reject(new Error(`ENGINE_VERSION_FAILED: ${error.slice(0, 128)}`)) })
|
|
92
|
+
})
|
|
93
|
+
if (!reported.includes(version)) throw new Error(`ENGINE_VERSION_MISMATCH: expected ${version} in ${JSON.stringify(reported.slice(0, 80))}`)
|
|
94
|
+
return Object.freeze({ path, sha256, version, versionOutput: reported, script: scriptPath, scriptSha256: script !== undefined ? scriptSha256 : null })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {object} options — every value is trusted Host state.
|
|
99
|
+
* @param {object} options.authority capability-broker authority (subject grants)
|
|
100
|
+
* @param {object} options.subject registered authority subject
|
|
101
|
+
* @param {object} options.binding { taskId, attemptId, machineId, workspaceId, location }
|
|
102
|
+
* @param {string} options.operationId unique operation identity
|
|
103
|
+
* @param {{executable:string, sha256:string, version:string, script?:string, scriptSha256?:string}} options.entry pinned native entry (script: optional digest-pinned interpreter script, e.g. a node CLI's cli.js)
|
|
104
|
+
* @param {string} options.runtimeRoot isolated absolute runtime root
|
|
105
|
+
* @param {string} options.workspace absolute conversation workspace
|
|
106
|
+
* @param {{catalogProvider?:string, catalogModel:string}} options.model requested model identity
|
|
107
|
+
* @param {string} [options.permissionMode='default'] Host-chosen Claude permission mode (bypassPermissions is refused: this port never auto-approves)
|
|
108
|
+
* @param {string} [options.billing='subscription'] billing evidence label
|
|
109
|
+
* @param {string[]} [options.tools] committed-grant tool names bridged over MCP
|
|
110
|
+
* @param {Array<{definition:{name}, parse:Function, execute:Function}>} [options.handlers] bridge handlers for `tools`
|
|
111
|
+
* @param {number} [options.deadlineMs=120000] bounded lifetime for the whole session
|
|
112
|
+
* @param {AbortSignal} [options.signal] host cancellation
|
|
113
|
+
* @param {{onBeforeRequest?:Function, onObserve?:Function, onSettle?:Function, onClose?:Function}} [options.hooks] the only funding/ledger recipients
|
|
114
|
+
* @param {Function} [options.onPermissionRequest] host permission callback (ask) => {behavior, message?}; absent means every forwarded ask is denied
|
|
115
|
+
* @param {Function} [options.provisionCredentials] (home) => Promise writing credentials into the isolated home
|
|
116
|
+
* @param {{probe?:Function, protect?:Function, supervise?:Function}} [options.launcher] process-launcher seam (tests may substitute)
|
|
117
|
+
*/
|
|
118
|
+
export async function createClaudeEngine(options) {
|
|
119
|
+
const {
|
|
120
|
+
authority, subject, binding, operationId,
|
|
121
|
+
entry, runtimeRoot, workspace,
|
|
122
|
+
model = {}, permissionMode = 'default', billing = 'subscription',
|
|
123
|
+
tools = [], handlers = [], systemPrompt = null,
|
|
124
|
+
deadlineMs = 120_000, signal,
|
|
125
|
+
hooks = {}, onPermissionRequest,
|
|
126
|
+
provisionCredentials = null,
|
|
127
|
+
launcher = DEFAULT_LAUNCHER, python,
|
|
128
|
+
} = options ?? {}
|
|
129
|
+
if (!authority || typeof authority.describe !== 'function' || typeof authority.assertModel !== 'function') throw new TypeError('ENGINE_AUTHORITY_REQUIRED')
|
|
130
|
+
if (!subject || typeof subject !== 'object') throw new TypeError('ENGINE_SUBJECT_REQUIRED')
|
|
131
|
+
if (!identifier(operationId)) throw new TypeError('INVALID_ENGINE_OPERATION')
|
|
132
|
+
if (!isAbsolute(runtimeRoot ?? '') || !isAbsolute(workspace ?? '')) throw new TypeError('INVALID_ENGINE_PATHS')
|
|
133
|
+
const catalogProvider = model.catalogProvider ?? 'anthropic'
|
|
134
|
+
const catalogModel = model.catalogModel
|
|
135
|
+
if (typeof catalogProvider !== 'string' || !catalogProvider || typeof catalogModel !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,99}$/.test(catalogModel)) throw new TypeError('INVALID_ENGINE_MODEL')
|
|
136
|
+
if (!ENGINE_PERMISSION_MODES.includes(permissionMode)) throw new TypeError(`ENGINE_PERMISSION_MODE_REFUSED: ${JSON.stringify(permissionMode)} (this port never auto-approves; bypassPermissions is not available)`)
|
|
137
|
+
if (!['subscription', 'api', 'unknown'].includes(billing)) throw new TypeError('INVALID_ENGINE_BILLING')
|
|
138
|
+
if (systemPrompt !== null && (typeof systemPrompt !== 'string' || !systemPrompt.trim() || systemPrompt.includes('\0') || Buffer.byteLength(systemPrompt) > 32_000)) throw new TypeError('INVALID_ENGINE_SYSTEM_PROMPT')
|
|
139
|
+
if (!Number.isSafeInteger(deadlineMs) || deadlineMs < 1000 || deadlineMs > 7_200_000) throw new TypeError('INVALID_ENGINE_DEADLINE')
|
|
140
|
+
for (const key of ['onBeforeRequest', 'onObserve', 'onSettle', 'onClose']) if (hooks[key] !== undefined && typeof hooks[key] !== 'function') throw new TypeError(`ENGINE_HOOK_${key.toUpperCase()}_REQUIRED_FUNCTION`)
|
|
141
|
+
if (onPermissionRequest !== undefined && typeof onPermissionRequest !== 'function') throw new TypeError('ENGINE_PERMISSION_CALLBACK_REQUIRED_FUNCTION')
|
|
142
|
+
if (provisionCredentials !== null && typeof provisionCredentials !== 'function') throw new TypeError('ENGINE_CREDENTIAL_PROVIDER_REQUIRED_FUNCTION')
|
|
143
|
+
if (!Array.isArray(tools) || tools.some(name => typeof name !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(name)) || new Set(tools).size !== tools.length) throw new TypeError('INVALID_ENGINE_TOOLS')
|
|
144
|
+
if (!Array.isArray(handlers) || handlers.some(handler => !handler?.definition?.name || typeof handler.parse !== 'function' || typeof handler.execute !== 'function')) throw new TypeError('INVALID_ENGINE_HANDLERS')
|
|
145
|
+
if (new Set(handlers.map(handler => handler.definition.name)).size !== handlers.length || tools.some(name => !handlers.some(handler => handler.definition.name === name))) throw new TypeError('UNKNOWN_ENGINE_BRIDGE_HANDLER')
|
|
146
|
+
|
|
147
|
+
authority.assertModel(subject, catalogProvider, catalogModel)
|
|
148
|
+
const { botId, revision } = authority.describe(subject)
|
|
149
|
+
signal?.throwIfAborted()
|
|
150
|
+
|
|
151
|
+
const cwd = await realpath(workspace)
|
|
152
|
+
if (!(await stat(cwd)).isDirectory()) throw new Error('INVALID_WORKSPACE')
|
|
153
|
+
await mkdir(runtimeRoot, { recursive: true, mode: 0o700 })
|
|
154
|
+
const root = await realpath(runtimeRoot)
|
|
155
|
+
const home = join(root, `e-${operationId}`)
|
|
156
|
+
await mkdir(home, { mode: 0o700 })
|
|
157
|
+
for (const folder of ['tmp', 'cache', 'config', 'data', 'appdata', 'local', 'b']) await mkdir(join(home, folder), { recursive: true, mode: 0o700 })
|
|
158
|
+
const probe = await launcher.probe({ home, python })
|
|
159
|
+
if (!probe?.available) throw new Error(`NATIVE_PEER_UNAVAILABLE: ${probe?.reason ?? 'unknown'}`)
|
|
160
|
+
await launcher.protect(probe.helper, home)
|
|
161
|
+
const pinned = await pinClaudeEntry(entry, home)
|
|
162
|
+
// Host-confirmed persona uses the CLI's own system slot. Keep its contents
|
|
163
|
+
// in the protected private home rather than process arguments or turn text.
|
|
164
|
+
const systemPromptFile = systemPrompt === null ? null : join(home, 'system-prompt.txt')
|
|
165
|
+
if (systemPromptFile) await writeFile(systemPromptFile, systemPrompt, { mode: 0o600, flag: 'wx' })
|
|
166
|
+
const grantedTools = new Set(tools)
|
|
167
|
+
const deadlineEnd = Date.now() + deadlineMs
|
|
168
|
+
|
|
169
|
+
const events = new EventEmitter()
|
|
170
|
+
const stats = { asks: 0, forwarded: 0, autoDeniedUnsupported: 0, duplicateDenied: 0, timeoutDenied: 0, hostDenied: 0, hostAllowed: 0, hostAnswered: 0 }
|
|
171
|
+
const frames = { init: 0, assistant: 0, results: 0, ignored: 0 }
|
|
172
|
+
let currentProcess = null, currentTurn = null, pendingLaunch = null, stopPromise = null, stopped = false, fatalError = null
|
|
173
|
+
let turnsDispatched = 0, launches = 0, resumeRefused = false, strayResults = 0, observeHookFailures = 0
|
|
174
|
+
let confirmedSessionId = null, initModel = null, identityMismatch = false, stopRequestedAt = null
|
|
175
|
+
let settleChain = Promise.resolve()
|
|
176
|
+
let guardTimer = null, deadlineTimer = null
|
|
177
|
+
let lastActivity = null
|
|
178
|
+
// Display observations have their own channel. They can never dispatch tools
|
|
179
|
+
// or alter the unique Host settlement, even when a display sink fails.
|
|
180
|
+
const activity = (identity, kind, facts = {}) => {
|
|
181
|
+
if (!identity) return
|
|
182
|
+
try { events.emit('activity', Object.freeze({ kind, ...identity, ...facts })) }
|
|
183
|
+
catch {
|
|
184
|
+
observeHookFailures++
|
|
185
|
+
try { events.emit('observe-failed', { kind: 'activity', error: 'NATIVE_ACTIVITY_OBSERVER_FAILED' }) } catch {}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const fatal = (error, reason) => {
|
|
190
|
+
if (stopped) return
|
|
191
|
+
fatalError = error
|
|
192
|
+
void stop(reason ?? 'engine-fatal')
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- permission broker --------------------------------------------------
|
|
196
|
+
const denyUnsupported = (ask, note) => {
|
|
197
|
+
stats.autoDeniedUnsupported++
|
|
198
|
+
events.emit('permission-resolved', { ask, behavior: 'deny', source: 'engine', reason: note })
|
|
199
|
+
return note
|
|
200
|
+
}
|
|
201
|
+
const broker = await createPermissionBroker({
|
|
202
|
+
pipeCandidates: brokerPipeCandidates(home),
|
|
203
|
+
isActive: () => !stopped && currentTurn !== null && !currentTurn.settled,
|
|
204
|
+
onAsk: ask => {
|
|
205
|
+
stats.asks++
|
|
206
|
+
events.emit('permission-ask', { ask })
|
|
207
|
+
if (ask.kind === 'question') {
|
|
208
|
+
if (!onPermissionRequest) { stats.hostDenied++; return }
|
|
209
|
+
stats.forwarded++
|
|
210
|
+
void Promise.resolve(onPermissionRequest(ask)).then(decision => {
|
|
211
|
+
if (!decision || typeof decision.behavior !== 'string') throw new TypeError('INVALID_PERMISSION_DECISION')
|
|
212
|
+
if (decision.behavior === 'answer') { if (typeof decision.message !== 'string') throw new TypeError('ANSWER_MESSAGE_REQUIRED') }
|
|
213
|
+
else if (decision.behavior !== 'deny') throw new TypeError('INVALID_PERMISSION_BEHAVIOR')
|
|
214
|
+
if (!broker.answer(ask.id, decision.behavior, decision.message)) stats.hostDenied++
|
|
215
|
+
}).catch(() => {
|
|
216
|
+
stats.hostDenied++
|
|
217
|
+
broker.answer(ask.id, 'deny', 'Host permission callback failed.')
|
|
218
|
+
})
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
// Permission asks: only committed-grant bridge tools can ever be allowed.
|
|
222
|
+
const bridged = /^mcp__dsh_task__([A-Za-z0-9_-]{1,64})$/.exec(ask.tool)
|
|
223
|
+
if (!bridged || !grantedTools.has(bridged[1])) {
|
|
224
|
+
// Answer directly on the wire: the broker only knows pending ids it created.
|
|
225
|
+
broker.answer(ask.id, 'deny', `Host: tool ${ask.tool} is not in this engine's committed grants.`)
|
|
226
|
+
denyUnsupported(ask, 'unsupported-tool')
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
if (!onPermissionRequest) { stats.hostDenied++; broker.answer(ask.id, 'deny', 'Host: no permission callback is configured.'); return }
|
|
230
|
+
stats.forwarded++
|
|
231
|
+
void Promise.resolve(onPermissionRequest(ask)).then(decision => {
|
|
232
|
+
if (!decision || typeof decision.behavior !== 'string') throw new TypeError('INVALID_PERMISSION_DECISION')
|
|
233
|
+
if (decision.behavior !== 'allow' && decision.behavior !== 'deny') throw new TypeError('INVALID_PERMISSION_BEHAVIOR')
|
|
234
|
+
if (broker.answer(ask.id, decision.behavior, typeof decision.message === 'string' ? decision.message : undefined)) {
|
|
235
|
+
if (decision.behavior === 'allow') stats.hostAllowed++; else stats.hostDenied++
|
|
236
|
+
} else stats.hostDenied++
|
|
237
|
+
}).catch(() => {
|
|
238
|
+
stats.hostDenied++
|
|
239
|
+
broker.answer(ask.id, 'deny', 'Host permission callback failed.')
|
|
240
|
+
})
|
|
241
|
+
},
|
|
242
|
+
onResolve: resolved => {
|
|
243
|
+
if (resolved.source === 'timeout') stats.timeoutDenied++
|
|
244
|
+
events.emit('permission-resolved', { ask: resolved, behavior: resolved.behavior, source: resolved.source })
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// ---- turn settlement ----------------------------------------------------
|
|
249
|
+
// Settles THE GIVEN TURN (never "whatever is current"): a late callback from an
|
|
250
|
+
// older generation cannot settle a newer turn. The turn slot stays reserved until
|
|
251
|
+
// the Host settlement callback completed; the public promise and the `turn` event
|
|
252
|
+
// fire only afterwards. A settlement failure rejects with the native evidence
|
|
253
|
+
// attached and stops the engine — it is never returned as success and never
|
|
254
|
+
// swallowed. Exactly one settlement runs per admitted turn.
|
|
255
|
+
const settleTurn = (turn, native, hostCause) => {
|
|
256
|
+
if (!turn || turn.settled) return false
|
|
257
|
+
turn.settled = true
|
|
258
|
+
turn.toolController.abort(new Error('ENGINE_TURN_ENDED'))
|
|
259
|
+
turn.phase = 'settling'
|
|
260
|
+
clearTimeout(turn.timer)
|
|
261
|
+
const turnProcess = turn.process
|
|
262
|
+
let ok = native.ok
|
|
263
|
+
let stopReason = native.stopReason
|
|
264
|
+
let outcome
|
|
265
|
+
let costUsd = null
|
|
266
|
+
if (hostCause) {
|
|
267
|
+
ok = false
|
|
268
|
+
stopReason = hostCause.stopReason
|
|
269
|
+
outcome = hostCause.outcome
|
|
270
|
+
} else {
|
|
271
|
+
costUsd = turnCostFromRunningTotal(native.cost ?? null, turnProcess?.costTotal ?? null)
|
|
272
|
+
if (typeof native.cost === 'number' && turnProcess) turnProcess.costTotal = native.cost
|
|
273
|
+
outcome = ok ? (confirmedSessionId ? 'succeeded' : 'unknown') : 'failed'
|
|
274
|
+
}
|
|
275
|
+
const turnResult = {
|
|
276
|
+
schemaVersion: 1, turnId: turn.turnId, operationId, driver: 'claude', ok, outcome, stopReason,
|
|
277
|
+
sessionId: confirmedSessionId, usage: native.usage ?? null,
|
|
278
|
+
usageStatus: native.usage && native.usage.input !== null && native.usage.output !== null ? 'reported' : 'unknown',
|
|
279
|
+
costUsd, costStatus: costUsd === null ? 'unknown' : 'cli-reported-running-total-delta',
|
|
280
|
+
billing, turnsDispatched,
|
|
281
|
+
dispatched: turn.dispatched === true,
|
|
282
|
+
errorText: native.isError && native.errors?.length ? claudeResultError(native) : hostCause?.errorText ?? null,
|
|
283
|
+
stopRequestedAt: stopRequestedAt !== null ? stopRequestedAt : null,
|
|
284
|
+
}
|
|
285
|
+
turn.result = turnResult
|
|
286
|
+
const releaseSlot = () => { if (currentTurn === turn) currentTurn = null }
|
|
287
|
+
settleChain = settleChain.then(async () => {
|
|
288
|
+
try {
|
|
289
|
+
await hooks.onSettle?.(turnResult)
|
|
290
|
+
} catch (error) {
|
|
291
|
+
// The ledger settlement failed. This turn is NOT successful and the failure
|
|
292
|
+
// is not swallowed: the rejection carries the native evidence, funding
|
|
293
|
+
// stays unknown (the CLI really may have run), and the engine stops so no
|
|
294
|
+
// next turn or fresh process can pretend success. Nothing is replayed.
|
|
295
|
+
fatalError = error instanceof Error ? error : new Error(String(error))
|
|
296
|
+
releaseSlot()
|
|
297
|
+
const settleError = new Error(`ENGINE_SETTLE_HOOK_FAILED: ${fatalError.message}`)
|
|
298
|
+
settleError.turnResult = turnResult
|
|
299
|
+
settleError.fundingStatus = 'unknown'
|
|
300
|
+
events.emit('turn-settle-failed', { turnResult, error: fatalError })
|
|
301
|
+
activity(turn.activity, 'turn-close', { outcome: 'unknown' })
|
|
302
|
+
turn.reject(settleError)
|
|
303
|
+
void stop('settle-hook-failed')
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
releaseSlot()
|
|
307
|
+
turn.phase = 'settled'
|
|
308
|
+
activity(turn.activity, 'turn-close', { outcome: turnResult.outcome === 'not-run' ? 'refused' : turnResult.outcome })
|
|
309
|
+
events.emit('turn', turnResult)
|
|
310
|
+
turn.resolve(turnResult)
|
|
311
|
+
})
|
|
312
|
+
return true
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const assertDispatchAuthorization = () => {
|
|
316
|
+
signal?.throwIfAborted()
|
|
317
|
+
authority.assertModel(subject, catalogProvider, catalogModel)
|
|
318
|
+
if (authority.describe(subject).revision !== revision) throw new Error('ENGINE_GRANT_CHANGED')
|
|
319
|
+
if (broker.closed) throw new Error('ENGINE_BROKER_CLOSED')
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---- process launch -----------------------------------------------------
|
|
323
|
+
const writeMcpConfig = async bridge => {
|
|
324
|
+
const mcpServers = {
|
|
325
|
+
dsh: { type: 'stdio', command: process.execPath, args: [PROXY_PATH, broker.pipePath], env: {} },
|
|
326
|
+
}
|
|
327
|
+
if (tools.length) {
|
|
328
|
+
// MCP clients inherit only a small default environment. Carry this
|
|
329
|
+
// Host-owned compiler root explicitly so nested native pipe helpers use
|
|
330
|
+
// the same short storage; the CLI profile and TEMP stay conversation-owned.
|
|
331
|
+
const compilerRoot = privateEnvironment(home).DSH_NATIVE_COMPILER_ROOT
|
|
332
|
+
mcpServers.dsh_task = { type: 'stdio', command: bridge.command, args: bridge.args,
|
|
333
|
+
env: { HOME: home, USERPROFILE: home, TEMP: join(home, 'tmp'), TMP: join(home, 'tmp'),
|
|
334
|
+
...(compilerRoot ? { DSH_NATIVE_COMPILER_ROOT: compilerRoot } : {}) } }
|
|
335
|
+
}
|
|
336
|
+
const path = join(home, `mcp-${++launches}.json`)
|
|
337
|
+
await writeFile(path, `${JSON.stringify({ mcpServers })}\n`, { mode: 0o600 })
|
|
338
|
+
return path
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const launch = async ({ resume = false, turn = null } = {}) => {
|
|
342
|
+
const remaining = deadlineEnd - Date.now()
|
|
343
|
+
if (remaining <= 1000) throw new Error('ENGINE_DEADLINE')
|
|
344
|
+
const launchId = launches + 1
|
|
345
|
+
const contextFor = ({ op, id }) => {
|
|
346
|
+
const turn = currentTurn
|
|
347
|
+
if (!turn || !turn.admitted || turn.settled || (op === 'call' && !['dispatching', 'running'].includes(turn.phase))) {
|
|
348
|
+
throw new Error('ENGINE_TOOL_TURN_REQUIRED')
|
|
349
|
+
}
|
|
350
|
+
return { turnId: turn.turnId, callId: `${operationId}-${launchId}-${id}`, signal: turn.toolController.signal }
|
|
351
|
+
}
|
|
352
|
+
const bridge = await createCliBootstrap({ authority, subject, binding, tools, handlers, contextFor, home: join(home, 'b'), ttlMs: Math.min(remaining, deadlineMs), helper: probe.helper })
|
|
353
|
+
const mcpConfigPath = await writeMcpConfig(bridge)
|
|
354
|
+
const allowedTools = ['mcp__dsh', ...tools.map(name => `mcp__dsh_task__${name}`)]
|
|
355
|
+
const args = [
|
|
356
|
+
'-p', '--output-format', 'stream-json', '--input-format', 'stream-json', '--verbose',
|
|
357
|
+
'--include-partial-messages', '--permission-mode', permissionMode,
|
|
358
|
+
// allowedTools is an auto-approval list, not a tool availability whitelist.
|
|
359
|
+
// Empty arguments must survive the native supervisor's Windows quoting.
|
|
360
|
+
'--tools', '', '--setting-sources', '',
|
|
361
|
+
'--settings', JSON.stringify({ disableAllHooks: true }), '--disable-slash-commands',
|
|
362
|
+
'--strict-mcp-config', '--mcp-config', mcpConfigPath,
|
|
363
|
+
'--permission-prompt-tool', 'mcp__dsh__approve',
|
|
364
|
+
'--disallowedTools', DISALLOWED_NATIVE_TOOLS.join(','),
|
|
365
|
+
'--allowedTools', allowedTools.join(','),
|
|
366
|
+
'--model', catalogModel,
|
|
367
|
+
...(systemPromptFile ? ['--append-system-prompt-file', systemPromptFile] : []),
|
|
368
|
+
...(resume && confirmedSessionId ? ['--resume', confirmedSessionId] : []),
|
|
369
|
+
]
|
|
370
|
+
if (digest(await readFile(pinned.path)) !== pinned.sha256 || (pinned.script && digest(await readFile(pinned.script)) !== pinned.scriptSha256)) { await bridge.close(); throw new Error('ENGINE_ENTRY_CHANGED') }
|
|
371
|
+
const env = privateEnvironment(home)
|
|
372
|
+
env.CLAUDE_CONFIG_DIR = join(home, '.claude')
|
|
373
|
+
// The broker locator is engine-constructed Host state, never caller input. The real
|
|
374
|
+
// CLI ignores it; the fixture reads it so it can play the permission-proxy's role.
|
|
375
|
+
env.DSH_CLAUDE_BROKER_PIPE = broker.pipePath
|
|
376
|
+
if (provisionCredentials) await provisionCredentials(home)
|
|
377
|
+
let supervisor
|
|
378
|
+
try {
|
|
379
|
+
supervisor = launcher.supervise(probe.helper, 'supervise', { executable: pinned.path, args: pinned.script ? [pinned.script, ...args] : args, cwd, ttlMs: remaining }, { home, env })
|
|
380
|
+
} catch (error) {
|
|
381
|
+
// The supervisor never came up: release the lease so nothing outlives the launch.
|
|
382
|
+
await bridge.close().catch(() => {})
|
|
383
|
+
throw error
|
|
384
|
+
}
|
|
385
|
+
const state = { supervisor, bridge, resume, turn, sawInit: false, dead: false, rootProcess: null, stopEvidence: null, costTotal: null, exitInfo: null,
|
|
386
|
+
activityGeneration: `native-${randomBytes(16).toString('hex')}` }
|
|
387
|
+
// Registered before the first await after spawning: a stop during launch ends THIS
|
|
388
|
+
// supervisor instead of orphaning a freshly spawned process tree.
|
|
389
|
+
pendingLaunch = state
|
|
390
|
+
const onControl = value => {
|
|
391
|
+
if (value.control === 'spawned') state.rootProcess = value
|
|
392
|
+
if (value.control === 'stopped') { state.stopEvidence = value; markDead(value) }
|
|
393
|
+
}
|
|
394
|
+
const markDead = stoppedControl => {
|
|
395
|
+
if (state.dead) return
|
|
396
|
+
state.dead = true
|
|
397
|
+
state.exitInfo = stoppedControl ? { exitCode: stoppedControl.exitCode, reason: stoppedControl.reason } : null
|
|
398
|
+
supervisor.events.off('control', onControl)
|
|
399
|
+
supervisor.events.off('message', onMessage)
|
|
400
|
+
const wasCurrent = currentProcess === state
|
|
401
|
+
if (wasCurrent) currentProcess = null
|
|
402
|
+
if (pendingLaunch === state) pendingLaunch = null
|
|
403
|
+
void bridge.close().catch(() => {})
|
|
404
|
+
// Without stop evidence the helper's state is unknown: never leave a live tree.
|
|
405
|
+
if (!stoppedControl) supervisor.end()
|
|
406
|
+
// Which turn may this death settle: the turn currently in flight on this
|
|
407
|
+
// process (a retained process dying mid-turn — possibly a later generation
|
|
408
|
+
// than the one that launched it), else this state's launching turn if it
|
|
409
|
+
// never settled (a death during launch). A former process can never settle
|
|
410
|
+
// a newer generation, and a settled turn is never re-settled.
|
|
411
|
+
let bound = null
|
|
412
|
+
if (wasCurrent && currentTurn && !currentTurn.settled) bound = currentTurn
|
|
413
|
+
else if (state.turn && !state.turn.settled) bound = state.turn
|
|
414
|
+
if (stopped || !bound) return
|
|
415
|
+
if (bound.process !== state) {
|
|
416
|
+
// The process died before this turn's prompt was written to its stdin:
|
|
417
|
+
// provably nothing was dispatched, so this is not-run — and no usage or
|
|
418
|
+
// cost figures are invented for a dispatch that never happened.
|
|
419
|
+
settleTurn(bound, { ok: false, stopReason: 'spawn-failed', cost: null }, { stopReason: 'spawn-failed', outcome: 'not-run' })
|
|
420
|
+
return
|
|
421
|
+
}
|
|
422
|
+
if (state.resume && !state.sawInit) {
|
|
423
|
+
// The resumed conversation no longer exists upstream: fail the continuation
|
|
424
|
+
// without ever replaying an earlier task into a fresh session.
|
|
425
|
+
resumeRefused = true
|
|
426
|
+
settleTurn(bound, { ok: false, stopReason: 'resume-refused', cost: null }, { stopReason: 'resume-refused', outcome: 'unknown' })
|
|
427
|
+
void stop('resume-refused')
|
|
428
|
+
} else {
|
|
429
|
+
settleTurn(bound, { ok: false, stopReason: 'exit-before-result', cost: null }, { stopReason: 'exit-before-result', outcome: 'unknown' })
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
const onMessage = frame => {
|
|
433
|
+
if (state.dead || stopped) return
|
|
434
|
+
let classified
|
|
435
|
+
try { classified = classifyClaudeFrame(frame) } catch { frames.ignored++; return }
|
|
436
|
+
switch (classified.type) {
|
|
437
|
+
case 'init': {
|
|
438
|
+
frames.init++
|
|
439
|
+
state.sawInit = true
|
|
440
|
+
initModel = classified.model
|
|
441
|
+
if (classified.sessionId) confirmedSessionId = classified.sessionId
|
|
442
|
+
if (classified.model !== null && classified.model !== catalogModel) {
|
|
443
|
+
identityMismatch = true
|
|
444
|
+
events.emit('session', { sessionId: confirmedSessionId, requestedModel: catalogModel, reportedModel: classified.model, verification: 'mismatch' })
|
|
445
|
+
if (state.turn && !state.turn.settled) settleTurn(state.turn, { ok: false, stopReason: 'model-identity-mismatch', cost: null }, { stopReason: 'model-identity-mismatch', outcome: 'failed' })
|
|
446
|
+
fatalError = new Error('ENGINE_MODEL_IDENTITY_MISMATCH')
|
|
447
|
+
void stop('model-identity-mismatch')
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
events.emit('session', { sessionId: confirmedSessionId, requestedModel: catalogModel, reportedModel: classified.model, permissionMode: classified.permissionMode, resumed: resume, verification: classified.model === catalogModel ? 'init-confirmed' : 'unreported' })
|
|
451
|
+
settleChain = settleChain.then(async () => { await hooks.onObserve?.({ kind: 'session-confirmed', sessionId: confirmedSessionId, model: classified.model, resumed: resume }) }).catch(error => {
|
|
452
|
+
// onObserve is an observation hook, not the ledger: money flows only
|
|
453
|
+
// through onSettle/onClose. Its failure is recorded and observable,
|
|
454
|
+
// never silently swallowed, and never a turn or financial outcome.
|
|
455
|
+
observeHookFailures++
|
|
456
|
+
events.emit('observe-failed', { kind: 'session-confirmed', error: String(error?.message ?? error) })
|
|
457
|
+
})
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
case 'assistant-delta':
|
|
461
|
+
events.emit('delta', { streamKind: 'assistant_text', delta: classified.delta })
|
|
462
|
+
return
|
|
463
|
+
case 'reasoning-delta':
|
|
464
|
+
events.emit('delta', { streamKind: 'reasoning_text', delta: classified.delta })
|
|
465
|
+
return
|
|
466
|
+
case 'assistant': {
|
|
467
|
+
frames.assistant++
|
|
468
|
+
if (classified.text) events.emit('assistant', { text: classified.text })
|
|
469
|
+
for (const use of classified.toolUses ?? []) {
|
|
470
|
+
const tool = { phase: 'started', id: use.id, name: use.name }
|
|
471
|
+
const observation = Object.freeze({ ...tool })
|
|
472
|
+
events.emit('tool', tool)
|
|
473
|
+
if (state === currentProcess && currentTurn?.process === state && !currentTurn.settled && ['dispatching', 'running'].includes(currentTurn.phase)) {
|
|
474
|
+
activity(currentTurn.activity, 'tool', { tool: observation })
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
case 'tool-result':
|
|
480
|
+
for (const result of classified.results) {
|
|
481
|
+
const tool = { phase: 'completed', id: result.toolUseId, ok: result.ok }
|
|
482
|
+
const observation = Object.freeze({ ...tool })
|
|
483
|
+
events.emit('tool', tool)
|
|
484
|
+
if (state === currentProcess && currentTurn?.process === state && !currentTurn.settled && ['dispatching', 'running'].includes(currentTurn.phase)) {
|
|
485
|
+
activity(currentTurn.activity, 'tool', { tool: observation })
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return
|
|
489
|
+
case 'result': {
|
|
490
|
+
frames.results++
|
|
491
|
+
if (classified.isError && state.resume && !state.sawInit) {
|
|
492
|
+
resumeRefused = true
|
|
493
|
+
if (state.turn && !state.turn.settled) settleTurn(state.turn, { ok: false, stopReason: 'resume-refused', cost: null, isError: true, errors: classified.errors }, { stopReason: 'resume-refused', outcome: 'unknown' })
|
|
494
|
+
void stop('resume-refused')
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
// A result only settles the turn currently in flight ON THIS process; a
|
|
498
|
+
// late frame from a settled turn (or a dead former process) is a stray.
|
|
499
|
+
if (state !== currentProcess || !currentTurn || currentTurn.settled) { strayResults++; return }
|
|
500
|
+
settleTurn(currentTurn, sumNativeTurnResults(currentTurn.deferred ?? null, { ...classified.native, isError: classified.isError, errors: classified.errors }))
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
default:
|
|
504
|
+
frames.ignored++
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
supervisor.events.on('control', onControl)
|
|
508
|
+
supervisor.events.on('message', onMessage)
|
|
509
|
+
supervisor.events.once('failure', error => { state.dead || fatal(error, 'native-failure'); markDead(null) })
|
|
510
|
+
supervisor.events.once('closed', () => markDead(null))
|
|
511
|
+
try {
|
|
512
|
+
const spawned = await supervisor.waitControl('spawned')
|
|
513
|
+
state.rootProcess = spawned
|
|
514
|
+
await bridge.bind({ pid: spawned.pid, start: spawned.start })
|
|
515
|
+
} catch (error) {
|
|
516
|
+
if (pendingLaunch === state) pendingLaunch = null
|
|
517
|
+
markDead(null)
|
|
518
|
+
throw error
|
|
519
|
+
}
|
|
520
|
+
if (pendingLaunch === state) pendingLaunch = null
|
|
521
|
+
if (stopped) {
|
|
522
|
+
// The engine was stopped while this launch resolved; the stop already settled
|
|
523
|
+
// the turn. Tear the just-started tree down instead of dispatching into it.
|
|
524
|
+
markDead(null)
|
|
525
|
+
throw new Error(fatalError ? `ENGINE_STOPPED: ${fatalError.message}` : 'ENGINE_STOPPED')
|
|
526
|
+
}
|
|
527
|
+
currentProcess = state
|
|
528
|
+
return state
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ---- stop ---------------------------------------------------------------
|
|
532
|
+
// The native tree stops FIRST and independently of every Host hook: a slow or
|
|
533
|
+
// stuck settlement may delay public promises, never the tree stop. Real tree-stop
|
|
534
|
+
// evidence comes only from the supervisor's own accounting. Public wait contract:
|
|
535
|
+
// stop() completes only after the settlement chain (and then onClose) ran; a
|
|
536
|
+
// trusted hook that never returns leaves stop() pending — observable through
|
|
537
|
+
// snapshot() — but the tree above is already stopped.
|
|
538
|
+
const stop = (reason = 'host-stop') => {
|
|
539
|
+
if (stopPromise) return stopPromise
|
|
540
|
+
stopRequestedAt = Date.now()
|
|
541
|
+
stopped = true
|
|
542
|
+
if (guardTimer) clearInterval(guardTimer)
|
|
543
|
+
if (deadlineTimer) clearTimeout(deadlineTimer)
|
|
544
|
+
const activeTurn = currentTurn
|
|
545
|
+
const stoppedActivity = activeTurn?.activity ?? lastActivity
|
|
546
|
+
if (activeTurn && !activeTurn.settled) {
|
|
547
|
+
settleTurn(activeTurn, { ok: false, stopReason: reason, cost: null }, {
|
|
548
|
+
stopReason: reason,
|
|
549
|
+
outcome: signal?.aborted || reason === 'interrupt' ? 'cancelled' : activeTurn.dispatched ? 'unknown' : 'not-run',
|
|
550
|
+
})
|
|
551
|
+
}
|
|
552
|
+
stopPromise = (async () => {
|
|
553
|
+
const primary = currentProcess
|
|
554
|
+
const targets = []
|
|
555
|
+
if (primary) targets.push(primary)
|
|
556
|
+
// A launch still in flight owns a live supervisor that stop must end, or the
|
|
557
|
+
// freshly spawned tree would outlive the engine.
|
|
558
|
+
if (pendingLaunch && pendingLaunch !== primary) targets.push(pendingLaunch)
|
|
559
|
+
const interruptedLaunches = targets.length - (primary ? 1 : 0)
|
|
560
|
+
currentProcess = null
|
|
561
|
+
pendingLaunch = null
|
|
562
|
+
let forcedKill = false
|
|
563
|
+
for (const target of targets) {
|
|
564
|
+
if (target.dead) continue
|
|
565
|
+
target.supervisor.end()
|
|
566
|
+
const exited = await Promise.race([target.supervisor.exit.then(() => true), new Promise(resolve => { const timer = setTimeout(() => resolve(false), 8000); timer.unref() })])
|
|
567
|
+
if (!exited) {
|
|
568
|
+
forcedKill = true
|
|
569
|
+
target.supervisor.child.kill()
|
|
570
|
+
await target.supervisor.exit
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
const native = primary?.stopEvidence ?? null
|
|
574
|
+
broker.close()
|
|
575
|
+
for (const target of targets) await target.bridge.close().catch(() => {})
|
|
576
|
+
const stopEvidence = {
|
|
577
|
+
stopRequestedAt, stoppedAt: Date.now(), reason,
|
|
578
|
+
pid: primary?.rootProcess?.pid ?? native?.pid ?? null,
|
|
579
|
+
exitCode: native?.exitCode ?? primary?.exitInfo?.exitCode ?? null,
|
|
580
|
+
nativeStopReason: native?.reason ?? primary?.exitInfo?.reason ?? (primary ? 'no-stopped-control' : 'no-process'),
|
|
581
|
+
containment: native?.containment ?? probe.supervisor ?? null,
|
|
582
|
+
// Only the launcher's own accounting (Windows job object) confirms a tree stop.
|
|
583
|
+
treeStopped: native?.treeStopped === true,
|
|
584
|
+
status: native?.treeStopped === true ? 'confirmed' : primary ? 'unconfirmed' : 'no-process',
|
|
585
|
+
interruptedLaunches,
|
|
586
|
+
forcedKill,
|
|
587
|
+
stopRequestedIsNotConfirmation: true,
|
|
588
|
+
}
|
|
589
|
+
// Supervisor-owned tree-stop evidence is observable before the Host
|
|
590
|
+
// settlement wait below, so a slow settlement can never masquerade as
|
|
591
|
+
// "not stopped yet".
|
|
592
|
+
events.emit('tree-stopped', stopEvidence)
|
|
593
|
+
if (stopEvidence.treeStopped) activity(stoppedActivity, 'stop-confirmed')
|
|
594
|
+
await settleChain
|
|
595
|
+
await hooks.onClose?.(stopEvidence)
|
|
596
|
+
return stopEvidence
|
|
597
|
+
})()
|
|
598
|
+
return stopPromise
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ---- guards -------------------------------------------------------------
|
|
602
|
+
guardTimer = setInterval(() => {
|
|
603
|
+
try {
|
|
604
|
+
if (broker.closed) throw new Error('ENGINE_BROKER_CLOSED')
|
|
605
|
+
if (currentProcess?.bridge.closed) throw new Error('ENGINE_BRIDGE_CLOSED')
|
|
606
|
+
authority.assertModel(subject, catalogProvider, catalogModel)
|
|
607
|
+
if (authority.describe(subject).revision !== revision) throw new Error('ENGINE_GRANT_CHANGED')
|
|
608
|
+
} catch (error) {
|
|
609
|
+
if (currentTurn && !currentTurn.settled) settleTurn(currentTurn, { ok: false, stopReason: 'grant-revoked', cost: null }, { stopReason: 'grant-revoked', outcome: currentTurn.dispatched ? 'unknown' : 'not-run' })
|
|
610
|
+
fatal(error instanceof Error ? error : new Error(String(error)), 'grant-revoked')
|
|
611
|
+
}
|
|
612
|
+
}, 100)
|
|
613
|
+
guardTimer.unref()
|
|
614
|
+
deadlineTimer = setTimeout(() => { fatal(new Error('ENGINE_DEADLINE'), 'deadline') }, deadlineMs)
|
|
615
|
+
deadlineTimer.unref()
|
|
616
|
+
const onAbort = () => { fatal(new Error('ENGINE_CANCELLED'), 'cancelled') }
|
|
617
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
618
|
+
if (signal?.aborted) onAbort()
|
|
619
|
+
|
|
620
|
+
// ---- public surface ------------------------------------------------------
|
|
621
|
+
/** Submit one conversation turn. The payload may carry `text` and nothing else.
|
|
622
|
+
* The turn slot is reserved synchronously before the first await, so a second
|
|
623
|
+
* same-tick submit is refused busy with zero admissions and zero launches. The
|
|
624
|
+
* returned promise completes only after the Host settlement callback ran. */
|
|
625
|
+
const submitTurn = async input => {
|
|
626
|
+
if (stopped) throw new Error(fatalError ? `ENGINE_STOPPED: ${fatalError.message}` : 'ENGINE_STOPPED')
|
|
627
|
+
if (currentTurn) throw new Error('ENGINE_TURN_IN_PROGRESS')
|
|
628
|
+
if (!input || typeof input !== 'object' || Array.isArray(input) || Object.keys(input).some(key => key !== 'text')) throw new TypeError('INVALID_TURN_INPUT: only { text } is accepted')
|
|
629
|
+
const text = input.text
|
|
630
|
+
if (!validTurnText(text)) throw new TypeError('INVALID_TURN_TEXT: 1..64000 bytes, no NUL, not blank')
|
|
631
|
+
const turnId = `t-${randomBytes(8).toString('hex')}`
|
|
632
|
+
let resolveTurn, rejectTurn
|
|
633
|
+
const promise = new Promise((resolve, reject) => { resolveTurn = resolve; rejectTurn = reject })
|
|
634
|
+
promise.catch(() => {}) // rejections carry the turn evidence; they are never unhandled
|
|
635
|
+
const turn = { turnId, phase: 'admitting', settled: false, admitted: false, dispatched: false, deferred: null, process: null, resolve: resolveTurn, reject: rejectTurn, timer: null, result: null,
|
|
636
|
+
toolController: new AbortController() }
|
|
637
|
+
// The turn deadline covers admission, launch, dispatch and the CLI's result.
|
|
638
|
+
const timer = setTimeout(() => {
|
|
639
|
+
settleTurn(turn, { ok: false, stopReason: 'turn-timeout', cost: null }, { stopReason: 'turn-timeout', outcome: turn.dispatched ? 'unknown' : 'not-run' })
|
|
640
|
+
fatal(new Error('ENGINE_TURN_TIMEOUT'), 'turn-timeout')
|
|
641
|
+
}, Math.max(1000, deadlineEnd - Date.now()))
|
|
642
|
+
timer.unref()
|
|
643
|
+
turn.timer = timer
|
|
644
|
+
// THE FENCE: the slot is taken before any await. It is freed only by a completed
|
|
645
|
+
// Host settlement (or an admission refusal, which proves nothing was dispatched).
|
|
646
|
+
currentTurn = turn
|
|
647
|
+
try {
|
|
648
|
+
// Host admission first: a refusal here proves nothing was dispatched.
|
|
649
|
+
await hooks.onBeforeRequest?.({ turnId, botId, ...binding, operationId, source: 'engine', driver: 'claude', requestedModel: catalogModel, entry: { path: pinned.path, sha256: pinned.sha256, version: pinned.version } })
|
|
650
|
+
} catch (error) {
|
|
651
|
+
if (currentTurn === turn) currentTurn = null
|
|
652
|
+
clearTimeout(turn.timer)
|
|
653
|
+
throw error
|
|
654
|
+
}
|
|
655
|
+
turn.admitted = true
|
|
656
|
+
turn.phase = 'authorized'
|
|
657
|
+
// From here on the admission exists: every terminal path settles it exactly once
|
|
658
|
+
// with real dispatch evidence. `not-run` means provably nothing was written to
|
|
659
|
+
// the CLI's stdin; a failed stdin write stays `unknown` (may-dispatch).
|
|
660
|
+
const failTurn = (stopReason, outcome, errorText) =>
|
|
661
|
+
settleTurn(turn, { ok: false, stopReason, cost: null }, { stopReason, outcome, ...(errorText !== undefined ? { errorText } : {}) })
|
|
662
|
+
if (stopped) {
|
|
663
|
+
// A stop while admission was pending has already settled this turn.
|
|
664
|
+
if (!turn.settled) failTurn('engine-stopped', 'not-run', fatalError ? String(fatalError.message) : undefined)
|
|
665
|
+
return promise
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
assertDispatchAuthorization()
|
|
669
|
+
} catch (error) {
|
|
670
|
+
failTurn(signal?.aborted ? 'cancelled' : 'grant-revoked', signal?.aborted ? 'cancelled' : 'not-run', String(error.message))
|
|
671
|
+
return promise
|
|
672
|
+
}
|
|
673
|
+
let state = currentProcess
|
|
674
|
+
if (!state || state.dead) {
|
|
675
|
+
try {
|
|
676
|
+
state = await launch({ resume: confirmedSessionId !== null, turn })
|
|
677
|
+
} catch (error) {
|
|
678
|
+
// markDead settles spawn/process failures; this backstop covers a supervisor
|
|
679
|
+
// that threw before any state existed. Both are single-settle guarded.
|
|
680
|
+
if (!turn.settled) failTurn('launch-failed', 'not-run', String(error.message))
|
|
681
|
+
return promise
|
|
682
|
+
}
|
|
683
|
+
// The init frame may already have failed the model identity check and stopped
|
|
684
|
+
// the engine while the launch was resolving; a stop during launch has already
|
|
685
|
+
// settled this turn with its own cause. Never dispatch into a dead engine.
|
|
686
|
+
if (stopped) {
|
|
687
|
+
if (!turn.settled) failTurn('engine-stopped', turn.dispatched ? 'unknown' : 'not-run', fatalError ? String(fatalError.message) : undefined)
|
|
688
|
+
return promise
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
// A grant revoked between admission and dispatch must not send the prompt.
|
|
692
|
+
try {
|
|
693
|
+
assertDispatchAuthorization()
|
|
694
|
+
} catch (error) {
|
|
695
|
+
failTurn(signal?.aborted ? 'cancelled' : 'grant-revoked', signal?.aborted ? 'cancelled' : 'not-run', String(error.message))
|
|
696
|
+
return promise
|
|
697
|
+
}
|
|
698
|
+
turn.process = state
|
|
699
|
+
turn.phase = 'dispatching'
|
|
700
|
+
turn.activity = Object.freeze({ turnId: turn.turnId, processGeneration: state.activityGeneration })
|
|
701
|
+
lastActivity = turn.activity
|
|
702
|
+
activity(turn.activity, 'turn-open')
|
|
703
|
+
try {
|
|
704
|
+
await state.supervisor.send(claudeUserMessage(text))
|
|
705
|
+
} catch (error) {
|
|
706
|
+
// The write failed mid-flight: the message may or may not have reached the CLI.
|
|
707
|
+
failTurn('stdin-write-failed', 'unknown', String(error.message))
|
|
708
|
+
return promise
|
|
709
|
+
}
|
|
710
|
+
turnsDispatched++
|
|
711
|
+
turn.dispatched = true
|
|
712
|
+
turn.phase = 'running'
|
|
713
|
+
return promise
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Host-side answer to a broker ask. Permission asks take allow/deny; questions take answer+message. */
|
|
717
|
+
const respondToRequest = (requestId, decision) => {
|
|
718
|
+
if (!identifier(requestId)) throw new TypeError('INVALID_REQUEST_ID')
|
|
719
|
+
if (!decision || typeof decision !== 'object' || Object.keys(decision).some(key => !['behavior', 'message'].includes(key))) throw new TypeError('INVALID_PERMISSION_DECISION_INPUT')
|
|
720
|
+
if (!['allow', 'deny', 'answer'].includes(decision.behavior)) throw new TypeError('INVALID_PERMISSION_BEHAVIOR')
|
|
721
|
+
if (decision.behavior === 'answer' && typeof decision.message !== 'string') throw new TypeError('ANSWER_MESSAGE_REQUIRED')
|
|
722
|
+
const answered = broker.answer(requestId, decision.behavior, decision.message)
|
|
723
|
+
if (answered) {
|
|
724
|
+
if (decision.behavior === 'allow') stats.hostAllowed++
|
|
725
|
+
else if (decision.behavior === 'deny') stats.hostDenied++
|
|
726
|
+
else stats.hostAnswered++
|
|
727
|
+
}
|
|
728
|
+
return answered
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const snapshot = () => Object.freeze({
|
|
732
|
+
schemaVersion: 1, driver: 'claude', botId, ...binding, operationId,
|
|
733
|
+
entry: { path: pinned.path, sha256: pinned.sha256, version: pinned.version, script: pinned.script, scriptSha256: pinned.scriptSha256 },
|
|
734
|
+
identity: {
|
|
735
|
+
catalogProvider, catalogModel, requestedModel: catalogModel,
|
|
736
|
+
reportedInitModel: initModel, verification: identityMismatch ? 'mismatch' : initModel ? 'init-confirmed' : 'unreported',
|
|
737
|
+
mismatch: identityMismatch,
|
|
738
|
+
},
|
|
739
|
+
sessionId: confirmedSessionId,
|
|
740
|
+
sessionConfirmedByHandshake: confirmedSessionId !== null,
|
|
741
|
+
turnsDispatched, launches, resumeRefused, strayResults,
|
|
742
|
+
// Observable turn fence state: which turn holds the slot, how far it got, and
|
|
743
|
+
// whether its Host settlement callback is still pending.
|
|
744
|
+
turn: currentTurn ? { turnId: currentTurn.turnId, phase: currentTurn.phase, admitted: currentTurn.admitted, dispatched: currentTurn.dispatched, settled: currentTurn.settled } : null,
|
|
745
|
+
settlementPending: currentTurn?.settled === true,
|
|
746
|
+
observeHookFailures,
|
|
747
|
+
frames: { ...frames },
|
|
748
|
+
permissions: { ...stats, brokerClosed: broker.closed, pending: broker.pendingCount },
|
|
749
|
+
billing: { kind: billing, amountUsd: null, amountStatus: 'unknown', note: 'cost flows only to Host onSettle from CLI-reported running-total deltas; nothing is fabricated here' },
|
|
750
|
+
process: currentProcess ? { pid: currentProcess.rootProcess?.pid ?? null, resumed: currentProcess.resume, sawInit: currentProcess.sawInit } : null,
|
|
751
|
+
stopRequestedAt, stopEvidence: stopPromise ? null : undefined,
|
|
752
|
+
fatalError: fatalError ? String(fatalError.message) : null,
|
|
753
|
+
closed: stopped,
|
|
754
|
+
limitations: [
|
|
755
|
+
'init reports the CLI-configured model; the protocol does not prove the response backend model',
|
|
756
|
+
'after a relaunch, the first cost delta is measured from a null baseline (the CLI restores a running total from an earlier state, not always the latest one seen)',
|
|
757
|
+
'steering and queued-turn continuation merging are not ported yet: one submitted message equals one settled turn',
|
|
758
|
+
],
|
|
759
|
+
})
|
|
760
|
+
|
|
761
|
+
return Object.freeze({
|
|
762
|
+
driver: 'claude',
|
|
763
|
+
get sessionId() { return confirmedSessionId },
|
|
764
|
+
get closed() { return stopped },
|
|
765
|
+
events,
|
|
766
|
+
submitTurn,
|
|
767
|
+
respondToRequest,
|
|
768
|
+
stop,
|
|
769
|
+
close: stop,
|
|
770
|
+
snapshot,
|
|
771
|
+
})
|
|
772
|
+
}
|