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.
- package/README.md +7 -4
- package/dist/assets/index-BNU7FIQx.css +1 -0
- package/dist/assets/index-k7mgzd5O.js +182 -0
- package/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/dist/approval-manager.js +1 -1
- package/server/dist/claude-process.d.ts +8 -5
- package/server/dist/claude-process.js +21 -63
- package/server/dist/claude-process.js.map +1 -1
- package/server/dist/coding-process.d.ts +83 -0
- package/server/dist/coding-process.js +32 -0
- package/server/dist/coding-process.js.map +1 -0
- package/server/dist/commit-event-handler.js +1 -0
- package/server/dist/commit-event-handler.js.map +1 -1
- package/server/dist/docs-routes.js +34 -6
- package/server/dist/docs-routes.js.map +1 -1
- package/server/dist/native-permissions.js +3 -2
- package/server/dist/native-permissions.js.map +1 -1
- package/server/dist/opencode-process.d.ts +120 -0
- package/server/dist/opencode-process.js +814 -0
- package/server/dist/opencode-process.js.map +1 -0
- package/server/dist/orchestrator-children.d.ts +9 -0
- package/server/dist/orchestrator-children.js +28 -1
- package/server/dist/orchestrator-children.js.map +1 -1
- package/server/dist/orchestrator-manager.js +17 -0
- package/server/dist/orchestrator-manager.js.map +1 -1
- package/server/dist/orchestrator-reports.js +9 -4
- package/server/dist/orchestrator-reports.js.map +1 -1
- package/server/dist/orchestrator-routes.js +8 -1
- package/server/dist/orchestrator-routes.js.map +1 -1
- package/server/dist/prompt-router.d.ts +2 -2
- package/server/dist/prompt-router.js +16 -0
- package/server/dist/prompt-router.js.map +1 -1
- package/server/dist/session-lifecycle.d.ts +4 -4
- package/server/dist/session-lifecycle.js +93 -29
- package/server/dist/session-lifecycle.js.map +1 -1
- package/server/dist/session-manager.d.ts +9 -0
- package/server/dist/session-manager.js +113 -50
- package/server/dist/session-manager.js.map +1 -1
- package/server/dist/session-persistence.d.ts +1 -0
- package/server/dist/session-persistence.js +6 -1
- package/server/dist/session-persistence.js.map +1 -1
- package/server/dist/session-restart-scheduler.d.ts +9 -2
- package/server/dist/session-restart-scheduler.js +14 -2
- package/server/dist/session-restart-scheduler.js.map +1 -1
- package/server/dist/session-routes.js +17 -3
- package/server/dist/session-routes.js.map +1 -1
- package/server/dist/stepflow-handler.d.ts +2 -2
- package/server/dist/stepflow-handler.js +4 -4
- package/server/dist/stepflow-handler.js.map +1 -1
- package/server/dist/tool-labels.d.ts +8 -0
- package/server/dist/tool-labels.js +51 -0
- package/server/dist/tool-labels.js.map +1 -0
- package/server/dist/tsconfig.tsbuildinfo +1 -1
- package/server/dist/types.d.ts +35 -10
- package/server/dist/types.js +4 -1
- package/server/dist/types.js.map +1 -1
- package/server/dist/webhook-dedup.d.ts +11 -0
- package/server/dist/webhook-dedup.js +23 -0
- package/server/dist/webhook-dedup.js.map +1 -1
- package/server/dist/webhook-handler.d.ts +20 -4
- package/server/dist/webhook-handler.js +256 -20
- package/server/dist/webhook-handler.js.map +1 -1
- package/server/dist/webhook-pr-cache.d.ts +57 -0
- package/server/dist/webhook-pr-cache.js +95 -0
- package/server/dist/webhook-pr-cache.js.map +1 -0
- package/server/dist/webhook-pr-github.d.ts +68 -0
- package/server/dist/webhook-pr-github.js +202 -0
- package/server/dist/webhook-pr-github.js.map +1 -0
- package/server/dist/webhook-pr-prompt.d.ts +27 -0
- package/server/dist/webhook-pr-prompt.js +251 -0
- package/server/dist/webhook-pr-prompt.js.map +1 -0
- package/server/dist/webhook-types.d.ts +70 -1
- package/server/dist/webhook-workspace.js +20 -1
- package/server/dist/webhook-workspace.js.map +1 -1
- package/server/dist/workflow-config.d.ts +2 -0
- package/server/dist/workflow-config.js.map +1 -1
- package/server/dist/workflow-loader.js +3 -0
- package/server/dist/workflow-loader.js.map +1 -1
- package/server/dist/workflow-routes.js +6 -2
- package/server/dist/workflow-routes.js.map +1 -1
- package/server/dist/ws-message-handler.js +22 -3
- package/server/dist/ws-message-handler.js.map +1 -1
- package/server/dist/ws-server.js +46 -13
- package/server/dist/ws-server.js.map +1 -1
- package/server/workflows/pr-review.md +27 -0
- package/dist/assets/index-BFkKlY3O.js +0 -182
- 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
|