codekin 0.5.5 → 0.6.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 (81) hide show
  1. package/dist/assets/index-BvKzbtKg.css +1 -0
  2. package/dist/assets/index-D59Xr9pK.js +182 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +1 -1
  5. package/server/dist/approval-manager.js +1 -1
  6. package/server/dist/claude-process.d.ts +8 -5
  7. package/server/dist/claude-process.js +21 -63
  8. package/server/dist/claude-process.js.map +1 -1
  9. package/server/dist/coding-process.d.ts +83 -0
  10. package/server/dist/coding-process.js +32 -0
  11. package/server/dist/coding-process.js.map +1 -0
  12. package/server/dist/commit-event-handler.js +1 -0
  13. package/server/dist/commit-event-handler.js.map +1 -1
  14. package/server/dist/docs-routes.js +22 -1
  15. package/server/dist/docs-routes.js.map +1 -1
  16. package/server/dist/native-permissions.js +2 -1
  17. package/server/dist/native-permissions.js.map +1 -1
  18. package/server/dist/opencode-process.d.ts +104 -0
  19. package/server/dist/opencode-process.js +657 -0
  20. package/server/dist/opencode-process.js.map +1 -0
  21. package/server/dist/orchestrator-children.d.ts +9 -0
  22. package/server/dist/orchestrator-children.js +28 -1
  23. package/server/dist/orchestrator-children.js.map +1 -1
  24. package/server/dist/orchestrator-manager.js +17 -0
  25. package/server/dist/orchestrator-manager.js.map +1 -1
  26. package/server/dist/orchestrator-routes.js +8 -1
  27. package/server/dist/orchestrator-routes.js.map +1 -1
  28. package/server/dist/prompt-router.d.ts +2 -2
  29. package/server/dist/prompt-router.js +12 -0
  30. package/server/dist/prompt-router.js.map +1 -1
  31. package/server/dist/session-lifecycle.d.ts +4 -4
  32. package/server/dist/session-lifecycle.js +42 -21
  33. package/server/dist/session-lifecycle.js.map +1 -1
  34. package/server/dist/session-manager.d.ts +9 -0
  35. package/server/dist/session-manager.js +87 -45
  36. package/server/dist/session-manager.js.map +1 -1
  37. package/server/dist/session-persistence.d.ts +1 -0
  38. package/server/dist/session-persistence.js +3 -1
  39. package/server/dist/session-persistence.js.map +1 -1
  40. package/server/dist/session-routes.js +15 -1
  41. package/server/dist/session-routes.js.map +1 -1
  42. package/server/dist/stepflow-handler.d.ts +2 -2
  43. package/server/dist/stepflow-handler.js +4 -4
  44. package/server/dist/stepflow-handler.js.map +1 -1
  45. package/server/dist/tool-labels.d.ts +8 -0
  46. package/server/dist/tool-labels.js +51 -0
  47. package/server/dist/tool-labels.js.map +1 -0
  48. package/server/dist/tsconfig.tsbuildinfo +1 -1
  49. package/server/dist/types.d.ts +25 -10
  50. package/server/dist/types.js +4 -1
  51. package/server/dist/types.js.map +1 -1
  52. package/server/dist/webhook-dedup.d.ts +11 -0
  53. package/server/dist/webhook-dedup.js +23 -0
  54. package/server/dist/webhook-dedup.js.map +1 -1
  55. package/server/dist/webhook-handler.d.ts +20 -4
  56. package/server/dist/webhook-handler.js +256 -20
  57. package/server/dist/webhook-handler.js.map +1 -1
  58. package/server/dist/webhook-pr-cache.d.ts +57 -0
  59. package/server/dist/webhook-pr-cache.js +95 -0
  60. package/server/dist/webhook-pr-cache.js.map +1 -0
  61. package/server/dist/webhook-pr-github.d.ts +68 -0
  62. package/server/dist/webhook-pr-github.js +202 -0
  63. package/server/dist/webhook-pr-github.js.map +1 -0
  64. package/server/dist/webhook-pr-prompt.d.ts +27 -0
  65. package/server/dist/webhook-pr-prompt.js +251 -0
  66. package/server/dist/webhook-pr-prompt.js.map +1 -0
  67. package/server/dist/webhook-types.d.ts +70 -1
  68. package/server/dist/webhook-workspace.js +20 -1
  69. package/server/dist/webhook-workspace.js.map +1 -1
  70. package/server/dist/workflow-config.d.ts +2 -0
  71. package/server/dist/workflow-config.js.map +1 -1
  72. package/server/dist/workflow-loader.js +3 -0
  73. package/server/dist/workflow-loader.js.map +1 -1
  74. package/server/dist/workflow-routes.js +6 -2
  75. package/server/dist/workflow-routes.js.map +1 -1
  76. package/server/dist/ws-message-handler.js +22 -3
  77. package/server/dist/ws-message-handler.js.map +1 -1
  78. package/server/dist/ws-server.js +4 -2
  79. package/server/dist/ws-server.js.map +1 -1
  80. package/dist/assets/index-BFkKlY3O.js +0 -182
  81. package/dist/assets/index-CjEQkT2b.css +0 -1
@@ -0,0 +1,657 @@
1
+ /**
2
+ * Manages an OpenCode server session via HTTP REST + SSE.
3
+ *
4
+ * OpenCode (github.com/anomalyco/opencode) uses a client/server architecture:
5
+ * - `opencode serve` runs a long-lived HTTP server
6
+ * - Sessions are created/managed via REST API
7
+ * - Real-time events stream via SSE (Server-Sent Events)
8
+ *
9
+ * This class wraps that model behind the same CodingProcess interface that
10
+ * ClaudeProcess implements, so SessionManager works identically for both.
11
+ *
12
+ * Key differences from ClaudeProcess:
13
+ * - No child process per session — one shared OpenCode server
14
+ * - Messages sent via HTTP POST, not stdin
15
+ * - Events received via SSE, not stdout NDJSON
16
+ * - Permissions handled via POST /permission/:id/reply, not control_response on stdin
17
+ */
18
+ import { EventEmitter } from 'events';
19
+ import { spawn } from 'child_process';
20
+ import { randomUUID } from 'crypto';
21
+ import { OPENCODE_CAPABILITIES } from './coding-process.js';
22
+ import { summarizeToolInput } from './tool-labels.js';
23
+ const serverState = {
24
+ process: null,
25
+ port: 0,
26
+ password: '',
27
+ ready: false,
28
+ startPromise: null,
29
+ };
30
+ /**
31
+ * Ensure the OpenCode server is running. Starts it if not already running.
32
+ * Returns the base URL for API calls.
33
+ */
34
+ async function ensureOpenCodeServer(workingDir) {
35
+ if (serverState.ready && serverState.process && !serverState.process.killed) {
36
+ return `http://localhost:${serverState.port}`;
37
+ }
38
+ if (serverState.startPromise) {
39
+ await serverState.startPromise;
40
+ return `http://localhost:${serverState.port}`;
41
+ }
42
+ serverState.startPromise = startOpenCodeServer(workingDir);
43
+ try {
44
+ await serverState.startPromise;
45
+ }
46
+ finally {
47
+ serverState.startPromise = null;
48
+ }
49
+ return `http://localhost:${serverState.port}`;
50
+ }
51
+ async function startOpenCodeServer(workingDir) {
52
+ // Pick a port in the ephemeral range
53
+ serverState.port = 14096 + Math.floor(Math.random() * 1000);
54
+ serverState.password = randomUUID();
55
+ const env = {
56
+ ...Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] != null)),
57
+ OPENCODE_SERVER_PASSWORD: serverState.password,
58
+ };
59
+ const proc = spawn('opencode', ['serve', '--port', String(serverState.port)], {
60
+ cwd: workingDir,
61
+ stdio: ['pipe', 'pipe', 'pipe'],
62
+ env,
63
+ });
64
+ serverState.process = proc;
65
+ proc.on('close', () => {
66
+ serverState.ready = false;
67
+ serverState.process = null;
68
+ });
69
+ // Wait for server to become ready (poll health endpoint)
70
+ const baseUrl = `http://localhost:${serverState.port}`;
71
+ const maxAttempts = 30;
72
+ for (let i = 0; i < maxAttempts; i++) {
73
+ await new Promise(r => setTimeout(r, 1000));
74
+ try {
75
+ const res = await fetch(`${baseUrl}/health`, {
76
+ headers: authHeaders(),
77
+ signal: AbortSignal.timeout(2000),
78
+ });
79
+ if (res.ok) {
80
+ serverState.ready = true;
81
+ console.log(`[opencode-server] Ready on port ${serverState.port}`);
82
+ return;
83
+ }
84
+ }
85
+ catch {
86
+ // Server not ready yet
87
+ }
88
+ }
89
+ // Kill orphaned process that never became healthy
90
+ if (serverState.process) {
91
+ serverState.process.kill('SIGTERM');
92
+ serverState.process = null;
93
+ }
94
+ throw new Error(`OpenCode server failed to start within ${maxAttempts}s`);
95
+ }
96
+ /** Build auth headers for OpenCode API calls. */
97
+ function authHeaders() {
98
+ if (!serverState.password)
99
+ return {};
100
+ const encoded = Buffer.from(`opencode:${serverState.password}`).toString('base64');
101
+ return { Authorization: `Basic ${encoded}` };
102
+ }
103
+ /**
104
+ * Fetch the list of configured models from the running OpenCode server.
105
+ * Returns an empty array if the server is not running.
106
+ */
107
+ export async function fetchOpenCodeModels(workingDir) {
108
+ try {
109
+ const baseUrl = await ensureOpenCodeServer(workingDir);
110
+ const res = await fetch(`${baseUrl}/config/providers`, {
111
+ headers: {
112
+ ...authHeaders(),
113
+ 'x-opencode-directory': workingDir,
114
+ },
115
+ signal: AbortSignal.timeout(15000),
116
+ });
117
+ if (!res.ok)
118
+ return { models: [], defaults: {} };
119
+ const data = await res.json();
120
+ const models = [];
121
+ for (const p of data.providers) {
122
+ for (const m of Object.values(p.models)) {
123
+ models.push({ id: m.id, name: m.name, providerID: p.id, providerName: p.name });
124
+ }
125
+ }
126
+ return { models, defaults: data.default ?? {} };
127
+ }
128
+ catch {
129
+ return { models: [], defaults: {} };
130
+ }
131
+ }
132
+ /** Stop the shared OpenCode server. */
133
+ export function stopOpenCodeServer() {
134
+ if (serverState.process) {
135
+ serverState.process.kill('SIGTERM');
136
+ serverState.process = null;
137
+ serverState.ready = false;
138
+ }
139
+ }
140
+ // ---------------------------------------------------------------------------
141
+ // OpenCodeProcess
142
+ // ---------------------------------------------------------------------------
143
+ export class OpenCodeProcess extends EventEmitter {
144
+ provider = 'opencode';
145
+ capabilities = OPENCODE_CAPABILITIES;
146
+ sessionId;
147
+ opencodeSessionId = null;
148
+ workingDir;
149
+ model;
150
+ alive = false;
151
+ abortController = null;
152
+ startupTimer = null;
153
+ permissionMode;
154
+ tasks = new Map();
155
+ turnComplete = false;
156
+ taskSeq = 0;
157
+ constructor(workingDir, opts) {
158
+ super();
159
+ this.workingDir = workingDir;
160
+ this.sessionId = opts?.sessionId || randomUUID();
161
+ this.opencodeSessionId = opts?.opencodeSessionId || null;
162
+ this.model = opts?.model;
163
+ this.permissionMode = opts?.permissionMode;
164
+ }
165
+ /** Connect to the OpenCode server, create a session, and subscribe to SSE events. */
166
+ start() {
167
+ if (this.alive)
168
+ return;
169
+ this.alive = true;
170
+ // Startup timeout
171
+ this.startupTimer = setTimeout(() => {
172
+ this.startupTimer = null;
173
+ if (this.alive) {
174
+ this.emit('error', 'OpenCode process failed to initialize within 60 seconds');
175
+ this.stop();
176
+ }
177
+ }, 60_000);
178
+ void this.initialize().catch((err) => {
179
+ this.emit('error', `OpenCode initialization failed: ${err instanceof Error ? err.message : String(err)}`);
180
+ this.stop();
181
+ });
182
+ }
183
+ async initialize() {
184
+ const baseUrl = await ensureOpenCodeServer(this.workingDir);
185
+ // Create or resume a session — must happen BEFORE SSE subscription
186
+ // so that this.opencodeSessionId is set and the session ID filter
187
+ // guards in handleSSEEvent() are active (prevents cross-session leakage).
188
+ if (this.opencodeSessionId) {
189
+ // Resume existing session — just reconnect to SSE
190
+ }
191
+ else {
192
+ const createRes = await fetch(`${baseUrl}/session`, {
193
+ method: 'POST',
194
+ headers: {
195
+ ...authHeaders(),
196
+ 'Content-Type': 'application/json',
197
+ 'x-opencode-directory': this.workingDir,
198
+ },
199
+ body: JSON.stringify({
200
+ title: `Codekin session ${this.sessionId.slice(0, 8)}`,
201
+ }),
202
+ });
203
+ if (!createRes.ok) {
204
+ throw new Error(`Failed to create OpenCode session: ${createRes.status} ${await createRes.text()}`);
205
+ }
206
+ const data = await createRes.json();
207
+ this.opencodeSessionId = data.id;
208
+ }
209
+ // Subscribe to SSE events AFTER opencodeSessionId is set so session
210
+ // filtering is active from the first event received.
211
+ this.subscribeToEvents(baseUrl);
212
+ // Clear startup timer and emit init
213
+ if (this.startupTimer) {
214
+ clearTimeout(this.startupTimer);
215
+ this.startupTimer = null;
216
+ }
217
+ // Model is stored as "providerID/modelID" — show everything after the first slash
218
+ const modelName = this.model?.includes('/') ? this.model.slice(this.model.indexOf('/') + 1) : (this.model || 'opencode (default)');
219
+ this.emit('system_init', modelName);
220
+ }
221
+ /** Subscribe to the OpenCode SSE event stream and map events to CodingProcess events. */
222
+ subscribeToEvents(baseUrl) {
223
+ this.abortController = new AbortController();
224
+ let reconnectDelay = 1000;
225
+ const MAX_RECONNECT_DELAY = 30_000;
226
+ const MAX_RECONNECT_ATTEMPTS = 20;
227
+ let reconnectAttempts = 0;
228
+ const connectSSE = () => {
229
+ if (!this.alive)
230
+ return;
231
+ void fetch(`${baseUrl}/event`, {
232
+ headers: {
233
+ ...authHeaders(),
234
+ Accept: 'text/event-stream',
235
+ 'x-opencode-directory': this.workingDir,
236
+ },
237
+ signal: this.abortController.signal,
238
+ }).then(async (res) => {
239
+ if (!res.ok || !res.body) {
240
+ if (this.alive) {
241
+ reconnectAttempts++;
242
+ if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
243
+ this.emit('error', `SSE reconnect failed after ${MAX_RECONNECT_ATTEMPTS} attempts (last status: ${res.status})`);
244
+ return;
245
+ }
246
+ console.warn(`[opencode-sse] Non-2xx ${res.status}, reconnecting in ${reconnectDelay / 1000}s (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
247
+ setTimeout(connectSSE, reconnectDelay);
248
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
249
+ }
250
+ return;
251
+ }
252
+ // Reset backoff on successful connection
253
+ reconnectDelay = 1000;
254
+ reconnectAttempts = 0;
255
+ const reader = res.body.getReader();
256
+ const decoder = new TextDecoder();
257
+ let buffer = '';
258
+ while (this.alive) {
259
+ const { done, value } = await reader.read();
260
+ if (done)
261
+ break;
262
+ buffer += decoder.decode(value, { stream: true });
263
+ const lines = buffer.split('\n');
264
+ buffer = lines.pop() || '';
265
+ let currentData = '';
266
+ for (const line of lines) {
267
+ if (line.startsWith('data: ')) {
268
+ currentData += line.slice(6);
269
+ }
270
+ else if (line === '' && currentData) {
271
+ try {
272
+ const event = JSON.parse(currentData);
273
+ this.handleSSEEvent(event);
274
+ }
275
+ catch {
276
+ // Ignore unparseable SSE data
277
+ }
278
+ currentData = '';
279
+ }
280
+ }
281
+ }
282
+ // Clean EOF — reconnect if still alive (server restart, proxy timeout, etc.)
283
+ if (this.alive) {
284
+ reconnectAttempts++;
285
+ if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
286
+ this.emit('error', `SSE reconnect failed after ${MAX_RECONNECT_ATTEMPTS} attempts`);
287
+ return;
288
+ }
289
+ console.warn(`[opencode-sse] Stream closed cleanly, reconnecting in ${reconnectDelay / 1000}s (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
290
+ setTimeout(connectSSE, reconnectDelay);
291
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
292
+ }
293
+ }).catch((err) => {
294
+ if (err instanceof Error && err.name === 'AbortError')
295
+ return;
296
+ if (this.alive) {
297
+ reconnectAttempts++;
298
+ if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
299
+ this.emit('error', `SSE reconnect failed after ${MAX_RECONNECT_ATTEMPTS} attempts`);
300
+ return;
301
+ }
302
+ console.warn(`[opencode-sse] Connection lost, reconnecting in ${reconnectDelay / 1000}s (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`, err);
303
+ setTimeout(connectSSE, reconnectDelay);
304
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
305
+ }
306
+ });
307
+ };
308
+ connectSSE();
309
+ }
310
+ /**
311
+ * Check whether an SSE event belongs to this process's OpenCode session.
312
+ * Returns true if the event should be processed, false if it should be skipped.
313
+ * Rejects events when opencodeSessionId is not yet set (init window) to prevent
314
+ * cross-session leakage on the shared SSE stream.
315
+ */
316
+ isOwnSession(properties) {
317
+ const sessionID = properties.sessionID;
318
+ // If we don't have our session ID yet, reject everything to prevent
319
+ // cross-session leakage during the initialization window.
320
+ if (!this.opencodeSessionId)
321
+ return false;
322
+ // If event has no session ID, accept (server-level event)
323
+ if (!sessionID)
324
+ return true;
325
+ return sessionID === this.opencodeSessionId;
326
+ }
327
+ /** Map an OpenCode SSE event to CodingProcess events. */
328
+ handleSSEEvent(event) {
329
+ const { type, properties } = event;
330
+ switch (type) {
331
+ // Delta events carry the actual streaming text content
332
+ case 'message.part.delta': {
333
+ if (!this.isOwnSession(properties))
334
+ break;
335
+ const field = properties.field;
336
+ const delta = properties.delta;
337
+ if (field === 'text' && delta) {
338
+ this.emit('text', delta);
339
+ }
340
+ break;
341
+ }
342
+ case 'message.part.updated': {
343
+ const part = properties.part;
344
+ if (!part)
345
+ break;
346
+ // Only process events for our session
347
+ if (!this.isOwnSession(properties))
348
+ break;
349
+ switch (part.type) {
350
+ case 'text': {
351
+ // Text content arrives via message.part.delta events, not here.
352
+ break;
353
+ }
354
+ case 'reasoning': {
355
+ // OpenCode uses 'text' field, not 'content'. Reasoning may be
356
+ // empty or encrypted (e.g. OpenAI models). Only emit if present.
357
+ const content = part.text || '';
358
+ if (content.length > 20) {
359
+ const match = content.match(/^(.+?[.!?\n])/);
360
+ const summary = match && match[1].length <= 120
361
+ ? match[1].replace(/\n/g, ' ').trim()
362
+ : content.slice(0, 80).trim();
363
+ this.emit('thinking', summary);
364
+ }
365
+ break;
366
+ }
367
+ case 'tool': {
368
+ // Tool state is an object {status, input, output, time, ...}, not a string
369
+ const toolName = part.tool || 'unknown';
370
+ const status = part.state?.status;
371
+ if (status === 'running') {
372
+ const inputStr = part.state?.input ? summarizeToolInput(toolName, part.state.input) : undefined;
373
+ this.emit('tool_active', toolName, inputStr);
374
+ // Detect task/todo tool calls and emit todo_update
375
+ if (part.state?.input && this.handleTaskTool(toolName, part.state.input)) {
376
+ this.emit('todo_update', Array.from(this.tasks.values()));
377
+ }
378
+ }
379
+ else if (status === 'completed') {
380
+ // Also check for task tools at completion (some providers only
381
+ // populate input at this stage, not during 'running')
382
+ if (part.state?.input && this.handleTaskTool(toolName, part.state.input)) {
383
+ this.emit('todo_update', Array.from(this.tasks.values()));
384
+ }
385
+ const output = part.state?.output;
386
+ const summary = output ? output.slice(0, 200) : undefined;
387
+ this.emit('tool_done', toolName, summary);
388
+ if (output) {
389
+ const truncated = output.length > 2000
390
+ ? output.slice(0, 2000) + `\n… (truncated, ${output.length} chars total)`
391
+ : output;
392
+ this.emit('tool_output', truncated, false);
393
+ }
394
+ }
395
+ else if (status === 'error') {
396
+ const errMsg = part.state?.error || 'unknown';
397
+ this.emit('tool_done', toolName, `Error: ${errMsg}`);
398
+ this.emit('tool_output', errMsg, true);
399
+ }
400
+ // 'pending' status — tool call parsed but not yet executing; no action needed
401
+ break;
402
+ }
403
+ // step-start / step-finish are agentic iteration boundaries — no mapping needed
404
+ }
405
+ break;
406
+ }
407
+ case 'session.status': {
408
+ if (!this.isOwnSession(properties))
409
+ break;
410
+ // Status may be a string ('idle') or object ({ type: 'idle' }) depending on OpenCode version
411
+ const status = properties.status;
412
+ const statusType = typeof status === 'string' ? status : status?.type;
413
+ if (statusType === 'idle') {
414
+ if (this.turnComplete)
415
+ break;
416
+ this.turnComplete = true;
417
+ this.emit('result', '', false);
418
+ }
419
+ break;
420
+ }
421
+ case 'session.error': {
422
+ if (!this.isOwnSession(properties))
423
+ break;
424
+ const error = properties.error;
425
+ this.emit('error', error?.message || 'Unknown OpenCode error');
426
+ break;
427
+ }
428
+ case 'permission.asked': {
429
+ if (!this.isOwnSession(properties))
430
+ break;
431
+ const requestId = properties.id;
432
+ if (!requestId) {
433
+ console.error('[opencode] permission.asked event missing required id field');
434
+ break;
435
+ }
436
+ // Real format: properties.permission is the type (e.g. "external_directory"),
437
+ // properties.metadata has details (filepath, parentDir), properties.patterns
438
+ // has the glob patterns being requested. No direct tool name — use permission type.
439
+ const permissionType = properties.permission || 'unknown';
440
+ const metadata = properties.metadata || {};
441
+ const patterns = properties.patterns || [];
442
+ const input = {
443
+ permission: permissionType,
444
+ ...metadata,
445
+ patterns,
446
+ };
447
+ // Auto-approve for headless sessions (webhook/workflow)
448
+ if (this.permissionMode === 'bypassPermissions' || this.permissionMode === 'dangerouslySkipPermissions') {
449
+ void this.replyToPermission(requestId, 'always');
450
+ return;
451
+ }
452
+ // Emit as control_request for SessionManager to handle
453
+ this.emit('control_request', requestId, permissionType, input);
454
+ break;
455
+ }
456
+ // message.completed signals that the model has finished its response
457
+ case 'message.completed': {
458
+ if (!this.isOwnSession(properties))
459
+ break;
460
+ if (this.turnComplete)
461
+ break;
462
+ this.turnComplete = true;
463
+ this.emit('result', '', false);
464
+ break;
465
+ }
466
+ // session.updated may carry idle status in some OpenCode versions
467
+ case 'session.updated': {
468
+ if (!this.isOwnSession(properties))
469
+ break;
470
+ const session = properties.session;
471
+ const sessionStatus = session?.status;
472
+ const sType = typeof sessionStatus === 'string' ? sessionStatus : sessionStatus?.type;
473
+ if (sType === 'idle') {
474
+ if (this.turnComplete)
475
+ break;
476
+ this.turnComplete = true;
477
+ this.emit('result', '', false);
478
+ }
479
+ break;
480
+ }
481
+ default:
482
+ // Log unhandled session-scoped events for debugging (skip noisy ones)
483
+ if (type !== 'heartbeat' && type !== 'server.connected' && type !== 'message.part.added') {
484
+ if (this.isOwnSession(properties)) {
485
+ console.log(`[opencode-sse] Unhandled event: ${type}`, JSON.stringify(properties).slice(0, 200));
486
+ }
487
+ }
488
+ break;
489
+ }
490
+ }
491
+ /** Reply to an OpenCode permission request via HTTP. */
492
+ async replyToPermission(requestId, type) {
493
+ try {
494
+ const baseUrl = `http://localhost:${serverState.port}`;
495
+ const res = await fetch(`${baseUrl}/permission/${requestId}/reply`, {
496
+ method: 'POST',
497
+ headers: {
498
+ ...authHeaders(),
499
+ 'Content-Type': 'application/json',
500
+ 'x-opencode-directory': this.workingDir,
501
+ },
502
+ body: JSON.stringify({ type }),
503
+ });
504
+ if (!res.ok) {
505
+ console.error(`[opencode] Permission reply failed: HTTP ${res.status} for ${requestId}`);
506
+ }
507
+ }
508
+ catch (err) {
509
+ console.error(`[opencode] Failed to reply to permission ${requestId}:`, err);
510
+ }
511
+ }
512
+ /**
513
+ * Detect TodoWrite/TaskCreate/TaskUpdate tool calls and emit todo_update events.
514
+ * Mirrors the task-tracking logic in ClaudeProcess.handleTaskTool().
515
+ */
516
+ handleTaskTool(toolName, input) {
517
+ // Normalize tool name — OpenCode may report as 'todowrite', 'TodoWrite', 'todo_write', etc.
518
+ const normalized = toolName.toLowerCase().replace(/_/g, '');
519
+ if (normalized === 'todowrite') {
520
+ const todos = input.todos;
521
+ if (!Array.isArray(todos))
522
+ return false;
523
+ this.tasks.clear();
524
+ this.taskSeq = 0;
525
+ for (const item of todos) {
526
+ const id = String(item.id || ++this.taskSeq);
527
+ const status = item.status;
528
+ if (status !== 'pending' && status !== 'in_progress' && status !== 'completed')
529
+ continue;
530
+ this.tasks.set(id, {
531
+ id,
532
+ subject: String(item.content || item.subject || ''),
533
+ status,
534
+ activeForm: item.activeForm ? String(item.activeForm) : undefined,
535
+ });
536
+ }
537
+ return true;
538
+ }
539
+ if (normalized === 'taskcreate') {
540
+ const id = String(++this.taskSeq);
541
+ this.tasks.set(id, {
542
+ id,
543
+ subject: String(input.subject || ''),
544
+ status: 'pending',
545
+ activeForm: input.activeForm ? String(input.activeForm) : undefined,
546
+ });
547
+ return true;
548
+ }
549
+ if (normalized === 'taskupdate') {
550
+ const id = String(input.taskId || '');
551
+ const task = this.tasks.get(id);
552
+ if (!task)
553
+ return false;
554
+ const status = input.status;
555
+ if (status === 'deleted') {
556
+ this.tasks.delete(id);
557
+ return true;
558
+ }
559
+ if (status === 'pending' || status === 'in_progress' || status === 'completed') {
560
+ task.status = status;
561
+ }
562
+ if (input.subject)
563
+ task.subject = String(input.subject);
564
+ if (input.activeForm !== undefined)
565
+ task.activeForm = input.activeForm ? String(input.activeForm) : undefined;
566
+ return true;
567
+ }
568
+ return false;
569
+ }
570
+ /** Send a user message to the OpenCode session. */
571
+ sendMessage(content) {
572
+ if (!this.alive || !this.opencodeSessionId) {
573
+ this.emit('error', 'OpenCode process is not connected');
574
+ return;
575
+ }
576
+ this.turnComplete = false; // reset completion latch for new turn
577
+ const baseUrl = `http://localhost:${serverState.port}`;
578
+ // Build request body with optional model override
579
+ const body = {
580
+ parts: [{ type: 'text', text: content }],
581
+ };
582
+ // Model is stored as "providerID/modelID" — split only at first slash so
583
+ // OpenRouter-style IDs like "openrouter/meta-llama/llama-3.1-8b" stay intact.
584
+ if (this.model && this.model.includes('/')) {
585
+ const slashIdx = this.model.indexOf('/');
586
+ const providerID = this.model.slice(0, slashIdx);
587
+ const modelID = this.model.slice(slashIdx + 1);
588
+ body.model = { providerID, modelID };
589
+ }
590
+ // Use prompt_async for fire-and-forget (events come via SSE)
591
+ void fetch(`${baseUrl}/session/${this.opencodeSessionId}/prompt_async`, {
592
+ method: 'POST',
593
+ headers: {
594
+ ...authHeaders(),
595
+ 'Content-Type': 'application/json',
596
+ 'x-opencode-directory': this.workingDir,
597
+ },
598
+ body: JSON.stringify(body),
599
+ }).then((res) => {
600
+ if (!res.ok) {
601
+ this.emit('error', `Failed to send message: HTTP ${res.status}`);
602
+ }
603
+ }).catch((err) => {
604
+ this.emit('error', `Failed to send message: ${err instanceof Error ? err.message : String(err)}`);
605
+ });
606
+ }
607
+ /** No-op for OpenCode — raw protocol data is Claude-specific. */
608
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
609
+ sendRaw(_) {
610
+ // OpenCode uses HTTP endpoints, not raw stdin
611
+ }
612
+ /**
613
+ * Respond to a permission/control request.
614
+ * Maps Codekin's allow/deny to OpenCode's once/always/reject.
615
+ */
616
+ sendControlResponse(requestId, behavior) {
617
+ const type = behavior === 'deny' ? 'reject' : 'once';
618
+ void this.replyToPermission(requestId, type);
619
+ }
620
+ /** Stop the OpenCode session and disconnect the SSE stream. */
621
+ stop() {
622
+ if (!this.alive)
623
+ return;
624
+ this.alive = false;
625
+ if (this.startupTimer) {
626
+ clearTimeout(this.startupTimer);
627
+ this.startupTimer = null;
628
+ }
629
+ if (this.abortController) {
630
+ this.abortController.abort();
631
+ this.abortController = null;
632
+ }
633
+ // Emit exit event to match ClaudeProcess behavior
634
+ this.emit('exit', 0, null);
635
+ }
636
+ isAlive() {
637
+ return this.alive;
638
+ }
639
+ isReady() {
640
+ return this.alive && this.opencodeSessionId !== null && serverState.port > 0;
641
+ }
642
+ getSessionId() {
643
+ return this.opencodeSessionId ?? this.sessionId;
644
+ }
645
+ waitForExit(timeoutMs = 10000) {
646
+ if (!this.alive)
647
+ return Promise.resolve();
648
+ return new Promise((resolve) => {
649
+ const timer = setTimeout(resolve, timeoutMs);
650
+ this.once('exit', () => {
651
+ clearTimeout(timer);
652
+ resolve();
653
+ });
654
+ });
655
+ }
656
+ }
657
+ //# sourceMappingURL=opencode-process.js.map