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.
Files changed (180) hide show
  1. package/bin/cli.ts +182 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/MermaidDiagram-By1J3Whj.js +2 -0
  4. package/dist/assets/_basePickBy-Dq6gnD-p.js +1 -0
  5. package/dist/assets/_baseUniq-SpgN6cGc.js +1 -0
  6. package/dist/assets/arc-D9SEA8dX.js +1 -0
  7. package/dist/assets/architectureDiagram-VXUJARFQ-CoTcwLeR.js +36 -0
  8. package/dist/assets/blockDiagram-VD42YOAC-Dvc63voV.js +122 -0
  9. package/dist/assets/c4Diagram-YG6GDRKO-tuDcG4_4.js +10 -0
  10. package/dist/assets/channel-CxhcHn7S.js +1 -0
  11. package/dist/assets/chunk-4BX2VUAB-x1hChYu9.js +1 -0
  12. package/dist/assets/chunk-55IACEB6-BX6HWnWP.js +1 -0
  13. package/dist/assets/chunk-B4BG7PRW-B4IlF03G.js +165 -0
  14. package/dist/assets/chunk-DI55MBZ5-TpYb5rLg.js +220 -0
  15. package/dist/assets/chunk-FMBD7UC4-DZX4LUPx.js +15 -0
  16. package/dist/assets/chunk-QN33PNHL-CuQs0NDY.js +1 -0
  17. package/dist/assets/chunk-QZHKN3VN-CygWwVx8.js +1 -0
  18. package/dist/assets/chunk-TZMSLE5B-CtI-IdKV.js +1 -0
  19. package/dist/assets/classDiagram-2ON5EDUG-BM0LptJF.js +1 -0
  20. package/dist/assets/classDiagram-v2-WZHVMYZB-BM0LptJF.js +1 -0
  21. package/dist/assets/clone-C6kt1rE7.js +1 -0
  22. package/dist/assets/cose-bilkent-S5V4N54A-PdySs991.js +1 -0
  23. package/dist/assets/cytoscape.esm-BQaXIfA_.js +331 -0
  24. package/dist/assets/dagre-6UL2VRFP-J0SxEiEu.js +4 -0
  25. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  26. package/dist/assets/diagram-PSM6KHXK-CW0HLXaH.js +24 -0
  27. package/dist/assets/diagram-QEK2KX5R-DADHF-k4.js +43 -0
  28. package/dist/assets/diagram-S2PKOQOG-9S0-PC3S.js +24 -0
  29. package/dist/assets/erDiagram-Q2GNP2WA-BEHAiY_q.js +60 -0
  30. package/dist/assets/flowDiagram-NV44I4VS-Cu6n9Xnd.js +162 -0
  31. package/dist/assets/ganttDiagram-JELNMOA3-Cr_BHryu.js +267 -0
  32. package/dist/assets/gitGraphDiagram-V2S2FVAM-D5pT35v5.js +65 -0
  33. package/dist/assets/graph-DKRbrxcs.js +1 -0
  34. package/dist/assets/index-Byjg9zgI.css +32 -0
  35. package/dist/assets/index-Cwj3m0hT.js +284 -0
  36. package/dist/assets/infoDiagram-HS3SLOUP-BaJlrgKV.js +2 -0
  37. package/dist/assets/init-Gi6I4Gst.js +1 -0
  38. package/dist/assets/journeyDiagram-XKPGCS4Q-CXC4-DJv.js +139 -0
  39. package/dist/assets/kanban-definition-3W4ZIXB7-BtHBu9cx.js +89 -0
  40. package/dist/assets/katex-BlHpptmG.js +261 -0
  41. package/dist/assets/layout-DnxSwLDJ.js +1 -0
  42. package/dist/assets/linear-CWbbIT8l.js +1 -0
  43. package/dist/assets/mermaid.core-DXn1cFAK.js +256 -0
  44. package/dist/assets/mindmap-definition-VGOIOE7T-BR8br1hs.js +68 -0
  45. package/dist/assets/ordinal-BENe2yWM.js +1 -0
  46. package/dist/assets/pieDiagram-ADFJNKIX-B7RmXV7I.js +30 -0
  47. package/dist/assets/quadrantDiagram-AYHSOK5B-DK0FhyV_.js +7 -0
  48. package/dist/assets/requirementDiagram-UZGBJVZJ-3zAGxSpL.js +64 -0
  49. package/dist/assets/sankeyDiagram-TZEHDZUN-DZppKtjc.js +10 -0
  50. package/dist/assets/sequenceDiagram-WL72ISMW-D1x3r3wV.js +145 -0
  51. package/dist/assets/stateDiagram-FKZM4ZOC-2ByD08Vq.js +1 -0
  52. package/dist/assets/stateDiagram-v2-4FDKWEC3-Vm1cYjgV.js +1 -0
  53. package/dist/assets/timeline-definition-IT6M3QCI-l5S35biR.js +61 -0
  54. package/dist/assets/treemap-GDKQZRPO-DIPadPiF.js +162 -0
  55. package/dist/assets/xychartDiagram-PRI3JC2R-9GUvwS2b.js +7 -0
  56. package/dist/favicon.svg +3 -0
  57. package/dist/fonts/Geist-Variable.woff2 +0 -0
  58. package/dist/fonts/GeistMono-Variable.woff2 +0 -0
  59. package/dist/icon-192.png +0 -0
  60. package/dist/icon-512.png +0 -0
  61. package/dist/index.html +33 -0
  62. package/dist/logo-codex.svg +14 -0
  63. package/dist/logo.svg +3 -0
  64. package/dist/manifest.json +26 -0
  65. package/dist/og-image.png +0 -0
  66. package/dist/sw.js +130 -0
  67. package/package.json +92 -0
  68. package/server/ADAPTERS.md +121 -0
  69. package/server/adapter-registry-types.ts +28 -0
  70. package/server/adapter-registry.ts +293 -0
  71. package/server/adapter-types.ts +51 -0
  72. package/server/agent-executor.ts +249 -0
  73. package/server/agent-mcp-bridge.ts +148 -0
  74. package/server/agent-mcp-stdio.ts +171 -0
  75. package/server/agent-mcp-tools.ts +76 -0
  76. package/server/agent-store.ts +183 -0
  77. package/server/agent-types.ts +79 -0
  78. package/server/aider-adapter.ts +525 -0
  79. package/server/auth.ts +153 -0
  80. package/server/auto-namer.ts +132 -0
  81. package/server/capability-discovery.ts +431 -0
  82. package/server/claude-container-auth.ts +40 -0
  83. package/server/claude-stdio-adapter.ts +452 -0
  84. package/server/clawhub-export.ts +218 -0
  85. package/server/cli-launcher.ts +1613 -0
  86. package/server/codex-adapter.ts +1987 -0
  87. package/server/codex-container-auth.ts +40 -0
  88. package/server/codex-home.ts +26 -0
  89. package/server/collective-intelligence.ts +401 -0
  90. package/server/commands-discovery.ts +160 -0
  91. package/server/constants.ts +5 -0
  92. package/server/container-manager.ts +328 -0
  93. package/server/cron-scheduler.ts +243 -0
  94. package/server/cron-store.ts +149 -0
  95. package/server/cron-types.ts +63 -0
  96. package/server/deliberation-engine.ts +323 -0
  97. package/server/embedding.ts +89 -0
  98. package/server/env-manager.ts +146 -0
  99. package/server/environment-detector.ts +106 -0
  100. package/server/environment-rules.ts +106 -0
  101. package/server/gallery-store.ts +216 -0
  102. package/server/gallery-types.ts +52 -0
  103. package/server/gallery-votes.ts +101 -0
  104. package/server/git-utils.ts +384 -0
  105. package/server/github-pr.ts +379 -0
  106. package/server/goose-adapter.ts +929 -0
  107. package/server/image-pull-manager.ts +144 -0
  108. package/server/index.ts +363 -0
  109. package/server/linear-project-manager.ts +78 -0
  110. package/server/moltbook-client.ts +160 -0
  111. package/server/openclaw-adapter.ts +916 -0
  112. package/server/openclaw-channel/channel.ts +188 -0
  113. package/server/openclaw-channel/index.ts +68 -0
  114. package/server/openclaw-channel/package.json +19 -0
  115. package/server/openclaw-channel/types.ts +93 -0
  116. package/server/opencode-adapter.ts +951 -0
  117. package/server/openhands-adapter.ts +775 -0
  118. package/server/orchestrator-executor.ts +184 -0
  119. package/server/orchestrator-store.ts +106 -0
  120. package/server/orchestrator-types.ts +86 -0
  121. package/server/path-resolver.ts +168 -0
  122. package/server/pr-poller.ts +162 -0
  123. package/server/proactive-keepalive.ts +121 -0
  124. package/server/prompt-manager.ts +103 -0
  125. package/server/protocol-monitor.ts +197 -0
  126. package/server/race-controller.ts +295 -0
  127. package/server/race-store.ts +82 -0
  128. package/server/recorder.ts +359 -0
  129. package/server/recording-hub/compat-validator.ts +122 -0
  130. package/server/recording-hub/diagnostics.ts +289 -0
  131. package/server/recording-hub/hub-routes.ts +146 -0
  132. package/server/recording-hub/hub-store.ts +224 -0
  133. package/server/replay.ts +78 -0
  134. package/server/routes/adapter-routes.ts +31 -0
  135. package/server/routes/agent-mcp-routes.ts +35 -0
  136. package/server/routes/agent-routes.ts +185 -0
  137. package/server/routes/auth-routes.ts +101 -0
  138. package/server/routes/ci-routes.ts +165 -0
  139. package/server/routes/commands-routes.ts +94 -0
  140. package/server/routes/cron-routes.ts +100 -0
  141. package/server/routes/env-routes.ts +50 -0
  142. package/server/routes/folder-routes.ts +56 -0
  143. package/server/routes/fs-routes.ts +403 -0
  144. package/server/routes/gallery-routes.ts +410 -0
  145. package/server/routes/git-routes.ts +108 -0
  146. package/server/routes/index.ts +62 -0
  147. package/server/routes/linear-routes.ts +254 -0
  148. package/server/routes/monitor-routes.ts +11 -0
  149. package/server/routes/orchestrator-routes.ts +210 -0
  150. package/server/routes/prompt-routes.ts +47 -0
  151. package/server/routes/race-routes.ts +103 -0
  152. package/server/routes/recording-routes.ts +101 -0
  153. package/server/routes/route-deps.ts +32 -0
  154. package/server/routes/session-routes.ts +560 -0
  155. package/server/routes/settings-routes.ts +108 -0
  156. package/server/routes/skills-routes.ts +52 -0
  157. package/server/routes/system-routes.ts +243 -0
  158. package/server/routes/webhook-routes.ts +135 -0
  159. package/server/routes.ts +44 -0
  160. package/server/security-middleware.ts +100 -0
  161. package/server/semantic-memory.ts +389 -0
  162. package/server/service.ts +733 -0
  163. package/server/session-folders.ts +107 -0
  164. package/server/session-git-info.ts +89 -0
  165. package/server/session-linear-issues.ts +94 -0
  166. package/server/session-names.ts +67 -0
  167. package/server/session-store.ts +169 -0
  168. package/server/session-types.ts +415 -0
  169. package/server/settings-manager.ts +120 -0
  170. package/server/shared-context.ts +401 -0
  171. package/server/skills-manager.ts +250 -0
  172. package/server/sub-session-manager.ts +263 -0
  173. package/server/terminal-manager.ts +185 -0
  174. package/server/update-checker.ts +114 -0
  175. package/server/usage-limits.ts +192 -0
  176. package/server/webhook-manager.ts +228 -0
  177. package/server/webhook-store.ts +168 -0
  178. package/server/webhook-types.ts +66 -0
  179. package/server/worktree-tracker.ts +84 -0
  180. 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
+ }