the-campfire 0.1.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/bin/cli.ts +182 -0
- package/dist/apple-touch-icon.png +0 -0
- package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
- package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
- package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
- package/dist/assets/arc-D9SEA8dX.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
- package/dist/assets/channel-CxhcHn7S.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
- package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
- package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
- package/dist/assets/clone-C6kt1rE7.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
- package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
- package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
- package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
- package/dist/assets/graph-DKRbrxcs.js +1 -0
- package/dist/assets/index-Byjg9zgI.css +32 -0
- package/dist/assets/index-Cwj3m0hT.js +284 -0
- package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
- package/dist/assets/katex-BlHpptmG.js +261 -0
- package/dist/assets/layout-DnxSwLDJ.js +1 -0
- package/dist/assets/linear-CWbbIT8l.js +1 -0
- package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
- package/dist/assets/ordinal-BENe2yWM.js +1 -0
- package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
- package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
- package/dist/favicon.svg +3 -0
- package/dist/fonts/Geist-Variable.woff2 +0 -0
- package/dist/fonts/GeistMono-Variable.woff2 +0 -0
- package/dist/icon-192.png +0 -0
- package/dist/icon-512.png +0 -0
- package/dist/index.html +33 -0
- package/dist/logo-codex.svg +14 -0
- package/dist/logo.svg +3 -0
- package/dist/manifest.json +26 -0
- package/dist/og-image.png +0 -0
- package/dist/sw.js +130 -0
- package/package.json +92 -0
- package/server/ADAPTERS.md +121 -0
- package/server/adapter-registry-types.ts +28 -0
- package/server/adapter-registry.ts +293 -0
- package/server/adapter-types.ts +51 -0
- package/server/agent-executor.ts +249 -0
- package/server/agent-mcp-bridge.ts +148 -0
- package/server/agent-mcp-stdio.ts +171 -0
- package/server/agent-mcp-tools.ts +76 -0
- package/server/agent-store.ts +183 -0
- package/server/agent-types.ts +79 -0
- package/server/aider-adapter.ts +525 -0
- package/server/auth.ts +153 -0
- package/server/auto-namer.ts +132 -0
- package/server/capability-discovery.ts +431 -0
- package/server/claude-container-auth.ts +40 -0
- package/server/claude-stdio-adapter.ts +452 -0
- package/server/clawhub-export.ts +218 -0
- package/server/cli-launcher.ts +1613 -0
- package/server/codex-adapter.ts +1987 -0
- package/server/codex-container-auth.ts +40 -0
- package/server/codex-home.ts +26 -0
- package/server/collective-intelligence.ts +401 -0
- package/server/commands-discovery.ts +160 -0
- package/server/constants.ts +5 -0
- package/server/container-manager.ts +328 -0
- package/server/cron-scheduler.ts +243 -0
- package/server/cron-store.ts +149 -0
- package/server/cron-types.ts +63 -0
- package/server/deliberation-engine.ts +323 -0
- package/server/embedding.ts +89 -0
- package/server/env-manager.ts +146 -0
- package/server/environment-detector.ts +106 -0
- package/server/environment-rules.ts +106 -0
- package/server/gallery-store.ts +216 -0
- package/server/gallery-types.ts +52 -0
- package/server/gallery-votes.ts +101 -0
- package/server/git-utils.ts +384 -0
- package/server/github-pr.ts +379 -0
- package/server/goose-adapter.ts +929 -0
- package/server/image-pull-manager.ts +144 -0
- package/server/index.ts +363 -0
- package/server/linear-project-manager.ts +78 -0
- package/server/moltbook-client.ts +160 -0
- package/server/openclaw-adapter.ts +916 -0
- package/server/openclaw-channel/channel.ts +188 -0
- package/server/openclaw-channel/index.ts +68 -0
- package/server/openclaw-channel/package.json +19 -0
- package/server/openclaw-channel/types.ts +93 -0
- package/server/opencode-adapter.ts +951 -0
- package/server/openhands-adapter.ts +775 -0
- package/server/orchestrator-executor.ts +184 -0
- package/server/orchestrator-store.ts +106 -0
- package/server/orchestrator-types.ts +86 -0
- package/server/path-resolver.ts +168 -0
- package/server/pr-poller.ts +162 -0
- package/server/proactive-keepalive.ts +121 -0
- package/server/prompt-manager.ts +103 -0
- package/server/protocol-monitor.ts +197 -0
- package/server/race-controller.ts +295 -0
- package/server/race-store.ts +82 -0
- package/server/recorder.ts +359 -0
- package/server/recording-hub/compat-validator.ts +122 -0
- package/server/recording-hub/diagnostics.ts +289 -0
- package/server/recording-hub/hub-routes.ts +146 -0
- package/server/recording-hub/hub-store.ts +224 -0
- package/server/replay.ts +78 -0
- package/server/routes/adapter-routes.ts +31 -0
- package/server/routes/agent-mcp-routes.ts +35 -0
- package/server/routes/agent-routes.ts +185 -0
- package/server/routes/auth-routes.ts +101 -0
- package/server/routes/ci-routes.ts +165 -0
- package/server/routes/commands-routes.ts +94 -0
- package/server/routes/cron-routes.ts +100 -0
- package/server/routes/env-routes.ts +50 -0
- package/server/routes/folder-routes.ts +56 -0
- package/server/routes/fs-routes.ts +403 -0
- package/server/routes/gallery-routes.ts +410 -0
- package/server/routes/git-routes.ts +108 -0
- package/server/routes/index.ts +62 -0
- package/server/routes/linear-routes.ts +254 -0
- package/server/routes/monitor-routes.ts +11 -0
- package/server/routes/orchestrator-routes.ts +210 -0
- package/server/routes/prompt-routes.ts +47 -0
- package/server/routes/race-routes.ts +103 -0
- package/server/routes/recording-routes.ts +101 -0
- package/server/routes/route-deps.ts +32 -0
- package/server/routes/session-routes.ts +560 -0
- package/server/routes/settings-routes.ts +108 -0
- package/server/routes/skills-routes.ts +52 -0
- package/server/routes/system-routes.ts +243 -0
- package/server/routes/webhook-routes.ts +135 -0
- package/server/routes.ts +44 -0
- package/server/security-middleware.ts +100 -0
- package/server/semantic-memory.ts +389 -0
- package/server/service.ts +733 -0
- package/server/session-folders.ts +107 -0
- package/server/session-git-info.ts +89 -0
- package/server/session-linear-issues.ts +94 -0
- package/server/session-names.ts +67 -0
- package/server/session-store.ts +169 -0
- package/server/session-types.ts +415 -0
- package/server/settings-manager.ts +120 -0
- package/server/shared-context.ts +401 -0
- package/server/skills-manager.ts +250 -0
- package/server/sub-session-manager.ts +263 -0
- package/server/terminal-manager.ts +185 -0
- package/server/update-checker.ts +114 -0
- package/server/usage-limits.ts +192 -0
- package/server/webhook-manager.ts +228 -0
- package/server/webhook-store.ts +168 -0
- package/server/webhook-types.ts +66 -0
- package/server/worktree-tracker.ts +84 -0
- package/server/ws-bridge.ts +2322 -0
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode ACP (Agent Client Protocol) Adapter
|
|
3
|
+
*
|
|
4
|
+
* Translates between the OpenCode ACP JSON-RPC protocol (stdin/stdout)
|
|
5
|
+
* and Campfire's BrowserIncomingMessage/BrowserOutgoingMessage types.
|
|
6
|
+
*
|
|
7
|
+
* This allows the browser to be completely unaware of which backend is running —
|
|
8
|
+
* it sees the same message types regardless of whether Claude Code, Codex, Goose,
|
|
9
|
+
* or OpenCode is the backend.
|
|
10
|
+
*
|
|
11
|
+
* Protocol reference:
|
|
12
|
+
* - OpenCode ACP uses JSON-RPC 2.0 over stdio (newline-delimited)
|
|
13
|
+
* - Methods: initialize, session/new, session/load, session/prompt,
|
|
14
|
+
* session/cancel, session/set_mode
|
|
15
|
+
* - Notifications: session/update with SessionUpdate payloads (kind field)
|
|
16
|
+
* - Permission: session/request_permission (JSON-RPC request, not notification)
|
|
17
|
+
* Client responds via JSON-RPC respond() with outcome field
|
|
18
|
+
*
|
|
19
|
+
* Key differences from Goose:
|
|
20
|
+
* - Notification method is "session/update" (not "session/notification")
|
|
21
|
+
* - Update kind field is update.kind (not update.sessionUpdate)
|
|
22
|
+
* - Update type strings use snake_case: agent_message_chunk, tool_call, etc.
|
|
23
|
+
* - Permissions use JSON-RPC request/response (not notification-based)
|
|
24
|
+
* - protocolVersion is numeric (1) not string ("v1")
|
|
25
|
+
* - clientCapabilities includes fs and terminal capabilities
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { randomUUID } from "node:crypto";
|
|
29
|
+
import type { Subprocess } from "bun";
|
|
30
|
+
import type {
|
|
31
|
+
BrowserIncomingMessage,
|
|
32
|
+
BrowserOutgoingMessage,
|
|
33
|
+
SessionState,
|
|
34
|
+
PermissionRequest,
|
|
35
|
+
CLIResultMessage,
|
|
36
|
+
} from "./session-types.js";
|
|
37
|
+
import type { RecorderManager } from "./recorder.js";
|
|
38
|
+
import type { AgentAdapter } from "./adapter-types.js";
|
|
39
|
+
|
|
40
|
+
// ─── JSON-RPC Types ──────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
interface JsonRpcRequest {
|
|
43
|
+
method: string;
|
|
44
|
+
id: number;
|
|
45
|
+
params: Record<string, unknown>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface JsonRpcNotification {
|
|
49
|
+
method: string;
|
|
50
|
+
params: Record<string, unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
interface JsonRpcResponse {
|
|
54
|
+
id: number;
|
|
55
|
+
result?: unknown;
|
|
56
|
+
error?: { code: number; message: string; data?: unknown };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type JsonRpcMessage = JsonRpcRequest | JsonRpcNotification | JsonRpcResponse;
|
|
60
|
+
|
|
61
|
+
// ─── Adapter Options ─────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
export interface OpenCodeAdapterOptions {
|
|
64
|
+
model?: string;
|
|
65
|
+
cwd?: string;
|
|
66
|
+
permissionMode?: string;
|
|
67
|
+
/** If provided, resume an existing session instead of starting a new one. */
|
|
68
|
+
opencodeSessionId?: string;
|
|
69
|
+
/** Optional recorder for raw message capture. */
|
|
70
|
+
recorder?: RecorderManager;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── JSON-RPC Transport ──────────────────────────────────────────────────────
|
|
74
|
+
// Same transport pattern as GooseAdapter and CodexAdapter.
|
|
75
|
+
|
|
76
|
+
class JsonRpcTransport {
|
|
77
|
+
private nextId = 1;
|
|
78
|
+
private pending = new Map<number, { resolve: (v: unknown) => void; reject: (e: Error) => void }>();
|
|
79
|
+
private notificationHandler: ((method: string, params: Record<string, unknown>) => void) | null = null;
|
|
80
|
+
private requestHandler: ((method: string, id: number, params: Record<string, unknown>) => void) | null = null;
|
|
81
|
+
private rawInCb: ((line: string) => void) | null = null;
|
|
82
|
+
private rawOutCb: ((data: string) => void) | null = null;
|
|
83
|
+
private writer: WritableStreamDefaultWriter<Uint8Array>;
|
|
84
|
+
private connected = true;
|
|
85
|
+
private buffer = "";
|
|
86
|
+
|
|
87
|
+
constructor(
|
|
88
|
+
stdin: WritableStream<Uint8Array> | { write(data: Uint8Array): number },
|
|
89
|
+
stdout: ReadableStream<Uint8Array>,
|
|
90
|
+
) {
|
|
91
|
+
let writable: WritableStream<Uint8Array>;
|
|
92
|
+
if ("write" in stdin && typeof stdin.write === "function") {
|
|
93
|
+
writable = new WritableStream({
|
|
94
|
+
write(chunk) {
|
|
95
|
+
(stdin as { write(data: Uint8Array): number }).write(chunk);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
writable = stdin as WritableStream<Uint8Array>;
|
|
100
|
+
}
|
|
101
|
+
this.writer = writable.getWriter();
|
|
102
|
+
this.readStdout(stdout);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private async readStdout(stdout: ReadableStream<Uint8Array>): Promise<void> {
|
|
106
|
+
const reader = stdout.getReader();
|
|
107
|
+
const decoder = new TextDecoder();
|
|
108
|
+
try {
|
|
109
|
+
while (true) {
|
|
110
|
+
const { done, value } = await reader.read();
|
|
111
|
+
if (done) break;
|
|
112
|
+
this.buffer += decoder.decode(value, { stream: true });
|
|
113
|
+
this.processBuffer();
|
|
114
|
+
}
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error("[opencode-adapter] stdout reader error:", err);
|
|
117
|
+
} finally {
|
|
118
|
+
this.connected = false;
|
|
119
|
+
for (const [, { reject }] of this.pending) {
|
|
120
|
+
reject(new Error("Transport closed"));
|
|
121
|
+
}
|
|
122
|
+
this.pending.clear();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private processBuffer(): void {
|
|
127
|
+
const lines = this.buffer.split("\n");
|
|
128
|
+
this.buffer = lines.pop() || "";
|
|
129
|
+
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
const trimmed = line.trim();
|
|
132
|
+
if (!trimmed) continue;
|
|
133
|
+
|
|
134
|
+
this.rawInCb?.(trimmed);
|
|
135
|
+
|
|
136
|
+
let msg: JsonRpcMessage;
|
|
137
|
+
try {
|
|
138
|
+
msg = JSON.parse(trimmed);
|
|
139
|
+
} catch {
|
|
140
|
+
console.warn("[opencode-adapter] Failed to parse JSON-RPC:", trimmed.substring(0, 200));
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
this.dispatch(msg);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private dispatch(msg: JsonRpcMessage): void {
|
|
149
|
+
if ("id" in msg && msg.id !== undefined) {
|
|
150
|
+
if ("method" in msg && msg.method) {
|
|
151
|
+
// Request FROM the server (needs a response)
|
|
152
|
+
this.requestHandler?.(msg.method, msg.id as number, (msg as JsonRpcRequest).params || {});
|
|
153
|
+
} else {
|
|
154
|
+
// Response to one of our requests
|
|
155
|
+
const pending = this.pending.get(msg.id as number);
|
|
156
|
+
if (pending) {
|
|
157
|
+
this.pending.delete(msg.id as number);
|
|
158
|
+
const resp = msg as JsonRpcResponse;
|
|
159
|
+
if (resp.error) {
|
|
160
|
+
pending.reject(new Error(resp.error.message));
|
|
161
|
+
} else {
|
|
162
|
+
pending.resolve(resp.result);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} else if ("method" in msg) {
|
|
167
|
+
this.notificationHandler?.(msg.method, (msg as JsonRpcNotification).params || {});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async call(method: string, params: Record<string, unknown> = {}): Promise<unknown> {
|
|
172
|
+
const id = this.nextId++;
|
|
173
|
+
return new Promise(async (resolve, reject) => {
|
|
174
|
+
this.pending.set(id, { resolve, reject });
|
|
175
|
+
const request = JSON.stringify({ jsonrpc: "2.0", method, id, params });
|
|
176
|
+
try {
|
|
177
|
+
await this.writeRaw(request + "\n");
|
|
178
|
+
} catch (err) {
|
|
179
|
+
this.pending.delete(id);
|
|
180
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async notify(method: string, params: Record<string, unknown> = {}): Promise<void> {
|
|
186
|
+
const notification = JSON.stringify({ jsonrpc: "2.0", method, params });
|
|
187
|
+
await this.writeRaw(notification + "\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async respond(id: number, result: unknown): Promise<void> {
|
|
191
|
+
const response = JSON.stringify({ jsonrpc: "2.0", id, result });
|
|
192
|
+
await this.writeRaw(response + "\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
onNotification(handler: (method: string, params: Record<string, unknown>) => void): void {
|
|
196
|
+
this.notificationHandler = handler;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
onRequest(handler: (method: string, id: number, params: Record<string, unknown>) => void): void {
|
|
200
|
+
this.requestHandler = handler;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
isConnected(): boolean {
|
|
204
|
+
return this.connected;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
onRawIncoming(cb: (line: string) => void): void {
|
|
208
|
+
this.rawInCb = cb;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
onRawOutgoing(cb: (data: string) => void): void {
|
|
212
|
+
this.rawOutCb = cb;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async writeRaw(data: string): Promise<void> {
|
|
216
|
+
if (!this.connected) {
|
|
217
|
+
throw new Error("Transport closed");
|
|
218
|
+
}
|
|
219
|
+
this.rawOutCb?.(data);
|
|
220
|
+
await this.writer.write(new TextEncoder().encode(data));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ─── OpenCode Adapter ────────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
export class OpenCodeAdapter implements AgentAdapter {
|
|
227
|
+
private transport: JsonRpcTransport;
|
|
228
|
+
private proc: Subprocess;
|
|
229
|
+
private sessionId: string; // Campfire session ID
|
|
230
|
+
private options: OpenCodeAdapterOptions;
|
|
231
|
+
|
|
232
|
+
private browserMessageCb: ((msg: BrowserIncomingMessage) => void) | null = null;
|
|
233
|
+
private sessionMetaCb: ((meta: { cliSessionId?: string; model?: string; cwd?: string }) => void) | null = null;
|
|
234
|
+
private disconnectCb: (() => void) | null = null;
|
|
235
|
+
private initErrorCb: ((error: string) => void) | null = null;
|
|
236
|
+
|
|
237
|
+
// State
|
|
238
|
+
private opencodeSessionId: string | null = null; // OpenCode's internal session ID
|
|
239
|
+
private connected = false;
|
|
240
|
+
private initialized = false;
|
|
241
|
+
private initFailed = false;
|
|
242
|
+
|
|
243
|
+
// Streaming accumulator
|
|
244
|
+
private streamingText = "";
|
|
245
|
+
private streamingActive = false;
|
|
246
|
+
|
|
247
|
+
// Tool call tracking
|
|
248
|
+
private activeToolCalls = new Map<string, { toolName: string; startTime: number }>();
|
|
249
|
+
|
|
250
|
+
// Permission request tracking (request_id → JSON-RPC id for respond())
|
|
251
|
+
private pendingPermissions = new Map<string, number>();
|
|
252
|
+
|
|
253
|
+
// Queue messages received before initialization completes
|
|
254
|
+
private pendingOutgoing: BrowserOutgoingMessage[] = [];
|
|
255
|
+
|
|
256
|
+
constructor(proc: Subprocess, sessionId: string, options: OpenCodeAdapterOptions = {}) {
|
|
257
|
+
this.proc = proc;
|
|
258
|
+
this.sessionId = sessionId;
|
|
259
|
+
this.options = options;
|
|
260
|
+
|
|
261
|
+
const stdout = proc.stdout;
|
|
262
|
+
const stdin = proc.stdin;
|
|
263
|
+
if (!stdout || !stdin || typeof stdout === "number" || typeof stdin === "number") {
|
|
264
|
+
throw new Error("OpenCode process must have stdio pipes");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
this.transport = new JsonRpcTransport(
|
|
268
|
+
stdin as WritableStream<Uint8Array> | { write(data: Uint8Array): number },
|
|
269
|
+
stdout as ReadableStream<Uint8Array>,
|
|
270
|
+
);
|
|
271
|
+
this.transport.onNotification((method, params) => this.handleNotification(method, params));
|
|
272
|
+
this.transport.onRequest((method, id, params) => this.handleRequest(method, id, params));
|
|
273
|
+
|
|
274
|
+
// Wire raw message recording
|
|
275
|
+
if (options.recorder) {
|
|
276
|
+
const recorder = options.recorder;
|
|
277
|
+
const cwd = options.cwd || "";
|
|
278
|
+
this.transport.onRawIncoming((line) => {
|
|
279
|
+
recorder.record(sessionId, "in", line, "cli", "opencode", cwd);
|
|
280
|
+
});
|
|
281
|
+
this.transport.onRawOutgoing((data) => {
|
|
282
|
+
recorder.record(sessionId, "out", data.trimEnd(), "cli", "opencode", cwd);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Monitor process exit
|
|
287
|
+
proc.exited.then(() => {
|
|
288
|
+
this.connected = false;
|
|
289
|
+
this.disconnectCb?.();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Start initialization
|
|
293
|
+
this.initialize();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Public API ──────────────────────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
sendBrowserMessage(msg: BrowserOutgoingMessage): boolean {
|
|
299
|
+
if (this.initFailed) return false;
|
|
300
|
+
|
|
301
|
+
// Queue messages if not yet initialized
|
|
302
|
+
if (!this.initialized || !this.opencodeSessionId) {
|
|
303
|
+
if (
|
|
304
|
+
msg.type === "user_message"
|
|
305
|
+
|| msg.type === "permission_response"
|
|
306
|
+
) {
|
|
307
|
+
console.log(`[opencode-adapter] Queuing ${msg.type} — adapter not yet initialized`);
|
|
308
|
+
this.pendingOutgoing.push(msg);
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
if (!this.connected) return false;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return this.dispatchOutgoing(msg);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private dispatchOutgoing(msg: BrowserOutgoingMessage): boolean {
|
|
318
|
+
switch (msg.type) {
|
|
319
|
+
case "user_message":
|
|
320
|
+
this.handleOutgoingUserMessage(msg);
|
|
321
|
+
return true;
|
|
322
|
+
case "permission_response":
|
|
323
|
+
this.handleOutgoingPermissionResponse(msg);
|
|
324
|
+
return true;
|
|
325
|
+
case "interrupt":
|
|
326
|
+
this.handleOutgoingInterrupt();
|
|
327
|
+
return true;
|
|
328
|
+
case "set_model":
|
|
329
|
+
this.handleOutgoingSetModel(msg.model);
|
|
330
|
+
return true;
|
|
331
|
+
case "set_permission_mode":
|
|
332
|
+
console.warn("[opencode-adapter] Runtime permission mode switching not yet supported for OpenCode");
|
|
333
|
+
return false;
|
|
334
|
+
case "mcp_get_status":
|
|
335
|
+
case "mcp_toggle":
|
|
336
|
+
case "mcp_reconnect":
|
|
337
|
+
case "mcp_set_servers":
|
|
338
|
+
console.warn(`[opencode-adapter] MCP management (${msg.type}) not yet supported for OpenCode`);
|
|
339
|
+
return false;
|
|
340
|
+
default:
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
onBrowserMessage(cb: (msg: BrowserIncomingMessage) => void): void {
|
|
346
|
+
this.browserMessageCb = cb;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
onSessionMeta(cb: (meta: { cliSessionId?: string; model?: string; cwd?: string }) => void): void {
|
|
350
|
+
this.sessionMetaCb = cb;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
onDisconnect(cb: () => void): void {
|
|
354
|
+
this.disconnectCb = cb;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
onInitError(cb: (error: string) => void): void {
|
|
358
|
+
this.initErrorCb = cb;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
isConnected(): boolean {
|
|
362
|
+
return this.connected;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async disconnect(): Promise<void> {
|
|
366
|
+
this.connected = false;
|
|
367
|
+
try {
|
|
368
|
+
this.proc.kill("SIGTERM");
|
|
369
|
+
await Promise.race([
|
|
370
|
+
this.proc.exited,
|
|
371
|
+
new Promise((r) => setTimeout(r, 5000)),
|
|
372
|
+
]);
|
|
373
|
+
} catch {}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
getBackendSessionId(): string | null {
|
|
377
|
+
return this.opencodeSessionId;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── Initialization ─────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
private async initialize(): Promise<void> {
|
|
383
|
+
try {
|
|
384
|
+
// Step 1: ACP initialize handshake
|
|
385
|
+
// OpenCode uses numeric protocolVersion (1) and richer clientCapabilities
|
|
386
|
+
const initResult = await this.transport.call("initialize", {
|
|
387
|
+
protocolVersion: 1,
|
|
388
|
+
clientCapabilities: {
|
|
389
|
+
fs: {
|
|
390
|
+
readTextFile: true,
|
|
391
|
+
writeTextFile: true,
|
|
392
|
+
},
|
|
393
|
+
terminal: true,
|
|
394
|
+
},
|
|
395
|
+
clientInfo: {
|
|
396
|
+
name: "campfire",
|
|
397
|
+
title: "Campfire",
|
|
398
|
+
version: "1.0.0",
|
|
399
|
+
},
|
|
400
|
+
}) as Record<string, unknown>;
|
|
401
|
+
|
|
402
|
+
this.connected = true;
|
|
403
|
+
this.initialized = true;
|
|
404
|
+
|
|
405
|
+
// Extract capabilities
|
|
406
|
+
const capabilities = initResult?.agentCapabilities as Record<string, unknown> || {};
|
|
407
|
+
const hasLoadSession = capabilities.loadSession === true;
|
|
408
|
+
|
|
409
|
+
// Step 2: Create or load a session
|
|
410
|
+
if (this.options.opencodeSessionId && hasLoadSession) {
|
|
411
|
+
// Resume existing session
|
|
412
|
+
await this.transport.call("session/load", {
|
|
413
|
+
sessionId: this.options.opencodeSessionId,
|
|
414
|
+
cwd: this.options.cwd || process.cwd(),
|
|
415
|
+
});
|
|
416
|
+
this.opencodeSessionId = this.options.opencodeSessionId;
|
|
417
|
+
} else {
|
|
418
|
+
// Create new session
|
|
419
|
+
const newResult = await this.transport.call("session/new", {
|
|
420
|
+
cwd: this.options.cwd || process.cwd(),
|
|
421
|
+
mcpServers: [],
|
|
422
|
+
}) as Record<string, unknown>;
|
|
423
|
+
this.opencodeSessionId = newResult?.sessionId as string || randomUUID();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Notify session metadata
|
|
427
|
+
this.sessionMetaCb?.({
|
|
428
|
+
cliSessionId: this.opencodeSessionId,
|
|
429
|
+
model: this.options.model,
|
|
430
|
+
cwd: this.options.cwd,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// Send session_init to browser
|
|
434
|
+
const state: SessionState = {
|
|
435
|
+
session_id: this.sessionId,
|
|
436
|
+
backend_type: "opencode",
|
|
437
|
+
model: this.options.model || "default",
|
|
438
|
+
cwd: this.options.cwd || "",
|
|
439
|
+
tools: ["bash", "file_read", "file_write", "file_edit"],
|
|
440
|
+
permissionMode: this.options.permissionMode || "default",
|
|
441
|
+
claude_code_version: "",
|
|
442
|
+
mcp_servers: [],
|
|
443
|
+
agents: [],
|
|
444
|
+
slash_commands: [],
|
|
445
|
+
skills: [],
|
|
446
|
+
total_cost_usd: 0,
|
|
447
|
+
num_turns: 0,
|
|
448
|
+
context_used_percent: 0,
|
|
449
|
+
is_compacting: false,
|
|
450
|
+
git_branch: "",
|
|
451
|
+
is_worktree: false,
|
|
452
|
+
repo_root: "",
|
|
453
|
+
git_ahead: 0,
|
|
454
|
+
git_behind: 0,
|
|
455
|
+
total_lines_added: 0,
|
|
456
|
+
total_lines_removed: 0,
|
|
457
|
+
total_duration_api_ms: 0,
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
this.emit({ type: "session_init", session: state });
|
|
461
|
+
|
|
462
|
+
// Flush queued messages
|
|
463
|
+
if (this.pendingOutgoing.length > 0) {
|
|
464
|
+
console.log(`[opencode-adapter] Flushing ${this.pendingOutgoing.length} queued message(s)`);
|
|
465
|
+
const queued = this.pendingOutgoing.splice(0);
|
|
466
|
+
for (const msg of queued) {
|
|
467
|
+
this.dispatchOutgoing(msg);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
} catch (err) {
|
|
471
|
+
const errorMsg = `OpenCode initialization failed: ${err}`;
|
|
472
|
+
console.error(`[opencode-adapter] ${errorMsg}`);
|
|
473
|
+
this.initFailed = true;
|
|
474
|
+
this.connected = false;
|
|
475
|
+
this.pendingOutgoing.length = 0;
|
|
476
|
+
this.emit({ type: "error", message: errorMsg });
|
|
477
|
+
this.initErrorCb?.(errorMsg);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ── Outgoing message handlers ──────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
private async handleOutgoingUserMessage(
|
|
484
|
+
msg: { type: "user_message"; content: string; images?: { media_type: string; data: string }[] },
|
|
485
|
+
): Promise<void> {
|
|
486
|
+
if (!this.opencodeSessionId) {
|
|
487
|
+
this.emit({ type: "error", message: "No OpenCode session started yet" });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const content: Array<Record<string, unknown>> = [];
|
|
492
|
+
|
|
493
|
+
// Add images if present
|
|
494
|
+
if (msg.images?.length) {
|
|
495
|
+
for (const img of msg.images) {
|
|
496
|
+
content.push({
|
|
497
|
+
type: "image",
|
|
498
|
+
data: img.data,
|
|
499
|
+
media_type: img.media_type,
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Add text
|
|
505
|
+
content.push({ type: "text", text: msg.content });
|
|
506
|
+
|
|
507
|
+
try {
|
|
508
|
+
// session/prompt is async — OpenCode streams notifications then returns
|
|
509
|
+
const result = await this.transport.call("session/prompt", {
|
|
510
|
+
sessionId: this.opencodeSessionId,
|
|
511
|
+
content,
|
|
512
|
+
}) as Record<string, unknown>;
|
|
513
|
+
|
|
514
|
+
// The response comes after all streaming is done
|
|
515
|
+
this.finishTurn(result);
|
|
516
|
+
} catch (err) {
|
|
517
|
+
this.emit({ type: "error", message: `Failed to send prompt: ${err}` });
|
|
518
|
+
this.emitResult("error_during_execution", true);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private async handleOutgoingPermissionResponse(
|
|
523
|
+
msg: { type: "permission_response"; request_id: string; behavior: "allow" | "deny" },
|
|
524
|
+
): Promise<void> {
|
|
525
|
+
// OpenCode uses JSON-RPC request/response (not notification-based like Goose).
|
|
526
|
+
// We stored the rpc id when the permission request arrived, and now respond to it.
|
|
527
|
+
const rpcId = this.pendingPermissions.get(msg.request_id);
|
|
528
|
+
if (rpcId !== undefined) {
|
|
529
|
+
try {
|
|
530
|
+
await this.transport.respond(rpcId, {
|
|
531
|
+
outcome: msg.behavior === "allow" ? "allow_once" : "reject_once",
|
|
532
|
+
});
|
|
533
|
+
this.pendingPermissions.delete(msg.request_id);
|
|
534
|
+
} catch (err) {
|
|
535
|
+
console.warn(`[opencode-adapter] Failed to send permission response: ${err}`);
|
|
536
|
+
}
|
|
537
|
+
} else {
|
|
538
|
+
console.warn(`[opencode-adapter] No pending permission for request_id: ${msg.request_id}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private async handleOutgoingInterrupt(): Promise<void> {
|
|
543
|
+
try {
|
|
544
|
+
await this.transport.notify("session/cancel", {});
|
|
545
|
+
} catch (err) {
|
|
546
|
+
console.warn("[opencode-adapter] Cancel failed:", err);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private async handleOutgoingSetModel(_model: string): Promise<void> {
|
|
551
|
+
// OpenCode model selection is typically done at session creation via config.
|
|
552
|
+
// Runtime model switching is not standardized in ACP for OpenCode.
|
|
553
|
+
console.warn("[opencode-adapter] Runtime model switching not yet supported for OpenCode");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ── Incoming notification handlers ─────────────────────────────────────
|
|
557
|
+
|
|
558
|
+
private handleNotification(method: string, params: Record<string, unknown>): void {
|
|
559
|
+
try {
|
|
560
|
+
switch (method) {
|
|
561
|
+
case "session/update":
|
|
562
|
+
// OpenCode uses "session/update" (vs Goose's "session/notification")
|
|
563
|
+
this.handleSessionUpdate(params);
|
|
564
|
+
break;
|
|
565
|
+
default:
|
|
566
|
+
console.log(`[opencode-adapter] Unhandled notification: ${method}`);
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
} catch (err) {
|
|
570
|
+
console.error(`[opencode-adapter] Error handling notification ${method}:`, err);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
private handleSessionUpdate(params: Record<string, unknown>): void {
|
|
575
|
+
const update = params.update as Record<string, unknown>;
|
|
576
|
+
if (!update) return;
|
|
577
|
+
|
|
578
|
+
// OpenCode uses "kind" field (vs Goose's "sessionUpdate")
|
|
579
|
+
const kind = update.kind as string;
|
|
580
|
+
console.log(`[opencode-adapter] ← session/update: ${kind}`);
|
|
581
|
+
|
|
582
|
+
switch (kind) {
|
|
583
|
+
case "agent_message_chunk":
|
|
584
|
+
this.handleAgentMessageChunk(update);
|
|
585
|
+
break;
|
|
586
|
+
case "agent_thought_chunk":
|
|
587
|
+
this.handleAgentThoughtChunk(update);
|
|
588
|
+
break;
|
|
589
|
+
case "tool_call":
|
|
590
|
+
this.handleToolCall(update);
|
|
591
|
+
break;
|
|
592
|
+
case "tool_call_update":
|
|
593
|
+
this.handleToolCallUpdate(update);
|
|
594
|
+
break;
|
|
595
|
+
case "plan":
|
|
596
|
+
// Planning steps — treat as thought chunks
|
|
597
|
+
this.handlePlan(update);
|
|
598
|
+
break;
|
|
599
|
+
default:
|
|
600
|
+
console.log(`[opencode-adapter] Unhandled session update kind: ${kind}`);
|
|
601
|
+
break;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
private handleRequest(method: string, id: number, params: Record<string, unknown>): void {
|
|
606
|
+
console.log(`[opencode-adapter] Received request: ${method}`);
|
|
607
|
+
try {
|
|
608
|
+
switch (method) {
|
|
609
|
+
case "session/request_permission": {
|
|
610
|
+
// OpenCode uses a proper JSON-RPC request for permissions (unlike Goose's notification).
|
|
611
|
+
// We store the rpc id so we can respond when the browser sends a permission_response.
|
|
612
|
+
const requestId = `opencode-perm-${randomUUID()}`;
|
|
613
|
+
this.pendingPermissions.set(requestId, id);
|
|
614
|
+
this.emitPermissionRequest(requestId, params);
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
case "fs/read_text_file": {
|
|
618
|
+
// OpenCode may request file reads — respond with not-supported (it has its own file tools)
|
|
619
|
+
this.transport.respond(id, { error: "fs/read_text_file not delegated to client" });
|
|
620
|
+
break;
|
|
621
|
+
}
|
|
622
|
+
case "fs/write_text_file": {
|
|
623
|
+
this.transport.respond(id, { error: "fs/write_text_file not delegated to client" });
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
default:
|
|
627
|
+
console.log(`[opencode-adapter] Unhandled request: ${method}`);
|
|
628
|
+
this.transport.respond(id, {});
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
} catch (err) {
|
|
632
|
+
console.error(`[opencode-adapter] Error handling request ${method}:`, err);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ── SessionUpdate handlers ─────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
private handleAgentMessageChunk(update: Record<string, unknown>): void {
|
|
639
|
+
const content = update.content as Record<string, unknown>;
|
|
640
|
+
if (!content) return;
|
|
641
|
+
|
|
642
|
+
const text = content.text as string || "";
|
|
643
|
+
|
|
644
|
+
// Start streaming if not already active
|
|
645
|
+
if (!this.streamingActive) {
|
|
646
|
+
this.streamingActive = true;
|
|
647
|
+
this.streamingText = "";
|
|
648
|
+
|
|
649
|
+
// Emit message_start
|
|
650
|
+
this.emit({
|
|
651
|
+
type: "stream_event",
|
|
652
|
+
event: {
|
|
653
|
+
type: "message_start",
|
|
654
|
+
message: {
|
|
655
|
+
id: this.makeMessageId("agent"),
|
|
656
|
+
type: "message",
|
|
657
|
+
role: "assistant",
|
|
658
|
+
model: this.options.model || "",
|
|
659
|
+
content: [],
|
|
660
|
+
stop_reason: null,
|
|
661
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
662
|
+
},
|
|
663
|
+
},
|
|
664
|
+
parent_tool_use_id: null,
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// Emit content_block_start
|
|
668
|
+
this.emit({
|
|
669
|
+
type: "stream_event",
|
|
670
|
+
event: {
|
|
671
|
+
type: "content_block_start",
|
|
672
|
+
index: 0,
|
|
673
|
+
content_block: { type: "text", text: "" },
|
|
674
|
+
},
|
|
675
|
+
parent_tool_use_id: null,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
this.streamingText += text;
|
|
680
|
+
|
|
681
|
+
// Emit content_block_delta (matches Claude's streaming format)
|
|
682
|
+
this.emit({
|
|
683
|
+
type: "stream_event",
|
|
684
|
+
event: {
|
|
685
|
+
type: "content_block_delta",
|
|
686
|
+
index: 0,
|
|
687
|
+
delta: { type: "text_delta", text },
|
|
688
|
+
},
|
|
689
|
+
parent_tool_use_id: null,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
private handleAgentThoughtChunk(update: Record<string, unknown>): void {
|
|
694
|
+
const content = update.content as Record<string, unknown>;
|
|
695
|
+
if (!content) return;
|
|
696
|
+
|
|
697
|
+
const thinking = content.thinking as string || content.text as string || content.thought as string || "";
|
|
698
|
+
if (!thinking) return;
|
|
699
|
+
|
|
700
|
+
this.emit({
|
|
701
|
+
type: "stream_event",
|
|
702
|
+
event: {
|
|
703
|
+
type: "content_block_start",
|
|
704
|
+
index: 0,
|
|
705
|
+
content_block: { type: "thinking", thinking },
|
|
706
|
+
},
|
|
707
|
+
parent_tool_use_id: null,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
private handlePlan(update: Record<string, unknown>): void {
|
|
712
|
+
// OpenCode planning steps — emit as thinking block
|
|
713
|
+
const steps = update.steps as unknown[];
|
|
714
|
+
if (!steps?.length) return;
|
|
715
|
+
|
|
716
|
+
const planText = Array.isArray(steps)
|
|
717
|
+
? steps.map((s: unknown) => {
|
|
718
|
+
if (typeof s === "string") return `- ${s}`;
|
|
719
|
+
if (typeof s === "object" && s !== null) return `- ${(s as Record<string, unknown>).description || JSON.stringify(s)}`;
|
|
720
|
+
return `- ${String(s)}`;
|
|
721
|
+
}).join("\n")
|
|
722
|
+
: "";
|
|
723
|
+
|
|
724
|
+
if (!planText) return;
|
|
725
|
+
|
|
726
|
+
this.emit({
|
|
727
|
+
type: "stream_event",
|
|
728
|
+
event: {
|
|
729
|
+
type: "content_block_start",
|
|
730
|
+
index: 0,
|
|
731
|
+
content_block: { type: "thinking", thinking: `Plan:\n${planText}` },
|
|
732
|
+
},
|
|
733
|
+
parent_tool_use_id: null,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
private handleToolCall(update: Record<string, unknown>): void {
|
|
738
|
+
// Flush any active text streaming before tool call
|
|
739
|
+
this.flushStreaming();
|
|
740
|
+
|
|
741
|
+
const toolName = update.toolName as string || update.tool_name as string || "unknown";
|
|
742
|
+
const args = update.arguments as Record<string, unknown> || update.args as Record<string, unknown> || {};
|
|
743
|
+
const toolCallId = update.id as string || update.tool_call_id as string || `opencode-tool-${randomUUID()}`;
|
|
744
|
+
|
|
745
|
+
this.activeToolCalls.set(toolCallId, { toolName, startTime: Date.now() });
|
|
746
|
+
|
|
747
|
+
// Map OpenCode tool names to Campfire-compatible names
|
|
748
|
+
const mappedName = this.mapToolName(toolName);
|
|
749
|
+
|
|
750
|
+
// Emit stream event for tool_use start
|
|
751
|
+
this.emit({
|
|
752
|
+
type: "stream_event",
|
|
753
|
+
event: {
|
|
754
|
+
type: "content_block_start",
|
|
755
|
+
index: 0,
|
|
756
|
+
content_block: { type: "tool_use", id: toolCallId, name: mappedName, input: {} },
|
|
757
|
+
},
|
|
758
|
+
parent_tool_use_id: null,
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
// Emit the tool_use assistant message
|
|
762
|
+
this.emit({
|
|
763
|
+
type: "assistant",
|
|
764
|
+
message: {
|
|
765
|
+
id: this.makeMessageId("tool_use", toolCallId),
|
|
766
|
+
type: "message",
|
|
767
|
+
role: "assistant",
|
|
768
|
+
model: this.options.model || "",
|
|
769
|
+
content: [
|
|
770
|
+
{
|
|
771
|
+
type: "tool_use",
|
|
772
|
+
id: toolCallId,
|
|
773
|
+
name: mappedName,
|
|
774
|
+
input: args,
|
|
775
|
+
},
|
|
776
|
+
],
|
|
777
|
+
stop_reason: null,
|
|
778
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
779
|
+
},
|
|
780
|
+
parent_tool_use_id: null,
|
|
781
|
+
timestamp: Date.now(),
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// Emit tool_progress
|
|
785
|
+
this.emit({
|
|
786
|
+
type: "tool_progress",
|
|
787
|
+
tool_use_id: toolCallId,
|
|
788
|
+
tool_name: mappedName,
|
|
789
|
+
elapsed_time_seconds: 0,
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
private handleToolCallUpdate(update: Record<string, unknown>): void {
|
|
794
|
+
const toolCallId = update.id as string || update.tool_call_id as string || "";
|
|
795
|
+
const result = update.result as string || update.content as string || update.output as string || "";
|
|
796
|
+
const status = update.status as string || "completed";
|
|
797
|
+
const isError = status === "failed" || status === "error";
|
|
798
|
+
|
|
799
|
+
// Clean up tracking
|
|
800
|
+
this.activeToolCalls.delete(toolCallId);
|
|
801
|
+
|
|
802
|
+
// Emit tool result
|
|
803
|
+
const safeContent = typeof result === "string" ? result : JSON.stringify(result);
|
|
804
|
+
this.emit({
|
|
805
|
+
type: "assistant",
|
|
806
|
+
message: {
|
|
807
|
+
id: this.makeMessageId("tool_result", toolCallId),
|
|
808
|
+
type: "message",
|
|
809
|
+
role: "assistant",
|
|
810
|
+
model: this.options.model || "",
|
|
811
|
+
content: [
|
|
812
|
+
{
|
|
813
|
+
type: "tool_result",
|
|
814
|
+
tool_use_id: toolCallId,
|
|
815
|
+
content: safeContent || (isError ? "Tool call failed" : "Tool call completed"),
|
|
816
|
+
is_error: isError,
|
|
817
|
+
},
|
|
818
|
+
],
|
|
819
|
+
stop_reason: null,
|
|
820
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
821
|
+
},
|
|
822
|
+
parent_tool_use_id: null,
|
|
823
|
+
timestamp: Date.now(),
|
|
824
|
+
});
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
828
|
+
|
|
829
|
+
private emit(msg: BrowserIncomingMessage): void {
|
|
830
|
+
this.browserMessageCb?.(msg);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Flush any in-progress text streaming (emit stop events). */
|
|
834
|
+
private flushStreaming(): void {
|
|
835
|
+
if (!this.streamingActive) return;
|
|
836
|
+
|
|
837
|
+
// Emit content_block_stop + message_delta
|
|
838
|
+
this.emit({
|
|
839
|
+
type: "stream_event",
|
|
840
|
+
event: { type: "content_block_stop", index: 0 },
|
|
841
|
+
parent_tool_use_id: null,
|
|
842
|
+
});
|
|
843
|
+
this.emit({
|
|
844
|
+
type: "stream_event",
|
|
845
|
+
event: {
|
|
846
|
+
type: "message_delta",
|
|
847
|
+
delta: { stop_reason: null },
|
|
848
|
+
usage: { output_tokens: 0 },
|
|
849
|
+
},
|
|
850
|
+
parent_tool_use_id: null,
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
// Emit full assistant message if there's accumulated text
|
|
854
|
+
if (this.streamingText) {
|
|
855
|
+
this.emit({
|
|
856
|
+
type: "assistant",
|
|
857
|
+
message: {
|
|
858
|
+
id: this.makeMessageId("agent"),
|
|
859
|
+
type: "message",
|
|
860
|
+
role: "assistant",
|
|
861
|
+
model: this.options.model || "",
|
|
862
|
+
content: [{ type: "text", text: this.streamingText }],
|
|
863
|
+
stop_reason: "end_turn",
|
|
864
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
865
|
+
},
|
|
866
|
+
parent_tool_use_id: null,
|
|
867
|
+
timestamp: Date.now(),
|
|
868
|
+
});
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
this.streamingText = "";
|
|
872
|
+
this.streamingActive = false;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** Called when session/prompt returns (turn completed). */
|
|
876
|
+
private finishTurn(result: Record<string, unknown>): void {
|
|
877
|
+
// Flush any remaining streaming
|
|
878
|
+
this.flushStreaming();
|
|
879
|
+
|
|
880
|
+
const stopReason = result?.stopReason as string || result?.stop_reason as string || "end_turn";
|
|
881
|
+
|
|
882
|
+
this.emitResult(
|
|
883
|
+
stopReason === "cancelled" ? "error_during_execution" : "success",
|
|
884
|
+
stopReason === "cancelled",
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
private emitResult(subtype: CLIResultMessage["subtype"], isError: boolean): void {
|
|
889
|
+
const result: CLIResultMessage = {
|
|
890
|
+
type: "result",
|
|
891
|
+
subtype,
|
|
892
|
+
is_error: isError,
|
|
893
|
+
duration_ms: 0,
|
|
894
|
+
duration_api_ms: 0,
|
|
895
|
+
num_turns: 1,
|
|
896
|
+
total_cost_usd: 0,
|
|
897
|
+
stop_reason: subtype === "success" ? "end_turn" : "error",
|
|
898
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
|
|
899
|
+
uuid: randomUUID(),
|
|
900
|
+
session_id: this.sessionId,
|
|
901
|
+
};
|
|
902
|
+
|
|
903
|
+
this.emit({ type: "result", data: result });
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
private emitPermissionRequest(requestId: string, params: Record<string, unknown>): void {
|
|
907
|
+
// ACP session/request_permission params contain toolCall info
|
|
908
|
+
const toolCall = params.toolCall as Record<string, unknown> || params;
|
|
909
|
+
const toolName = toolCall.toolName as string || toolCall.tool_name as string || "unknown";
|
|
910
|
+
const args = toolCall.arguments as Record<string, unknown> || {};
|
|
911
|
+
const description = toolCall.description as string || params.description as string;
|
|
912
|
+
|
|
913
|
+
const mappedName = this.mapToolName(toolName);
|
|
914
|
+
|
|
915
|
+
const perm: PermissionRequest = {
|
|
916
|
+
request_id: requestId,
|
|
917
|
+
tool_name: mappedName,
|
|
918
|
+
input: args,
|
|
919
|
+
description: description || `OpenCode wants to use: ${mappedName}`,
|
|
920
|
+
tool_use_id: (toolCall.id as string) || requestId,
|
|
921
|
+
timestamp: Date.now(),
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
this.emit({ type: "permission_request", request: perm });
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Map OpenCode tool names to Campfire-compatible tool names. */
|
|
928
|
+
private mapToolName(toolName: string): string {
|
|
929
|
+
// OpenCode tool names (may vary; pass-through for unknown tools)
|
|
930
|
+
const toolMap: Record<string, string> = {
|
|
931
|
+
"bash": "Bash",
|
|
932
|
+
"file_read": "Read",
|
|
933
|
+
"file_write": "Write",
|
|
934
|
+
"file_edit": "Edit",
|
|
935
|
+
"glob": "Glob",
|
|
936
|
+
"grep": "Grep",
|
|
937
|
+
// LSP-based tool names
|
|
938
|
+
"lsp_diagnostics": "Diagnostics",
|
|
939
|
+
"lsp_hover": "Hover",
|
|
940
|
+
"lsp_definition": "Definition",
|
|
941
|
+
"lsp_references": "References",
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
return toolMap[toolName] || toolName;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
private makeMessageId(kind: string, sourceId?: string): string {
|
|
948
|
+
if (sourceId) return `opencode-${kind}-${sourceId}`;
|
|
949
|
+
return `opencode-${kind}-${randomUUID()}`;
|
|
950
|
+
}
|
|
951
|
+
}
|