@polderlabs/bizar-plugin 0.6.2 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,390 @@
1
+ /**
2
+ * plugins/bizar/src/opencode-runner.ts
3
+ *
4
+ * v0.8.0 — Process-based background agent runner.
5
+ *
6
+ * Replaces the v0.4–v0.7 `opencode serve` HTTP path. The HTTP API is
7
+ * passive — see the opencode docs:
8
+ * "When you run opencode it starts a TUI and a server. Where the
9
+ * TUI is the client that talks to the server."
10
+ *
11
+ * `opencode run <prompt>` is the active path: it spawns the agent
12
+ * loop in-process, drives it to completion, and exits. This module
13
+ * gives the plugin a clean way to spawn one `opencode run` per
14
+ * background agent, capture its output to the LogWriter's log file,
15
+ * track the PID for status/kill, and extract the opencode session
16
+ * id from the structured log stream.
17
+ *
18
+ * ─────────────────────────────────────────────────────────────────
19
+ * Why this exists (v0.7.0-alpha.1 → v0.8.0)
20
+ * ─────────────────────────────────────────────────────────────────
21
+ * Pre-v0.8.0, the plugin POSTed to `/api/session/{id}/prompt` on the
22
+ * opencode serve child. The server admitted the prompt
23
+ * (`session.next.prompt.admitted` event) but no agent loop processed
24
+ * it. The result: a session record was created, a tmux pane was
25
+ * spawned, and nothing happened. The user saw "spawns but does
26
+ * nothing" and was right.
27
+ *
28
+ * This module fixes that by spawning `opencode run` directly.
29
+ *
30
+ * ─────────────────────────────────────────────────────────────────
31
+ * Wire format
32
+ * ─────────────────────────────────────────────────────────────────
33
+ * `opencode run` writes structured logs to stderr, one log per line:
34
+ *
35
+ * timestamp=2026-06-24T01:25:13.537Z level=INFO run=<uuid> message="creating instance" directory=/tmp
36
+ * timestamp=2026-06-24T01:25:16.555Z level=INFO run=<uuid> message=created id=ses_… title="…" agent=…
37
+ * timestamp=2026-06-24T01:25:16.951Z level=INFO run=<uuid> message=loop session.id=ses_… step=0
38
+ * timestamp=2026-06-24T01:25:21.253Z level=INFO run=<uuid> message="exiting loop" session.id=ses_…
39
+ *
40
+ * The `message=created id=ses_<id>` line is how we recover the
41
+ * sessionId (the agent generated it; we don't pre-allocate). We
42
+ * parse the line with `message=created id=(ses_[A-Za-z0-9_]+)`.
43
+ *
44
+ * Both stdout and stderr are piped to `logPath` so the operator's
45
+ * tmux pane (and the dashboard's log viewer) can watch the agent
46
+ * work in real time. We prefix each line with the stream name
47
+ * ([stdout] / [stderr]) for human readability; downstream parsers
48
+ * can split on the first `] `.
49
+ */
50
+ import { createWriteStream, mkdirSync, existsSync } from "node:fs";
51
+ import { dirname } from "node:path";
52
+ import type { Subprocess } from "bun";
53
+
54
+ export type AgentState = "starting" | "running" | "done" | "failed" | "killed";
55
+
56
+ export interface AgentStatus {
57
+ state: AgentState;
58
+ sessionId?: string;
59
+ processId: number;
60
+ startedAt: number;
61
+ endedAt?: number;
62
+ exitCode?: number;
63
+ error?: string;
64
+ }
65
+
66
+ export interface SpawnAgentOptions {
67
+ /** The prompt to send. */
68
+ prompt: string;
69
+ /** Agent name (mimir, thor, …) — used as the session title prefix. */
70
+ agent: string;
71
+ /** Optional model override in "providerID/modelID" format. */
72
+ model?: { providerID: string; modelID: string };
73
+ /** Working directory for the opencode run. */
74
+ worktree: string;
75
+ /** Absolute path to the log file (the LogWriter's output path). */
76
+ logPath: string;
77
+ /** Optional session title (defaults to `bgr:<agent>`). */
78
+ title?: string;
79
+ /** Extra env vars to pass through. */
80
+ env?: Record<string, string>;
81
+ /** How long to wait for the sessionId to appear in stderr (ms). */
82
+ sessionIdTimeoutMs?: number;
83
+ }
84
+
85
+ export interface SpawnAgentResult {
86
+ ok: boolean;
87
+ sessionId?: string;
88
+ processId?: number;
89
+ error?: string;
90
+ }
91
+
92
+ type ExitCallback = (status: AgentStatus) => void;
93
+
94
+ interface AgentRecord {
95
+ status: AgentStatus;
96
+ proc: Subprocess;
97
+ onExit: ExitCallback[];
98
+ // Resolvers for the spawn promise. Consumed when the sessionId
99
+ // arrives (or the process exits before that).
100
+ spawnResolvers: Array<(result: SpawnAgentResult) => void>;
101
+ }
102
+
103
+ // Module-level registry of running agents.
104
+ const agents = new Map<number, AgentRecord>();
105
+
106
+ // --- Public API -----------------------------------------------------------
107
+
108
+ /**
109
+ * Pure: build the argv array for `opencode run` from SpawnAgentOptions.
110
+ *
111
+ * Extracted from spawnAgent so it can be unit-tested without spawning
112
+ * a real process. Throws when opts.agent is empty — `opencode run`
113
+ * requires a known agent name; passing an empty string would silently
114
+ * fall back to opencode's default agent and break session attribution
115
+ * (the title prefix `bgr:<agent>:...` would also degrade to `bgr::...`).
116
+ *
117
+ * Arg layout (matches `opencode run --help`):
118
+ * opencode run
119
+ * --dir <worktree>
120
+ * --print-logs
121
+ * --log-level INFO
122
+ * --title <title>
123
+ * --agent <agent> ← REQUIRED; was missing pre-v0.8.1
124
+ * [--model <providerID>/<modelID>] ← optional override
125
+ * -- <prompt>
126
+ */
127
+ export function buildOpencodeRunArgs(opts: SpawnAgentOptions): string[] {
128
+ if (!opts.agent) {
129
+ throw new Error("bizar_spawn_background: agent is required");
130
+ }
131
+ const args: string[] = [
132
+ "opencode",
133
+ "run",
134
+ "--dir", opts.worktree,
135
+ "--print-logs",
136
+ "--log-level", "INFO",
137
+ "--title", opts.title || `bgr:${opts.agent}:${Date.now()}`,
138
+ "--agent", opts.agent,
139
+ ];
140
+ if (opts.model) {
141
+ args.push("--model", `${opts.model.providerID}/${opts.model.modelID}`);
142
+ }
143
+ // `--` separates flags from positional so a prompt starting with
144
+ // `-` is treated as a message.
145
+ args.push("--", opts.prompt);
146
+ return args;
147
+ }
148
+
149
+ /**
150
+ * Spawn a single `opencode run` process. The promise resolves once
151
+ * the opencode child has reported its session id in the structured
152
+ * log stream (or once the process exits before that — typically
153
+ * because of an early error like a missing API key).
154
+ *
155
+ * @param opts
156
+ * @returns Promise resolving with `{ ok, sessionId?, processId? }` or `{ ok: false, error }`
157
+ */
158
+ export async function spawnAgent(opts: SpawnAgentOptions): Promise<SpawnAgentResult> {
159
+ // 1. Pre-flight: log dir must exist so the stream can open.
160
+ const logDir = dirname(opts.logPath);
161
+ if (!existsSync(logDir)) {
162
+ try {
163
+ mkdirSync(logDir, { recursive: true });
164
+ } catch (err: unknown) {
165
+ return {
166
+ ok: false,
167
+ error: `cannot create log dir ${logDir}: ${err instanceof Error ? err.message : String(err)}`,
168
+ };
169
+ }
170
+ }
171
+
172
+ // 2. Build argv. Pulled into a pure function so tests can assert the
173
+ // flag layout (notably the `--agent` flag and the migrated model
174
+ // ID format) without spawning a real `opencode run` process.
175
+ const args = buildOpencodeRunArgs(opts);
176
+
177
+ // 3. Spawn the process.
178
+ let proc: Subprocess;
179
+ try {
180
+ proc = Bun.spawn(args, {
181
+ stdout: "pipe",
182
+ stderr: "pipe",
183
+ env: { ...process.env, ...(opts.env || {}) },
184
+ });
185
+ } catch (err: unknown) {
186
+ return {
187
+ ok: false,
188
+ error: `failed to spawn opencode run: ${err instanceof Error ? err.message : String(err)}`,
189
+ };
190
+ }
191
+
192
+ // 4. Track the agent.
193
+ const rec: AgentRecord = {
194
+ status: {
195
+ state: "starting",
196
+ processId: proc.pid,
197
+ startedAt: Date.now(),
198
+ },
199
+ proc,
200
+ onExit: [],
201
+ spawnResolvers: [],
202
+ };
203
+ agents.set(proc.pid, rec);
204
+
205
+ // 5. Pipe stdout+stderr to the log file. Both go to the same file;
206
+ // lines are prefixed [stdout] / [stderr] for readability.
207
+ const logStream = createWriteStream(opts.logPath, { flags: "a" });
208
+ const decoder = new TextDecoder("utf-8");
209
+ const sessionIdRegex = /message=created id=(ses_[A-Za-z0-9_]+)/;
210
+ let sessionId: string | undefined;
211
+
212
+ const resolveSpawnIfReady = (forceError?: string) => {
213
+ const resolvers = rec.spawnResolvers.splice(0);
214
+ if (resolvers.length === 0) return;
215
+ if (forceError) {
216
+ for (const r of resolvers) {
217
+ r({ ok: false, processId: proc.pid, error: forceError });
218
+ }
219
+ return;
220
+ }
221
+ if (sessionId) {
222
+ rec.status.state = "running";
223
+ for (const r of resolvers) {
224
+ r({ ok: true, sessionId, processId: proc.pid });
225
+ }
226
+ }
227
+ };
228
+
229
+ const readStream = async (
230
+ reader: ReadableStreamDefaultReader<Uint8Array>,
231
+ label: "stderr" | "stdout",
232
+ ): Promise<void> => {
233
+ let buf = "";
234
+ try {
235
+ while (true) {
236
+ const { done, value } = await reader.read();
237
+ if (done) break;
238
+ const text = decoder.decode(value, { stream: true });
239
+ logStream.write(`[${label}] ${text}`);
240
+ if (label === "stderr" && !sessionId) {
241
+ buf += text;
242
+ const m = buf.match(sessionIdRegex);
243
+ if (m && m[1]) {
244
+ sessionId = m[1];
245
+ rec.status.sessionId = sessionId;
246
+ resolveSpawnIfReady();
247
+ }
248
+ }
249
+ }
250
+ } finally {
251
+ try { reader.releaseLock(); } catch { /* ignore */ }
252
+ }
253
+ };
254
+
255
+ // 6. Stream readers + exit handler — install BEFORE returning the
256
+ // promise so a fast-exiting process still produces a clean
257
+ // resolution.
258
+ const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader() as unknown as ReadableStreamDefaultReader<Uint8Array<ArrayBufferLike>>;
259
+ const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader() as unknown as ReadableStreamDefaultReader<Uint8Array<ArrayBufferLike>>;
260
+ void readStream(stderrReader, "stderr");
261
+ void readStream(stdoutReader, "stdout");
262
+
263
+ proc.exited
264
+ .then((exitCode: number | null) => {
265
+ const code = exitCode ?? -1;
266
+ rec.status.exitCode = exitCode ?? undefined;
267
+ rec.status.endedAt = Date.now();
268
+ if (rec.status.state !== "killed") {
269
+ rec.status.state = code === 0 ? "done" : "failed";
270
+ if (code !== 0 && !rec.status.error) {
271
+ rec.status.error = `opencode run exited with code ${code}`;
272
+ }
273
+ }
274
+ // Resolve any unresolved spawn promises.
275
+ if (!sessionId) {
276
+ resolveSpawnIfReady(rec.status.error || "opencode run exited before reporting session id");
277
+ }
278
+ // Fire subscribers.
279
+ const cbs = rec.onExit.splice(0);
280
+ for (const cb of cbs) {
281
+ try { cb({ ...rec.status }); } catch { /* ignore */ }
282
+ }
283
+ // Best-effort flush + close of the log stream.
284
+ try { logStream.end(); } catch { /* ignore */ }
285
+ })
286
+ .catch(() => {
287
+ // proc.exited only rejects if the proc was already awaited or
288
+ // never spawned; safe to ignore.
289
+ });
290
+
291
+ // 7. Race the spawn promise against a timeout. The default is 10s
292
+ // which is plenty for opencode to print the "created" line on
293
+ // a warm host (<500ms in practice).
294
+ const sessionIdTimeoutMs = opts.sessionIdTimeoutMs ?? 10_000;
295
+ return new Promise<SpawnAgentResult>((resolve) => {
296
+ rec.spawnResolvers.push(resolve);
297
+ setTimeout(() => {
298
+ if (!sessionId) {
299
+ resolveSpawnIfReady(
300
+ `opencode run did not report a session id within ${sessionIdTimeoutMs}ms`,
301
+ );
302
+ }
303
+ }, sessionIdTimeoutMs);
304
+ });
305
+ }
306
+
307
+ /**
308
+ * Snapshot the current status of an agent. Returns null if the
309
+ * processId is not tracked (e.g. the plugin restarted and lost its
310
+ * in-memory map).
311
+ */
312
+ export function getStatus(processId: number): AgentStatus | null {
313
+ const rec = agents.get(processId);
314
+ if (!rec) return null;
315
+ return { ...rec.status };
316
+ }
317
+
318
+ /**
319
+ * Subscribe to the exit event. The callback fires exactly once when
320
+ * the process exits (whether naturally or via signal). Multiple
321
+ * subscribers are allowed; all of them fire in registration order.
322
+ *
323
+ * @returns unsubscribe function
324
+ */
325
+ export function onExit(processId: number, cb: ExitCallback): () => void {
326
+ const rec = agents.get(processId);
327
+ if (!rec) return () => undefined;
328
+ rec.onExit.push(cb);
329
+ // If the agent has already exited, fire the callback immediately
330
+ // (next microtask) so the caller doesn't have to special-case
331
+ // pre-exited agents.
332
+ if (rec.status.endedAt !== undefined) {
333
+ queueMicrotask(() => {
334
+ try { cb({ ...rec.status }); } catch { /* ignore */ }
335
+ });
336
+ }
337
+ return () => {
338
+ const i = rec.onExit.indexOf(cb);
339
+ if (i >= 0) rec.onExit.splice(i, 1);
340
+ };
341
+ }
342
+
343
+ /**
344
+ * Kill the agent. Sends the requested signal (default SIGTERM),
345
+ * waits up to 5s, then SIGKILL. Idempotent.
346
+ */
347
+ export function killAgent(
348
+ processId: number,
349
+ signal: "SIGTERM" | "SIGKILL" = "SIGTERM",
350
+ ): { ok: boolean; error?: string } {
351
+ const rec = agents.get(processId);
352
+ if (!rec) {
353
+ return { ok: true, error: "no such process tracked" };
354
+ }
355
+ if (rec.status.endedAt !== undefined) {
356
+ return { ok: true, error: "process already exited" };
357
+ }
358
+ rec.status.state = "killed";
359
+ try {
360
+ rec.proc.kill(signal);
361
+ } catch (err: unknown) {
362
+ return {
363
+ ok: false,
364
+ error: `kill(${signal}) failed: ${err instanceof Error ? err.message : String(err)}`,
365
+ };
366
+ }
367
+ // Schedule a forced kill after 5s in case SIGTERM is ignored.
368
+ setTimeout(() => {
369
+ if (rec.status.endedAt === undefined) {
370
+ try { rec.proc.kill("SIGKILL"); } catch { /* ignore */ }
371
+ }
372
+ }, 5_000);
373
+ return { ok: true };
374
+ }
375
+
376
+ /**
377
+ * List every tracked agent. Used by the plugin's startup to reconcile
378
+ * state and by tests.
379
+ */
380
+ export function list(): AgentStatus[] {
381
+ return Array.from(agents.values()).map((r) => ({ ...r.status }));
382
+ }
383
+
384
+ /**
385
+ * For tests: clear the in-memory registry. Does NOT kill running
386
+ * processes — callers must `killAgent()` first if they want that.
387
+ */
388
+ export function _resetForTests(): void {
389
+ agents.clear();
390
+ }