codekin 0.6.5 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/dist/assets/index-CLuQVRRb.css +1 -0
- package/dist/assets/index-JVnFWiSw.js +185 -0
- package/dist/index.html +2 -2
- package/package.json +4 -2
- package/server/dist/anthropic-models.d.ts +40 -0
- package/server/dist/anthropic-models.js +212 -0
- package/server/dist/anthropic-models.js.map +1 -0
- package/server/dist/claude-process.d.ts +31 -3
- package/server/dist/claude-process.js +126 -9
- package/server/dist/claude-process.js.map +1 -1
- package/server/dist/codex-process.d.ts +147 -0
- package/server/dist/codex-process.js +741 -0
- package/server/dist/codex-process.js.map +1 -0
- package/server/dist/coding-process.d.ts +16 -4
- package/server/dist/coding-process.js +10 -0
- package/server/dist/coding-process.js.map +1 -1
- package/server/dist/config.d.ts +13 -0
- package/server/dist/config.js +18 -0
- package/server/dist/config.js.map +1 -1
- package/server/dist/opencode-process.d.ts +142 -5
- package/server/dist/opencode-process.js +664 -84
- package/server/dist/opencode-process.js.map +1 -1
- package/server/dist/orchestrator-children.d.ts +53 -6
- package/server/dist/orchestrator-children.js +292 -68
- package/server/dist/orchestrator-children.js.map +1 -1
- package/server/dist/orchestrator-manager.d.ts +10 -0
- package/server/dist/orchestrator-manager.js +70 -18
- package/server/dist/orchestrator-manager.js.map +1 -1
- package/server/dist/orchestrator-monitor.d.ts +7 -1
- package/server/dist/orchestrator-monitor.js +49 -19
- package/server/dist/orchestrator-monitor.js.map +1 -1
- package/server/dist/orchestrator-notify.d.ts +16 -3
- package/server/dist/orchestrator-notify.js +22 -7
- package/server/dist/orchestrator-notify.js.map +1 -1
- package/server/dist/orchestrator-outbox.d.ts +48 -0
- package/server/dist/orchestrator-outbox.js +154 -0
- package/server/dist/orchestrator-outbox.js.map +1 -0
- package/server/dist/orchestrator-session-router.js +40 -1
- package/server/dist/orchestrator-session-router.js.map +1 -1
- package/server/dist/prompt-router.d.ts +22 -1
- package/server/dist/prompt-router.js +94 -11
- package/server/dist/prompt-router.js.map +1 -1
- package/server/dist/session-archive.js +11 -1
- package/server/dist/session-archive.js.map +1 -1
- package/server/dist/session-lifecycle.js +36 -0
- package/server/dist/session-lifecycle.js.map +1 -1
- package/server/dist/session-manager.d.ts +19 -2
- package/server/dist/session-manager.js +116 -27
- package/server/dist/session-manager.js.map +1 -1
- package/server/dist/session-naming.d.ts +4 -0
- package/server/dist/session-naming.js +26 -5
- package/server/dist/session-naming.js.map +1 -1
- package/server/dist/session-routes.js +38 -1
- package/server/dist/session-routes.js.map +1 -1
- package/server/dist/stepflow-handler.js +2 -2
- package/server/dist/stepflow-handler.js.map +1 -1
- package/server/dist/tsconfig.tsbuildinfo +1 -1
- package/server/dist/types.d.ts +24 -3
- package/server/dist/types.js +1 -9
- package/server/dist/types.js.map +1 -1
- package/server/dist/upload-routes.d.ts +7 -0
- package/server/dist/upload-routes.js +53 -24
- package/server/dist/upload-routes.js.map +1 -1
- package/server/dist/webhook-handler.js +3 -3
- package/server/dist/webhook-handler.js.map +1 -1
- package/server/dist/workflow-config.d.ts +2 -2
- package/server/dist/workflow-engine.d.ts +19 -0
- package/server/dist/workflow-engine.js +27 -3
- package/server/dist/workflow-engine.js.map +1 -1
- package/server/dist/workflow-loader.d.ts +5 -5
- package/server/dist/workflow-loader.js +90 -59
- package/server/dist/workflow-loader.js.map +1 -1
- package/server/dist/ws-message-handler.js +19 -8
- package/server/dist/ws-message-handler.js.map +1 -1
- package/server/dist/ws-server.js +25 -1
- package/server/dist/ws-server.js.map +1 -1
- package/dist/assets/index-B0xIzdCK.js +0 -187
- package/dist/assets/index-Q2WSVlHo.css +0 -1
|
@@ -22,6 +22,42 @@ import { readFileSync, existsSync, statSync } from 'fs';
|
|
|
22
22
|
import { extname } from 'path';
|
|
23
23
|
import { OPENCODE_CAPABILITIES } from './coding-process.js';
|
|
24
24
|
import { summarizeToolInput } from './tool-labels.js';
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Codekin context + permission mapping
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
/**
|
|
29
|
+
* Codekin environment context appended to OpenCode's agent system prompt via
|
|
30
|
+
* the `system` field on each prompt request. OpenCode APPENDS this to its
|
|
31
|
+
* tuned per-model prompt (verified against OpenCode 1.15) — unlike
|
|
32
|
+
* `agent.*.prompt` config which would REPLACE it. Mirrors the
|
|
33
|
+
* `--append-system-prompt` text used for the Claude path (claude-process.ts).
|
|
34
|
+
*/
|
|
35
|
+
export const OPENCODE_SYSTEM_CONTEXT = [
|
|
36
|
+
'You are running inside a web-based terminal (Codekin).',
|
|
37
|
+
'Tool permissions are managed by the system through an approval UI.',
|
|
38
|
+
'Do not tell the user to click approve or grant permission. Just proceed with your work.',
|
|
39
|
+
'If a tool call fails, read the error message carefully. Common causes: wrong file path, missing dependency, syntax error, or network issue.',
|
|
40
|
+
].join(' ');
|
|
41
|
+
/**
|
|
42
|
+
* Map Codekin's PermissionMode to an OpenCode permission ruleset, applied at
|
|
43
|
+
* session creation (and via PATCH on resume). Without this, bypass-mode
|
|
44
|
+
* sessions still hit server-side `ask` states (external_directory, doom_loop)
|
|
45
|
+
* that must round-trip through the UI even though the user opted out.
|
|
46
|
+
* Returns undefined for modes where OpenCode's defaults are appropriate.
|
|
47
|
+
*/
|
|
48
|
+
export function permissionRulesetFor(mode) {
|
|
49
|
+
switch (mode) {
|
|
50
|
+
case 'bypassPermissions':
|
|
51
|
+
case 'dangerouslySkipPermissions':
|
|
52
|
+
return [{ permission: '*', pattern: '*', action: 'allow' }];
|
|
53
|
+
case 'acceptEdits':
|
|
54
|
+
return [{ permission: 'edit', pattern: '*', action: 'allow' }];
|
|
55
|
+
default:
|
|
56
|
+
// 'default' and 'plan' use OpenCode's defaults; plan-mode safety comes
|
|
57
|
+
// from selecting the read-only `plan` agent on each prompt.
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
25
61
|
const serverState = {
|
|
26
62
|
process: null,
|
|
27
63
|
port: 0,
|
|
@@ -87,6 +123,9 @@ async function startOpenCodeServer(workingDir) {
|
|
|
87
123
|
if (res.ok) {
|
|
88
124
|
serverState.ready = true;
|
|
89
125
|
console.log(`[opencode-server] Ready on port ${serverState.port}`);
|
|
126
|
+
// One-shot version check — warns when the server is older than the
|
|
127
|
+
// version this integration was built against. Non-fatal.
|
|
128
|
+
void checkServerVersion(baseUrl);
|
|
90
129
|
return;
|
|
91
130
|
}
|
|
92
131
|
}
|
|
@@ -101,6 +140,50 @@ async function startOpenCodeServer(workingDir) {
|
|
|
101
140
|
}
|
|
102
141
|
throw new Error(`OpenCode server failed to start within ${maxAttempts}s`);
|
|
103
142
|
}
|
|
143
|
+
/** Minimum OpenCode version this integration is tested against. */
|
|
144
|
+
export const MIN_TESTED_OPENCODE_VERSION = '1.15.0';
|
|
145
|
+
/** Returns true when version `a` is older than version `b` (semver-ish numeric compare). */
|
|
146
|
+
export function isVersionOlder(a, b) {
|
|
147
|
+
const pa = a.split('.').map(Number);
|
|
148
|
+
const pb = b.split('.').map(Number);
|
|
149
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
150
|
+
const x = pa[i] ?? 0;
|
|
151
|
+
const y = pb[i] ?? 0;
|
|
152
|
+
if (Number.isNaN(x) || Number.isNaN(y))
|
|
153
|
+
return false;
|
|
154
|
+
if (x !== y)
|
|
155
|
+
return x < y;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Query the server's version (GET /global/health) and warn when it's older
|
|
161
|
+
* than the version this integration was built against. Older servers may
|
|
162
|
+
* lack endpoints we rely on (summarize, abort, permission replies).
|
|
163
|
+
*/
|
|
164
|
+
async function checkServerVersion(baseUrl) {
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetch(`${baseUrl}/global/health`, {
|
|
167
|
+
headers: authHeaders(),
|
|
168
|
+
signal: AbortSignal.timeout(5000),
|
|
169
|
+
});
|
|
170
|
+
if (!res.ok)
|
|
171
|
+
return;
|
|
172
|
+
const data = await res.json();
|
|
173
|
+
if (typeof data.version !== 'string')
|
|
174
|
+
return;
|
|
175
|
+
if (isVersionOlder(data.version, MIN_TESTED_OPENCODE_VERSION)) {
|
|
176
|
+
console.warn(`[opencode-server] Server version ${data.version} is older than the tested version ${MIN_TESTED_OPENCODE_VERSION} — ` +
|
|
177
|
+
'some features (compact, abort, native permissions) may not work. Consider upgrading OpenCode.');
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
console.log(`[opencode-server] Version ${data.version}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// Older servers may not expose /global/health — nothing to report.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
104
187
|
/** Build auth headers for OpenCode API calls. */
|
|
105
188
|
function authHeaders() {
|
|
106
189
|
if (!serverState.password)
|
|
@@ -137,6 +220,29 @@ export async function fetchOpenCodeModels(workingDir) {
|
|
|
137
220
|
return { models: [], defaults: {} };
|
|
138
221
|
}
|
|
139
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Fetch the list of commands (slash commands, skills, MCP prompts) from the
|
|
225
|
+
* running OpenCode server. Returns an empty array if the server is not running.
|
|
226
|
+
*/
|
|
227
|
+
export async function fetchOpenCodeCommands(workingDir) {
|
|
228
|
+
try {
|
|
229
|
+
const baseUrl = await ensureOpenCodeServer(workingDir);
|
|
230
|
+
const res = await fetch(`${baseUrl}/command`, {
|
|
231
|
+
headers: {
|
|
232
|
+
...authHeaders(),
|
|
233
|
+
'x-opencode-directory': workingDir,
|
|
234
|
+
},
|
|
235
|
+
signal: AbortSignal.timeout(15000),
|
|
236
|
+
});
|
|
237
|
+
if (!res.ok)
|
|
238
|
+
return [];
|
|
239
|
+
const data = await res.json();
|
|
240
|
+
return Array.isArray(data) ? data : [];
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
}
|
|
140
246
|
/** Stop the shared OpenCode server. */
|
|
141
247
|
export function stopOpenCodeServer() {
|
|
142
248
|
if (serverState.process) {
|
|
@@ -148,6 +254,10 @@ export function stopOpenCodeServer() {
|
|
|
148
254
|
// ---------------------------------------------------------------------------
|
|
149
255
|
// OpenCodeProcess
|
|
150
256
|
// ---------------------------------------------------------------------------
|
|
257
|
+
/** How often the turn watchdog checks for a stalled turn. */
|
|
258
|
+
const TURN_WATCHDOG_INTERVAL_MS = 30_000;
|
|
259
|
+
/** How long without any session SSE event before we poll the server to resync. */
|
|
260
|
+
const TURN_STALL_THRESHOLD_MS = 60_000;
|
|
151
261
|
export class OpenCodeProcess extends EventEmitter {
|
|
152
262
|
provider = 'opencode';
|
|
153
263
|
capabilities = OPENCODE_CAPABILITIES;
|
|
@@ -159,9 +269,33 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
159
269
|
abortController = null;
|
|
160
270
|
startupTimer = null;
|
|
161
271
|
permissionMode;
|
|
272
|
+
/** OpenCode commands available on the server, keyed by name (for /name routing). */
|
|
273
|
+
commands = new Map();
|
|
162
274
|
tasks = new Map();
|
|
163
275
|
turnComplete = false;
|
|
276
|
+
/** True while a prompt/command turn is running server-side (set on send, cleared on completion). */
|
|
277
|
+
turnInFlight = false;
|
|
278
|
+
/** OpenCode child sessions spawned by this session's subagents (task tool). */
|
|
279
|
+
childSessionIds = new Set();
|
|
280
|
+
/** Latest token/cost usage per assistant message ID (message.updated fires repeatedly). */
|
|
281
|
+
usageByMessage = new Map();
|
|
282
|
+
/** Last emitted usage totals, serialized — suppresses duplicate usage events. */
|
|
283
|
+
lastEmittedUsage = '';
|
|
284
|
+
/** Messages received while a turn was in flight — sent when the turn completes. */
|
|
285
|
+
pendingMessages = [];
|
|
286
|
+
/** Recent already-displayed assistant text (from output history) for resume hydration dedup. */
|
|
287
|
+
recentOutputText = '';
|
|
164
288
|
taskSeq = 0;
|
|
289
|
+
/**
|
|
290
|
+
* Watchdog that detects turns stalled by a missed completion event.
|
|
291
|
+
* The turn-completion latch (turnComplete) is only released by SSE events
|
|
292
|
+
* (session.idle / message.completed / session.status). If the SSE stream
|
|
293
|
+
* drops and the completion event is lost, the turn would hang forever —
|
|
294
|
+
* the watchdog polls the server's message history to recover.
|
|
295
|
+
*/
|
|
296
|
+
turnWatchdog = null;
|
|
297
|
+
/** Timestamp of the last SSE event explicitly scoped to this session. */
|
|
298
|
+
lastSessionEventTime = 0;
|
|
165
299
|
/** Whether we've received streaming delta events this turn (to avoid double-emitting text). */
|
|
166
300
|
receivedDeltas = false;
|
|
167
301
|
/** Whether we've already emitted text via message.part.updated (to avoid re-emitting from message.updated). */
|
|
@@ -191,6 +325,7 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
191
325
|
this.opencodeSessionId = opts?.opencodeSessionId || null;
|
|
192
326
|
this.model = opts?.model;
|
|
193
327
|
this.permissionMode = opts?.permissionMode;
|
|
328
|
+
this.recentOutputText = opts?.recentOutputText ?? '';
|
|
194
329
|
}
|
|
195
330
|
/** Connect to the OpenCode server, create a session, and subscribe to SSE events. */
|
|
196
331
|
start() {
|
|
@@ -215,8 +350,34 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
215
350
|
// Create or resume a session — must happen BEFORE SSE subscription
|
|
216
351
|
// so that this.opencodeSessionId is set and the session ID filter
|
|
217
352
|
// guards in handleSSEEvent() are active (prevents cross-session leakage).
|
|
353
|
+
const permission = permissionRulesetFor(this.permissionMode);
|
|
218
354
|
if (this.opencodeSessionId) {
|
|
219
|
-
// Resume existing session —
|
|
355
|
+
// Resume existing session — reconnect to SSE, but push the current
|
|
356
|
+
// permission ruleset since the mode may have changed since creation
|
|
357
|
+
// (mode changes restart the process with resume).
|
|
358
|
+
if (permission) {
|
|
359
|
+
try {
|
|
360
|
+
const patchRes = await fetch(`${baseUrl}/session/${this.opencodeSessionId}`, {
|
|
361
|
+
method: 'PATCH',
|
|
362
|
+
headers: {
|
|
363
|
+
...authHeaders(),
|
|
364
|
+
'Content-Type': 'application/json',
|
|
365
|
+
'x-opencode-directory': this.workingDir,
|
|
366
|
+
},
|
|
367
|
+
body: JSON.stringify({ permission }),
|
|
368
|
+
signal: AbortSignal.timeout(10_000),
|
|
369
|
+
});
|
|
370
|
+
if (!patchRes.ok) {
|
|
371
|
+
console.warn(`[opencode] Failed to update session permissions: HTTP ${patchRes.status}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
console.warn('[opencode] Failed to update session permissions:', err);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Hydrate any assistant response that completed while we were detached
|
|
379
|
+
// (backend crash/restart mid-turn) — non-fatal on failure.
|
|
380
|
+
void this.hydrateMissedTail(baseUrl);
|
|
220
381
|
}
|
|
221
382
|
else {
|
|
222
383
|
const createRes = await fetch(`${baseUrl}/session`, {
|
|
@@ -228,6 +389,7 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
228
389
|
},
|
|
229
390
|
body: JSON.stringify({
|
|
230
391
|
title: `Codekin session ${this.sessionId.slice(0, 8)}`,
|
|
392
|
+
...(permission ? { permission } : {}),
|
|
231
393
|
}),
|
|
232
394
|
});
|
|
233
395
|
if (!createRes.ok) {
|
|
@@ -236,6 +398,10 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
236
398
|
const data = await createRes.json();
|
|
237
399
|
this.opencodeSessionId = data.id;
|
|
238
400
|
}
|
|
401
|
+
// Load available commands (slash commands / skills / MCP prompts) so
|
|
402
|
+
// sendMessage can route `/name args` input to the command endpoint.
|
|
403
|
+
// Non-fatal — command routing simply stays disabled on failure.
|
|
404
|
+
void this.loadCommands(baseUrl);
|
|
239
405
|
// Subscribe to SSE events AFTER opencodeSessionId is set so session
|
|
240
406
|
// filtering is active from the first event received.
|
|
241
407
|
this.subscribeToEvents(baseUrl);
|
|
@@ -252,17 +418,77 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
252
418
|
const modelName = this.model.includes('/') ? this.model.slice(this.model.indexOf('/') + 1) : this.model;
|
|
253
419
|
this.emit('system_init', modelName);
|
|
254
420
|
}
|
|
421
|
+
// Surface plan mode in the UI — OpenCode plan mode is implemented by
|
|
422
|
+
// selecting the read-only `plan` agent on every prompt this session.
|
|
423
|
+
if (this.permissionMode === 'plan') {
|
|
424
|
+
this.emit('planning_mode', true);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/** Fetch the command list from the server (used for slash-command routing). */
|
|
428
|
+
async loadCommands(baseUrl) {
|
|
429
|
+
try {
|
|
430
|
+
const res = await fetch(`${baseUrl}/command`, {
|
|
431
|
+
headers: {
|
|
432
|
+
...authHeaders(),
|
|
433
|
+
'x-opencode-directory': this.workingDir,
|
|
434
|
+
},
|
|
435
|
+
signal: AbortSignal.timeout(10_000),
|
|
436
|
+
});
|
|
437
|
+
if (!res.ok)
|
|
438
|
+
return;
|
|
439
|
+
const data = await res.json();
|
|
440
|
+
if (Array.isArray(data)) {
|
|
441
|
+
this.commands = new Map(data.map(c => [c.name, c]));
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
console.warn('[opencode] Failed to load command list:', err);
|
|
446
|
+
}
|
|
255
447
|
}
|
|
256
448
|
/** Subscribe to the OpenCode SSE event stream and map events to CodingProcess events. */
|
|
257
|
-
subscribeToEvents(
|
|
449
|
+
subscribeToEvents(initialBaseUrl) {
|
|
258
450
|
this.abortController = new AbortController();
|
|
259
451
|
let reconnectDelay = 1000;
|
|
260
452
|
const MAX_RECONNECT_DELAY = 30_000;
|
|
261
453
|
const MAX_RECONNECT_ATTEMPTS = 20;
|
|
262
454
|
let reconnectAttempts = 0;
|
|
263
|
-
|
|
455
|
+
let firstConnect = true;
|
|
456
|
+
/** Count a failed attempt and schedule a retry. Returns false when retries are exhausted. */
|
|
457
|
+
const scheduleReconnect = (reason) => {
|
|
458
|
+
reconnectAttempts++;
|
|
459
|
+
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
|
|
460
|
+
this.emit('error', `SSE reconnect failed after ${MAX_RECONNECT_ATTEMPTS} attempts (${reason})`);
|
|
461
|
+
this.stop();
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
console.warn(`[opencode-sse] ${reason}, reconnecting in ${reconnectDelay / 1000}s (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
|
|
465
|
+
setTimeout(() => { void connectSSE(); }, reconnectDelay);
|
|
466
|
+
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
|
467
|
+
return true;
|
|
468
|
+
};
|
|
469
|
+
const connectSSE = async () => {
|
|
264
470
|
if (!this.alive)
|
|
265
471
|
return;
|
|
472
|
+
// Re-resolve the base URL on every attempt: if the shared OpenCode
|
|
473
|
+
// server died, it respawns on a NEW random port — reconnecting to the
|
|
474
|
+
// old URL would never succeed. ensureOpenCodeServer respawns the server
|
|
475
|
+
// if needed and returns the current URL. The first connect reuses the
|
|
476
|
+
// URL from initialize() to avoid a redundant health check.
|
|
477
|
+
let baseUrl = initialBaseUrl;
|
|
478
|
+
if (!firstConnect) {
|
|
479
|
+
try {
|
|
480
|
+
baseUrl = await ensureOpenCodeServer(this.workingDir);
|
|
481
|
+
}
|
|
482
|
+
catch (err) {
|
|
483
|
+
if (this.alive) {
|
|
484
|
+
scheduleReconnect(`Server unavailable (${err instanceof Error ? err.message : String(err)})`);
|
|
485
|
+
}
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (!this.alive)
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
firstConnect = false;
|
|
266
492
|
void fetch(`${baseUrl}/event`, {
|
|
267
493
|
headers: {
|
|
268
494
|
...authHeaders(),
|
|
@@ -273,20 +499,18 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
273
499
|
}).then(async (res) => {
|
|
274
500
|
if (!res.ok || !res.body) {
|
|
275
501
|
if (this.alive) {
|
|
276
|
-
|
|
277
|
-
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
|
|
278
|
-
this.emit('error', `SSE reconnect failed after ${MAX_RECONNECT_ATTEMPTS} attempts (last status: ${res.status})`);
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
console.warn(`[opencode-sse] Non-2xx ${res.status}, reconnecting in ${reconnectDelay / 1000}s (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
|
|
282
|
-
setTimeout(connectSSE, reconnectDelay);
|
|
283
|
-
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
|
502
|
+
scheduleReconnect(`Non-2xx ${res.status}`);
|
|
284
503
|
}
|
|
285
504
|
return;
|
|
286
505
|
}
|
|
287
506
|
// Reset backoff on successful connection
|
|
288
507
|
reconnectDelay = 1000;
|
|
289
508
|
reconnectAttempts = 0;
|
|
509
|
+
// If a turn was in flight across the reconnect, the completion event
|
|
510
|
+
// may have been lost while disconnected — resync immediately.
|
|
511
|
+
if (!this.turnComplete && this.turnWatchdog) {
|
|
512
|
+
void this.checkTurnLiveness(true);
|
|
513
|
+
}
|
|
290
514
|
const reader = res.body.getReader();
|
|
291
515
|
const decoder = new TextDecoder();
|
|
292
516
|
let buffer = '';
|
|
@@ -316,31 +540,17 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
316
540
|
}
|
|
317
541
|
// Clean EOF — reconnect if still alive (server restart, proxy timeout, etc.)
|
|
318
542
|
if (this.alive) {
|
|
319
|
-
|
|
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] Stream closed cleanly, reconnecting in ${reconnectDelay / 1000}s (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
|
|
325
|
-
setTimeout(connectSSE, reconnectDelay);
|
|
326
|
-
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
|
543
|
+
scheduleReconnect('Stream closed cleanly');
|
|
327
544
|
}
|
|
328
545
|
}).catch((err) => {
|
|
329
546
|
if (err instanceof Error && err.name === 'AbortError')
|
|
330
547
|
return;
|
|
331
548
|
if (this.alive) {
|
|
332
|
-
|
|
333
|
-
if (reconnectAttempts > MAX_RECONNECT_ATTEMPTS) {
|
|
334
|
-
this.emit('error', `SSE reconnect failed after ${MAX_RECONNECT_ATTEMPTS} attempts`);
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
console.warn(`[opencode-sse] Connection lost, reconnecting in ${reconnectDelay / 1000}s (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`, err);
|
|
338
|
-
setTimeout(connectSSE, reconnectDelay);
|
|
339
|
-
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
|
|
549
|
+
scheduleReconnect(`Connection lost (${err instanceof Error ? err.message : String(err)})`);
|
|
340
550
|
}
|
|
341
551
|
});
|
|
342
552
|
};
|
|
343
|
-
connectSSE();
|
|
553
|
+
void connectSSE();
|
|
344
554
|
}
|
|
345
555
|
/**
|
|
346
556
|
* Check whether an SSE event belongs to this process's OpenCode session.
|
|
@@ -374,9 +584,35 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
374
584
|
this.deltaBuffer = '';
|
|
375
585
|
}
|
|
376
586
|
}
|
|
587
|
+
/**
|
|
588
|
+
* Mark the current turn as complete: release the latch, flush any buffered
|
|
589
|
+
* text, stop the stall watchdog, and emit the result event. Idempotent.
|
|
590
|
+
*/
|
|
591
|
+
completeTurn() {
|
|
592
|
+
if (this.turnComplete)
|
|
593
|
+
return;
|
|
594
|
+
this.turnComplete = true;
|
|
595
|
+
this.turnInFlight = false;
|
|
596
|
+
this.clearTurnWatchdog();
|
|
597
|
+
this.flushDeltaBuffer();
|
|
598
|
+
this.emit('result', '', false);
|
|
599
|
+
// Send the next queued message (received mid-turn) after result handlers run.
|
|
600
|
+
const next = this.pendingMessages.shift();
|
|
601
|
+
if (next !== undefined && this.alive) {
|
|
602
|
+
setImmediate(() => { this.sendMessage(next); });
|
|
603
|
+
}
|
|
604
|
+
}
|
|
377
605
|
/** Map an OpenCode SSE event to CodingProcess events. */
|
|
378
606
|
handleSSEEvent(event) {
|
|
379
607
|
const { type, properties } = event;
|
|
608
|
+
// Track liveness of this session's event flow for the turn watchdog.
|
|
609
|
+
// Only count events explicitly scoped to our session (or a subagent child
|
|
610
|
+
// session) — server-level events (heartbeats etc.) say nothing about our
|
|
611
|
+
// turn's progress.
|
|
612
|
+
const evtSessionID = properties.sessionID;
|
|
613
|
+
if (evtSessionID && (evtSessionID === this.opencodeSessionId || this.childSessionIds.has(evtSessionID))) {
|
|
614
|
+
this.lastSessionEventTime = Date.now();
|
|
615
|
+
}
|
|
380
616
|
switch (type) {
|
|
381
617
|
// Delta events carry the actual streaming text content
|
|
382
618
|
case 'message.part.delta': {
|
|
@@ -447,9 +683,15 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
447
683
|
const part = properties.part;
|
|
448
684
|
if (!part)
|
|
449
685
|
break;
|
|
450
|
-
// Only process events for our session
|
|
451
|
-
|
|
686
|
+
// Only process events for our session. Subagent child sessions get
|
|
687
|
+
// their tool activity surfaced (text/reasoning is internal to the
|
|
688
|
+
// subagent and would pollute the main transcript).
|
|
689
|
+
if (!this.isOwnSession(properties)) {
|
|
690
|
+
if (evtSessionID && this.childSessionIds.has(evtSessionID) && part.type === 'tool') {
|
|
691
|
+
this.handleChildToolPart(part);
|
|
692
|
+
}
|
|
452
693
|
break;
|
|
694
|
+
}
|
|
453
695
|
if (process.env.CODEKIN_DEBUG_SSE) {
|
|
454
696
|
console.log(`[opencode-sse] part.updated type=${part.type} len=${part.text?.length ?? 0} text=${part.text?.slice(0, 80)} receivedDeltas=${this.receivedDeltas} emittedPartText=${this.emittedPartText}`);
|
|
455
697
|
}
|
|
@@ -503,14 +745,14 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
503
745
|
this.emit('tool_active', toolName, inputStr);
|
|
504
746
|
// Detect task/todo tool calls and emit todo_update
|
|
505
747
|
if (part.state?.input && this.handleTaskTool(toolName, part.state.input)) {
|
|
506
|
-
this.emit('todo_update', Array.from(this.tasks.values()));
|
|
748
|
+
this.emit('todo_update', Array.from(this.tasks.values(), t => ({ ...t })));
|
|
507
749
|
}
|
|
508
750
|
}
|
|
509
751
|
else if (status === 'completed') {
|
|
510
752
|
// Also check for task tools at completion (some providers only
|
|
511
753
|
// populate input at this stage, not during 'running')
|
|
512
754
|
if (part.state?.input && this.handleTaskTool(toolName, part.state.input)) {
|
|
513
|
-
this.emit('todo_update', Array.from(this.tasks.values()));
|
|
755
|
+
this.emit('todo_update', Array.from(this.tasks.values(), t => ({ ...t })));
|
|
514
756
|
}
|
|
515
757
|
const output = part.state?.output;
|
|
516
758
|
const summary = output ? output.slice(0, 200) : undefined;
|
|
@@ -530,7 +772,14 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
530
772
|
// 'pending' status — tool call parsed but not yet executing; no action needed
|
|
531
773
|
break;
|
|
532
774
|
}
|
|
533
|
-
|
|
775
|
+
case 'step-finish': {
|
|
776
|
+
// Agentic iteration boundary — any buffered text below the echo
|
|
777
|
+
// threshold belongs to the finished step; flush it now instead of
|
|
778
|
+
// holding it until turn end.
|
|
779
|
+
this.flushDeltaBuffer();
|
|
780
|
+
break;
|
|
781
|
+
}
|
|
782
|
+
// step-start is an agentic iteration boundary — no mapping needed
|
|
534
783
|
}
|
|
535
784
|
break;
|
|
536
785
|
}
|
|
@@ -541,11 +790,7 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
541
790
|
const status = properties.status;
|
|
542
791
|
const statusType = typeof status === 'string' ? status : status?.type;
|
|
543
792
|
if (statusType === 'idle') {
|
|
544
|
-
|
|
545
|
-
break;
|
|
546
|
-
this.turnComplete = true;
|
|
547
|
-
this.flushDeltaBuffer();
|
|
548
|
-
this.emit('result', '', false);
|
|
793
|
+
this.completeTurn();
|
|
549
794
|
}
|
|
550
795
|
break;
|
|
551
796
|
}
|
|
@@ -588,26 +833,32 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
588
833
|
case 'message.completed': {
|
|
589
834
|
if (!this.isOwnSession(properties))
|
|
590
835
|
break;
|
|
591
|
-
|
|
592
|
-
break;
|
|
593
|
-
this.turnComplete = true;
|
|
594
|
-
this.flushDeltaBuffer();
|
|
595
|
-
this.emit('result', '', false);
|
|
836
|
+
this.completeTurn();
|
|
596
837
|
break;
|
|
597
838
|
}
|
|
598
|
-
// session.updated may carry idle status in some OpenCode versions
|
|
839
|
+
// session.updated may carry idle status in some OpenCode versions.
|
|
840
|
+
// session.created/session.updated also announce subagent child sessions
|
|
841
|
+
// (parentID = our session) which we track to surface their tool activity.
|
|
842
|
+
case 'session.created':
|
|
599
843
|
case 'session.updated': {
|
|
844
|
+
const session = (properties.info ?? properties.session);
|
|
845
|
+
const sessId = session?.id;
|
|
846
|
+
const parentID = session?.parentID;
|
|
847
|
+
if (sessId && parentID && parentID === this.opencodeSessionId && !this.childSessionIds.has(sessId)) {
|
|
848
|
+
this.childSessionIds.add(sessId);
|
|
849
|
+
const title = typeof session?.title === 'string' && session.title ? session.title : 'subagent';
|
|
850
|
+
this.emit('tool_active', 'Task', title);
|
|
851
|
+
}
|
|
600
852
|
if (!this.isOwnSession(properties))
|
|
601
853
|
break;
|
|
602
|
-
|
|
854
|
+
// Guard: a session object for a different session (e.g. a child) must
|
|
855
|
+
// not complete our turn even if it reports idle.
|
|
856
|
+
if (sessId && sessId !== this.opencodeSessionId)
|
|
857
|
+
break;
|
|
603
858
|
const sessionStatus = session?.status;
|
|
604
859
|
const sType = typeof sessionStatus === 'string' ? sessionStatus : sessionStatus?.type;
|
|
605
860
|
if (sType === 'idle') {
|
|
606
|
-
|
|
607
|
-
break;
|
|
608
|
-
this.turnComplete = true;
|
|
609
|
-
this.flushDeltaBuffer();
|
|
610
|
-
this.emit('result', '', false);
|
|
861
|
+
this.completeTurn();
|
|
611
862
|
}
|
|
612
863
|
break;
|
|
613
864
|
}
|
|
@@ -615,11 +866,7 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
615
866
|
case 'session.idle': {
|
|
616
867
|
if (!this.isOwnSession(properties))
|
|
617
868
|
break;
|
|
618
|
-
|
|
619
|
-
break;
|
|
620
|
-
this.turnComplete = true;
|
|
621
|
-
this.flushDeltaBuffer();
|
|
622
|
-
this.emit('result', '', false);
|
|
869
|
+
this.completeTurn();
|
|
623
870
|
break;
|
|
624
871
|
}
|
|
625
872
|
// OpenCode >=1.4 sends message.updated with full message info including parts.
|
|
@@ -628,7 +875,10 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
628
875
|
if (!this.isOwnSession(properties))
|
|
629
876
|
break;
|
|
630
877
|
const info = properties.info;
|
|
631
|
-
if (!info || info.role !== 'assistant'
|
|
878
|
+
if (!info || info.role !== 'assistant')
|
|
879
|
+
break;
|
|
880
|
+
this.trackUsage(info);
|
|
881
|
+
if (!info.parts)
|
|
632
882
|
break;
|
|
633
883
|
if (process.env.CODEKIN_DEBUG_SSE) {
|
|
634
884
|
console.log(`[opencode-sse] message.updated parts=${info.parts.length} types=${info.parts.map(p => p.type).join(',')}`);
|
|
@@ -651,27 +901,224 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
651
901
|
break;
|
|
652
902
|
}
|
|
653
903
|
}
|
|
654
|
-
/**
|
|
655
|
-
|
|
904
|
+
/**
|
|
905
|
+
* Accumulate token/cost usage from assistant message.updated info and emit
|
|
906
|
+
* cumulative session totals. message.updated fires repeatedly per message,
|
|
907
|
+
* so usage is keyed by message ID (latest wins) and duplicate totals are
|
|
908
|
+
* suppressed.
|
|
909
|
+
*/
|
|
910
|
+
trackUsage(info) {
|
|
911
|
+
const t = info.tokens;
|
|
912
|
+
if (!t || !info.id)
|
|
913
|
+
return;
|
|
914
|
+
const input = (t.input ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0);
|
|
915
|
+
const output = (t.output ?? 0) + (t.reasoning ?? 0);
|
|
916
|
+
if (input === 0 && output === 0)
|
|
917
|
+
return;
|
|
918
|
+
this.usageByMessage.set(info.id, { input, output, cost: info.cost ?? 0 });
|
|
919
|
+
let inputTokens = 0;
|
|
920
|
+
let outputTokens = 0;
|
|
921
|
+
let costUsd = 0;
|
|
922
|
+
for (const u of this.usageByMessage.values()) {
|
|
923
|
+
inputTokens += u.input;
|
|
924
|
+
outputTokens += u.output;
|
|
925
|
+
costUsd += u.cost;
|
|
926
|
+
}
|
|
927
|
+
const key = `${inputTokens}/${outputTokens}/${costUsd}`;
|
|
928
|
+
if (key === this.lastEmittedUsage)
|
|
929
|
+
return;
|
|
930
|
+
this.lastEmittedUsage = key;
|
|
931
|
+
this.emit('usage', { inputTokens, outputTokens, costUsd });
|
|
932
|
+
}
|
|
933
|
+
/** Surface a subagent (child session) tool part as tool activity in the main session. */
|
|
934
|
+
handleChildToolPart(part) {
|
|
935
|
+
const toolName = part.tool || 'unknown';
|
|
936
|
+
const status = part.state?.status;
|
|
937
|
+
if (status === 'running') {
|
|
938
|
+
const inputStr = part.state?.input ? summarizeToolInput(toolName, part.state.input) : undefined;
|
|
939
|
+
this.emit('tool_active', toolName, inputStr);
|
|
940
|
+
}
|
|
941
|
+
else if (status === 'completed') {
|
|
942
|
+
const output = part.state?.output;
|
|
943
|
+
this.emit('tool_done', toolName, output ? output.slice(0, 200) : undefined);
|
|
944
|
+
}
|
|
945
|
+
else if (status === 'error') {
|
|
946
|
+
this.emit('tool_done', toolName, `Error: ${part.state?.error || 'unknown'}`);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
/** Start (or restart) the stalled-turn watchdog for an in-flight turn. */
|
|
950
|
+
startTurnWatchdog() {
|
|
951
|
+
this.clearTurnWatchdog();
|
|
952
|
+
this.lastSessionEventTime = Date.now();
|
|
953
|
+
this.turnWatchdog = setInterval(() => {
|
|
954
|
+
void this.checkTurnLiveness();
|
|
955
|
+
}, TURN_WATCHDOG_INTERVAL_MS);
|
|
956
|
+
}
|
|
957
|
+
clearTurnWatchdog() {
|
|
958
|
+
if (this.turnWatchdog) {
|
|
959
|
+
clearInterval(this.turnWatchdog);
|
|
960
|
+
this.turnWatchdog = null;
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Detect a turn stalled by a missed SSE completion event and recover.
|
|
965
|
+
* If no session-scoped event has arrived recently, poll the server's
|
|
966
|
+
* message history: when the last assistant message is marked completed,
|
|
967
|
+
* the turn finished server-side and we only missed the event — force
|
|
968
|
+
* completion so the session doesn't hang forever.
|
|
969
|
+
*
|
|
970
|
+
* A long-running tool with no event flow is NOT force-completed: the poll
|
|
971
|
+
* only completes the turn when the server itself says the message is done.
|
|
972
|
+
*/
|
|
973
|
+
async checkTurnLiveness(force = false) {
|
|
974
|
+
if (!this.alive || this.turnComplete) {
|
|
975
|
+
this.clearTurnWatchdog();
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
if (!force && Date.now() - this.lastSessionEventTime < TURN_STALL_THRESHOLD_MS)
|
|
979
|
+
return;
|
|
980
|
+
if (!this.opencodeSessionId)
|
|
981
|
+
return;
|
|
656
982
|
try {
|
|
657
983
|
const baseUrl = `http://localhost:${serverState.port}`;
|
|
658
|
-
const res = await fetch(`${baseUrl}/
|
|
659
|
-
method: 'POST',
|
|
984
|
+
const res = await fetch(`${baseUrl}/session/${this.opencodeSessionId}/message`, {
|
|
660
985
|
headers: {
|
|
661
986
|
...authHeaders(),
|
|
662
|
-
'Content-Type': 'application/json',
|
|
663
987
|
'x-opencode-directory': this.workingDir,
|
|
664
988
|
},
|
|
665
|
-
|
|
989
|
+
signal: AbortSignal.timeout(10_000),
|
|
666
990
|
});
|
|
667
|
-
if (!res.ok)
|
|
668
|
-
|
|
991
|
+
if (!res.ok)
|
|
992
|
+
return;
|
|
993
|
+
const messages = await res.json();
|
|
994
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
995
|
+
return;
|
|
996
|
+
// Entries may be flat message objects or { info, parts } wrappers
|
|
997
|
+
// depending on OpenCode version.
|
|
998
|
+
const last = messages[messages.length - 1];
|
|
999
|
+
const info = (last.info ?? last);
|
|
1000
|
+
if (info.role === 'assistant' && info.time?.completed) {
|
|
1001
|
+
console.warn(`[opencode] Missed turn-completion event for session ${this.opencodeSessionId} — recovered via message poll`);
|
|
1002
|
+
this.recoverMissedText(last);
|
|
1003
|
+
this.completeTurn();
|
|
669
1004
|
}
|
|
670
1005
|
}
|
|
671
1006
|
catch (err) {
|
|
672
|
-
console.
|
|
1007
|
+
console.warn(`[opencode] Turn liveness poll failed for ${this.opencodeSessionId}:`, err);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* When a turn completed server-side but its SSE events were lost (stream
|
|
1012
|
+
* drop), the assistant's response text was never emitted. Recover it from
|
|
1013
|
+
* the polled message-history entry so the user isn't left with a silent turn.
|
|
1014
|
+
*/
|
|
1015
|
+
recoverMissedText(entry) {
|
|
1016
|
+
if (this.receivedDeltas || this.emittedPartText || this.deltaBuffer)
|
|
1017
|
+
return;
|
|
1018
|
+
const parts = (entry.parts ?? entry.info?.parts);
|
|
1019
|
+
if (!Array.isArray(parts))
|
|
1020
|
+
return;
|
|
1021
|
+
const text = parts
|
|
1022
|
+
.filter((p) => p.type === 'text' && p.text)
|
|
1023
|
+
.map((p) => p.text)
|
|
1024
|
+
.join('\n');
|
|
1025
|
+
if (!text)
|
|
1026
|
+
return;
|
|
1027
|
+
let out = text;
|
|
1028
|
+
if (this.lastUserInput && out.startsWith(this.lastUserInput)) {
|
|
1029
|
+
out = out.slice(this.lastUserInput.length);
|
|
1030
|
+
}
|
|
1031
|
+
if (out) {
|
|
1032
|
+
this.emittedPartText = true;
|
|
1033
|
+
console.warn(`[opencode] Recovered ${out.length} chars of missed assistant text for session ${this.opencodeSessionId}`);
|
|
1034
|
+
this.emit('text', out);
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* On resume, recover the tail of the conversation that may have been lost
|
|
1039
|
+
* while Codekin was detached (backend crash/restart mid-turn). Fetches the
|
|
1040
|
+
* session's message history from OpenCode and re-emits the last assistant
|
|
1041
|
+
* message's text — unless it was already displayed (present in the
|
|
1042
|
+
* persisted output history passed via recentOutputText).
|
|
1043
|
+
*/
|
|
1044
|
+
async hydrateMissedTail(baseUrl) {
|
|
1045
|
+
if (!this.opencodeSessionId)
|
|
1046
|
+
return;
|
|
1047
|
+
try {
|
|
1048
|
+
const res = await fetch(`${baseUrl}/session/${this.opencodeSessionId}/message`, {
|
|
1049
|
+
headers: {
|
|
1050
|
+
...authHeaders(),
|
|
1051
|
+
'x-opencode-directory': this.workingDir,
|
|
1052
|
+
},
|
|
1053
|
+
signal: AbortSignal.timeout(10_000),
|
|
1054
|
+
});
|
|
1055
|
+
if (!res.ok)
|
|
1056
|
+
return;
|
|
1057
|
+
const messages = await res.json();
|
|
1058
|
+
if (!Array.isArray(messages) || messages.length === 0)
|
|
1059
|
+
return;
|
|
1060
|
+
const last = messages[messages.length - 1];
|
|
1061
|
+
const info = (last.info ?? last);
|
|
1062
|
+
// Only hydrate a *completed* assistant message that is the latest entry —
|
|
1063
|
+
// an in-flight turn is handled by the watchdog, and a trailing user
|
|
1064
|
+
// message means there's nothing of ours to recover.
|
|
1065
|
+
if (info.role !== 'assistant' || !info.time?.completed)
|
|
1066
|
+
return;
|
|
1067
|
+
const parts = (last.parts ?? info.parts);
|
|
1068
|
+
if (!Array.isArray(parts))
|
|
1069
|
+
return;
|
|
1070
|
+
const text = parts
|
|
1071
|
+
.filter((p) => p.type === 'text' && p.text)
|
|
1072
|
+
.map((p) => p.text)
|
|
1073
|
+
.join('\n');
|
|
1074
|
+
if (!text)
|
|
1075
|
+
return;
|
|
1076
|
+
// Already shown before the restart — nothing was lost.
|
|
1077
|
+
if (this.recentOutputText.includes(text))
|
|
1078
|
+
return;
|
|
1079
|
+
console.warn(`[opencode] Hydrating ${text.length} chars of missed assistant text on resume for ${this.opencodeSessionId}`);
|
|
1080
|
+
this.emit('text', text);
|
|
1081
|
+
}
|
|
1082
|
+
catch (err) {
|
|
1083
|
+
console.warn(`[opencode] Resume hydration failed for ${this.opencodeSessionId}:`, err);
|
|
673
1084
|
}
|
|
674
1085
|
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Reply to an OpenCode permission request via HTTP, with retries.
|
|
1088
|
+
* A dropped reply leaves OpenCode blocked on the permission forever, so
|
|
1089
|
+
* failures are retried and ultimately surfaced as a session error instead
|
|
1090
|
+
* of being silently swallowed.
|
|
1091
|
+
*/
|
|
1092
|
+
/** Base backoff delay between permission reply retries (overridable in tests). */
|
|
1093
|
+
permissionRetryDelayMs = 1000;
|
|
1094
|
+
async replyToPermission(requestId, type) {
|
|
1095
|
+
const MAX_ATTEMPTS = 3;
|
|
1096
|
+
const baseUrl = `http://localhost:${serverState.port}`;
|
|
1097
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
1098
|
+
try {
|
|
1099
|
+
const res = await fetch(`${baseUrl}/permission/${requestId}/reply`, {
|
|
1100
|
+
method: 'POST',
|
|
1101
|
+
headers: {
|
|
1102
|
+
...authHeaders(),
|
|
1103
|
+
'Content-Type': 'application/json',
|
|
1104
|
+
'x-opencode-directory': this.workingDir,
|
|
1105
|
+
},
|
|
1106
|
+
body: JSON.stringify({ type }),
|
|
1107
|
+
signal: AbortSignal.timeout(10_000),
|
|
1108
|
+
});
|
|
1109
|
+
if (res.ok)
|
|
1110
|
+
return;
|
|
1111
|
+
console.error(`[opencode] Permission reply failed: HTTP ${res.status} for ${requestId} (attempt ${attempt}/${MAX_ATTEMPTS})`);
|
|
1112
|
+
}
|
|
1113
|
+
catch (err) {
|
|
1114
|
+
console.error(`[opencode] Failed to reply to permission ${requestId} (attempt ${attempt}/${MAX_ATTEMPTS}):`, err);
|
|
1115
|
+
}
|
|
1116
|
+
if (attempt < MAX_ATTEMPTS) {
|
|
1117
|
+
await new Promise(r => setTimeout(r, this.permissionRetryDelayMs * attempt));
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
this.emit('error', `Failed to deliver permission response (${type}) — OpenCode may still be waiting for approval`);
|
|
1121
|
+
}
|
|
675
1122
|
/**
|
|
676
1123
|
* Detect TodoWrite/TaskCreate/TaskUpdate tool calls and emit todo_update events.
|
|
677
1124
|
* Mirrors the task-tracking logic in ClaudeProcess.handleTaskTool().
|
|
@@ -679,14 +1126,17 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
679
1126
|
handleTaskTool(toolName, input) {
|
|
680
1127
|
// Normalize tool name — OpenCode may report as 'todowrite', 'TodoWrite', 'todo_write', etc.
|
|
681
1128
|
const normalized = toolName.toLowerCase().replace(/_/g, '');
|
|
1129
|
+
// Note: taskSeq is intentionally NOT reset on TodoWrite — keeping ids
|
|
1130
|
+
// monotonic across list generations lets the frontend detect a brand-new
|
|
1131
|
+
// list by id and avoids collisions with later TaskCreate/TaskUpdate calls.
|
|
682
1132
|
if (normalized === 'todowrite') {
|
|
683
1133
|
const todos = input.todos;
|
|
684
1134
|
if (!Array.isArray(todos))
|
|
685
1135
|
return false;
|
|
686
1136
|
this.tasks.clear();
|
|
687
|
-
this.taskSeq = 0;
|
|
688
1137
|
for (const item of todos) {
|
|
689
1138
|
const id = String(item.id || ++this.taskSeq);
|
|
1139
|
+
this.syncTaskSeq(id);
|
|
690
1140
|
const status = item.status;
|
|
691
1141
|
if (status !== 'pending' && status !== 'in_progress' && status !== 'completed')
|
|
692
1142
|
continue;
|
|
@@ -700,7 +1150,8 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
700
1150
|
return true;
|
|
701
1151
|
}
|
|
702
1152
|
if (normalized === 'taskcreate') {
|
|
703
|
-
const id = String(++this.taskSeq);
|
|
1153
|
+
const id = String(input.taskId || input.id || ++this.taskSeq);
|
|
1154
|
+
this.syncTaskSeq(id);
|
|
704
1155
|
this.tasks.set(id, {
|
|
705
1156
|
id,
|
|
706
1157
|
subject: String(input.subject || ''),
|
|
@@ -711,9 +1162,19 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
711
1162
|
}
|
|
712
1163
|
if (normalized === 'taskupdate') {
|
|
713
1164
|
const id = String(input.taskId || '');
|
|
714
|
-
|
|
715
|
-
if (!task)
|
|
1165
|
+
if (!id)
|
|
716
1166
|
return false;
|
|
1167
|
+
let task = this.tasks.get(id);
|
|
1168
|
+
if (!task) {
|
|
1169
|
+
// Unknown id — our in-memory map can diverge from the provider's real
|
|
1170
|
+
// task list (e.g. after a process restart mid-session). Upsert instead
|
|
1171
|
+
// of dropping the update, otherwise the UI shows a stale list forever.
|
|
1172
|
+
if (input.status === 'deleted')
|
|
1173
|
+
return false;
|
|
1174
|
+
task = { id, subject: String(input.subject || `Task ${id}`), status: 'pending' };
|
|
1175
|
+
this.tasks.set(id, task);
|
|
1176
|
+
this.syncTaskSeq(id);
|
|
1177
|
+
}
|
|
717
1178
|
const status = input.status;
|
|
718
1179
|
if (status === 'deleted') {
|
|
719
1180
|
this.tasks.delete(id);
|
|
@@ -730,13 +1191,40 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
730
1191
|
}
|
|
731
1192
|
return false;
|
|
732
1193
|
}
|
|
1194
|
+
/** Keep taskSeq ahead of any numeric id we have seen, so generated ids never collide. */
|
|
1195
|
+
syncTaskSeq(id) {
|
|
1196
|
+
const n = Number(id);
|
|
1197
|
+
if (Number.isInteger(n) && n > this.taskSeq)
|
|
1198
|
+
this.taskSeq = n;
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Seed task state from a previous process's last known list (session restore).
|
|
1202
|
+
* Without this, a restarted process starts with an empty map and TaskUpdate
|
|
1203
|
+
* calls referencing pre-restart task ids would otherwise be lost.
|
|
1204
|
+
*/
|
|
1205
|
+
seedTasks(tasks) {
|
|
1206
|
+
this.tasks.clear();
|
|
1207
|
+
for (const t of tasks) {
|
|
1208
|
+
this.tasks.set(t.id, { ...t });
|
|
1209
|
+
this.syncTaskSeq(t.id);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
733
1212
|
/** Send a user message to the OpenCode session. */
|
|
734
1213
|
sendMessage(content) {
|
|
735
1214
|
if (!this.alive || !this.opencodeSessionId) {
|
|
736
1215
|
this.emit('error', 'OpenCode process is not connected');
|
|
737
1216
|
return;
|
|
738
1217
|
}
|
|
1218
|
+
// A turn is already running server-side — queue locally and send when it
|
|
1219
|
+
// completes. Sending prompt_async mid-turn would reset our turn latches,
|
|
1220
|
+
// letting the FIRST turn's idle event instantly "complete" the second
|
|
1221
|
+
// turn and confuse the watchdog. (Claude's CLI queues stdin natively.)
|
|
1222
|
+
if (this.turnInFlight && !this.turnComplete) {
|
|
1223
|
+
this.pendingMessages.push(content);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
739
1226
|
this.turnComplete = false; // reset completion latch for new turn
|
|
1227
|
+
this.turnInFlight = true;
|
|
740
1228
|
this.receivedDeltas = false;
|
|
741
1229
|
this.emittedPartText = false;
|
|
742
1230
|
this.deltaBuffer = '';
|
|
@@ -744,6 +1232,7 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
744
1232
|
this.reasoningBuffer = '';
|
|
745
1233
|
this.emittedReasoningSummary = false;
|
|
746
1234
|
this.inReasoningPhase = false;
|
|
1235
|
+
this.startTurnWatchdog();
|
|
747
1236
|
const baseUrl = `http://localhost:${serverState.port}`;
|
|
748
1237
|
// Parse [Attached files: ...] prefix and convert image paths to proper parts.
|
|
749
1238
|
// The frontend uploads images to the screenshots dir and wraps them as:
|
|
@@ -754,11 +1243,12 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
754
1243
|
if (attachMatch) {
|
|
755
1244
|
textContent = content.slice(attachMatch[0].length);
|
|
756
1245
|
const filePaths = attachMatch[1].split(',').map(p => p.trim());
|
|
757
|
-
|
|
1246
|
+
// Binary formats sent as data-URL file parts (provider handles decoding).
|
|
1247
|
+
const fileMimeMap = {
|
|
758
1248
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
759
1249
|
'.gif': 'image/gif', '.webp': 'image/webp',
|
|
1250
|
+
'.pdf': 'application/pdf',
|
|
760
1251
|
};
|
|
761
|
-
const textExtensions = new Set(['.md', '.txt', '.csv', '.json', '.xml', '.yaml', '.yml', '.log']);
|
|
762
1252
|
for (const filePath of filePaths) {
|
|
763
1253
|
if (!existsSync(filePath)) {
|
|
764
1254
|
console.warn(`[opencode] Attached file not found: ${filePath}`);
|
|
@@ -772,23 +1262,86 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
772
1262
|
continue;
|
|
773
1263
|
}
|
|
774
1264
|
const ext = extname(filePath).toLowerCase();
|
|
775
|
-
const
|
|
776
|
-
|
|
1265
|
+
const fileMime = fileMimeMap[ext];
|
|
1266
|
+
const fileName = filePath.split('/').pop() || filePath;
|
|
1267
|
+
if (fileMime) {
|
|
777
1268
|
const base64 = readFileSync(filePath).toString('base64');
|
|
778
|
-
parts.push({ type: 'file', mime:
|
|
779
|
-
}
|
|
780
|
-
else if (textExtensions.has(ext)) {
|
|
781
|
-
// Send text-based files as inline text content
|
|
782
|
-
const fileContent = readFileSync(filePath, 'utf-8');
|
|
783
|
-
const fileName = filePath.split('/').pop() || filePath;
|
|
784
|
-
parts.push({ type: 'text', text: `--- ${fileName} ---\n${fileContent}` });
|
|
1269
|
+
parts.push({ type: 'file', mime: fileMime, filename: fileName, url: `data:${fileMime};base64,${base64}` });
|
|
785
1270
|
}
|
|
786
1271
|
else {
|
|
787
|
-
|
|
1272
|
+
// Everything else: inline as text when the content looks like text
|
|
1273
|
+
// (no NUL byte in the first 8 KB) — covers source code, configs,
|
|
1274
|
+
// logs, etc. without maintaining an extension allowlist.
|
|
1275
|
+
const buf = readFileSync(filePath);
|
|
1276
|
+
const probe = buf.subarray(0, 8192);
|
|
1277
|
+
if (probe.includes(0)) {
|
|
1278
|
+
console.warn(`[opencode] Unsupported binary attachment: ${ext || '(no extension)'} (${filePath})`);
|
|
1279
|
+
parts.push({ type: 'text', text: `[Attachment skipped: ${fileName} is an unsupported binary format]` });
|
|
1280
|
+
}
|
|
1281
|
+
else {
|
|
1282
|
+
parts.push({ type: 'text', text: `--- ${fileName} ---\n${buf.toString('utf-8')}` });
|
|
1283
|
+
}
|
|
788
1284
|
}
|
|
789
1285
|
}
|
|
790
1286
|
}
|
|
791
1287
|
this.lastUserInput = textContent.trim();
|
|
1288
|
+
// /compact and /summarize map to OpenCode's native summarize endpoint,
|
|
1289
|
+
// which condenses the conversation server-side. The summarization runs as
|
|
1290
|
+
// a turn — SSE events stream in and the idle event completes the latch.
|
|
1291
|
+
const trimmedText = textContent.trim();
|
|
1292
|
+
if (!attachMatch && (trimmedText === '/compact' || trimmedText === '/summarize')) {
|
|
1293
|
+
const summarizeBody = {};
|
|
1294
|
+
if (this.model && this.model.includes('/')) {
|
|
1295
|
+
const slashIdx = this.model.indexOf('/');
|
|
1296
|
+
summarizeBody.providerID = this.model.slice(0, slashIdx);
|
|
1297
|
+
summarizeBody.modelID = this.model.slice(slashIdx + 1);
|
|
1298
|
+
}
|
|
1299
|
+
void fetch(`${baseUrl}/session/${this.opencodeSessionId}/summarize`, {
|
|
1300
|
+
method: 'POST',
|
|
1301
|
+
headers: {
|
|
1302
|
+
...authHeaders(),
|
|
1303
|
+
'Content-Type': 'application/json',
|
|
1304
|
+
'x-opencode-directory': this.workingDir,
|
|
1305
|
+
},
|
|
1306
|
+
body: JSON.stringify(summarizeBody),
|
|
1307
|
+
}).then((res) => {
|
|
1308
|
+
if (!res.ok) {
|
|
1309
|
+
this.emit('error', `Failed to compact conversation: HTTP ${res.status}`);
|
|
1310
|
+
}
|
|
1311
|
+
}).catch((err) => {
|
|
1312
|
+
this.emit('error', `Failed to compact conversation: ${err instanceof Error ? err.message : String(err)}`);
|
|
1313
|
+
});
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
// Route known slash commands (`/name args`) to OpenCode's command
|
|
1317
|
+
// endpoint so its commands/skills/MCP prompts work from Codekin.
|
|
1318
|
+
// Only when there are no attached files — commands take text args.
|
|
1319
|
+
const cmdMatch = !attachMatch ? textContent.trim().match(/^\/([a-zA-Z0-9_:-]+)(?:\s+([\s\S]*))?$/) : null;
|
|
1320
|
+
const command = cmdMatch ? this.commands.get(cmdMatch[1]) : undefined;
|
|
1321
|
+
if (cmdMatch && command) {
|
|
1322
|
+
const cmdBody = {
|
|
1323
|
+
command: command.name,
|
|
1324
|
+
...(cmdMatch[2] ? { arguments: cmdMatch[2] } : {}),
|
|
1325
|
+
...(this.model ? { model: this.model } : {}),
|
|
1326
|
+
agent: this.permissionMode === 'plan' ? 'plan' : 'build',
|
|
1327
|
+
};
|
|
1328
|
+
void fetch(`${baseUrl}/session/${this.opencodeSessionId}/command`, {
|
|
1329
|
+
method: 'POST',
|
|
1330
|
+
headers: {
|
|
1331
|
+
...authHeaders(),
|
|
1332
|
+
'Content-Type': 'application/json',
|
|
1333
|
+
'x-opencode-directory': this.workingDir,
|
|
1334
|
+
},
|
|
1335
|
+
body: JSON.stringify(cmdBody),
|
|
1336
|
+
}).then((res) => {
|
|
1337
|
+
if (!res.ok) {
|
|
1338
|
+
this.emit('error', `Failed to run command /${command.name}: HTTP ${res.status}`);
|
|
1339
|
+
}
|
|
1340
|
+
}).catch((err) => {
|
|
1341
|
+
this.emit('error', `Failed to run command /${command.name}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1342
|
+
});
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
792
1345
|
if (textContent.trim()) {
|
|
793
1346
|
parts.push({ type: 'text', text: textContent });
|
|
794
1347
|
}
|
|
@@ -802,6 +1355,12 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
802
1355
|
const modelID = this.model.slice(slashIdx + 1);
|
|
803
1356
|
body.model = { providerID, modelID };
|
|
804
1357
|
}
|
|
1358
|
+
// Select the agent per turn: plan mode uses OpenCode's read-only `plan`
|
|
1359
|
+
// agent (real plan mode); everything else uses the default `build` agent.
|
|
1360
|
+
body.agent = this.permissionMode === 'plan' ? 'plan' : 'build';
|
|
1361
|
+
// Append Codekin environment context to the agent's system prompt
|
|
1362
|
+
// (OpenCode appends `system` — it does not replace the tuned prompt).
|
|
1363
|
+
body.system = OPENCODE_SYSTEM_CONTEXT;
|
|
805
1364
|
// Use prompt_async for fire-and-forget (events come via SSE)
|
|
806
1365
|
void fetch(`${baseUrl}/session/${this.opencodeSessionId}/prompt_async`, {
|
|
807
1366
|
method: 'POST',
|
|
@@ -826,17 +1385,38 @@ export class OpenCodeProcess extends EventEmitter {
|
|
|
826
1385
|
}
|
|
827
1386
|
/**
|
|
828
1387
|
* Respond to a permission/control request.
|
|
829
|
-
* Maps Codekin's allow/deny to OpenCode's once/always
|
|
1388
|
+
* Maps Codekin's allow/deny/allow_always to OpenCode's once/reject/always.
|
|
1389
|
+
* 'always' makes OpenCode remember the grant server-side, so the same
|
|
1390
|
+
* permission won't round-trip through the approval UI again this session.
|
|
830
1391
|
*/
|
|
831
1392
|
sendControlResponse(requestId, behavior) {
|
|
832
|
-
const type = behavior === 'deny' ? 'reject' : 'once';
|
|
1393
|
+
const type = behavior === 'deny' ? 'reject' : behavior === 'allow_always' ? 'always' : 'once';
|
|
833
1394
|
void this.replyToPermission(requestId, type);
|
|
834
1395
|
}
|
|
835
|
-
/**
|
|
1396
|
+
/**
|
|
1397
|
+
* Stop the OpenCode session and disconnect the SSE stream. If a turn is
|
|
1398
|
+
* still running server-side, abort it — otherwise OpenCode keeps generating
|
|
1399
|
+
* (and editing files) with nobody attached.
|
|
1400
|
+
*/
|
|
836
1401
|
stop() {
|
|
837
1402
|
if (!this.alive)
|
|
838
1403
|
return;
|
|
839
1404
|
this.alive = false;
|
|
1405
|
+
this.pendingMessages = [];
|
|
1406
|
+
if (this.turnInFlight && this.opencodeSessionId) {
|
|
1407
|
+
this.turnInFlight = false;
|
|
1408
|
+
void fetch(`http://localhost:${serverState.port}/session/${this.opencodeSessionId}/abort`, {
|
|
1409
|
+
method: 'POST',
|
|
1410
|
+
headers: {
|
|
1411
|
+
...authHeaders(),
|
|
1412
|
+
'x-opencode-directory': this.workingDir,
|
|
1413
|
+
},
|
|
1414
|
+
signal: AbortSignal.timeout(5000),
|
|
1415
|
+
}).catch((err) => {
|
|
1416
|
+
console.warn(`[opencode] Failed to abort in-flight turn for ${this.opencodeSessionId}:`, err);
|
|
1417
|
+
});
|
|
1418
|
+
}
|
|
1419
|
+
this.clearTurnWatchdog();
|
|
840
1420
|
if (this.startupTimer) {
|
|
841
1421
|
clearTimeout(this.startupTimer);
|
|
842
1422
|
this.startupTimer = null;
|