codekin 0.6.4 → 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.
Files changed (86) hide show
  1. package/README.md +8 -5
  2. package/bin/codekin.mjs +69 -6
  3. package/dist/assets/index-CLuQVRRb.css +1 -0
  4. package/dist/assets/index-JVnFWiSw.js +185 -0
  5. package/dist/index.html +2 -2
  6. package/package.json +6 -4
  7. package/server/dist/anthropic-models.d.ts +40 -0
  8. package/server/dist/anthropic-models.js +212 -0
  9. package/server/dist/anthropic-models.js.map +1 -0
  10. package/server/dist/claude-process.d.ts +32 -3
  11. package/server/dist/claude-process.js +129 -9
  12. package/server/dist/claude-process.js.map +1 -1
  13. package/server/dist/codex-process.d.ts +147 -0
  14. package/server/dist/codex-process.js +741 -0
  15. package/server/dist/codex-process.js.map +1 -0
  16. package/server/dist/coding-process.d.ts +16 -4
  17. package/server/dist/coding-process.js +10 -0
  18. package/server/dist/coding-process.js.map +1 -1
  19. package/server/dist/commit-event-handler.d.ts +14 -1
  20. package/server/dist/commit-event-handler.js +40 -8
  21. package/server/dist/commit-event-handler.js.map +1 -1
  22. package/server/dist/config.d.ts +25 -0
  23. package/server/dist/config.js +42 -0
  24. package/server/dist/config.js.map +1 -1
  25. package/server/dist/opencode-process.d.ts +142 -5
  26. package/server/dist/opencode-process.js +664 -84
  27. package/server/dist/opencode-process.js.map +1 -1
  28. package/server/dist/orchestrator-children.d.ts +94 -7
  29. package/server/dist/orchestrator-children.js +375 -65
  30. package/server/dist/orchestrator-children.js.map +1 -1
  31. package/server/dist/orchestrator-manager.d.ts +10 -0
  32. package/server/dist/orchestrator-manager.js +70 -18
  33. package/server/dist/orchestrator-manager.js.map +1 -1
  34. package/server/dist/orchestrator-monitor.d.ts +7 -1
  35. package/server/dist/orchestrator-monitor.js +62 -19
  36. package/server/dist/orchestrator-monitor.js.map +1 -1
  37. package/server/dist/orchestrator-notify.d.ts +42 -0
  38. package/server/dist/orchestrator-notify.js +42 -0
  39. package/server/dist/orchestrator-notify.js.map +1 -0
  40. package/server/dist/orchestrator-outbox.d.ts +48 -0
  41. package/server/dist/orchestrator-outbox.js +154 -0
  42. package/server/dist/orchestrator-outbox.js.map +1 -0
  43. package/server/dist/orchestrator-session-router.js +43 -1
  44. package/server/dist/orchestrator-session-router.js.map +1 -1
  45. package/server/dist/prompt-router.d.ts +22 -1
  46. package/server/dist/prompt-router.js +94 -11
  47. package/server/dist/prompt-router.js.map +1 -1
  48. package/server/dist/session-archive.js +11 -1
  49. package/server/dist/session-archive.js.map +1 -1
  50. package/server/dist/session-lifecycle.d.ts +1 -0
  51. package/server/dist/session-lifecycle.js +37 -0
  52. package/server/dist/session-lifecycle.js.map +1 -1
  53. package/server/dist/session-manager.d.ts +49 -2
  54. package/server/dist/session-manager.js +221 -33
  55. package/server/dist/session-manager.js.map +1 -1
  56. package/server/dist/session-naming.d.ts +4 -0
  57. package/server/dist/session-naming.js +26 -5
  58. package/server/dist/session-naming.js.map +1 -1
  59. package/server/dist/session-routes.js +42 -2
  60. package/server/dist/session-routes.js.map +1 -1
  61. package/server/dist/stepflow-handler.js +2 -2
  62. package/server/dist/stepflow-handler.js.map +1 -1
  63. package/server/dist/tsconfig.tsbuildinfo +1 -1
  64. package/server/dist/types.d.ts +24 -3
  65. package/server/dist/types.js +1 -9
  66. package/server/dist/types.js.map +1 -1
  67. package/server/dist/upload-routes.d.ts +7 -0
  68. package/server/dist/upload-routes.js +85 -28
  69. package/server/dist/upload-routes.js.map +1 -1
  70. package/server/dist/webhook-handler.js +3 -3
  71. package/server/dist/webhook-handler.js.map +1 -1
  72. package/server/dist/workflow-config.d.ts +2 -2
  73. package/server/dist/workflow-engine.d.ts +20 -0
  74. package/server/dist/workflow-engine.js +52 -15
  75. package/server/dist/workflow-engine.js.map +1 -1
  76. package/server/dist/workflow-loader.d.ts +5 -5
  77. package/server/dist/workflow-loader.js +169 -54
  78. package/server/dist/workflow-loader.js.map +1 -1
  79. package/server/dist/workflow-routes.js +36 -2
  80. package/server/dist/workflow-routes.js.map +1 -1
  81. package/server/dist/ws-message-handler.js +24 -9
  82. package/server/dist/ws-message-handler.js.map +1 -1
  83. package/server/dist/ws-server.js +53 -11
  84. package/server/dist/ws-server.js.map +1 -1
  85. package/dist/assets/index-BRB_Ksyk.js +0 -182
  86. 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 — just reconnect to SSE
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(baseUrl) {
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
- const connectSSE = () => {
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
- reconnectAttempts++;
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
- 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] 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
- reconnectAttempts++;
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
- if (!this.isOwnSession(properties))
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
- // step-start / step-finish are agentic iteration boundaries — no mapping needed
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
- if (this.turnComplete)
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
- if (this.turnComplete)
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
- const session = properties.session;
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
- if (this.turnComplete)
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
- if (this.turnComplete)
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' || !info.parts)
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
- /** Reply to an OpenCode permission request via HTTP. */
655
- async replyToPermission(requestId, type) {
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}/permission/${requestId}/reply`, {
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
- body: JSON.stringify({ type }),
989
+ signal: AbortSignal.timeout(10_000),
666
990
  });
667
- if (!res.ok) {
668
- console.error(`[opencode] Permission reply failed: HTTP ${res.status} for ${requestId}`);
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.error(`[opencode] Failed to reply to permission ${requestId}:`, err);
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
- const task = this.tasks.get(id);
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
- const imageMimeMap = {
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 imageMime = imageMimeMap[ext];
776
- if (imageMime) {
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: imageMime, filename: filePath.split('/').pop(), url: `data:${imageMime};base64,${base64}` });
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
- console.warn(`[opencode] Unsupported file type for attachment: ${ext} (${filePath})`);
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/reject.
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
- /** Stop the OpenCode session and disconnect the SSE stream. */
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;