codekin 0.5.5 → 0.6.1

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 (88) hide show
  1. package/README.md +7 -4
  2. package/dist/assets/index-BNU7FIQx.css +1 -0
  3. package/dist/assets/index-k7mgzd5O.js +182 -0
  4. package/dist/index.html +2 -2
  5. package/package.json +1 -1
  6. package/server/dist/approval-manager.js +1 -1
  7. package/server/dist/claude-process.d.ts +8 -5
  8. package/server/dist/claude-process.js +21 -63
  9. package/server/dist/claude-process.js.map +1 -1
  10. package/server/dist/coding-process.d.ts +83 -0
  11. package/server/dist/coding-process.js +32 -0
  12. package/server/dist/coding-process.js.map +1 -0
  13. package/server/dist/commit-event-handler.js +1 -0
  14. package/server/dist/commit-event-handler.js.map +1 -1
  15. package/server/dist/docs-routes.js +34 -6
  16. package/server/dist/docs-routes.js.map +1 -1
  17. package/server/dist/native-permissions.js +3 -2
  18. package/server/dist/native-permissions.js.map +1 -1
  19. package/server/dist/opencode-process.d.ts +120 -0
  20. package/server/dist/opencode-process.js +814 -0
  21. package/server/dist/opencode-process.js.map +1 -0
  22. package/server/dist/orchestrator-children.d.ts +9 -0
  23. package/server/dist/orchestrator-children.js +28 -1
  24. package/server/dist/orchestrator-children.js.map +1 -1
  25. package/server/dist/orchestrator-manager.js +17 -0
  26. package/server/dist/orchestrator-manager.js.map +1 -1
  27. package/server/dist/orchestrator-reports.js +9 -4
  28. package/server/dist/orchestrator-reports.js.map +1 -1
  29. package/server/dist/orchestrator-routes.js +8 -1
  30. package/server/dist/orchestrator-routes.js.map +1 -1
  31. package/server/dist/prompt-router.d.ts +2 -2
  32. package/server/dist/prompt-router.js +16 -0
  33. package/server/dist/prompt-router.js.map +1 -1
  34. package/server/dist/session-lifecycle.d.ts +4 -4
  35. package/server/dist/session-lifecycle.js +93 -29
  36. package/server/dist/session-lifecycle.js.map +1 -1
  37. package/server/dist/session-manager.d.ts +9 -0
  38. package/server/dist/session-manager.js +113 -50
  39. package/server/dist/session-manager.js.map +1 -1
  40. package/server/dist/session-persistence.d.ts +1 -0
  41. package/server/dist/session-persistence.js +6 -1
  42. package/server/dist/session-persistence.js.map +1 -1
  43. package/server/dist/session-restart-scheduler.d.ts +9 -2
  44. package/server/dist/session-restart-scheduler.js +14 -2
  45. package/server/dist/session-restart-scheduler.js.map +1 -1
  46. package/server/dist/session-routes.js +17 -3
  47. package/server/dist/session-routes.js.map +1 -1
  48. package/server/dist/stepflow-handler.d.ts +2 -2
  49. package/server/dist/stepflow-handler.js +4 -4
  50. package/server/dist/stepflow-handler.js.map +1 -1
  51. package/server/dist/tool-labels.d.ts +8 -0
  52. package/server/dist/tool-labels.js +51 -0
  53. package/server/dist/tool-labels.js.map +1 -0
  54. package/server/dist/tsconfig.tsbuildinfo +1 -1
  55. package/server/dist/types.d.ts +35 -10
  56. package/server/dist/types.js +4 -1
  57. package/server/dist/types.js.map +1 -1
  58. package/server/dist/webhook-dedup.d.ts +11 -0
  59. package/server/dist/webhook-dedup.js +23 -0
  60. package/server/dist/webhook-dedup.js.map +1 -1
  61. package/server/dist/webhook-handler.d.ts +20 -4
  62. package/server/dist/webhook-handler.js +256 -20
  63. package/server/dist/webhook-handler.js.map +1 -1
  64. package/server/dist/webhook-pr-cache.d.ts +57 -0
  65. package/server/dist/webhook-pr-cache.js +95 -0
  66. package/server/dist/webhook-pr-cache.js.map +1 -0
  67. package/server/dist/webhook-pr-github.d.ts +68 -0
  68. package/server/dist/webhook-pr-github.js +202 -0
  69. package/server/dist/webhook-pr-github.js.map +1 -0
  70. package/server/dist/webhook-pr-prompt.d.ts +27 -0
  71. package/server/dist/webhook-pr-prompt.js +251 -0
  72. package/server/dist/webhook-pr-prompt.js.map +1 -0
  73. package/server/dist/webhook-types.d.ts +70 -1
  74. package/server/dist/webhook-workspace.js +20 -1
  75. package/server/dist/webhook-workspace.js.map +1 -1
  76. package/server/dist/workflow-config.d.ts +2 -0
  77. package/server/dist/workflow-config.js.map +1 -1
  78. package/server/dist/workflow-loader.js +3 -0
  79. package/server/dist/workflow-loader.js.map +1 -1
  80. package/server/dist/workflow-routes.js +6 -2
  81. package/server/dist/workflow-routes.js.map +1 -1
  82. package/server/dist/ws-message-handler.js +22 -3
  83. package/server/dist/ws-message-handler.js.map +1 -1
  84. package/server/dist/ws-server.js +46 -13
  85. package/server/dist/ws-server.js.map +1 -1
  86. package/server/workflows/pr-review.md +27 -0
  87. package/dist/assets/index-BFkKlY3O.js +0 -182
  88. package/dist/assets/index-CjEQkT2b.css +0 -1
@@ -0,0 +1,814 @@
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 { readFileSync, existsSync } from 'fs';
22
+ import { extname } from 'path';
23
+ import { OPENCODE_CAPABILITIES } from './coding-process.js';
24
+ import { summarizeToolInput } from './tool-labels.js';
25
+ const serverState = {
26
+ process: null,
27
+ port: 0,
28
+ password: '',
29
+ ready: false,
30
+ startPromise: null,
31
+ };
32
+ /**
33
+ * Ensure the OpenCode server is running. Starts it if not already running.
34
+ * Returns the base URL for API calls.
35
+ */
36
+ async function ensureOpenCodeServer(workingDir) {
37
+ if (serverState.ready && serverState.process && !serverState.process.killed) {
38
+ return `http://localhost:${serverState.port}`;
39
+ }
40
+ if (serverState.startPromise) {
41
+ await serverState.startPromise;
42
+ return `http://localhost:${serverState.port}`;
43
+ }
44
+ serverState.startPromise = startOpenCodeServer(workingDir);
45
+ try {
46
+ await serverState.startPromise;
47
+ }
48
+ finally {
49
+ serverState.startPromise = null;
50
+ }
51
+ return `http://localhost:${serverState.port}`;
52
+ }
53
+ async function startOpenCodeServer(workingDir) {
54
+ // Pick a port in the ephemeral range
55
+ serverState.port = 14096 + Math.floor(Math.random() * 1000);
56
+ serverState.password = randomUUID();
57
+ // Strip API keys and GIT_* vars (except GIT_EDITOR) — same filtering as
58
+ // claude-process.ts. GIT_INDEX_FILE=.git/index breaks worktrees where
59
+ // .git is a file, and stale API keys override OpenCode's own auth.
60
+ const API_KEY_VARS = new Set(['ANTHROPIC_API_KEY', 'CLAUDE_CODE_API_KEY', 'AUTH_TOKEN', 'AUTH_TOKEN_FILE']);
61
+ const env = {
62
+ ...Object.fromEntries(Object.entries(process.env).filter((entry) => entry[1] != null &&
63
+ !API_KEY_VARS.has(entry[0]) &&
64
+ (!entry[0].startsWith('GIT_') || entry[0] === 'GIT_EDITOR'))),
65
+ OPENCODE_SERVER_PASSWORD: serverState.password,
66
+ };
67
+ const proc = spawn('opencode', ['serve', '--port', String(serverState.port)], {
68
+ cwd: workingDir,
69
+ stdio: 'ignore', // prevents buffer deadlock — pipes were never drained
70
+ env,
71
+ });
72
+ serverState.process = proc;
73
+ proc.on('close', () => {
74
+ serverState.ready = false;
75
+ serverState.process = null;
76
+ });
77
+ // Wait for server to become ready (poll health endpoint)
78
+ const baseUrl = `http://localhost:${serverState.port}`;
79
+ const maxAttempts = 30;
80
+ for (let i = 0; i < maxAttempts; i++) {
81
+ await new Promise(r => setTimeout(r, 1000));
82
+ try {
83
+ const res = await fetch(`${baseUrl}/health`, {
84
+ headers: authHeaders(),
85
+ signal: AbortSignal.timeout(2000),
86
+ });
87
+ if (res.ok) {
88
+ serverState.ready = true;
89
+ console.log(`[opencode-server] Ready on port ${serverState.port}`);
90
+ return;
91
+ }
92
+ }
93
+ catch {
94
+ // Server not ready yet
95
+ }
96
+ }
97
+ // Kill orphaned process that never became healthy
98
+ if (serverState.process) {
99
+ serverState.process.kill('SIGTERM');
100
+ serverState.process = null;
101
+ }
102
+ throw new Error(`OpenCode server failed to start within ${maxAttempts}s`);
103
+ }
104
+ /** Build auth headers for OpenCode API calls. */
105
+ function authHeaders() {
106
+ if (!serverState.password)
107
+ return {};
108
+ const encoded = Buffer.from(`opencode:${serverState.password}`).toString('base64');
109
+ return { Authorization: `Basic ${encoded}` };
110
+ }
111
+ /**
112
+ * Fetch the list of configured models from the running OpenCode server.
113
+ * Returns an empty array if the server is not running.
114
+ */
115
+ export async function fetchOpenCodeModels(workingDir) {
116
+ try {
117
+ const baseUrl = await ensureOpenCodeServer(workingDir);
118
+ const res = await fetch(`${baseUrl}/config/providers`, {
119
+ headers: {
120
+ ...authHeaders(),
121
+ 'x-opencode-directory': workingDir,
122
+ },
123
+ signal: AbortSignal.timeout(15000),
124
+ });
125
+ if (!res.ok)
126
+ return { models: [], defaults: {} };
127
+ const data = await res.json();
128
+ const models = [];
129
+ for (const p of data.providers) {
130
+ for (const m of Object.values(p.models)) {
131
+ models.push({ id: m.id, name: m.name, providerID: p.id, providerName: p.name });
132
+ }
133
+ }
134
+ return { models, defaults: data.default ?? {} };
135
+ }
136
+ catch {
137
+ return { models: [], defaults: {} };
138
+ }
139
+ }
140
+ /** Stop the shared OpenCode server. */
141
+ export function stopOpenCodeServer() {
142
+ if (serverState.process) {
143
+ serverState.process.kill('SIGTERM');
144
+ serverState.process = null;
145
+ serverState.ready = false;
146
+ }
147
+ }
148
+ // ---------------------------------------------------------------------------
149
+ // OpenCodeProcess
150
+ // ---------------------------------------------------------------------------
151
+ export class OpenCodeProcess extends EventEmitter {
152
+ provider = 'opencode';
153
+ capabilities = OPENCODE_CAPABILITIES;
154
+ sessionId;
155
+ opencodeSessionId = null;
156
+ workingDir;
157
+ model;
158
+ alive = false;
159
+ abortController = null;
160
+ startupTimer = null;
161
+ permissionMode;
162
+ tasks = new Map();
163
+ turnComplete = false;
164
+ taskSeq = 0;
165
+ /** Whether we've received streaming delta events this turn (to avoid double-emitting text). */
166
+ receivedDeltas = false;
167
+ /** Whether we've already emitted text via message.part.updated (to avoid re-emitting from message.updated). */
168
+ emittedPartText = false;
169
+ /** Last user input text — used to detect and strip user echo from assistant deltas. */
170
+ lastUserInput = '';
171
+ /** Buffer for initial text deltas — held until we can check for user echo prefix. */
172
+ deltaBuffer = '';
173
+ /** Whether the delta buffer has been flushed (user echo check complete). */
174
+ deltaBufferFlushed = false;
175
+ /** Accumulated reasoning delta text for emitting thinking summaries during streaming. */
176
+ reasoningBuffer = '';
177
+ /** Whether we've already emitted a thinking summary from reasoning deltas. */
178
+ emittedReasoningSummary = false;
179
+ constructor(workingDir, opts) {
180
+ super();
181
+ this.workingDir = workingDir;
182
+ this.sessionId = opts?.sessionId || randomUUID();
183
+ this.opencodeSessionId = opts?.opencodeSessionId || null;
184
+ this.model = opts?.model;
185
+ this.permissionMode = opts?.permissionMode;
186
+ }
187
+ /** Connect to the OpenCode server, create a session, and subscribe to SSE events. */
188
+ start() {
189
+ if (this.alive)
190
+ return;
191
+ this.alive = true;
192
+ // Startup timeout
193
+ this.startupTimer = setTimeout(() => {
194
+ this.startupTimer = null;
195
+ if (this.alive) {
196
+ this.emit('error', 'OpenCode process failed to initialize within 60 seconds');
197
+ this.stop();
198
+ }
199
+ }, 60_000);
200
+ void this.initialize().catch((err) => {
201
+ this.emit('error', `OpenCode initialization failed: ${err instanceof Error ? err.message : String(err)}`);
202
+ this.stop();
203
+ });
204
+ }
205
+ async initialize() {
206
+ const baseUrl = await ensureOpenCodeServer(this.workingDir);
207
+ // Create or resume a session — must happen BEFORE SSE subscription
208
+ // so that this.opencodeSessionId is set and the session ID filter
209
+ // guards in handleSSEEvent() are active (prevents cross-session leakage).
210
+ if (this.opencodeSessionId) {
211
+ // Resume existing session — just reconnect to SSE
212
+ }
213
+ else {
214
+ const createRes = await fetch(`${baseUrl}/session`, {
215
+ method: 'POST',
216
+ headers: {
217
+ ...authHeaders(),
218
+ 'Content-Type': 'application/json',
219
+ 'x-opencode-directory': this.workingDir,
220
+ },
221
+ body: JSON.stringify({
222
+ title: `Codekin session ${this.sessionId.slice(0, 8)}`,
223
+ }),
224
+ });
225
+ if (!createRes.ok) {
226
+ throw new Error(`Failed to create OpenCode session: ${createRes.status} ${await createRes.text()}`);
227
+ }
228
+ const data = await createRes.json();
229
+ this.opencodeSessionId = data.id;
230
+ }
231
+ // Subscribe to SSE events AFTER opencodeSessionId is set so session
232
+ // filtering is active from the first event received.
233
+ this.subscribeToEvents(baseUrl);
234
+ // Clear startup timer and emit init
235
+ if (this.startupTimer) {
236
+ clearTimeout(this.startupTimer);
237
+ this.startupTimer = null;
238
+ }
239
+ // Model is stored as "providerID/modelID" — show everything after the first slash
240
+ const modelName = this.model?.includes('/') ? this.model.slice(this.model.indexOf('/') + 1) : (this.model || 'opencode (default)');
241
+ this.emit('system_init', modelName);
242
+ }
243
+ /** Subscribe to the OpenCode SSE event stream and map events to CodingProcess events. */
244
+ subscribeToEvents(baseUrl) {
245
+ this.abortController = new AbortController();
246
+ let reconnectDelay = 1000;
247
+ const MAX_RECONNECT_DELAY = 30_000;
248
+ const MAX_RECONNECT_ATTEMPTS = 20;
249
+ let reconnectAttempts = 0;
250
+ const connectSSE = () => {
251
+ if (!this.alive)
252
+ return;
253
+ void fetch(`${baseUrl}/event`, {
254
+ headers: {
255
+ ...authHeaders(),
256
+ Accept: 'text/event-stream',
257
+ 'x-opencode-directory': this.workingDir,
258
+ },
259
+ signal: this.abortController.signal,
260
+ }).then(async (res) => {
261
+ if (!res.ok || !res.body) {
262
+ if (this.alive) {
263
+ reconnectAttempts++;
264
+ if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
265
+ this.emit('error', `SSE reconnect failed after ${MAX_RECONNECT_ATTEMPTS} attempts (last status: ${res.status})`);
266
+ return;
267
+ }
268
+ console.warn(`[opencode-sse] Non-2xx ${res.status}, reconnecting in ${reconnectDelay / 1000}s (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
269
+ setTimeout(connectSSE, reconnectDelay);
270
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
271
+ }
272
+ return;
273
+ }
274
+ // Reset backoff on successful connection
275
+ reconnectDelay = 1000;
276
+ reconnectAttempts = 0;
277
+ const reader = res.body.getReader();
278
+ const decoder = new TextDecoder();
279
+ let buffer = '';
280
+ while (this.alive) {
281
+ const { done, value } = await reader.read();
282
+ if (done)
283
+ break;
284
+ buffer += decoder.decode(value, { stream: true });
285
+ const lines = buffer.split('\n');
286
+ buffer = lines.pop() || '';
287
+ let currentData = '';
288
+ for (const line of lines) {
289
+ if (line.startsWith('data: ')) {
290
+ currentData += line.slice(6);
291
+ }
292
+ else if (line === '' && currentData) {
293
+ try {
294
+ const event = JSON.parse(currentData);
295
+ this.handleSSEEvent(event);
296
+ }
297
+ catch {
298
+ // Ignore unparseable SSE data
299
+ }
300
+ currentData = '';
301
+ }
302
+ }
303
+ }
304
+ // Clean EOF — reconnect if still alive (server restart, proxy timeout, etc.)
305
+ if (this.alive) {
306
+ reconnectAttempts++;
307
+ if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
308
+ this.emit('error', `SSE reconnect failed after ${MAX_RECONNECT_ATTEMPTS} attempts`);
309
+ return;
310
+ }
311
+ console.warn(`[opencode-sse] Stream closed cleanly, reconnecting in ${reconnectDelay / 1000}s (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
312
+ setTimeout(connectSSE, reconnectDelay);
313
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
314
+ }
315
+ }).catch((err) => {
316
+ if (err instanceof Error && err.name === 'AbortError')
317
+ return;
318
+ if (this.alive) {
319
+ reconnectAttempts++;
320
+ if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
321
+ this.emit('error', `SSE reconnect failed after ${MAX_RECONNECT_ATTEMPTS} attempts`);
322
+ return;
323
+ }
324
+ console.warn(`[opencode-sse] Connection lost, reconnecting in ${reconnectDelay / 1000}s (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`, err);
325
+ setTimeout(connectSSE, reconnectDelay);
326
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
327
+ }
328
+ });
329
+ };
330
+ connectSSE();
331
+ }
332
+ /**
333
+ * Check whether an SSE event belongs to this process's OpenCode session.
334
+ * Returns true if the event should be processed, false if it should be skipped.
335
+ * Rejects events when opencodeSessionId is not yet set (init window) to prevent
336
+ * cross-session leakage on the shared SSE stream.
337
+ */
338
+ isOwnSession(properties) {
339
+ const sessionID = properties.sessionID;
340
+ // If we don't have our session ID yet, reject everything to prevent
341
+ // cross-session leakage during the initialization window.
342
+ if (!this.opencodeSessionId)
343
+ return false;
344
+ // If event has no session ID, accept (server-level event)
345
+ if (!sessionID)
346
+ return true;
347
+ return sessionID === this.opencodeSessionId;
348
+ }
349
+ /** Flush any buffered text deltas that haven't been emitted yet (e.g. turn ended before buffer threshold). */
350
+ flushDeltaBuffer() {
351
+ if (!this.deltaBufferFlushed && this.deltaBuffer) {
352
+ this.deltaBufferFlushed = true;
353
+ if (this.lastUserInput && this.deltaBuffer.startsWith(this.lastUserInput)) {
354
+ const remainder = this.deltaBuffer.slice(this.lastUserInput.length);
355
+ if (remainder)
356
+ this.emit('text', remainder);
357
+ }
358
+ else {
359
+ this.emit('text', this.deltaBuffer);
360
+ }
361
+ this.deltaBuffer = '';
362
+ }
363
+ }
364
+ /** Map an OpenCode SSE event to CodingProcess events. */
365
+ handleSSEEvent(event) {
366
+ const { type, properties } = event;
367
+ switch (type) {
368
+ // Delta events carry the actual streaming text content
369
+ case 'message.part.delta': {
370
+ if (!this.isOwnSession(properties))
371
+ break;
372
+ const field = properties.field;
373
+ const delta = properties.delta;
374
+ if (field === 'text' && delta) {
375
+ this.receivedDeltas = true;
376
+ // Buffer initial deltas to detect and strip user echo prefix.
377
+ // Some providers echo the user message at the start of the assistant
378
+ // response, which causes duplicate display.
379
+ if (!this.deltaBufferFlushed && this.lastUserInput) {
380
+ this.deltaBuffer += delta;
381
+ if (this.deltaBuffer.length >= this.lastUserInput.length) {
382
+ this.deltaBufferFlushed = true;
383
+ if (this.deltaBuffer.startsWith(this.lastUserInput)) {
384
+ const remainder = this.deltaBuffer.slice(this.lastUserInput.length);
385
+ if (remainder)
386
+ this.emit('text', remainder);
387
+ }
388
+ else {
389
+ this.emit('text', this.deltaBuffer);
390
+ }
391
+ this.deltaBuffer = '';
392
+ }
393
+ // Still buffering — don't emit yet
394
+ }
395
+ else {
396
+ this.emit('text', delta);
397
+ }
398
+ }
399
+ else if (field === 'reasoning' && delta) {
400
+ // Accumulate reasoning deltas and emit a thinking summary once we
401
+ // have enough content, so the UI shows a thinking indicator during
402
+ // streaming (not only when message.part.updated arrives later).
403
+ this.reasoningBuffer += delta;
404
+ if (this.reasoningBuffer.length > 20 && !this.emittedReasoningSummary) {
405
+ this.emittedReasoningSummary = true;
406
+ const match = this.reasoningBuffer.match(/^(.+?[.!?\n])/);
407
+ const summary = match && match[1].length <= 120
408
+ ? match[1].replace(/\n/g, ' ').trim()
409
+ : this.reasoningBuffer.slice(0, 80).trim();
410
+ this.emit('thinking', summary);
411
+ }
412
+ }
413
+ break;
414
+ }
415
+ case 'message.part.updated': {
416
+ const part = properties.part;
417
+ if (!part)
418
+ break;
419
+ // Only process events for our session
420
+ if (!this.isOwnSession(properties))
421
+ break;
422
+ switch (part.type) {
423
+ case 'text': {
424
+ // Text may arrive via message.part.delta (streaming) or as full
425
+ // content here (OpenCode >=1.4 message.updated). Only emit if we
426
+ // haven't already streamed it via delta events or emitted it from
427
+ // an earlier message.part.updated event.
428
+ if (part.text && !this.receivedDeltas && !this.emittedPartText) {
429
+ this.emittedPartText = true;
430
+ // Strip user echo prefix if the full text starts with the last input
431
+ let text = part.text;
432
+ if (this.lastUserInput && text.startsWith(this.lastUserInput)) {
433
+ text = text.slice(this.lastUserInput.length);
434
+ }
435
+ if (text)
436
+ this.emit('text', text);
437
+ }
438
+ break;
439
+ }
440
+ case 'reasoning': {
441
+ // OpenCode uses 'text' field, not 'content'. Reasoning may be
442
+ // empty or encrypted (e.g. OpenAI models). Only emit if present.
443
+ const content = part.text || '';
444
+ if (content.length > 20) {
445
+ const match = content.match(/^(.+?[.!?\n])/);
446
+ const summary = match && match[1].length <= 120
447
+ ? match[1].replace(/\n/g, ' ').trim()
448
+ : content.slice(0, 80).trim();
449
+ this.emit('thinking', summary);
450
+ }
451
+ break;
452
+ }
453
+ case 'tool': {
454
+ // Tool state is an object {status, input, output, time, ...}, not a string
455
+ const toolName = part.tool || 'unknown';
456
+ const status = part.state?.status;
457
+ if (status === 'running') {
458
+ const inputStr = part.state?.input ? summarizeToolInput(toolName, part.state.input) : undefined;
459
+ this.emit('tool_active', toolName, inputStr);
460
+ // Detect task/todo tool calls and emit todo_update
461
+ if (part.state?.input && this.handleTaskTool(toolName, part.state.input)) {
462
+ this.emit('todo_update', Array.from(this.tasks.values()));
463
+ }
464
+ }
465
+ else if (status === 'completed') {
466
+ // Also check for task tools at completion (some providers only
467
+ // populate input at this stage, not during 'running')
468
+ if (part.state?.input && this.handleTaskTool(toolName, part.state.input)) {
469
+ this.emit('todo_update', Array.from(this.tasks.values()));
470
+ }
471
+ const output = part.state?.output;
472
+ const summary = output ? output.slice(0, 200) : undefined;
473
+ this.emit('tool_done', toolName, summary);
474
+ if (output) {
475
+ const truncated = output.length > 2000
476
+ ? output.slice(0, 2000) + `\n… (truncated, ${output.length} chars total)`
477
+ : output;
478
+ this.emit('tool_output', truncated, false);
479
+ }
480
+ }
481
+ else if (status === 'error') {
482
+ const errMsg = part.state?.error || 'unknown';
483
+ this.emit('tool_done', toolName, `Error: ${errMsg}`);
484
+ this.emit('tool_output', errMsg, true);
485
+ }
486
+ // 'pending' status — tool call parsed but not yet executing; no action needed
487
+ break;
488
+ }
489
+ // step-start / step-finish are agentic iteration boundaries — no mapping needed
490
+ }
491
+ break;
492
+ }
493
+ case 'session.status': {
494
+ if (!this.isOwnSession(properties))
495
+ break;
496
+ // Status may be a string ('idle') or object ({ type: 'idle' }) depending on OpenCode version
497
+ const status = properties.status;
498
+ const statusType = typeof status === 'string' ? status : status?.type;
499
+ if (statusType === 'idle') {
500
+ if (this.turnComplete)
501
+ break;
502
+ this.turnComplete = true;
503
+ this.flushDeltaBuffer();
504
+ this.emit('result', '', false);
505
+ }
506
+ break;
507
+ }
508
+ case 'session.error': {
509
+ if (!this.isOwnSession(properties))
510
+ break;
511
+ const error = properties.error;
512
+ this.emit('error', error?.message || 'Unknown OpenCode error');
513
+ break;
514
+ }
515
+ case 'permission.asked': {
516
+ if (!this.isOwnSession(properties))
517
+ break;
518
+ const requestId = properties.id;
519
+ if (!requestId) {
520
+ console.error('[opencode] permission.asked event missing required id field');
521
+ break;
522
+ }
523
+ // Real format: properties.permission is the type (e.g. "external_directory"),
524
+ // properties.metadata has details (filepath, parentDir), properties.patterns
525
+ // has the glob patterns being requested. No direct tool name — use permission type.
526
+ const permissionType = properties.permission || 'unknown';
527
+ const metadata = properties.metadata || {};
528
+ const patterns = properties.patterns || [];
529
+ const input = {
530
+ permission: permissionType,
531
+ ...metadata,
532
+ patterns,
533
+ };
534
+ // Auto-approve for headless sessions (webhook/workflow)
535
+ if (this.permissionMode === 'bypassPermissions' || this.permissionMode === 'dangerouslySkipPermissions') {
536
+ void this.replyToPermission(requestId, 'always');
537
+ return;
538
+ }
539
+ // Emit as control_request for SessionManager to handle
540
+ this.emit('control_request', requestId, permissionType, input);
541
+ break;
542
+ }
543
+ // message.completed signals that the model has finished its response
544
+ case 'message.completed': {
545
+ if (!this.isOwnSession(properties))
546
+ break;
547
+ if (this.turnComplete)
548
+ break;
549
+ this.turnComplete = true;
550
+ this.flushDeltaBuffer();
551
+ this.emit('result', '', false);
552
+ break;
553
+ }
554
+ // session.updated may carry idle status in some OpenCode versions
555
+ case 'session.updated': {
556
+ if (!this.isOwnSession(properties))
557
+ break;
558
+ const session = properties.session;
559
+ const sessionStatus = session?.status;
560
+ const sType = typeof sessionStatus === 'string' ? sessionStatus : sessionStatus?.type;
561
+ if (sType === 'idle') {
562
+ if (this.turnComplete)
563
+ break;
564
+ this.turnComplete = true;
565
+ this.flushDeltaBuffer();
566
+ this.emit('result', '', false);
567
+ }
568
+ break;
569
+ }
570
+ // OpenCode >=1.4 sends session.idle as a standalone event (not nested in session.status)
571
+ case 'session.idle': {
572
+ if (!this.isOwnSession(properties))
573
+ break;
574
+ if (this.turnComplete)
575
+ break;
576
+ this.turnComplete = true;
577
+ this.flushDeltaBuffer();
578
+ this.emit('result', '', false);
579
+ break;
580
+ }
581
+ // OpenCode >=1.4 sends message.updated with full message info including parts.
582
+ // Extract parts and process them like message.part.updated events.
583
+ case 'message.updated': {
584
+ if (!this.isOwnSession(properties))
585
+ break;
586
+ const info = properties.info;
587
+ if (!info || info.role !== 'assistant' || !info.parts)
588
+ break;
589
+ for (const part of info.parts) {
590
+ this.handleSSEEvent({ type: 'message.part.updated', properties: { ...properties, part } });
591
+ }
592
+ break;
593
+ }
594
+ default:
595
+ // Log unhandled session-scoped events for debugging (skip noisy ones)
596
+ if (type !== 'heartbeat' && type !== 'server.connected' && type !== 'message.part.added') {
597
+ if (this.isOwnSession(properties)) {
598
+ console.log(`[opencode-sse] Unhandled event: ${type}`, JSON.stringify(properties).slice(0, 200));
599
+ }
600
+ }
601
+ break;
602
+ }
603
+ }
604
+ /** Reply to an OpenCode permission request via HTTP. */
605
+ async replyToPermission(requestId, type) {
606
+ try {
607
+ const baseUrl = `http://localhost:${serverState.port}`;
608
+ const res = await fetch(`${baseUrl}/permission/${requestId}/reply`, {
609
+ method: 'POST',
610
+ headers: {
611
+ ...authHeaders(),
612
+ 'Content-Type': 'application/json',
613
+ 'x-opencode-directory': this.workingDir,
614
+ },
615
+ body: JSON.stringify({ type }),
616
+ });
617
+ if (!res.ok) {
618
+ console.error(`[opencode] Permission reply failed: HTTP ${res.status} for ${requestId}`);
619
+ }
620
+ }
621
+ catch (err) {
622
+ console.error(`[opencode] Failed to reply to permission ${requestId}:`, err);
623
+ }
624
+ }
625
+ /**
626
+ * Detect TodoWrite/TaskCreate/TaskUpdate tool calls and emit todo_update events.
627
+ * Mirrors the task-tracking logic in ClaudeProcess.handleTaskTool().
628
+ */
629
+ handleTaskTool(toolName, input) {
630
+ // Normalize tool name — OpenCode may report as 'todowrite', 'TodoWrite', 'todo_write', etc.
631
+ const normalized = toolName.toLowerCase().replace(/_/g, '');
632
+ if (normalized === 'todowrite') {
633
+ const todos = input.todos;
634
+ if (!Array.isArray(todos))
635
+ return false;
636
+ this.tasks.clear();
637
+ this.taskSeq = 0;
638
+ for (const item of todos) {
639
+ const id = String(item.id || ++this.taskSeq);
640
+ const status = item.status;
641
+ if (status !== 'pending' && status !== 'in_progress' && status !== 'completed')
642
+ continue;
643
+ this.tasks.set(id, {
644
+ id,
645
+ subject: String(item.content || item.subject || ''),
646
+ status,
647
+ activeForm: item.activeForm ? String(item.activeForm) : undefined,
648
+ });
649
+ }
650
+ return true;
651
+ }
652
+ if (normalized === 'taskcreate') {
653
+ const id = String(++this.taskSeq);
654
+ this.tasks.set(id, {
655
+ id,
656
+ subject: String(input.subject || ''),
657
+ status: 'pending',
658
+ activeForm: input.activeForm ? String(input.activeForm) : undefined,
659
+ });
660
+ return true;
661
+ }
662
+ if (normalized === 'taskupdate') {
663
+ const id = String(input.taskId || '');
664
+ const task = this.tasks.get(id);
665
+ if (!task)
666
+ return false;
667
+ const status = input.status;
668
+ if (status === 'deleted') {
669
+ this.tasks.delete(id);
670
+ return true;
671
+ }
672
+ if (status === 'pending' || status === 'in_progress' || status === 'completed') {
673
+ task.status = status;
674
+ }
675
+ if (input.subject)
676
+ task.subject = String(input.subject);
677
+ if (input.activeForm !== undefined)
678
+ task.activeForm = input.activeForm ? String(input.activeForm) : undefined;
679
+ return true;
680
+ }
681
+ return false;
682
+ }
683
+ /** Send a user message to the OpenCode session. */
684
+ sendMessage(content) {
685
+ if (!this.alive || !this.opencodeSessionId) {
686
+ this.emit('error', 'OpenCode process is not connected');
687
+ return;
688
+ }
689
+ this.turnComplete = false; // reset completion latch for new turn
690
+ this.receivedDeltas = false;
691
+ this.emittedPartText = false;
692
+ this.deltaBuffer = '';
693
+ this.deltaBufferFlushed = false;
694
+ this.reasoningBuffer = '';
695
+ this.emittedReasoningSummary = false;
696
+ const baseUrl = `http://localhost:${serverState.port}`;
697
+ // Parse [Attached files: ...] prefix and convert image paths to proper parts.
698
+ // The frontend uploads images to the screenshots dir and wraps them as:
699
+ // [Attached files: /path/to/img1, /path/to/img2]\nuser text
700
+ const parts = [];
701
+ let textContent = content;
702
+ const attachMatch = content.match(/^\[Attached files: ([^\]]+)\]\n?/);
703
+ if (attachMatch) {
704
+ textContent = content.slice(attachMatch[0].length);
705
+ const filePaths = attachMatch[1].split(',').map(p => p.trim());
706
+ const imageMimeMap = {
707
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
708
+ '.gif': 'image/gif', '.webp': 'image/webp',
709
+ };
710
+ const textExtensions = new Set(['.md', '.txt', '.csv', '.json', '.xml', '.yaml', '.yml', '.log']);
711
+ for (const filePath of filePaths) {
712
+ if (!existsSync(filePath)) {
713
+ console.warn(`[opencode] Attached file not found: ${filePath}`);
714
+ continue;
715
+ }
716
+ const ext = extname(filePath).toLowerCase();
717
+ const imageMime = imageMimeMap[ext];
718
+ if (imageMime) {
719
+ const base64 = readFileSync(filePath).toString('base64');
720
+ parts.push({ type: 'file', mime: imageMime, filename: filePath.split('/').pop(), url: `data:${imageMime};base64,${base64}` });
721
+ }
722
+ else if (textExtensions.has(ext)) {
723
+ // Send text-based files as inline text content
724
+ const fileContent = readFileSync(filePath, 'utf-8');
725
+ const fileName = filePath.split('/').pop() || filePath;
726
+ parts.push({ type: 'text', text: `--- ${fileName} ---\n${fileContent}` });
727
+ }
728
+ else {
729
+ console.warn(`[opencode] Unsupported file type for attachment: ${ext} (${filePath})`);
730
+ }
731
+ }
732
+ }
733
+ this.lastUserInput = textContent.trim();
734
+ if (textContent.trim()) {
735
+ parts.push({ type: 'text', text: textContent });
736
+ }
737
+ // Build request body with optional model override
738
+ const body = { parts };
739
+ // Model is stored as "providerID/modelID" — split only at first slash so
740
+ // OpenRouter-style IDs like "openrouter/meta-llama/llama-3.1-8b" stay intact.
741
+ if (this.model && this.model.includes('/')) {
742
+ const slashIdx = this.model.indexOf('/');
743
+ const providerID = this.model.slice(0, slashIdx);
744
+ const modelID = this.model.slice(slashIdx + 1);
745
+ body.model = { providerID, modelID };
746
+ }
747
+ // Use prompt_async for fire-and-forget (events come via SSE)
748
+ void fetch(`${baseUrl}/session/${this.opencodeSessionId}/prompt_async`, {
749
+ method: 'POST',
750
+ headers: {
751
+ ...authHeaders(),
752
+ 'Content-Type': 'application/json',
753
+ 'x-opencode-directory': this.workingDir,
754
+ },
755
+ body: JSON.stringify(body),
756
+ }).then((res) => {
757
+ if (!res.ok) {
758
+ this.emit('error', `Failed to send message: HTTP ${res.status}`);
759
+ }
760
+ }).catch((err) => {
761
+ this.emit('error', `Failed to send message: ${err instanceof Error ? err.message : String(err)}`);
762
+ });
763
+ }
764
+ /** No-op for OpenCode — raw protocol data is Claude-specific. */
765
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
766
+ sendRaw(_) {
767
+ // OpenCode uses HTTP endpoints, not raw stdin
768
+ }
769
+ /**
770
+ * Respond to a permission/control request.
771
+ * Maps Codekin's allow/deny to OpenCode's once/always/reject.
772
+ */
773
+ sendControlResponse(requestId, behavior) {
774
+ const type = behavior === 'deny' ? 'reject' : 'once';
775
+ void this.replyToPermission(requestId, type);
776
+ }
777
+ /** Stop the OpenCode session and disconnect the SSE stream. */
778
+ stop() {
779
+ if (!this.alive)
780
+ return;
781
+ this.alive = false;
782
+ if (this.startupTimer) {
783
+ clearTimeout(this.startupTimer);
784
+ this.startupTimer = null;
785
+ }
786
+ if (this.abortController) {
787
+ this.abortController.abort();
788
+ this.abortController = null;
789
+ }
790
+ // Emit exit event to match ClaudeProcess behavior
791
+ this.emit('exit', 0, null);
792
+ }
793
+ isAlive() {
794
+ return this.alive;
795
+ }
796
+ isReady() {
797
+ return this.alive && this.opencodeSessionId !== null && serverState.port > 0;
798
+ }
799
+ getSessionId() {
800
+ return this.opencodeSessionId ?? this.sessionId;
801
+ }
802
+ waitForExit(timeoutMs = 10000) {
803
+ if (!this.alive)
804
+ return Promise.resolve();
805
+ return new Promise((resolve) => {
806
+ const timer = setTimeout(resolve, timeoutMs);
807
+ this.once('exit', () => {
808
+ clearTimeout(timer);
809
+ resolve();
810
+ });
811
+ });
812
+ }
813
+ }
814
+ //# sourceMappingURL=opencode-process.js.map