@polderlabs/bizar-plugin 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.ts +174 -48
- package/package.json +2 -1
- package/src/background-state.ts +38 -2
- package/src/background.ts +13 -0
- package/src/commands.ts +28 -11
- package/src/dashboard-client.ts +235 -0
- package/src/event-stream.ts +32 -0
- package/src/opencode-runner.ts +362 -0
- package/src/tools/bg-spawn.ts +161 -124
- package/tests/config.test.ts +2 -2
- package/tests/dashboard-client.test.ts +159 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard-client.ts
|
|
3
|
+
*
|
|
4
|
+
* v0.7.0-alpha.1 — Plugin-side bridge to the Bizar dashboard via the
|
|
5
|
+
* @polderlabs/bizar-sdk. Replaces (does not remove) the file-based
|
|
6
|
+
* `serve-info.ts` bridge.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { createDashboardPublisher } from "./dashboard-client.js";
|
|
10
|
+
*
|
|
11
|
+
* const publisher = createDashboardPublisher({
|
|
12
|
+
* logger: myLogger,
|
|
13
|
+
* });
|
|
14
|
+
* await publisher.start();
|
|
15
|
+
* publisher.publish({
|
|
16
|
+
* type: "session.created",
|
|
17
|
+
* properties: { sessionId: "ses_1", agent: "mimir" },
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* Behavior:
|
|
21
|
+
* - Reads `BIZAR_DASHBOARD_URL` (default http://127.0.0.1:4098).
|
|
22
|
+
* - Reads `BIZAR_DASHBOARD_PASSWORD` first, then falls back to
|
|
23
|
+
* `~/.cache/bizarharness/dash-auth.json` (the file written by the
|
|
24
|
+
* dashboard on first start).
|
|
25
|
+
* - `publish()` is fire-and-forget: returns a Promise that resolves
|
|
26
|
+
* after one HTTP round-trip or rejects on failure. NEVER throws
|
|
27
|
+
* into the caller — failures are logged and swallowed (the plugin
|
|
28
|
+
* must keep running even when the dashboard is down).
|
|
29
|
+
* - `publish()` queues events if the dashboard is unreachable and
|
|
30
|
+
* drains the queue on reconnect (best-effort).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
createBizarClient,
|
|
35
|
+
isBizarError,
|
|
36
|
+
type BizarClient,
|
|
37
|
+
type DashboardEvent,
|
|
38
|
+
} from "@polderlabs/bizar-sdk";
|
|
39
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import { homedir } from "node:os";
|
|
42
|
+
|
|
43
|
+
const DEFAULT_DASHBOARD_URL = "http://127.0.0.1:4098";
|
|
44
|
+
const DEFAULT_AUTH_FILE_PATHS = [
|
|
45
|
+
join(homedir(), ".cache", "bizarharness", "dash-auth.json"),
|
|
46
|
+
join(homedir(), ".cache", "bizar", "dash-auth.json"),
|
|
47
|
+
];
|
|
48
|
+
/**
|
|
49
|
+
* The auth file paths to search. Override at test-time via
|
|
50
|
+
* `process.env.BIZAR_DASHBOARD_AUTH_FILE` (single file) or
|
|
51
|
+
* `process.env.BIZAR_DASHBOARD_AUTH_FILES` (colon-separated list).
|
|
52
|
+
*/
|
|
53
|
+
function getAuthFilePaths(): string[] {
|
|
54
|
+
const single = process.env.BIZAR_DASHBOARD_AUTH_FILE;
|
|
55
|
+
if (single) return [single];
|
|
56
|
+
const multi = process.env.BIZAR_DASHBOARD_AUTH_FILES;
|
|
57
|
+
if (multi) return multi.split(":").filter(Boolean);
|
|
58
|
+
return DEFAULT_AUTH_FILE_PATHS;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Logger {
|
|
62
|
+
debug(message: string): void;
|
|
63
|
+
info(message: string): void;
|
|
64
|
+
warn(message: string): void;
|
|
65
|
+
error(message: string): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface DashboardPublisherOptions {
|
|
69
|
+
logger: Logger;
|
|
70
|
+
baseUrl?: string;
|
|
71
|
+
password?: string;
|
|
72
|
+
/** Disable the publisher entirely (e.g. BIZAR_DASHBOARD_DISABLE=1). */
|
|
73
|
+
disabled?: boolean;
|
|
74
|
+
/** Max queued events while the dashboard is unreachable. */
|
|
75
|
+
queueLimit?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read the dashboard auth record from one of the candidate paths.
|
|
80
|
+
* Returns null if no usable file exists. Never throws.
|
|
81
|
+
*/
|
|
82
|
+
function readDashboardAuth(): { password: string; baseUrl?: string; port?: number } | null {
|
|
83
|
+
for (const candidate of getAuthFilePaths()) {
|
|
84
|
+
if (!existsSync(candidate)) continue;
|
|
85
|
+
try {
|
|
86
|
+
const raw = readFileSync(candidate, "utf8");
|
|
87
|
+
const parsed = JSON.parse(raw) as {
|
|
88
|
+
password?: unknown;
|
|
89
|
+
baseUrl?: unknown;
|
|
90
|
+
port?: unknown;
|
|
91
|
+
};
|
|
92
|
+
if (typeof parsed.password !== "string" || parsed.password.length < 16) continue;
|
|
93
|
+
return {
|
|
94
|
+
password: parsed.password,
|
|
95
|
+
baseUrl: typeof parsed.baseUrl === "string" ? parsed.baseUrl : undefined,
|
|
96
|
+
port: typeof parsed.port === "number" ? parsed.port : undefined,
|
|
97
|
+
};
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore malformed file
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface DashboardPublisher {
|
|
106
|
+
start(): Promise<void>;
|
|
107
|
+
publish(event: DashboardEvent): Promise<void>;
|
|
108
|
+
stop(): void;
|
|
109
|
+
/** Whether the publisher is currently configured and ready. */
|
|
110
|
+
isReady(): boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createDashboardPublisher(
|
|
114
|
+
options: DashboardPublisherOptions,
|
|
115
|
+
): DashboardPublisher {
|
|
116
|
+
const { logger, disabled = false, queueLimit = 100 } = options;
|
|
117
|
+
|
|
118
|
+
let client: BizarClient | null = null;
|
|
119
|
+
const queue: DashboardEvent[] = [];
|
|
120
|
+
let flushing = false;
|
|
121
|
+
|
|
122
|
+
function resolveConfig(): { baseUrl: string; password: string } | null {
|
|
123
|
+
let baseUrl = options.baseUrl ?? process.env.BIZAR_DASHBOARD_URL;
|
|
124
|
+
if (!baseUrl) {
|
|
125
|
+
const port = process.env.BIZAR_DASHBOARD_PORT;
|
|
126
|
+
baseUrl = port ? `http://127.0.0.1:${port}` : DEFAULT_DASHBOARD_URL;
|
|
127
|
+
}
|
|
128
|
+
const password =
|
|
129
|
+
options.password ??
|
|
130
|
+
process.env.BIZAR_DASHBOARD_PASSWORD ??
|
|
131
|
+
readDashboardAuth()?.password;
|
|
132
|
+
|
|
133
|
+
if (!password) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return { baseUrl, password };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isReady(): boolean {
|
|
140
|
+
return client !== null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function start(): Promise<void> {
|
|
144
|
+
if (disabled) {
|
|
145
|
+
logger.debug("bizar: dashboard publisher disabled by config");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const cfg = resolveConfig();
|
|
149
|
+
if (!cfg) {
|
|
150
|
+
logger.debug(
|
|
151
|
+
"bizar: dashboard publisher not started (no password in env or auth file)",
|
|
152
|
+
);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
client = createBizarClient({
|
|
156
|
+
baseUrl: cfg.baseUrl,
|
|
157
|
+
password: cfg.password,
|
|
158
|
+
});
|
|
159
|
+
logger.info(
|
|
160
|
+
`bizar: dashboard publisher started (url=${cfg.baseUrl}, password len=${cfg.password.length})`,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Verify the dashboard is reachable. Non-fatal — publish() will retry
|
|
164
|
+
// and surface connection errors as warnings.
|
|
165
|
+
try {
|
|
166
|
+
const health = await client.health.check();
|
|
167
|
+
if (isBizarError(health)) {
|
|
168
|
+
logger.warn(
|
|
169
|
+
`bizar: dashboard unreachable at ${cfg.baseUrl} (${health.name}); publish events will be queued`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
logger.warn(
|
|
174
|
+
`bizar: dashboard health check failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
void flushQueue();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function publish(event: DashboardEvent): Promise<void> {
|
|
182
|
+
if (!client) {
|
|
183
|
+
logger.debug(
|
|
184
|
+
`bizar: dashboard publisher not ready — dropping event ${event.type}`,
|
|
185
|
+
);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (queue.length >= queueLimit) {
|
|
189
|
+
queue.shift();
|
|
190
|
+
logger.warn(
|
|
191
|
+
`bizar: dashboard publisher queue full (${queueLimit}); dropping oldest event`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
queue.push(event);
|
|
195
|
+
if (!flushing) {
|
|
196
|
+
void flushQueue();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function flushQueue(): Promise<void> {
|
|
201
|
+
if (flushing || !client) return;
|
|
202
|
+
flushing = true;
|
|
203
|
+
try {
|
|
204
|
+
while (queue.length > 0 && client) {
|
|
205
|
+
const next = queue.shift()!;
|
|
206
|
+
try {
|
|
207
|
+
const result = await client.events.publish(next);
|
|
208
|
+
if (isBizarError(result)) {
|
|
209
|
+
logger.warn(
|
|
210
|
+
`bizar: dashboard publish failed (${result.name}); requeueing ${next.type}`,
|
|
211
|
+
);
|
|
212
|
+
queue.unshift(next);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
} catch (err) {
|
|
216
|
+
logger.warn(
|
|
217
|
+
`bizar: dashboard publish threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
218
|
+
);
|
|
219
|
+
queue.unshift(next);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
flushing = false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function stop(): void {
|
|
229
|
+
client = null;
|
|
230
|
+
queue.length = 0;
|
|
231
|
+
logger.debug("bizar: dashboard publisher stopped");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { start, publish, stop, isReady };
|
|
235
|
+
}
|
package/src/event-stream.ts
CHANGED
|
@@ -120,6 +120,8 @@ export class EventStream {
|
|
|
120
120
|
private _logger: Logger;
|
|
121
121
|
private _http: HttpClient;
|
|
122
122
|
private _handlers = new Map<string, Set<SessionEventHandler>>();
|
|
123
|
+
/** Global handler invoked for every event regardless of session (v0.7.0). */
|
|
124
|
+
private _globalHandler: SessionEventHandler | null = null;
|
|
123
125
|
private _connected = false;
|
|
124
126
|
private _aborted = false;
|
|
125
127
|
private _abortController: AbortController | null = null;
|
|
@@ -128,6 +130,21 @@ export class EventStream {
|
|
|
128
130
|
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
129
131
|
private _connectPromise: Promise<void> | null = null;
|
|
130
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Register a global handler invoked for every event (regardless of
|
|
135
|
+
* session). Used to forward events to the dashboard publisher. Returns
|
|
136
|
+
* an unsubscribe function. (v0.7.0-alpha.1 — wired into the v2
|
|
137
|
+
* SDK dashboard-client.ts bridge.)
|
|
138
|
+
*/
|
|
139
|
+
onEvent(handler: SessionEventHandler): () => void {
|
|
140
|
+
this._globalHandler = handler;
|
|
141
|
+
return () => {
|
|
142
|
+
if (this._globalHandler === handler) {
|
|
143
|
+
this._globalHandler = null;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
constructor(opts: {
|
|
132
149
|
baseUrl: string;
|
|
133
150
|
directory: string;
|
|
@@ -439,6 +456,21 @@ export class EventStream {
|
|
|
439
456
|
}
|
|
440
457
|
|
|
441
458
|
private dispatchToHandlers(sessionID: string, event: StreamEvent): void {
|
|
459
|
+
// v0.7.0-alpha.1 — Forward every event to the global handler
|
|
460
|
+
// (typically the dashboard publisher) BEFORE the per-session dispatch.
|
|
461
|
+
// Failures here are isolated so one bad handler doesn't break the
|
|
462
|
+
// dispatch chain for other subscribers.
|
|
463
|
+
if (this._globalHandler) {
|
|
464
|
+
try {
|
|
465
|
+
this._globalHandler(event);
|
|
466
|
+
} catch (err: unknown) {
|
|
467
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
468
|
+
this._logger.warn(
|
|
469
|
+
`bizar: SSE global handler threw for session ${sessionID} (type=${event.type}): ${msg}`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
442
474
|
const set = this._handlers.get(sessionID);
|
|
443
475
|
if (!set || set.size === 0) {
|
|
444
476
|
this._logger.debug(
|
|
@@ -0,0 +1,362 @@
|
|
|
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
|
+
* Spawn a single `opencode run` process. The promise resolves once
|
|
110
|
+
* the opencode child has reported its session id in the structured
|
|
111
|
+
* log stream (or once the process exits before that — typically
|
|
112
|
+
* because of an early error like a missing API key).
|
|
113
|
+
*
|
|
114
|
+
* @param opts
|
|
115
|
+
* @returns Promise resolving with `{ ok, sessionId?, processId? }` or `{ ok: false, error }`
|
|
116
|
+
*/
|
|
117
|
+
export async function spawnAgent(opts: SpawnAgentOptions): Promise<SpawnAgentResult> {
|
|
118
|
+
// 1. Pre-flight: log dir must exist so the stream can open.
|
|
119
|
+
const logDir = dirname(opts.logPath);
|
|
120
|
+
if (!existsSync(logDir)) {
|
|
121
|
+
try {
|
|
122
|
+
mkdirSync(logDir, { recursive: true });
|
|
123
|
+
} catch (err: unknown) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: `cannot create log dir ${logDir}: ${err instanceof Error ? err.message : String(err)}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 2. Build argv. Note: opencode run takes the prompt as a positional
|
|
132
|
+
// arg. `--dir` sets the worktree. `--print-logs` ensures the
|
|
133
|
+
// structured log stream goes to stderr.
|
|
134
|
+
const args: string[] = [
|
|
135
|
+
"opencode",
|
|
136
|
+
"run",
|
|
137
|
+
"--dir", opts.worktree,
|
|
138
|
+
"--print-logs",
|
|
139
|
+
"--log-level", "INFO",
|
|
140
|
+
"--title", opts.title || `bgr:${opts.agent}:${Date.now()}`,
|
|
141
|
+
];
|
|
142
|
+
if (opts.model) {
|
|
143
|
+
args.push("--model", `${opts.model.providerID}/${opts.model.modelID}`);
|
|
144
|
+
}
|
|
145
|
+
// `--` separates flags from positional so a prompt starting with
|
|
146
|
+
// `-` is treated as a message.
|
|
147
|
+
args.push("--", opts.prompt);
|
|
148
|
+
|
|
149
|
+
// 3. Spawn the process.
|
|
150
|
+
let proc: Subprocess;
|
|
151
|
+
try {
|
|
152
|
+
proc = Bun.spawn(args, {
|
|
153
|
+
stdout: "pipe",
|
|
154
|
+
stderr: "pipe",
|
|
155
|
+
env: { ...process.env, ...(opts.env || {}) },
|
|
156
|
+
});
|
|
157
|
+
} catch (err: unknown) {
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
error: `failed to spawn opencode run: ${err instanceof Error ? err.message : String(err)}`,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 4. Track the agent.
|
|
165
|
+
const rec: AgentRecord = {
|
|
166
|
+
status: {
|
|
167
|
+
state: "starting",
|
|
168
|
+
processId: proc.pid,
|
|
169
|
+
startedAt: Date.now(),
|
|
170
|
+
},
|
|
171
|
+
proc,
|
|
172
|
+
onExit: [],
|
|
173
|
+
spawnResolvers: [],
|
|
174
|
+
};
|
|
175
|
+
agents.set(proc.pid, rec);
|
|
176
|
+
|
|
177
|
+
// 5. Pipe stdout+stderr to the log file. Both go to the same file;
|
|
178
|
+
// lines are prefixed [stdout] / [stderr] for readability.
|
|
179
|
+
const logStream = createWriteStream(opts.logPath, { flags: "a" });
|
|
180
|
+
const decoder = new TextDecoder("utf-8");
|
|
181
|
+
const sessionIdRegex = /message=created id=(ses_[A-Za-z0-9_]+)/;
|
|
182
|
+
let sessionId: string | undefined;
|
|
183
|
+
|
|
184
|
+
const resolveSpawnIfReady = (forceError?: string) => {
|
|
185
|
+
const resolvers = rec.spawnResolvers.splice(0);
|
|
186
|
+
if (resolvers.length === 0) return;
|
|
187
|
+
if (forceError) {
|
|
188
|
+
for (const r of resolvers) {
|
|
189
|
+
r({ ok: false, processId: proc.pid, error: forceError });
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (sessionId) {
|
|
194
|
+
rec.status.state = "running";
|
|
195
|
+
for (const r of resolvers) {
|
|
196
|
+
r({ ok: true, sessionId, processId: proc.pid });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const readStream = async (
|
|
202
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
203
|
+
label: "stderr" | "stdout",
|
|
204
|
+
): Promise<void> => {
|
|
205
|
+
let buf = "";
|
|
206
|
+
try {
|
|
207
|
+
while (true) {
|
|
208
|
+
const { done, value } = await reader.read();
|
|
209
|
+
if (done) break;
|
|
210
|
+
const text = decoder.decode(value, { stream: true });
|
|
211
|
+
logStream.write(`[${label}] ${text}`);
|
|
212
|
+
if (label === "stderr" && !sessionId) {
|
|
213
|
+
buf += text;
|
|
214
|
+
const m = buf.match(sessionIdRegex);
|
|
215
|
+
if (m && m[1]) {
|
|
216
|
+
sessionId = m[1];
|
|
217
|
+
rec.status.sessionId = sessionId;
|
|
218
|
+
resolveSpawnIfReady();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} finally {
|
|
223
|
+
try { reader.releaseLock(); } catch { /* ignore */ }
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// 6. Stream readers + exit handler — install BEFORE returning the
|
|
228
|
+
// promise so a fast-exiting process still produces a clean
|
|
229
|
+
// resolution.
|
|
230
|
+
const stderrReader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
|
|
231
|
+
const stdoutReader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
|
|
232
|
+
void readStream(stderrReader, "stderr");
|
|
233
|
+
void readStream(stdoutReader, "stdout");
|
|
234
|
+
|
|
235
|
+
proc.exited
|
|
236
|
+
.then((exitCode: number | null) => {
|
|
237
|
+
const code = exitCode ?? -1;
|
|
238
|
+
rec.status.exitCode = exitCode ?? undefined;
|
|
239
|
+
rec.status.endedAt = Date.now();
|
|
240
|
+
if (rec.status.state !== "killed") {
|
|
241
|
+
rec.status.state = code === 0 ? "done" : "failed";
|
|
242
|
+
if (code !== 0 && !rec.status.error) {
|
|
243
|
+
rec.status.error = `opencode run exited with code ${code}`;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Resolve any unresolved spawn promises.
|
|
247
|
+
if (!sessionId) {
|
|
248
|
+
resolveSpawnIfReady(rec.status.error || "opencode run exited before reporting session id");
|
|
249
|
+
}
|
|
250
|
+
// Fire subscribers.
|
|
251
|
+
const cbs = rec.onExit.splice(0);
|
|
252
|
+
for (const cb of cbs) {
|
|
253
|
+
try { cb({ ...rec.status }); } catch { /* ignore */ }
|
|
254
|
+
}
|
|
255
|
+
// Best-effort flush + close of the log stream.
|
|
256
|
+
try { logStream.end(); } catch { /* ignore */ }
|
|
257
|
+
})
|
|
258
|
+
.catch(() => {
|
|
259
|
+
// proc.exited only rejects if the proc was already awaited or
|
|
260
|
+
// never spawned; safe to ignore.
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// 7. Race the spawn promise against a timeout. The default is 10s
|
|
264
|
+
// which is plenty for opencode to print the "created" line on
|
|
265
|
+
// a warm host (<500ms in practice).
|
|
266
|
+
const sessionIdTimeoutMs = opts.sessionIdTimeoutMs ?? 10_000;
|
|
267
|
+
return new Promise<SpawnAgentResult>((resolve) => {
|
|
268
|
+
rec.spawnResolvers.push(resolve);
|
|
269
|
+
setTimeout(() => {
|
|
270
|
+
if (!sessionId) {
|
|
271
|
+
resolveSpawnIfReady(
|
|
272
|
+
`opencode run did not report a session id within ${sessionIdTimeoutMs}ms`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}, sessionIdTimeoutMs);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Snapshot the current status of an agent. Returns null if the
|
|
281
|
+
* processId is not tracked (e.g. the plugin restarted and lost its
|
|
282
|
+
* in-memory map).
|
|
283
|
+
*/
|
|
284
|
+
export function getStatus(processId: number): AgentStatus | null {
|
|
285
|
+
const rec = agents.get(processId);
|
|
286
|
+
if (!rec) return null;
|
|
287
|
+
return { ...rec.status };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Subscribe to the exit event. The callback fires exactly once when
|
|
292
|
+
* the process exits (whether naturally or via signal). Multiple
|
|
293
|
+
* subscribers are allowed; all of them fire in registration order.
|
|
294
|
+
*
|
|
295
|
+
* @returns unsubscribe function
|
|
296
|
+
*/
|
|
297
|
+
export function onExit(processId: number, cb: ExitCallback): () => void {
|
|
298
|
+
const rec = agents.get(processId);
|
|
299
|
+
if (!rec) return () => undefined;
|
|
300
|
+
rec.onExit.push(cb);
|
|
301
|
+
// If the agent has already exited, fire the callback immediately
|
|
302
|
+
// (next microtask) so the caller doesn't have to special-case
|
|
303
|
+
// pre-exited agents.
|
|
304
|
+
if (rec.status.endedAt !== undefined) {
|
|
305
|
+
queueMicrotask(() => {
|
|
306
|
+
try { cb({ ...rec.status }); } catch { /* ignore */ }
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return () => {
|
|
310
|
+
const i = rec.onExit.indexOf(cb);
|
|
311
|
+
if (i >= 0) rec.onExit.splice(i, 1);
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Kill the agent. Sends the requested signal (default SIGTERM),
|
|
317
|
+
* waits up to 5s, then SIGKILL. Idempotent.
|
|
318
|
+
*/
|
|
319
|
+
export function killAgent(
|
|
320
|
+
processId: number,
|
|
321
|
+
signal: "SIGTERM" | "SIGKILL" = "SIGTERM",
|
|
322
|
+
): { ok: boolean; error?: string } {
|
|
323
|
+
const rec = agents.get(processId);
|
|
324
|
+
if (!rec) {
|
|
325
|
+
return { ok: true, error: "no such process tracked" };
|
|
326
|
+
}
|
|
327
|
+
if (rec.status.endedAt !== undefined) {
|
|
328
|
+
return { ok: true, error: "process already exited" };
|
|
329
|
+
}
|
|
330
|
+
rec.status.state = "killed";
|
|
331
|
+
try {
|
|
332
|
+
rec.proc.kill(signal);
|
|
333
|
+
} catch (err: unknown) {
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
error: `kill(${signal}) failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
// Schedule a forced kill after 5s in case SIGTERM is ignored.
|
|
340
|
+
setTimeout(() => {
|
|
341
|
+
if (rec.status.endedAt === undefined) {
|
|
342
|
+
try { rec.proc.kill("SIGKILL"); } catch { /* ignore */ }
|
|
343
|
+
}
|
|
344
|
+
}, 5_000);
|
|
345
|
+
return { ok: true };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* List every tracked agent. Used by the plugin's startup to reconcile
|
|
350
|
+
* state and by tests.
|
|
351
|
+
*/
|
|
352
|
+
export function list(): AgentStatus[] {
|
|
353
|
+
return Array.from(agents.values()).map((r) => ({ ...r.status }));
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* For tests: clear the in-memory registry. Does NOT kill running
|
|
358
|
+
* processes — callers must `killAgent()` first if they want that.
|
|
359
|
+
*/
|
|
360
|
+
export function _resetForTests(): void {
|
|
361
|
+
agents.clear();
|
|
362
|
+
}
|