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.
- package/README.md +14 -1
- package/agent-hooks.mjs +7 -1
- package/agent-pool.mjs +16 -0
- package/agent-prompts.mjs +190 -4
- package/agent-sdk.mjs +6 -1
- package/agent-work-analyzer.mjs +48 -9
- package/autofix.mjs +32 -18
- package/bosun.schema.json +1 -1
- package/kanban-adapter.mjs +62 -12
- package/monitor.mjs +25 -6
- package/opencode-shell.mjs +881 -0
- package/package.json +5 -2
- package/primary-agent.mjs +43 -0
- package/setup.mjs +33 -4
- package/task-executor.mjs +43 -14
- package/ui/app.js +10 -7
- package/ui/components/chat-view.js +31 -9
- package/ui/components/session-list.js +20 -4
- package/ui/modules/router.js +2 -0
- package/ui/tabs/agents.js +66 -8
- package/ui-server.mjs +142 -5
- package/workflow-engine.mjs +664 -10
- package/workflow-nodes.mjs +250 -1
- package/workflow-templates/github.mjs +389 -71
- package/workflow-templates/planning.mjs +31 -11
- package/workflow-templates.mjs +3 -0
|
@@ -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
|
+
}
|