mstro-app 0.5.1 → 0.5.6
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/PRIVACY.md +9 -9
- package/README.md +71 -28
- package/bin/commands/config.js +1 -1
- package/bin/mstro.js +55 -4
- package/dist/server/cli/eta-estimator.d.ts +55 -0
- package/dist/server/cli/eta-estimator.d.ts.map +1 -0
- package/dist/server/cli/eta-estimator.js +222 -0
- package/dist/server/cli/eta-estimator.js.map +1 -0
- package/dist/server/cli/headless/claude-invoker-process.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker-process.js +9 -1
- package/dist/server/cli/headless/claude-invoker-process.js.map +1 -1
- package/dist/server/cli/headless/mcp-config.d.ts +22 -5
- package/dist/server/cli/headless/mcp-config.d.ts.map +1 -1
- package/dist/server/cli/headless/mcp-config.js +7 -5
- package/dist/server/cli/headless/mcp-config.js.map +1 -1
- package/dist/server/cli/headless/runner.d.ts.map +1 -1
- package/dist/server/cli/headless/runner.js +19 -0
- package/dist/server/cli/headless/runner.js.map +1 -1
- package/dist/server/cli/headless/stall-assessor.d.ts +50 -0
- package/dist/server/cli/headless/stall-assessor.d.ts.map +1 -1
- package/dist/server/cli/headless/stall-assessor.js +64 -9
- package/dist/server/cli/headless/stall-assessor.js.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.d.ts +21 -0
- package/dist/server/cli/headless/tool-watchdog.d.ts.map +1 -1
- package/dist/server/cli/headless/tool-watchdog.js +19 -12
- package/dist/server/cli/headless/tool-watchdog.js.map +1 -1
- package/dist/server/cli/headless/types.d.ts +16 -1
- package/dist/server/cli/headless/types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-history-store.d.ts.map +1 -1
- package/dist/server/cli/improvisation-history-store.js +5 -1
- package/dist/server/cli/improvisation-history-store.js.map +1 -1
- package/dist/server/cli/improvisation-output-queue.d.ts +5 -1
- package/dist/server/cli/improvisation-output-queue.d.ts.map +1 -1
- package/dist/server/cli/improvisation-output-queue.js +30 -7
- package/dist/server/cli/improvisation-output-queue.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +35 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +58 -1
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/cli/improvisation-types.d.ts +9 -0
- package/dist/server/cli/improvisation-types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-types.js.map +1 -1
- package/dist/server/cli/retry/retry-runner-factory.d.ts.map +1 -1
- package/dist/server/cli/retry/retry-runner-factory.js +1 -0
- package/dist/server/cli/retry/retry-runner-factory.js.map +1 -1
- package/dist/server/engines/EngineEvent.d.ts +126 -0
- package/dist/server/engines/EngineEvent.d.ts.map +1 -0
- package/dist/server/engines/EngineEvent.js +11 -0
- package/dist/server/engines/EngineEvent.js.map +1 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.d.ts +47 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.d.ts.map +1 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.js +338 -0
- package/dist/server/engines/claude/ClaudeCodeEngine.js.map +1 -0
- package/dist/server/engines/factory.d.ts +21 -0
- package/dist/server/engines/factory.d.ts.map +1 -0
- package/dist/server/engines/factory.js +152 -0
- package/dist/server/engines/factory.js.map +1 -0
- package/dist/server/engines/opencode/OpenCodeEngine.d.ts +148 -0
- package/dist/server/engines/opencode/OpenCodeEngine.d.ts.map +1 -0
- package/dist/server/engines/opencode/OpenCodeEngine.js +630 -0
- package/dist/server/engines/opencode/OpenCodeEngine.js.map +1 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.d.ts +172 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.d.ts.map +1 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.js +390 -0
- package/dist/server/engines/opencode/OpenCodeServerManager.js.map +1 -0
- package/dist/server/engines/opencode/model-catalog.d.ts +94 -0
- package/dist/server/engines/opencode/model-catalog.d.ts.map +1 -0
- package/dist/server/engines/opencode/model-catalog.js +141 -0
- package/dist/server/engines/opencode/model-catalog.js.map +1 -0
- package/dist/server/engines/types.d.ts +146 -0
- package/dist/server/engines/types.d.ts.map +1 -0
- package/dist/server/engines/types.js +4 -0
- package/dist/server/engines/types.js.map +1 -0
- package/dist/server/index.js +9 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/mcp/bouncer-haiku.d.ts +17 -4
- package/dist/server/mcp/bouncer-haiku.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-haiku.js +8 -124
- package/dist/server/mcp/bouncer-haiku.js.map +1 -1
- package/dist/server/mcp/bouncer-integration.d.ts +45 -0
- package/dist/server/mcp/bouncer-integration.d.ts.map +1 -1
- package/dist/server/mcp/bouncer-integration.js +69 -5
- package/dist/server/mcp/bouncer-integration.js.map +1 -1
- package/dist/server/mcp/classifier/BouncerClassifier.d.ts +34 -0
- package/dist/server/mcp/classifier/BouncerClassifier.d.ts.map +1 -0
- package/dist/server/mcp/classifier/BouncerClassifier.js +4 -0
- package/dist/server/mcp/classifier/BouncerClassifier.js.map +1 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts +17 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.d.ts.map +1 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +142 -0
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js.map +1 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts +68 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.d.ts.map +1 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js +182 -0
- package/dist/server/mcp/classifier/OpenCodeBouncerClassifier.js.map +1 -0
- package/dist/server/mcp/classifier/factory.d.ts +70 -0
- package/dist/server/mcp/classifier/factory.d.ts.map +1 -0
- package/dist/server/mcp/classifier/factory.js +155 -0
- package/dist/server/mcp/classifier/factory.js.map +1 -0
- package/dist/server/mcp/server.js +52 -0
- package/dist/server/mcp/server.js.map +1 -1
- package/dist/server/routes/index.d.ts +1 -0
- package/dist/server/routes/index.d.ts.map +1 -1
- package/dist/server/routes/index.js +1 -0
- package/dist/server/routes/index.js.map +1 -1
- package/dist/server/routes/internal.d.ts +16 -0
- package/dist/server/routes/internal.d.ts.map +1 -0
- package/dist/server/routes/internal.js +94 -0
- package/dist/server/routes/internal.js.map +1 -0
- package/dist/server/services/plan/agent-resolver.d.ts +26 -0
- package/dist/server/services/plan/agent-resolver.d.ts.map +1 -0
- package/dist/server/services/plan/agent-resolver.js +102 -0
- package/dist/server/services/plan/agent-resolver.js.map +1 -0
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +59 -11
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +3 -1
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/issue-prompt-builder.d.ts.map +1 -1
- package/dist/server/services/plan/issue-prompt-builder.js +33 -1
- package/dist/server/services/plan/issue-prompt-builder.js.map +1 -1
- package/dist/server/services/plan/parser-core.d.ts.map +1 -1
- package/dist/server/services/plan/parser-core.js +1 -0
- package/dist/server/services/plan/parser-core.js.map +1 -1
- package/dist/server/services/plan/types.d.ts +1 -0
- package/dist/server/services/plan/types.d.ts.map +1 -1
- package/dist/server/services/runtime-info.d.ts +3 -0
- package/dist/server/services/runtime-info.d.ts.map +1 -0
- package/dist/server/services/runtime-info.js +21 -0
- package/dist/server/services/runtime-info.js.map +1 -0
- package/dist/server/services/settings.d.ts +76 -2
- package/dist/server/services/settings.d.ts.map +1 -1
- package/dist/server/services/settings.js +127 -4
- package/dist/server/services/settings.js.map +1 -1
- package/dist/server/services/websocket/ask-user-question-bridge.d.ts +32 -0
- package/dist/server/services/websocket/ask-user-question-bridge.d.ts.map +1 -0
- package/dist/server/services/websocket/ask-user-question-bridge.js +115 -0
- package/dist/server/services/websocket/ask-user-question-bridge.js.map +1 -0
- package/dist/server/services/websocket/git-branch-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-branch-handlers.js +19 -6
- package/dist/server/services/websocket/git-branch-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +25 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +84 -2
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/quality-complexity.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-complexity.js +78 -26
- package/dist/server/services/websocket/quality-complexity.js.map +1 -1
- package/dist/server/services/websocket/quality-eta.d.ts +47 -0
- package/dist/server/services/websocket/quality-eta.d.ts.map +1 -0
- package/dist/server/services/websocket/quality-eta.js +110 -0
- package/dist/server/services/websocket/quality-eta.js.map +1 -0
- package/dist/server/services/websocket/quality-grading.d.ts +27 -4
- package/dist/server/services/websocket/quality-grading.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-grading.js +369 -201
- package/dist/server/services/websocket/quality-grading.js.map +1 -1
- package/dist/server/services/websocket/quality-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-handlers.js +145 -7
- package/dist/server/services/websocket/quality-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-operations.d.ts +34 -0
- package/dist/server/services/websocket/quality-operations.d.ts.map +1 -0
- package/dist/server/services/websocket/quality-operations.js +47 -0
- package/dist/server/services/websocket/quality-operations.js.map +1 -0
- package/dist/server/services/websocket/quality-persistence.d.ts +9 -0
- package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-persistence.js +10 -0
- package/dist/server/services/websocket/quality-persistence.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +105 -56
- package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
- package/dist/server/services/websocket/quality-service.d.ts +9 -1
- package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-service.js +334 -14
- package/dist/server/services/websocket/quality-service.js.map +1 -1
- package/dist/server/services/websocket/quality-tools.d.ts +21 -0
- package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-tools.js +49 -0
- package/dist/server/services/websocket/quality-tools.js.map +1 -1
- package/dist/server/services/websocket/quality-types.d.ts +35 -2
- package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-types.js +1 -1
- package/dist/server/services/websocket/quality-types.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.d.ts +3 -1
- package/dist/server/services/websocket/session-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +60 -9
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/session-history.js +3 -0
- package/dist/server/services/websocket/session-history.js.map +1 -1
- package/dist/server/services/websocket/session-initialization.d.ts.map +1 -1
- package/dist/server/services/websocket/session-initialization.js +158 -42
- package/dist/server/services/websocket/session-initialization.js.map +1 -1
- package/dist/server/services/websocket/session-registry.d.ts +25 -0
- package/dist/server/services/websocket/session-registry.d.ts.map +1 -1
- package/dist/server/services/websocket/session-registry.js +19 -0
- package/dist/server/services/websocket/session-registry.js.map +1 -1
- package/dist/server/services/websocket/settings-handlers.d.ts +1 -1
- package/dist/server/services/websocket/settings-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/settings-handlers.js +35 -4
- package/dist/server/services/websocket/settings-handlers.js.map +1 -1
- package/dist/server/services/websocket/tab-broadcast.d.ts +7 -2
- package/dist/server/services/websocket/tab-broadcast.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-broadcast.js +10 -2
- package/dist/server/services/websocket/tab-broadcast.js.map +1 -1
- package/dist/server/services/websocket/tab-event-buffer.d.ts +97 -8
- package/dist/server/services/websocket/tab-event-buffer.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-event-buffer.js +138 -12
- package/dist/server/services/websocket/tab-event-buffer.js.map +1 -1
- package/dist/server/services/websocket/tab-event-replay.d.ts +29 -13
- package/dist/server/services/websocket/tab-event-replay.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-event-replay.js +55 -2
- package/dist/server/services/websocket/tab-event-replay.js.map +1 -1
- package/dist/server/services/websocket/tab-handlers.d.ts +9 -1
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-handlers.js +47 -2
- package/dist/server/services/websocket/tab-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +67 -7
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/dist/server/services/websocket/types.js +12 -6
- package/dist/server/services/websocket/types.js.map +1 -1
- package/package.json +5 -3
- package/server/cli/eta-estimator.ts +249 -0
- package/server/cli/headless/claude-invoker-process.ts +9 -1
- package/server/cli/headless/mcp-config.ts +30 -5
- package/server/cli/headless/runner.ts +21 -0
- package/server/cli/headless/stall-assessor.ts +93 -0
- package/server/cli/headless/tool-watchdog.ts +21 -0
- package/server/cli/headless/types.ts +16 -1
- package/server/cli/improvisation-history-store.ts +4 -1
- package/server/cli/improvisation-output-queue.ts +29 -7
- package/server/cli/improvisation-session-manager.ts +63 -1
- package/server/cli/improvisation-types.ts +9 -0
- package/server/cli/retry/retry-runner-factory.ts +1 -0
- package/server/engines/EngineEvent.ts +156 -0
- package/server/engines/claude/ClaudeCodeEngine.ts +404 -0
- package/server/engines/factory.ts +176 -0
- package/server/engines/opencode/OpenCodeEngine.ts +786 -0
- package/server/engines/opencode/OpenCodeServerManager.ts +577 -0
- package/server/engines/opencode/model-catalog.ts +217 -0
- package/server/engines/types.ts +173 -0
- package/server/index.ts +9 -1
- package/server/mcp/bouncer-haiku.ts +21 -145
- package/server/mcp/bouncer-integration.ts +107 -5
- package/server/mcp/classifier/BouncerClassifier.ts +40 -0
- package/server/mcp/classifier/ClaudeBouncerClassifier.ts +189 -0
- package/server/mcp/classifier/OpenCodeBouncerClassifier.ts +305 -0
- package/server/mcp/classifier/factory.ts +195 -0
- package/server/mcp/server.ts +57 -0
- package/server/routes/index.ts +1 -0
- package/server/routes/internal.ts +112 -0
- package/server/services/plan/agent-resolver.ts +115 -0
- package/server/services/plan/agents/code-review.md +38 -8
- package/server/services/plan/composer.ts +63 -11
- package/server/services/plan/executor.ts +3 -1
- package/server/services/plan/issue-prompt-builder.ts +39 -1
- package/server/services/plan/parser-core.ts +1 -0
- package/server/services/plan/types.ts +4 -0
- package/server/services/runtime-info.ts +24 -0
- package/server/services/settings.ts +161 -4
- package/server/services/websocket/ask-user-question-bridge.ts +148 -0
- package/server/services/websocket/git-branch-handlers.ts +20 -6
- package/server/services/websocket/handler.ts +89 -2
- package/server/services/websocket/quality-complexity.ts +80 -26
- package/server/services/websocket/quality-eta.ts +155 -0
- package/server/services/websocket/quality-grading.ts +445 -222
- package/server/services/websocket/quality-handlers.ts +153 -7
- package/server/services/websocket/quality-operations.ts +72 -0
- package/server/services/websocket/quality-persistence.ts +17 -0
- package/server/services/websocket/quality-review-agent.ts +154 -64
- package/server/services/websocket/quality-service.ts +361 -13
- package/server/services/websocket/quality-tools.ts +51 -0
- package/server/services/websocket/quality-types.ts +41 -2
- package/server/services/websocket/session-handlers.ts +67 -10
- package/server/services/websocket/session-history.ts +3 -0
- package/server/services/websocket/session-initialization.ts +189 -46
- package/server/services/websocket/session-registry.ts +37 -0
- package/server/services/websocket/settings-handlers.ts +41 -4
- package/server/services/websocket/tab-broadcast.ts +10 -2
- package/server/services/websocket/tab-event-buffer.ts +143 -11
- package/server/services/websocket/tab-event-replay.ts +70 -3
- package/server/services/websocket/tab-handlers.ts +53 -5
- package/server/services/websocket/types.ts +85 -7
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
+
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OpenCodeEngine
|
|
6
|
+
*
|
|
7
|
+
* Adapter that wraps the OpenCode SDK (@opencode-ai/sdk) behind the
|
|
8
|
+
* CodingAgentEngine interface. Owns a single OpenCode `session` per Mstro
|
|
9
|
+
* improvisation:
|
|
10
|
+
*
|
|
11
|
+
* - `startSession` creates (or resumes) an OpenCode session and opens an
|
|
12
|
+
* SSE subscription to `/event`. A background pump consumes the stream
|
|
13
|
+
* and translates each payload into the engine-agnostic `EngineEvent`
|
|
14
|
+
* shape so the rest of the system does not need to know which engine
|
|
15
|
+
* produced the events.
|
|
16
|
+
* - `sendPrompt` dispatches `session.promptAsync` and resolves as soon as
|
|
17
|
+
* the server has accepted the prompt. Streaming output arrives via SSE.
|
|
18
|
+
* - `cancel` calls `session.abort`, which the OpenCode server eventually
|
|
19
|
+
* reflects back as a `session.idle` event.
|
|
20
|
+
* - `dispose` stops the pump and deletes the underlying session.
|
|
21
|
+
*
|
|
22
|
+
* The concrete SSE → EngineEvent mapping is documented inline in
|
|
23
|
+
* `handleSseEvent`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
AssistantMessage,
|
|
28
|
+
Message,
|
|
29
|
+
OpencodeClient,
|
|
30
|
+
Part,
|
|
31
|
+
Permission,
|
|
32
|
+
ReasoningPart,
|
|
33
|
+
Event as SseEvent,
|
|
34
|
+
StepFinishPart,
|
|
35
|
+
TextPart,
|
|
36
|
+
ToolPart,
|
|
37
|
+
} from '@opencode-ai/sdk'
|
|
38
|
+
import {
|
|
39
|
+
type BouncerDecision,
|
|
40
|
+
type EnginePermissionReviewRequest,
|
|
41
|
+
formatDenialMessage,
|
|
42
|
+
reviewEnginePermission,
|
|
43
|
+
} from '../../mcp/bouncer-integration.js'
|
|
44
|
+
import type { EngineEvent, EngineId } from '../EngineEvent.js'
|
|
45
|
+
import type {
|
|
46
|
+
CodingAgentEngine,
|
|
47
|
+
EngineUsage,
|
|
48
|
+
PromptAttachment,
|
|
49
|
+
StartSessionOptions,
|
|
50
|
+
} from '../types.js'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bouncer entry-point signature. Exposed as an option on OpenCodeEngine so
|
|
54
|
+
* tests can swap in a stub without patching module internals. Production
|
|
55
|
+
* code passes `reviewEnginePermission` from bouncer-integration.ts, which
|
|
56
|
+
* in turn calls {@link reviewOperation} — the single source of truth for
|
|
57
|
+
* security decisions across every engine.
|
|
58
|
+
*/
|
|
59
|
+
export type ReviewEnginePermissionFn = (
|
|
60
|
+
request: EnginePermissionReviewRequest,
|
|
61
|
+
) => Promise<BouncerDecision>
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Inferred type of the value returned by `OpencodeClient.event.subscribe()`.
|
|
65
|
+
* We derive it from the SDK rather than importing the underlying
|
|
66
|
+
* `ServerSentEventsResult` symbol so we don't depend on an internal SDK
|
|
67
|
+
* module path that isn't part of its public exports map.
|
|
68
|
+
*/
|
|
69
|
+
type EventSubscription = Awaited<
|
|
70
|
+
ReturnType<OpencodeClient['event']['subscribe']>
|
|
71
|
+
>
|
|
72
|
+
|
|
73
|
+
type Resolver = (r: IteratorResult<EngineEvent>) => void
|
|
74
|
+
|
|
75
|
+
/** Construction-time dependencies for {@link OpenCodeEngine}. */
|
|
76
|
+
export interface OpenCodeEngineOptions {
|
|
77
|
+
/**
|
|
78
|
+
* Typed SDK client, already bound to a running opencode server. Usually
|
|
79
|
+
* obtained from `OpenCodeServerManager.getClient()`. Tests inject a
|
|
80
|
+
* hand-rolled mock matching the subset of methods used here.
|
|
81
|
+
*/
|
|
82
|
+
client: OpencodeClient
|
|
83
|
+
/**
|
|
84
|
+
* Directory query parameter forwarded to each request. OpenCode scopes
|
|
85
|
+
* sessions and events by directory — the value is typically the same
|
|
86
|
+
* working directory passed to `startSession`.
|
|
87
|
+
*/
|
|
88
|
+
directory?: string
|
|
89
|
+
/**
|
|
90
|
+
* Override the bouncer review function. Defaults to
|
|
91
|
+
* `reviewEnginePermission` from `cli/server/mcp/bouncer-integration.ts`
|
|
92
|
+
* — which wraps the unified {@link reviewOperation} entry point used by
|
|
93
|
+
* the Claude MCP path. Tests inject a stub to drive specific decisions.
|
|
94
|
+
*/
|
|
95
|
+
reviewPermission?: ReviewEnginePermissionFn
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Narrowing helper: OpenCode wraps the message union under `info`. */
|
|
99
|
+
function isAssistantMessage(msg: Message): msg is AssistantMessage {
|
|
100
|
+
return msg.role === 'assistant'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Token counts from `StepFinishPart` or the `AssistantMessage.tokens`
|
|
105
|
+
* object. Both sources share the same shape.
|
|
106
|
+
*/
|
|
107
|
+
interface TokenCounts {
|
|
108
|
+
input: number
|
|
109
|
+
output: number
|
|
110
|
+
reasoning: number
|
|
111
|
+
cache: {
|
|
112
|
+
read: number
|
|
113
|
+
write: number
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class OpenCodeEngine implements CodingAgentEngine {
|
|
118
|
+
readonly engineId: EngineId = 'opencode'
|
|
119
|
+
|
|
120
|
+
private readonly client: OpencodeClient
|
|
121
|
+
private readonly directory: string | undefined
|
|
122
|
+
private readonly reviewPermission: ReviewEnginePermissionFn
|
|
123
|
+
|
|
124
|
+
private sessionOptions: StartSessionOptions | null = null
|
|
125
|
+
private openCodeSessionId: string | undefined
|
|
126
|
+
/** True once the caller has called `startSession` successfully. */
|
|
127
|
+
private started = false
|
|
128
|
+
|
|
129
|
+
/** Active SSE subscription returned by `client.event.subscribe()`. */
|
|
130
|
+
private subscription: EventSubscription | null = null
|
|
131
|
+
/** Background task consuming `subscription.stream`. */
|
|
132
|
+
private pumpPromise: Promise<void> | null = null
|
|
133
|
+
|
|
134
|
+
/** In-flight prompt promise — enforces the one-prompt-at-a-time contract. */
|
|
135
|
+
private currentPromptPromise: Promise<void> | null = null
|
|
136
|
+
|
|
137
|
+
private disposed = false
|
|
138
|
+
private iteratorDone = false
|
|
139
|
+
private readonly queue: EngineEvent[] = []
|
|
140
|
+
private readonly pending: Resolver[] = []
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Tool-call id → start timestamp. Populated on the first `running`
|
|
144
|
+
* state of a ToolPart so we can compute `durationMs` when the part
|
|
145
|
+
* transitions to `completed` or `error`.
|
|
146
|
+
*/
|
|
147
|
+
private readonly toolStartTimes: Map<string, number> = new Map()
|
|
148
|
+
/**
|
|
149
|
+
* Tool-call ids we have already announced via `tool.start`. Prevents
|
|
150
|
+
* duplicate starts when OpenCode emits multiple `running` updates.
|
|
151
|
+
*/
|
|
152
|
+
private readonly toolStarted: Set<string> = new Set()
|
|
153
|
+
|
|
154
|
+
private usage: EngineUsage = {
|
|
155
|
+
inputTokens: 0,
|
|
156
|
+
outputTokens: 0,
|
|
157
|
+
lastUpdatedAt: Date.now(),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
constructor(options: OpenCodeEngineOptions) {
|
|
161
|
+
if (!options || !options.client) {
|
|
162
|
+
throw new Error('OpenCodeEngine: client is required')
|
|
163
|
+
}
|
|
164
|
+
this.client = options.client
|
|
165
|
+
this.directory = options.directory
|
|
166
|
+
this.reviewPermission = options.reviewPermission ?? reviewEnginePermission
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async startSession(options: StartSessionOptions): Promise<void> {
|
|
170
|
+
if (this.started) {
|
|
171
|
+
throw new Error('OpenCodeEngine: startSession called more than once')
|
|
172
|
+
}
|
|
173
|
+
if (this.disposed) {
|
|
174
|
+
throw new Error('OpenCodeEngine: cannot start a disposed engine')
|
|
175
|
+
}
|
|
176
|
+
this.sessionOptions = options
|
|
177
|
+
const dir = options.workingDir || this.directory
|
|
178
|
+
|
|
179
|
+
if (options.resumeSessionId) {
|
|
180
|
+
this.openCodeSessionId = options.resumeSessionId
|
|
181
|
+
} else {
|
|
182
|
+
const created = await this.client.session.create({
|
|
183
|
+
query: dir ? { directory: dir } : undefined,
|
|
184
|
+
})
|
|
185
|
+
const session = extractData<{ id: string }>(created)
|
|
186
|
+
if (!session || typeof session.id !== 'string') {
|
|
187
|
+
throw new Error(
|
|
188
|
+
'OpenCodeEngine: session.create did not return a session id',
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
this.openCodeSessionId = session.id
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
this.subscription = await this.client.event.subscribe({
|
|
195
|
+
query: dir ? { directory: dir } : undefined,
|
|
196
|
+
})
|
|
197
|
+
this.pumpPromise = this.runEventPump()
|
|
198
|
+
this.started = true
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async sendPrompt(
|
|
202
|
+
prompt: string,
|
|
203
|
+
_attachments?: PromptAttachment[],
|
|
204
|
+
): Promise<void> {
|
|
205
|
+
if (!this.started || !this.openCodeSessionId) {
|
|
206
|
+
throw new Error('OpenCodeEngine: sendPrompt called before startSession')
|
|
207
|
+
}
|
|
208
|
+
if (this.disposed) {
|
|
209
|
+
throw new Error('OpenCodeEngine: sendPrompt called after dispose')
|
|
210
|
+
}
|
|
211
|
+
if (this.currentPromptPromise) {
|
|
212
|
+
throw new Error('OpenCodeEngine: another prompt is already in flight')
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const model = parseModel(this.sessionOptions?.model)
|
|
216
|
+
const sendPromise = (async () => {
|
|
217
|
+
const result = await this.client.session.promptAsync({
|
|
218
|
+
path: { id: this.openCodeSessionId as string },
|
|
219
|
+
query: this.directory ? { directory: this.directory } : undefined,
|
|
220
|
+
body: {
|
|
221
|
+
parts: [{ type: 'text', text: prompt }],
|
|
222
|
+
...(model ? { model } : {}),
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
const err = extractError(result)
|
|
226
|
+
if (err) {
|
|
227
|
+
throw new Error(err)
|
|
228
|
+
}
|
|
229
|
+
})()
|
|
230
|
+
|
|
231
|
+
this.currentPromptPromise = sendPromise
|
|
232
|
+
try {
|
|
233
|
+
await sendPromise
|
|
234
|
+
} finally {
|
|
235
|
+
this.currentPromptPromise = null
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async cancel(): Promise<void> {
|
|
240
|
+
if (!this.openCodeSessionId || this.disposed) return
|
|
241
|
+
try {
|
|
242
|
+
await this.client.session.abort({
|
|
243
|
+
path: { id: this.openCodeSessionId },
|
|
244
|
+
query: this.directory ? { directory: this.directory } : undefined,
|
|
245
|
+
})
|
|
246
|
+
} catch (err) {
|
|
247
|
+
// Swallow — the Bouncer/UI layer only cares that we asked. A real
|
|
248
|
+
// failure surfaces as an `engine.error` emitted by the event pump.
|
|
249
|
+
this.emit({
|
|
250
|
+
kind: 'engine.error',
|
|
251
|
+
sessionId: this.sessionIdForEvent(),
|
|
252
|
+
timestamp: Date.now(),
|
|
253
|
+
code: 'OPENCODE_ABORT_ERROR',
|
|
254
|
+
message: err instanceof Error ? err.message : String(err),
|
|
255
|
+
fatal: false,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
getUsage(): EngineUsage {
|
|
261
|
+
return { ...this.usage }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async dispose(): Promise<void> {
|
|
265
|
+
if (this.disposed) return
|
|
266
|
+
this.disposed = true
|
|
267
|
+
|
|
268
|
+
// Stop the SSE pump. Calling `return()` on the generator is the
|
|
269
|
+
// documented way to break a `for await` loop inside the pump.
|
|
270
|
+
const sub = this.subscription
|
|
271
|
+
this.subscription = null
|
|
272
|
+
if (sub) {
|
|
273
|
+
try {
|
|
274
|
+
await sub.stream.return(undefined)
|
|
275
|
+
} catch {
|
|
276
|
+
// ignore — stream may already be exhausted
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (this.pumpPromise) {
|
|
281
|
+
try {
|
|
282
|
+
await this.pumpPromise
|
|
283
|
+
} catch {
|
|
284
|
+
// pump errors are already surfaced as engine.error events
|
|
285
|
+
}
|
|
286
|
+
this.pumpPromise = null
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Best-effort session deletion. Failures are non-fatal at dispose.
|
|
290
|
+
if (this.openCodeSessionId) {
|
|
291
|
+
try {
|
|
292
|
+
await this.client.session.delete({
|
|
293
|
+
path: { id: this.openCodeSessionId },
|
|
294
|
+
query: this.directory ? { directory: this.directory } : undefined,
|
|
295
|
+
})
|
|
296
|
+
} catch {
|
|
297
|
+
// ignore
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
this.closeIterator()
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
[Symbol.asyncIterator](): AsyncIterator<EngineEvent> {
|
|
305
|
+
return {
|
|
306
|
+
next: (): Promise<IteratorResult<EngineEvent>> => {
|
|
307
|
+
if (this.queue.length > 0) {
|
|
308
|
+
return Promise.resolve({
|
|
309
|
+
value: this.queue.shift() as EngineEvent,
|
|
310
|
+
done: false,
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
if (this.iteratorDone) {
|
|
314
|
+
return Promise.resolve({ value: undefined, done: true })
|
|
315
|
+
}
|
|
316
|
+
return new Promise<IteratorResult<EngineEvent>>((resolve) => {
|
|
317
|
+
this.pending.push(resolve)
|
|
318
|
+
})
|
|
319
|
+
},
|
|
320
|
+
return: (): Promise<IteratorResult<EngineEvent>> => {
|
|
321
|
+
this.closeIterator()
|
|
322
|
+
return Promise.resolve({ value: undefined, done: true })
|
|
323
|
+
},
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------- private ----------
|
|
328
|
+
|
|
329
|
+
private sessionIdForEvent(): string {
|
|
330
|
+
return this.openCodeSessionId ?? 'pending'
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
private emit(event: EngineEvent): void {
|
|
334
|
+
if (this.iteratorDone) return
|
|
335
|
+
const resolver = this.pending.shift()
|
|
336
|
+
if (resolver) {
|
|
337
|
+
resolver({ value: event, done: false })
|
|
338
|
+
} else {
|
|
339
|
+
this.queue.push(event)
|
|
340
|
+
}
|
|
341
|
+
if (event.kind === 'engine.error' && event.fatal) {
|
|
342
|
+
this.closeIterator()
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private closeIterator(): void {
|
|
347
|
+
if (this.iteratorDone) return
|
|
348
|
+
this.iteratorDone = true
|
|
349
|
+
const waiting = this.pending.splice(0)
|
|
350
|
+
for (const resolve of waiting) {
|
|
351
|
+
resolve({ value: undefined, done: true })
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Long-running task that consumes the SSE stream. Exits when the stream
|
|
357
|
+
* ends naturally (dispose called `stream.return()`) or when an error
|
|
358
|
+
* propagates out of the generator.
|
|
359
|
+
*/
|
|
360
|
+
private async runEventPump(): Promise<void> {
|
|
361
|
+
const sub = this.subscription
|
|
362
|
+
if (!sub) return
|
|
363
|
+
try {
|
|
364
|
+
for await (const event of sub.stream) {
|
|
365
|
+
if (this.disposed) break
|
|
366
|
+
this.handleSseEvent(event)
|
|
367
|
+
}
|
|
368
|
+
} catch (err) {
|
|
369
|
+
if (this.disposed) return
|
|
370
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
371
|
+
this.emit({
|
|
372
|
+
kind: 'engine.error',
|
|
373
|
+
sessionId: this.sessionIdForEvent(),
|
|
374
|
+
timestamp: Date.now(),
|
|
375
|
+
code: 'OPENCODE_EVENT_STREAM_ERROR',
|
|
376
|
+
message,
|
|
377
|
+
fatal: true,
|
|
378
|
+
})
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Core mapping from OpenCode SSE events to EngineEvents.
|
|
384
|
+
*
|
|
385
|
+
* - message.part.updated (TextPart) → message.delta
|
|
386
|
+
* - message.part.updated (ReasoningPart) → message.thinking
|
|
387
|
+
* - message.part.updated (ToolPart running) → tool.start (once per callID)
|
|
388
|
+
* - message.part.updated (ToolPart done) → tool.end
|
|
389
|
+
* - message.part.updated (StepFinishPart) → usage.update
|
|
390
|
+
* - message.updated (AssistantMessage) → usage.update (if tokens set)
|
|
391
|
+
* - permission.updated → permission.request
|
|
392
|
+
* - session.idle → session.idle
|
|
393
|
+
* - session.error → engine.error
|
|
394
|
+
*
|
|
395
|
+
* Events for sessions other than the one we own are ignored so that a
|
|
396
|
+
* shared server emitting events for multiple clients does not cross
|
|
397
|
+
* streams.
|
|
398
|
+
*/
|
|
399
|
+
private handleSseEvent(event: SseEvent): void {
|
|
400
|
+
if (!this.openCodeSessionId) return
|
|
401
|
+
const ours = belongsToSession(event, this.openCodeSessionId)
|
|
402
|
+
if (!ours) return
|
|
403
|
+
|
|
404
|
+
switch (event.type) {
|
|
405
|
+
case 'message.part.updated':
|
|
406
|
+
this.handlePartUpdated(event.properties.part, event.properties.delta)
|
|
407
|
+
return
|
|
408
|
+
|
|
409
|
+
case 'message.updated':
|
|
410
|
+
this.handleMessageUpdated(event.properties.info)
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
case 'permission.updated': {
|
|
414
|
+
const p = event.properties
|
|
415
|
+
// Emit the observability event first so consumers (UI, audit) see
|
|
416
|
+
// the permission request before the Bouncer decision arrives.
|
|
417
|
+
this.emit({
|
|
418
|
+
kind: 'permission.request',
|
|
419
|
+
sessionId: this.sessionIdForEvent(),
|
|
420
|
+
timestamp: Date.now(),
|
|
421
|
+
raw: p,
|
|
422
|
+
requestId: p.id,
|
|
423
|
+
toolName: p.type,
|
|
424
|
+
input: (p.metadata ?? {}) as Record<string, unknown>,
|
|
425
|
+
reason: p.title,
|
|
426
|
+
})
|
|
427
|
+
// Fire-and-forget: resolve via the Bouncer, respond through the
|
|
428
|
+
// SDK, and — on denial — emit a user-visible engine.error. We do
|
|
429
|
+
// not block the SSE pump on this async work so other events from
|
|
430
|
+
// the same stream continue to flow.
|
|
431
|
+
void this.resolvePermission(p)
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
case 'session.idle':
|
|
436
|
+
this.emit({
|
|
437
|
+
kind: 'session.idle',
|
|
438
|
+
sessionId: this.sessionIdForEvent(),
|
|
439
|
+
timestamp: Date.now(),
|
|
440
|
+
})
|
|
441
|
+
return
|
|
442
|
+
|
|
443
|
+
case 'session.error': {
|
|
444
|
+
const errObj = event.properties.error
|
|
445
|
+
const msg =
|
|
446
|
+
errObj && 'data' in errObj && errObj.data && 'message' in errObj.data
|
|
447
|
+
? String((errObj.data as { message?: unknown }).message ?? '')
|
|
448
|
+
: errObj?.name ?? 'OpenCode session error'
|
|
449
|
+
this.emit({
|
|
450
|
+
kind: 'engine.error',
|
|
451
|
+
sessionId: this.sessionIdForEvent(),
|
|
452
|
+
timestamp: Date.now(),
|
|
453
|
+
raw: errObj,
|
|
454
|
+
code: errObj?.name ?? 'OPENCODE_SESSION_ERROR',
|
|
455
|
+
message: msg,
|
|
456
|
+
fatal: false,
|
|
457
|
+
})
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
default:
|
|
462
|
+
return
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Route an OpenCode `permission.updated` through the unified Bouncer
|
|
468
|
+
* and respond to the SDK so the server never hangs waiting.
|
|
469
|
+
*
|
|
470
|
+
* Contract:
|
|
471
|
+
* - Approval (allow / warn_allow) → SDK `{ response: 'once' }`.
|
|
472
|
+
* - Denial (deny) → SDK `{ response: 'reject' }` *and*
|
|
473
|
+
* a user-visible `engine.error` carrying the same message the Claude
|
|
474
|
+
* MCP path returns on a deny (see `cli/server/mcp/server.ts`), so
|
|
475
|
+
* both engines surface denials with identical wording.
|
|
476
|
+
*
|
|
477
|
+
* Any error — bouncer failure, SDK failure — is treated as a deny for
|
|
478
|
+
* safety: we tell OpenCode `reject`, emit an engine.error, and keep the
|
|
479
|
+
* session alive (non-fatal).
|
|
480
|
+
*/
|
|
481
|
+
private async resolvePermission(permission: Permission): Promise<void> {
|
|
482
|
+
if (this.disposed) return
|
|
483
|
+
const sessionId = this.openCodeSessionId
|
|
484
|
+
if (!sessionId) return
|
|
485
|
+
|
|
486
|
+
const decision = await this.reviewPermissionSafely(permission, sessionId)
|
|
487
|
+
const approved = decision.decision !== 'deny'
|
|
488
|
+
const response: 'once' | 'reject' = approved ? 'once' : 'reject'
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
await this.client.postSessionIdPermissionsPermissionId({
|
|
492
|
+
path: { id: sessionId, permissionID: permission.id },
|
|
493
|
+
body: { response },
|
|
494
|
+
query: this.directory ? { directory: this.directory } : undefined,
|
|
495
|
+
})
|
|
496
|
+
} catch (err) {
|
|
497
|
+
if (this.disposed) return
|
|
498
|
+
this.emit({
|
|
499
|
+
kind: 'engine.error',
|
|
500
|
+
sessionId: this.sessionIdForEvent(),
|
|
501
|
+
timestamp: Date.now(),
|
|
502
|
+
code: 'OPENCODE_PERMISSION_RESPOND_ERROR',
|
|
503
|
+
message: err instanceof Error ? err.message : String(err),
|
|
504
|
+
fatal: false,
|
|
505
|
+
raw: { permissionId: permission.id, response },
|
|
506
|
+
})
|
|
507
|
+
// Without a successful respond, OpenCode will time out on its side.
|
|
508
|
+
// Still emit the denial event below if this was a deny decision so
|
|
509
|
+
// the user sees why their operation was blocked.
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (!approved && !this.disposed) {
|
|
513
|
+
this.emit({
|
|
514
|
+
kind: 'engine.error',
|
|
515
|
+
sessionId: this.sessionIdForEvent(),
|
|
516
|
+
timestamp: Date.now(),
|
|
517
|
+
code: 'BOUNCER_DENIED',
|
|
518
|
+
message: formatDenialMessage(decision),
|
|
519
|
+
fatal: false,
|
|
520
|
+
raw: { permissionId: permission.id, toolName: permission.type, decision },
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private async reviewPermissionSafely(
|
|
526
|
+
permission: Permission,
|
|
527
|
+
sessionId: string,
|
|
528
|
+
): Promise<BouncerDecision> {
|
|
529
|
+
try {
|
|
530
|
+
return await this.reviewPermission({
|
|
531
|
+
toolName: permission.type,
|
|
532
|
+
input: (permission.metadata ?? {}) as Record<string, unknown>,
|
|
533
|
+
context: {
|
|
534
|
+
purpose: 'OpenCode permission request',
|
|
535
|
+
workingDirectory: this.directory ?? this.sessionOptions?.workingDir,
|
|
536
|
+
sessionId,
|
|
537
|
+
},
|
|
538
|
+
})
|
|
539
|
+
} catch (err) {
|
|
540
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
541
|
+
return {
|
|
542
|
+
decision: 'deny',
|
|
543
|
+
confidence: 0,
|
|
544
|
+
reasoning: `Security analysis failed: ${message}. Denying for safety.`,
|
|
545
|
+
threatLevel: 'critical',
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private handlePartUpdated(part: Part, delta: string | undefined): void {
|
|
551
|
+
switch (part.type) {
|
|
552
|
+
case 'text':
|
|
553
|
+
this.onTextPart(part, delta)
|
|
554
|
+
return
|
|
555
|
+
case 'reasoning':
|
|
556
|
+
this.onReasoningPart(part, delta)
|
|
557
|
+
return
|
|
558
|
+
case 'tool':
|
|
559
|
+
this.onToolPart(part)
|
|
560
|
+
return
|
|
561
|
+
case 'step-finish':
|
|
562
|
+
this.onStepFinish(part)
|
|
563
|
+
return
|
|
564
|
+
default:
|
|
565
|
+
return
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
private onTextPart(part: TextPart, delta: string | undefined): void {
|
|
570
|
+
// OpenCode sets `delta` to the incremental chunk. Fall back to the
|
|
571
|
+
// full text if `delta` is absent and this is the first time we see
|
|
572
|
+
// this part id (best effort — the contract only requires the text
|
|
573
|
+
// be eventually concatenable).
|
|
574
|
+
const text = delta ?? ''
|
|
575
|
+
if (!text) return
|
|
576
|
+
this.emit({
|
|
577
|
+
kind: 'message.delta',
|
|
578
|
+
sessionId: this.sessionIdForEvent(),
|
|
579
|
+
timestamp: Date.now(),
|
|
580
|
+
text,
|
|
581
|
+
raw: part,
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private onReasoningPart(
|
|
586
|
+
part: ReasoningPart,
|
|
587
|
+
delta: string | undefined,
|
|
588
|
+
): void {
|
|
589
|
+
const text = delta ?? ''
|
|
590
|
+
if (!text) return
|
|
591
|
+
this.emit({
|
|
592
|
+
kind: 'message.thinking',
|
|
593
|
+
sessionId: this.sessionIdForEvent(),
|
|
594
|
+
timestamp: Date.now(),
|
|
595
|
+
text,
|
|
596
|
+
raw: part,
|
|
597
|
+
})
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private onToolPart(part: ToolPart): void {
|
|
601
|
+
const callId = part.callID
|
|
602
|
+
if (!callId) return
|
|
603
|
+
const status = part.state.status
|
|
604
|
+
if (status === 'running' || status === 'pending') {
|
|
605
|
+
this.emitToolStartOnce(callId, part)
|
|
606
|
+
return
|
|
607
|
+
}
|
|
608
|
+
if (status === 'completed' || status === 'error') {
|
|
609
|
+
this.emitToolStartOnce(callId, part)
|
|
610
|
+
this.emitToolEnd(callId, part)
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private emitToolStartOnce(callId: string, part: ToolPart): void {
|
|
615
|
+
if (this.toolStarted.has(callId)) return
|
|
616
|
+
const now = Date.now()
|
|
617
|
+
this.toolStarted.add(callId)
|
|
618
|
+
this.toolStartTimes.set(callId, now)
|
|
619
|
+
this.emit({
|
|
620
|
+
kind: 'tool.start',
|
|
621
|
+
sessionId: this.sessionIdForEvent(),
|
|
622
|
+
timestamp: now,
|
|
623
|
+
toolCallId: callId,
|
|
624
|
+
toolName: part.tool,
|
|
625
|
+
input: (part.state.input ?? {}) as Record<string, unknown>,
|
|
626
|
+
raw: part,
|
|
627
|
+
})
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
private emitToolEnd(callId: string, part: ToolPart): void {
|
|
631
|
+
const state = part.state
|
|
632
|
+
const now = Date.now()
|
|
633
|
+
const start = this.toolStartTimes.get(callId) ?? now
|
|
634
|
+
const isError = state.status === 'error'
|
|
635
|
+
const result = isError
|
|
636
|
+
? (state as { error: string }).error
|
|
637
|
+
: (state as { output: string }).output
|
|
638
|
+
this.emit({
|
|
639
|
+
kind: 'tool.end',
|
|
640
|
+
sessionId: this.sessionIdForEvent(),
|
|
641
|
+
timestamp: now,
|
|
642
|
+
toolCallId: callId,
|
|
643
|
+
toolName: part.tool,
|
|
644
|
+
input: (state.input ?? {}) as Record<string, unknown>,
|
|
645
|
+
result: result ?? '',
|
|
646
|
+
isError,
|
|
647
|
+
durationMs: Math.max(0, now - start),
|
|
648
|
+
raw: part,
|
|
649
|
+
})
|
|
650
|
+
this.toolStartTimes.delete(callId)
|
|
651
|
+
// Keep `toolStarted` set so late duplicate `running` updates are
|
|
652
|
+
// deduped rather than spawning a new tool.start for a finished call.
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private onStepFinish(part: StepFinishPart): void {
|
|
656
|
+
this.applyTokens(part.tokens, part)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
private handleMessageUpdated(msg: Message): void {
|
|
660
|
+
if (!isAssistantMessage(msg)) return
|
|
661
|
+
if (!msg.tokens) return
|
|
662
|
+
this.applyTokens(msg.tokens, msg)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private applyTokens(tokens: TokenCounts, raw: unknown): void {
|
|
666
|
+
const input = tokens.input ?? 0
|
|
667
|
+
const output = tokens.output ?? 0
|
|
668
|
+
const cacheRead = tokens.cache?.read ?? 0
|
|
669
|
+
const cacheWrite = tokens.cache?.write ?? 0
|
|
670
|
+
// Contract: usage values are monotonically non-decreasing. If this
|
|
671
|
+
// update regresses any counter, keep the running maximum.
|
|
672
|
+
const nextInput = Math.max(input, this.usage.inputTokens)
|
|
673
|
+
const nextOutput = Math.max(output, this.usage.outputTokens)
|
|
674
|
+
const nextCacheRead = Math.max(
|
|
675
|
+
cacheRead,
|
|
676
|
+
this.usage.cacheReadTokens ?? 0,
|
|
677
|
+
)
|
|
678
|
+
const nextCacheWrite = Math.max(
|
|
679
|
+
cacheWrite,
|
|
680
|
+
this.usage.cacheCreationTokens ?? 0,
|
|
681
|
+
)
|
|
682
|
+
const changed =
|
|
683
|
+
nextInput !== this.usage.inputTokens ||
|
|
684
|
+
nextOutput !== this.usage.outputTokens ||
|
|
685
|
+
nextCacheRead !== (this.usage.cacheReadTokens ?? 0) ||
|
|
686
|
+
nextCacheWrite !== (this.usage.cacheCreationTokens ?? 0)
|
|
687
|
+
if (!changed) return
|
|
688
|
+
|
|
689
|
+
this.usage = {
|
|
690
|
+
inputTokens: nextInput,
|
|
691
|
+
outputTokens: nextOutput,
|
|
692
|
+
cacheReadTokens: nextCacheRead,
|
|
693
|
+
cacheCreationTokens: nextCacheWrite,
|
|
694
|
+
lastUpdatedAt: Date.now(),
|
|
695
|
+
}
|
|
696
|
+
this.emit({
|
|
697
|
+
kind: 'usage.update',
|
|
698
|
+
sessionId: this.sessionIdForEvent(),
|
|
699
|
+
timestamp: Date.now(),
|
|
700
|
+
inputTokens: nextInput,
|
|
701
|
+
outputTokens: nextOutput,
|
|
702
|
+
cacheReadTokens: nextCacheRead,
|
|
703
|
+
cacheCreationTokens: nextCacheWrite,
|
|
704
|
+
raw,
|
|
705
|
+
})
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Returns the session id carried by an SSE event, or `undefined` if the
|
|
711
|
+
* event is global/unrelated. Used to filter events for the session we own.
|
|
712
|
+
*/
|
|
713
|
+
function belongsToSession(event: SseEvent, ownSessionId: string): boolean {
|
|
714
|
+
switch (event.type) {
|
|
715
|
+
case 'message.part.updated':
|
|
716
|
+
return event.properties.part.sessionID === ownSessionId
|
|
717
|
+
case 'message.updated':
|
|
718
|
+
return event.properties.info.sessionID === ownSessionId
|
|
719
|
+
case 'permission.updated':
|
|
720
|
+
return event.properties.sessionID === ownSessionId
|
|
721
|
+
case 'session.idle':
|
|
722
|
+
return event.properties.sessionID === ownSessionId
|
|
723
|
+
case 'session.error':
|
|
724
|
+
// sessionID is optional on error events. If absent, treat as ours.
|
|
725
|
+
return (
|
|
726
|
+
!event.properties.sessionID ||
|
|
727
|
+
event.properties.sessionID === ownSessionId
|
|
728
|
+
)
|
|
729
|
+
default:
|
|
730
|
+
return false
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Parse the `model` option string into OpenCode's `{ providerID, modelID }`
|
|
736
|
+
* shape. Accepts `"provider/model"` slugs (documented format in
|
|
737
|
+
* StartSessionOptions). Returns `undefined` for unparseable input so the
|
|
738
|
+
* server falls back to its default model.
|
|
739
|
+
*/
|
|
740
|
+
function parseModel(
|
|
741
|
+
modelString: string | undefined,
|
|
742
|
+
): { providerID: string; modelID: string } | undefined {
|
|
743
|
+
if (!modelString) return undefined
|
|
744
|
+
const slashIndex = modelString.indexOf('/')
|
|
745
|
+
if (slashIndex <= 0 || slashIndex === modelString.length - 1) {
|
|
746
|
+
return undefined
|
|
747
|
+
}
|
|
748
|
+
return {
|
|
749
|
+
providerID: modelString.slice(0, slashIndex),
|
|
750
|
+
modelID: modelString.slice(slashIndex + 1),
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* The SDK client returns `{ data, error, response, request }` by default.
|
|
756
|
+
* Pull out `data` regardless of whether the caller configured ThrowOnError.
|
|
757
|
+
*/
|
|
758
|
+
function extractData<T>(result: unknown): T | undefined {
|
|
759
|
+
if (result && typeof result === 'object' && 'data' in result) {
|
|
760
|
+
return (result as { data?: T }).data
|
|
761
|
+
}
|
|
762
|
+
return result as T
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Return the error message from an SDK response, or `undefined` if the
|
|
767
|
+
* call succeeded. Mirrors `extractData` for the error channel.
|
|
768
|
+
*/
|
|
769
|
+
function extractError(result: unknown): string | undefined {
|
|
770
|
+
if (
|
|
771
|
+
result &&
|
|
772
|
+
typeof result === 'object' &&
|
|
773
|
+
'error' in result &&
|
|
774
|
+
(result as { error?: unknown }).error
|
|
775
|
+
) {
|
|
776
|
+
const err = (result as { error: unknown }).error
|
|
777
|
+
if (err && typeof err === 'object' && 'data' in err) {
|
|
778
|
+
const data = (err as { data?: unknown }).data
|
|
779
|
+
if (data && typeof data === 'object' && 'message' in data) {
|
|
780
|
+
return String((data as { message?: unknown }).message ?? 'OpenCode error')
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return err instanceof Error ? err.message : JSON.stringify(err)
|
|
784
|
+
}
|
|
785
|
+
return undefined
|
|
786
|
+
}
|