bosun 0.35.2 → 0.35.3

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.
@@ -0,0 +1,881 @@
1
+ /**
2
+ * opencode-shell.mjs — Persistent OpenCode agent adapter for bosun.
3
+ *
4
+ * Uses the OpenCode SDK (@opencode-ai/sdk) to maintain persistent sessions
5
+ * with multi-turn conversation, tool use (shell, file I/O, MCP), and
6
+ * real-time event streaming via Server-Sent Events.
7
+ *
8
+ * OpenCode runs a local HTTP server (Go binary) and exposes a type-safe
9
+ * REST + SSE client. Each named bosun session maps to an OpenCode server
10
+ * session UUID. Sessions persist across restarts by storing the UUID map
11
+ * in logs/opencode-shell-state.json.
12
+ *
13
+ * SDK: @opencode-ai/sdk → https://opencode.ai/docs/sdk/
14
+ * Server: opencode binary on PATH (https://opencode.ai)
15
+ */
16
+
17
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
18
+ import { resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { resolveAgentSdkConfig } from "./agent-sdk.mjs";
21
+ import { resolveRepoRoot } from "./repo-root.mjs";
22
+ import {
23
+ isTransientStreamError,
24
+ streamRetryDelay,
25
+ MAX_STREAM_RETRIES,
26
+ } from "./stream-resilience.mjs";
27
+
28
+ const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
29
+
30
+ // ── Configuration ────────────────────────────────────────────────────────────
31
+
32
+ const DEFAULT_TIMEOUT_MS = 60 * 60 * 1000; // 60 min — matches other adapters
33
+ const STATE_FILE = resolve(__dirname, "logs", "opencode-shell-state.json");
34
+ const MAX_PERSISTENT_TURNS = 50;
35
+
36
+ const REPO_ROOT = resolveRepoRoot();
37
+
38
+ // ── State (module-scope — mandatory per AGENTS.md) ────────────────────────────
39
+
40
+ let _sdk = null; // lazy-imported @opencode-ai/sdk module
41
+ let _client = null; // REST client instance (createOpencodeClient)
42
+ let _server = null; // server handle (has .close())
43
+ let _serverReady = false; // true once ensureServerStarted() has succeeded
44
+
45
+ let activeTurn = false;
46
+ let turnCount = 0;
47
+ let activeNamedSessionId = null; // bosun logical name ("primary", task-id, etc.)
48
+
49
+ /** Map: bosun named session id → OpenCode server session UUID */
50
+ const _sessionMap = new Map();
51
+
52
+ /** The OpenCode server session UUID currently in use */
53
+ let _activeServerSessionId = null;
54
+
55
+ let agentSdk = resolveAgentSdkConfig();
56
+
57
+ // ── Helpers ───────────────────────────────────────────────────────────────────
58
+
59
+ function timestamp() {
60
+ return new Date().toISOString();
61
+ }
62
+
63
+ function envFlagEnabled(value) {
64
+ const raw = String(value ?? "").trim().toLowerCase();
65
+ return ["1", "true", "yes", "on", "y"].includes(raw);
66
+ }
67
+
68
+ /**
69
+ * Parse "provider/modelId" or just "modelId" into { providerID, modelID }.
70
+ */
71
+ function resolveModelConfig() {
72
+ const raw = String(
73
+ process.env.OPENCODE_MODEL ||
74
+ process.env.OPENCODE_MODEL_ID ||
75
+ "",
76
+ ).trim();
77
+
78
+ // Explicit separate overrides win
79
+ const explicitProvider = String(process.env.OPENCODE_PROVIDER_ID || "").trim();
80
+ const explicitModel = String(process.env.OPENCODE_MODEL_ID || "").trim();
81
+ if (explicitProvider && explicitModel) {
82
+ return { providerID: explicitProvider, modelID: explicitModel };
83
+ }
84
+
85
+ if (!raw) return null; // let OpenCode use its configured default
86
+
87
+ // "anthropic/claude-3-5-sonnet-20241022" → { providerID: "anthropic", modelID: "..." }
88
+ const slashIdx = raw.indexOf("/");
89
+ if (slashIdx > 0) {
90
+ return {
91
+ providerID: raw.slice(0, slashIdx),
92
+ modelID: raw.slice(slashIdx + 1),
93
+ };
94
+ }
95
+
96
+ // bare model name — no provider prefix
97
+ return { providerID: null, modelID: raw };
98
+ }
99
+
100
+ function resolvePort() {
101
+ const raw = Number(process.env.OPENCODE_PORT || "4096");
102
+ return Number.isFinite(raw) && raw > 0 ? raw : 4096;
103
+ }
104
+
105
+ function resolveTimeoutMs() {
106
+ const raw = Number(process.env.OPENCODE_TIMEOUT_MS || "0");
107
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TIMEOUT_MS;
108
+ }
109
+
110
+ // ── SDK Loading ───────────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Lazy-import @opencode-ai/sdk (cached at module scope per AGENTS.md rules).
114
+ * Returns the module or null if not installed.
115
+ */
116
+ async function loadOpencodeSDK() {
117
+ if (_sdk) return _sdk;
118
+ try {
119
+ _sdk = await import("@opencode-ai/sdk");
120
+ console.log("[opencode-shell] SDK loaded successfully");
121
+ return _sdk;
122
+ } catch (err) {
123
+ console.error(`[opencode-shell] failed to load @opencode-ai/sdk: ${err.message}`);
124
+ return null;
125
+ }
126
+ }
127
+
128
+ // ── Server Lifecycle ──────────────────────────────────────────────────────────
129
+
130
+ /**
131
+ * Start the OpenCode server if not already running.
132
+ * Caches handles at module scope — safe to call on every turn.
133
+ *
134
+ * createOpencode() starts a local Go server and returns { client, server }.
135
+ * createOpencodeClient() attaches to an already-running server.
136
+ */
137
+ async function ensureServerStarted() {
138
+ if (_serverReady && _client) return true;
139
+
140
+ const sdk = await loadOpencodeSDK();
141
+ if (!sdk) {
142
+ console.error("[opencode-shell] SDK not available — cannot start server");
143
+ return false;
144
+ }
145
+
146
+ if (envFlagEnabled(process.env.OPENCODE_SDK_DISABLED)) {
147
+ console.warn("[opencode-shell] disabled via OPENCODE_SDK_DISABLED");
148
+ return false;
149
+ }
150
+
151
+ const port = resolvePort();
152
+
153
+ // Build optional config overrides
154
+ const configOverride = {};
155
+ const modelCfg = resolveModelConfig();
156
+ if (modelCfg?.modelID) {
157
+ // OpenCode config accepts: { model: "provider/modelId" }
158
+ const fullModel = modelCfg.providerID
159
+ ? `${modelCfg.providerID}/${modelCfg.modelID}`
160
+ : modelCfg.modelID;
161
+ configOverride.model = fullModel;
162
+ }
163
+
164
+ try {
165
+ const { createOpencode } = sdk;
166
+ const result = await createOpencode({
167
+ hostname: "127.0.0.1",
168
+ port,
169
+ timeout: 10_000,
170
+ config: Object.keys(configOverride).length ? configOverride : undefined,
171
+ });
172
+
173
+ _client = result.client;
174
+ _server = result.server;
175
+ _serverReady = true;
176
+
177
+ // Register cleanup on normal process exit
178
+ process.once("exit", () => {
179
+ try {
180
+ if (_server && typeof _server.close === "function") _server.close();
181
+ } catch {
182
+ /* best-effort */
183
+ }
184
+ });
185
+
186
+ console.log(`[opencode-shell] server started (port ${port})`);
187
+ return true;
188
+ } catch (startErr) {
189
+ // If server already running, try client-only attach
190
+ console.warn(
191
+ `[opencode-shell] createOpencode() failed: ${startErr.message} — trying client-only attach`,
192
+ );
193
+ try {
194
+ const { createOpencodeClient } = sdk;
195
+ _client = createOpencodeClient({ baseUrl: `http://127.0.0.1:${port}` });
196
+ _serverReady = true;
197
+ console.log(`[opencode-shell] attached to existing server at port ${port}`);
198
+ return true;
199
+ } catch (attachErr) {
200
+ console.error(
201
+ `[opencode-shell] client-only attach also failed: ${attachErr.message}`,
202
+ );
203
+ return false;
204
+ }
205
+ }
206
+ }
207
+
208
+ // ── State Persistence ─────────────────────────────────────────────────────────
209
+
210
+ async function loadState() {
211
+ try {
212
+ const raw = await readFile(STATE_FILE, "utf8");
213
+ const data = JSON.parse(raw);
214
+ activeNamedSessionId = data.activeNamedSessionId || null;
215
+ turnCount = data.turnCount || 0;
216
+ if (data.sessionMap && typeof data.sessionMap === "object") {
217
+ for (const [k, v] of Object.entries(data.sessionMap)) {
218
+ _sessionMap.set(k, v);
219
+ }
220
+ }
221
+ _activeServerSessionId = data.activeServerSessionId || null;
222
+ console.log(
223
+ `[opencode-shell] loaded state: named=${activeNamedSessionId}, turns=${turnCount}, sessions=${_sessionMap.size}`,
224
+ );
225
+ } catch {
226
+ activeNamedSessionId = null;
227
+ turnCount = 0;
228
+ _activeServerSessionId = null;
229
+ }
230
+ }
231
+
232
+ async function saveState() {
233
+ try {
234
+ await mkdir(resolve(__dirname, "logs"), { recursive: true });
235
+ const sessionMapObj = Object.fromEntries(_sessionMap.entries());
236
+ await writeFile(
237
+ STATE_FILE,
238
+ JSON.stringify(
239
+ {
240
+ activeNamedSessionId,
241
+ activeServerSessionId: _activeServerSessionId,
242
+ sessionMap: sessionMapObj,
243
+ turnCount,
244
+ updatedAt: timestamp(),
245
+ },
246
+ null,
247
+ 2,
248
+ ),
249
+ "utf8",
250
+ );
251
+ } catch (err) {
252
+ console.warn(`[opencode-shell] failed to save state: ${err.message}`);
253
+ }
254
+ }
255
+
256
+ // ── Session Management ────────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * Verify a server session UUID still exists on the server.
260
+ * OpenCode sessions are ephemeral per server start, so UUIDs from
261
+ * a previous run are invalid after restart.
262
+ */
263
+ async function serverSessionExists(serverSessionId) {
264
+ if (!serverSessionId || !_client) return false;
265
+ try {
266
+ const result = await _client.session.get({ path: { id: serverSessionId } });
267
+ return !result.error;
268
+ } catch {
269
+ return false;
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Get or create an OpenCode server session for the given bosun named session.
275
+ * Recovers stale UUIDs (from previous server runs) by creating fresh sessions.
276
+ */
277
+ async function getOrCreateServerSession(namedId) {
278
+ const existing = _sessionMap.get(namedId);
279
+
280
+ if (existing) {
281
+ // Verify the session is still alive on the server
282
+ const alive = await serverSessionExists(existing);
283
+ if (alive) {
284
+ _activeServerSessionId = existing;
285
+ return existing;
286
+ }
287
+ // Stale UUID — server was restarted; create a fresh session
288
+ console.log(
289
+ `[opencode-shell] session ${namedId} (${existing.slice(0, 8)}) stale — creating fresh`,
290
+ );
291
+ _sessionMap.delete(namedId);
292
+ }
293
+
294
+ try {
295
+ const newSession = await _client.session.create({
296
+ body: { title: `bosun/${namedId}` },
297
+ });
298
+ const newId = newSession?.data?.id || newSession?.id;
299
+ if (!newId) throw new Error("session.create() returned no id");
300
+ _sessionMap.set(namedId, newId);
301
+ _activeServerSessionId = newId;
302
+ console.log(
303
+ `[opencode-shell] created server session ${newId.slice(0, 8)} for "${namedId}"`,
304
+ );
305
+ await saveState();
306
+ return newId;
307
+ } catch (err) {
308
+ console.error(`[opencode-shell] failed to create server session: ${err.message}`);
309
+ throw err;
310
+ }
311
+ }
312
+
313
+ // ── Event Formatting ──────────────────────────────────────────────────────────
314
+
315
+ /**
316
+ * Format an OpenCode SSE event into a human-readable string for streaming.
317
+ * OpenCode SSE events have { type, properties } shape.
318
+ * Returns null for events that should not be forwarded.
319
+ */
320
+ function formatOpencodeEvent(event) {
321
+ if (!event) return null;
322
+ const { type, properties: p = {} } = event;
323
+
324
+ switch (type) {
325
+ // ── Session lifecycle ──────────────────────────────────────────────────
326
+ case "session.created":
327
+ case "session.updated":
328
+ return null; // internal bookkeeping
329
+
330
+ case "session.error":
331
+ return `❌ OpenCode error: ${p.error || p.message || "unknown"}`;
332
+
333
+ // ── Message streaming ──────────────────────────────────────────────────
334
+ case "message.part": {
335
+ // Partial content blocks — only emit substantive text to avoid noise
336
+ if (p.type === "text" && typeof p.content === "string" && p.content.length > 20) {
337
+ return p.content;
338
+ }
339
+ // Reasoning / thinking blocks
340
+ if (p.type === "thinking" && p.thinking) {
341
+ return `💭 ${p.thinking.slice(0, 300)}`;
342
+ }
343
+ return null;
344
+ }
345
+
346
+ case "message.completed": {
347
+ // Full message — extract text if not already emitted via message.part
348
+ if (!p.body) return null;
349
+ const parts = Array.isArray(p.body.parts) ? p.body.parts : [];
350
+ const texts = parts
351
+ .filter((pt) => pt.type === "text" && typeof pt.text === "string")
352
+ .map((pt) => pt.text.trim())
353
+ .filter(Boolean);
354
+ if (texts.length > 0) return texts.join("\n");
355
+ return null;
356
+ }
357
+
358
+ // ── Tool calls (embedded in message events via properties.tool) ────────
359
+ case "tool.start": {
360
+ const tool = p.tool || "";
361
+ if (tool.startsWith("mcp_")) {
362
+ const [, server, ...nameParts] = tool.split("_");
363
+ return `🔌 MCP [${server}]: ${nameParts.join("_")}`;
364
+ }
365
+ if (tool === "bash" || tool === "shell" || tool === "run") {
366
+ return `⚡ Running: \`${p.input?.command || p.input?.cmd || tool}\``;
367
+ }
368
+ if (tool === "write" || tool === "edit" || tool === "file_write") {
369
+ return `✏️ Writing: ${p.input?.path || p.input?.file_path || "file"}`;
370
+ }
371
+ if (tool === "read" || tool === "file_read") {
372
+ return `📖 Reading: ${p.input?.path || p.input?.file_path || "file"}`;
373
+ }
374
+ if (tool === "web_search" || tool === "webSearch") {
375
+ return `🔍 Searching: ${p.input?.query || ""}`;
376
+ }
377
+ if (tool === "glob" || tool === "find") {
378
+ return `🔎 Finding: ${p.input?.pattern || p.input?.query || ""}`;
379
+ }
380
+ // Generic tool
381
+ return `🔧 Tool: ${tool}`;
382
+ }
383
+
384
+ case "tool.complete": {
385
+ const tool = p.tool || "";
386
+ const isError = !!p.error || p.exitCode !== undefined && p.exitCode !== 0;
387
+ const status = isError ? "❌" : "✅";
388
+
389
+ if (tool.startsWith("mcp_")) {
390
+ const [, server, ...nameParts] = tool.split("_");
391
+ const errMsg = p.error ? `: ${p.error}` : "";
392
+ return `${status} MCP [${server}/${nameParts.join("_")}]${errMsg}`;
393
+ }
394
+ if (tool === "bash" || tool === "shell" || tool === "run") {
395
+ const cmd = p.input?.command || p.input?.cmd || tool;
396
+ const output = typeof p.output === "string" ? p.output.slice(-400) : "";
397
+ const exitPart = p.exitCode !== undefined ? ` (exit ${p.exitCode})` : "";
398
+ return `${status} Command: \`${cmd}\`${exitPart}${output ? `\n${output}` : ""}`;
399
+ }
400
+ if (tool === "write" || tool === "edit" || tool === "file_write") {
401
+ const path = p.input?.path || p.input?.file_path || "file";
402
+ return `${status} File written: ${path}`;
403
+ }
404
+ return null; // suppress other complete events
405
+ }
406
+
407
+ // ── File changes ───────────────────────────────────────────────────────
408
+ case "file.updated":
409
+ case "file.created": {
410
+ const action = type === "file.created" ? "➕" : "✏️";
411
+ return `${action} ${p.path || p.file || "file"}`;
412
+ }
413
+
414
+ case "file.deleted":
415
+ return `🗑️ Deleted: ${p.path || p.file || "file"}`;
416
+
417
+ // ── Error / completion ─────────────────────────────────────────────────
418
+ case "prompt.completed":
419
+ case "turn.completed":
420
+ return null; // handled by caller
421
+
422
+ case "error":
423
+ case "prompt.error":
424
+ return `❌ Error: ${p.message || p.error || "unknown"}`;
425
+
426
+ default:
427
+ return null;
428
+ }
429
+ }
430
+
431
+ // ── Prompt Safety ─────────────────────────────────────────────────────────────
432
+
433
+ const MAX_PROMPT_BYTES = 180_000;
434
+
435
+ function sanitizeAndTruncatePrompt(text) {
436
+ if (typeof text !== "string") return "";
437
+ // eslint-disable-next-line no-control-regex
438
+ const sanitized = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
439
+ const bytes = Buffer.byteLength(sanitized, "utf8");
440
+ if (bytes <= MAX_PROMPT_BYTES) return sanitized;
441
+ const buf = Buffer.from(sanitized, "utf8").slice(0, MAX_PROMPT_BYTES);
442
+ const truncated = buf.toString("utf8");
443
+ const removedBytes = bytes - MAX_PROMPT_BYTES;
444
+ console.warn(
445
+ `[opencode-shell] prompt truncated: ${bytes} → ${MAX_PROMPT_BYTES} bytes (removed ${removedBytes} bytes)`,
446
+ );
447
+ return truncated + `\n\n[...prompt truncated — ${removedBytes} bytes removed]`;
448
+ }
449
+
450
+ // ── Main Execution ────────────────────────────────────────────────────────────
451
+
452
+ /**
453
+ * Send a message to the OpenCode agent and stream events back.
454
+ *
455
+ * Concurrency model:
456
+ * • client.session.prompt() is blocking — it resolves when the turn finishes.
457
+ * • client.event.subscribe() is an SSE stream — we run it concurrently to
458
+ * forward live events to onEvent as they arrive.
459
+ * • Both are torn down together in the finally block.
460
+ *
461
+ * @param {string} userMessage
462
+ * @param {object} options
463
+ * @param {function} [options.onEvent] - Callback for each formatted event string
464
+ * @param {object} [options.statusData] - Orchestrator status for context
465
+ * @param {number} [options.timeoutMs] - Timeout in ms
466
+ * @param {boolean} [options.persistent] - Reuse session across calls
467
+ * @param {string} [options.sessionId] - Named session identifier
468
+ * @param {boolean} [options.sendRawEvents] - Also pass raw event object to onEvent
469
+ * @param {AbortController} [options.abortController] - External abort signal
470
+ * @returns {Promise<{finalResponse: string, items: Array, usage: null}>}
471
+ */
472
+ export async function execOpencodePrompt(userMessage, options = {}) {
473
+ const {
474
+ onEvent = null,
475
+ statusData = null,
476
+ timeoutMs = resolveTimeoutMs(),
477
+ persistent = false,
478
+ sessionId = null,
479
+ sendRawEvents = false,
480
+ abortController = null,
481
+ } = options;
482
+
483
+ // Re-read config in case it changed hot
484
+ agentSdk = resolveAgentSdkConfig({ reload: true });
485
+ if (agentSdk.primary !== "opencode") {
486
+ return {
487
+ finalResponse: `❌ Agent SDK set to "${agentSdk.primary}" — OpenCode disabled.`,
488
+ items: [],
489
+ usage: null,
490
+ };
491
+ }
492
+
493
+ if (envFlagEnabled(process.env.OPENCODE_SDK_DISABLED)) {
494
+ return {
495
+ finalResponse: "❌ OpenCode disabled via OPENCODE_SDK_DISABLED.",
496
+ items: [],
497
+ usage: null,
498
+ };
499
+ }
500
+
501
+ if (activeTurn) {
502
+ return {
503
+ finalResponse: "⏳ OpenCode agent is still executing a previous task. Please wait.",
504
+ items: [],
505
+ usage: null,
506
+ };
507
+ }
508
+
509
+ activeTurn = true;
510
+
511
+ try {
512
+ const started = await ensureServerStarted();
513
+ if (!started) {
514
+ return {
515
+ finalResponse: "❌ OpenCode server could not be started. Check that the opencode binary is on PATH.",
516
+ items: [],
517
+ usage: null,
518
+ };
519
+ }
520
+
521
+ // Resolve which bosun session to use
522
+ const namedId = persistent
523
+ ? (sessionId || activeNamedSessionId || "primary")
524
+ : (sessionId || `ephemeral-${Date.now()}`);
525
+
526
+ if (persistent && namedId !== activeNamedSessionId) {
527
+ activeNamedSessionId = namedId;
528
+ }
529
+
530
+ // Ensure we have a server session UUID
531
+ let serverSessionId;
532
+ try {
533
+ serverSessionId = await getOrCreateServerSession(namedId);
534
+ } catch (err) {
535
+ return {
536
+ finalResponse: `❌ Could not establish OpenCode session: ${err.message}`,
537
+ items: [],
538
+ usage: null,
539
+ };
540
+ }
541
+
542
+ // Build enriched prompt
543
+ let prompt = userMessage;
544
+ if (statusData) {
545
+ const statusSnippet = JSON.stringify(statusData, null, 2).slice(0, 2000);
546
+ prompt = `[Orchestrator Status]\n\`\`\`json\n${statusSnippet}\n\`\`\`\n\n# YOUR TASK — EXECUTE NOW\n\n${userMessage}\n\n---\nDo NOT respond with "Ready" or ask what to do. EXECUTE this task.`;
547
+ } else {
548
+ prompt = `${userMessage}\n\n---\nDo NOT respond with "Ready" or ask what to do. EXECUTE this task end-to-end.`;
549
+ }
550
+ const safePrompt = sanitizeAndTruncatePrompt(prompt);
551
+
552
+ // Resolve model config
553
+ const modelCfg = resolveModelConfig();
554
+ const promptBody = {
555
+ parts: [{ type: "text", text: safePrompt }],
556
+ };
557
+ if (modelCfg?.modelID) {
558
+ promptBody.model = {
559
+ ...(modelCfg.providerID ? { providerID: modelCfg.providerID } : {}),
560
+ modelID: modelCfg.modelID,
561
+ };
562
+ }
563
+
564
+ // ── Retry loop ──────────────────────────────────────────────────────────
565
+ for (let attempt = 0; attempt < MAX_STREAM_RETRIES; attempt++) {
566
+ const controller = abortController || new AbortController();
567
+ const timer = setTimeout(() => controller.abort("timeout"), timeoutMs);
568
+
569
+ // SSE event subscription — runs concurrently; collects formatted strings
570
+ let sseSubscription = null;
571
+ const sseForwardingPromise = (async () => {
572
+ if (!onEvent) return;
573
+ try {
574
+ const evStream = await _client.event.subscribe();
575
+ sseSubscription = evStream;
576
+ for await (const event of evStream.stream) {
577
+ if (controller.signal.aborted) break;
578
+ // Only forward events belonging to our session
579
+ const eventSessionId =
580
+ event.properties?.sessionId ||
581
+ event.properties?.session_id ||
582
+ event.sessionId;
583
+ if (eventSessionId && eventSessionId !== serverSessionId) continue;
584
+ const formatted = formatOpencodeEvent(event);
585
+ if (formatted) {
586
+ try {
587
+ if (sendRawEvents) {
588
+ await onEvent(formatted, event);
589
+ } else {
590
+ await onEvent(formatted);
591
+ }
592
+ } catch {
593
+ /* best-effort */
594
+ }
595
+ }
596
+ }
597
+ } catch (streamErr) {
598
+ // Non-fatal: SSE stream closure during abort or server shutdown
599
+ if (!controller.signal.aborted) {
600
+ console.warn(`[opencode-shell] SSE stream error: ${streamErr.message}`);
601
+ }
602
+ }
603
+ })();
604
+
605
+ try {
606
+ // Race the blocking prompt call against the abort signal so the turn
607
+ // is promptly cancelled even if the SDK doesn't natively accept AbortSignal.
608
+ const abortRace = new Promise((_, reject) => {
609
+ if (controller.signal.aborted) {
610
+ const e = new Error("AbortError");
611
+ e.name = "AbortError";
612
+ reject(e);
613
+ return;
614
+ }
615
+ const onAbort = () => {
616
+ const e = new Error("AbortError");
617
+ e.name = "AbortError";
618
+ reject(e);
619
+ };
620
+ controller.signal.addEventListener("abort", onAbort, { once: true });
621
+ });
622
+
623
+ const result = await Promise.race([
624
+ _client.session.prompt({
625
+ path: { id: serverSessionId },
626
+ body: promptBody,
627
+ }),
628
+ abortRace,
629
+ ]);
630
+
631
+ clearTimeout(timer);
632
+
633
+ // Tear down SSE subscription (close so the async iterator exits)
634
+ try {
635
+ if (sseSubscription && typeof sseSubscription.destroy === "function") {
636
+ sseSubscription.destroy();
637
+ }
638
+ } catch {
639
+ /* best-effort */
640
+ }
641
+ await sseForwardingPromise.catch(() => {});
642
+
643
+ // Extract text response from result
644
+ const info = result?.data?.info || result?.info || {};
645
+ const parts =
646
+ result?.data?.parts ||
647
+ result?.parts ||
648
+ (Array.isArray(info.parts) ? info.parts : []);
649
+
650
+ const textParts = parts
651
+ .filter((p) => p?.type === "text" && typeof p.text === "string")
652
+ .map((p) => p.text.trim())
653
+ .filter(Boolean);
654
+
655
+ const finalResponse =
656
+ textParts.join("\n") ||
657
+ (typeof info.content === "string" ? info.content.trim() : "") ||
658
+ "(Agent completed with no text output)";
659
+
660
+ // Track turn count
661
+ turnCount++;
662
+ if (persistent || turnCount % 10 === 0) {
663
+ await saveState().catch(() => {});
664
+ }
665
+
666
+ // Rotate ephemeral sessions to avoid unbounded session accumulation
667
+ if (!persistent && namedId.startsWith("ephemeral-")) {
668
+ _sessionMap.delete(namedId);
669
+ _activeServerSessionId = null;
670
+ }
671
+
672
+ return { finalResponse, items: parts, usage: null };
673
+ } catch (err) {
674
+ clearTimeout(timer);
675
+
676
+ // Clean up SSE on error
677
+ try {
678
+ if (sseSubscription && typeof sseSubscription.destroy === "function") {
679
+ sseSubscription.destroy();
680
+ }
681
+ } catch {
682
+ /* best-effort */
683
+ }
684
+ await sseForwardingPromise.catch(() => {});
685
+
686
+ if (err.name === "AbortError" || controller.signal.aborted) {
687
+ const reason = controller.signal.reason;
688
+ const msg =
689
+ reason === "user_stop"
690
+ ? "🛑 Agent stopped by user."
691
+ : `⏱️ Agent timed out after ${timeoutMs / 1000}s`;
692
+
693
+ // Try to abort the server-side turn
694
+ try {
695
+ await _client.session.abort({ path: { id: serverSessionId } });
696
+ } catch {
697
+ /* best-effort */
698
+ }
699
+
700
+ return { finalResponse: msg, items: [], usage: null };
701
+ }
702
+
703
+ // Transient network/HTTP errors — retry with backoff
704
+ if (isTransientStreamError(err)) {
705
+ const attemptsLeft = MAX_STREAM_RETRIES - 1 - attempt;
706
+ if (attemptsLeft > 0) {
707
+ const delay = streamRetryDelay(attempt);
708
+ console.warn(
709
+ `[opencode-shell] transient error (attempt ${attempt + 1}/${MAX_STREAM_RETRIES}): ${err.message} — retrying in ${Math.round(delay)}ms`,
710
+ );
711
+ await new Promise((r) => setTimeout(r, delay));
712
+ continue;
713
+ }
714
+ return {
715
+ finalResponse: `❌ OpenCode: connection failed after ${MAX_STREAM_RETRIES} retries: ${err.message}`,
716
+ items: [],
717
+ usage: null,
718
+ };
719
+ }
720
+
721
+ throw err;
722
+ }
723
+ }
724
+
725
+ return {
726
+ finalResponse: "❌ OpenCode agent failed after all retry attempts.",
727
+ items: [],
728
+ usage: null,
729
+ };
730
+ } finally {
731
+ activeTurn = false;
732
+ }
733
+ }
734
+
735
+ // ── Steering ───────────────────────────────────────────────────────────────────
736
+
737
+ /**
738
+ * Attempt to interrupt an in-flight OpenCode turn.
739
+ *
740
+ * OpenCode does not support mid-turn message injection (unlike Codex steer).
741
+ * The correct pattern is abort + re-queue a new prompt with the steering message.
742
+ * This function aborts the active turn; the caller is responsible for re-queuing.
743
+ *
744
+ * @param {string} _message - Steering message (for logging; will be surfaced to caller)
745
+ * @returns {Promise<{ok: boolean, reason?: string, mode?: string}>}
746
+ */
747
+ export async function steerOpencodePrompt(_message) {
748
+ try {
749
+ agentSdk = resolveAgentSdkConfig({ reload: true });
750
+ if (agentSdk.primary !== "opencode") {
751
+ return { ok: false, reason: "agent_sdk_not_opencode" };
752
+ }
753
+ if (!agentSdk.capabilities?.steering) {
754
+ return { ok: false, reason: "steering_disabled" };
755
+ }
756
+ if (!_activeServerSessionId) {
757
+ return { ok: false, reason: "no_active_session" };
758
+ }
759
+ if (!_client) {
760
+ return { ok: false, reason: "client_not_initialized" };
761
+ }
762
+
763
+ await _client.session.abort({ path: { id: _activeServerSessionId } });
764
+ return { ok: true, mode: "abort" };
765
+ } catch (err) {
766
+ return { ok: false, reason: err.message || "abort_failed" };
767
+ }
768
+ }
769
+
770
+ // ── Status / Info ──────────────────────────────────────────────────────────────
771
+
772
+ export function isOpencodeBusy() {
773
+ return !!activeTurn;
774
+ }
775
+
776
+ export function getSessionInfo() {
777
+ return {
778
+ namedSessionId: activeNamedSessionId,
779
+ serverSessionId: _activeServerSessionId,
780
+ turnCount,
781
+ isActive: _serverReady,
782
+ isBusy: activeTurn,
783
+ sessionCount: _sessionMap.size,
784
+ };
785
+ }
786
+
787
+ export function getActiveSessionId() {
788
+ return activeNamedSessionId;
789
+ }
790
+
791
+ // ── Session Management Exports ─────────────────────────────────────────────────
792
+
793
+ export async function listSessions() {
794
+ const sessions = [];
795
+ for (const [namedId, serverUUID] of _sessionMap.entries()) {
796
+ sessions.push({
797
+ id: namedId,
798
+ serverSessionId: serverUUID,
799
+ isActive: namedId === activeNamedSessionId,
800
+ });
801
+ }
802
+ // Also query the server for its live sessions if available
803
+ if (_client) {
804
+ try {
805
+ const result = await _client.session.list();
806
+ const serverSessions = result?.data || result || [];
807
+ for (const ss of serverSessions) {
808
+ const ssId = ss?.id;
809
+ if (!ssId) continue;
810
+ // Only include server sessions not already mapped
811
+ const alreadyMapped = sessions.some((s) => s.serverSessionId === ssId);
812
+ if (!alreadyMapped) {
813
+ sessions.push({
814
+ id: `server:${ssId}`,
815
+ serverSessionId: ssId,
816
+ isActive: ssId === _activeServerSessionId,
817
+ serverManaged: true,
818
+ });
819
+ }
820
+ }
821
+ } catch {
822
+ /* best-effort */
823
+ }
824
+ }
825
+ return sessions;
826
+ }
827
+
828
+ export async function switchSession(namedId) {
829
+ activeNamedSessionId = namedId;
830
+ _activeServerSessionId = _sessionMap.get(namedId) || null;
831
+ console.log(`[opencode-shell] switched to session "${namedId}"`);
832
+ await saveState();
833
+ }
834
+
835
+ export async function createSession(namedId) {
836
+ if (_sessionMap.has(namedId)) {
837
+ return { id: namedId, serverSessionId: _sessionMap.get(namedId) };
838
+ }
839
+ // Defer actual server session creation until first prompt
840
+ return { id: namedId, serverSessionId: null };
841
+ }
842
+
843
+ // ── Reset ──────────────────────────────────────────────────────────────────────
844
+
845
+ export async function resetSession() {
846
+ // Abort active turn if any
847
+ if (_activeServerSessionId && _client) {
848
+ try {
849
+ await _client.session.abort({ path: { id: _activeServerSessionId } });
850
+ } catch {
851
+ /* best-effort */
852
+ }
853
+ }
854
+ activeTurn = false;
855
+ _activeServerSessionId = null;
856
+ activeNamedSessionId = null;
857
+ turnCount = 0;
858
+ _sessionMap.clear();
859
+ await saveState();
860
+ console.log("[opencode-shell] session reset");
861
+ }
862
+
863
+ // ── Initialisation ─────────────────────────────────────────────────────────────
864
+
865
+ export async function initOpencodeShell() {
866
+ await loadState();
867
+
868
+ if (envFlagEnabled(process.env.OPENCODE_SDK_DISABLED)) {
869
+ console.warn("[opencode-shell] SDK disabled via OPENCODE_SDK_DISABLED — skipping init");
870
+ return;
871
+ }
872
+
873
+ const sdk = await loadOpencodeSDK();
874
+ if (sdk) {
875
+ console.log("[opencode-shell] initialised (server will start on first prompt)");
876
+ } else {
877
+ console.warn(
878
+ "[opencode-shell] initialised WITHOUT @opencode-ai/sdk — install it to use OpenCode as primary agent",
879
+ );
880
+ }
881
+ }