@polderlabs/bizar 5.5.0 → 5.5.2
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/bizar-dash/dist/assets/{EnvVarsSection-L413CQEI.js → EnvVarsSection-DoIzqrlb.js} +3 -3
- package/bizar-dash/dist/assets/{EnvVarsSection-L413CQEI.js.map → EnvVarsSection-DoIzqrlb.js.map} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-0kGQb1S6.js → MobileChat-CoUCUsbr.js} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-0kGQb1S6.js.map → MobileChat-CoUCUsbr.js.map} +1 -1
- package/bizar-dash/dist/assets/MobileSettings-BeO04g52.js +1 -0
- package/bizar-dash/dist/assets/MobileSettings-BeO04g52.js.map +1 -0
- package/bizar-dash/dist/assets/{Toast-C6f1zFJ2.js → Toast-D9nv5N6m.js} +1 -1
- package/bizar-dash/dist/assets/{Toast-C6f1zFJ2.js.map → Toast-D9nv5N6m.js.map} +1 -1
- package/bizar-dash/dist/assets/{icons-BsWXWkEF.js → icons-Btipv2pp.js} +100 -90
- package/bizar-dash/dist/assets/icons-Btipv2pp.js.map +1 -0
- package/bizar-dash/dist/assets/main-DHXPuddY.js +18 -0
- package/bizar-dash/dist/assets/main-DHXPuddY.js.map +1 -0
- package/bizar-dash/dist/assets/{main-BklYy02e.css → main-UG4jKK-F.css} +1 -1
- package/bizar-dash/dist/assets/mobile-H5KH9cTL.js +1 -0
- package/bizar-dash/dist/assets/{mobile-CsaN4CxH.js.map → mobile-H5KH9cTL.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile-layout-iFBdjEE4.js → mobile-layout-BqHxVb6_.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-layout-iFBdjEE4.js.map → mobile-layout-BqHxVb6_.js.map} +1 -1
- package/bizar-dash/dist/assets/{useSlashCommands-DGJcC9iQ.js → useSlashCommands-C24-O1e7.js} +2 -2
- package/bizar-dash/dist/assets/{useSlashCommands-DGJcC9iQ.js.map → useSlashCommands-C24-O1e7.js.map} +1 -1
- package/bizar-dash/dist/index.html +7 -7
- package/bizar-dash/dist/mobile.html +3 -3
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/bg-spawner.mjs +522 -305
- package/bizar-dash/src/server/headroom.mjs +3 -1
- package/bizar-dash/src/server/memory-lightrag.mjs +26 -2
- package/bizar-dash/src/server/memory-store.mjs +74 -28
- package/bizar-dash/src/server/opencode-sdk.mjs +63 -3
- package/bizar-dash/src/server/plugins/registry.mjs +1 -1
- package/bizar-dash/src/server/routes/activity.mjs +85 -0
- package/bizar-dash/src/server/routes/background.mjs +31 -13
- package/bizar-dash/src/server/routes/memory.mjs +91 -38
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +1 -1
- package/bizar-dash/src/server/server.mjs +4 -1
- package/bizar-dash/src/web/components/Sidebar.tsx +0 -1
- package/bizar-dash/src/web/styles/main.css +52 -13
- package/bizar-dash/src/web/styles/memory.css +17 -0
- package/bizar-dash/src/web/styles/settings.css +6 -56
- package/bizar-dash/src/web/views/Marketplace.tsx +192 -31
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +157 -7
- package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +11 -0
- package/bizar-dash/src/web/views/settings/MemorySection.tsx +65 -11
- package/bizar-dash/tests/background-sdk-session.test.mjs +94 -0
- package/bizar-dash/tests/background-session-events.test.mjs +74 -0
- package/bizar-dash/tests/background-steer-sdk.test.mjs +71 -0
- package/bizar-dash/tests/background-steer.test.mjs +25 -7
- package/bizar-dash/tests/memory-namespace.test.mjs +2 -2
- package/bizar-dash/tests/memory-status-root.test.mjs +144 -0
- package/bizar-dash/tests/memory-store.test.mjs +5 -4
- package/bizar-dash/tests/memory-vault-config-endpoint.test.mjs +173 -0
- package/bizar-dash/tests/settings-mode-wiring.test.tsx +1 -1
- package/bizar-dash/tests/settings-nav.test.tsx +0 -1
- package/bizar-dash/tests/views/Marketplace.test.tsx +200 -0
- package/bizar-dash/tests/views/Memory.test.tsx +274 -0
- package/bizar-dash/tests/views/sidebar.test.tsx +116 -0
- package/bizar-dash/tests/views/topbar.test.tsx +87 -0
- package/cli/commands/marketplace.mjs +1 -1
- package/cli/memory.mjs +1 -1
- package/package.json +1 -1
- package/plugins/bizar/src/background-state.ts +11 -0
- package/plugins/bizar/src/background.ts +212 -34
- package/plugins/bizar/src/opencode-runner.ts +80 -353
- package/plugins/bizar/src/tools/bg-send-message.ts +160 -61
- package/plugins/bizar/src/tools/bg-spawn.ts +235 -156
- package/plugins/bizar/tests/tools/bg-send-message.test.ts +84 -11
- package/plugins/bizar/tests/tools/bg-spawn-http.test.ts +223 -0
- package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js +0 -1
- package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js.map +0 -1
- package/bizar-dash/dist/assets/icons-BsWXWkEF.js.map +0 -1
- package/bizar-dash/dist/assets/main-CBTgYSeG.js +0 -18
- package/bizar-dash/dist/assets/main-CBTgYSeG.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-CsaN4CxH.js +0 -1
|
@@ -1,50 +1,73 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* src/server/bg-spawner.mjs
|
|
3
3
|
*
|
|
4
|
-
* v5.
|
|
4
|
+
* v5.5.1 — Dashboard-side background agent spawner, rewired to use the
|
|
5
|
+
* opencode SDK (sessions + promptAsync) instead of `opencode run` subprocesses.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
* Background agents run as long-lived opencode-serve SDK sessions, which:
|
|
8
|
+
* - Accept mid-flight prompts (the "true mid-flight steer" promised in
|
|
9
|
+
* v0.9.x / v5.5.0).
|
|
10
|
+
* - Don't need an OS subprocess per agent (we used to fork one
|
|
11
|
+
* `opencode run` per agent).
|
|
12
|
+
* - Are portable across POSIX and Windows (no signals).
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* What this module owns:
|
|
15
|
+
* - Instance lifecycle (create, status, list, kill, pause, resume, steer).
|
|
16
|
+
* - State file (`~/.cache/bizar/bg/<instanceId>.json`) — same shape as
|
|
17
|
+
* v5.5.0 so the dashboard's existing list view picks up new instances
|
|
18
|
+
* without any migration. `processId` is left `null`; the schema field
|
|
19
|
+
* stays (backwards compat) but is no longer the source of truth for
|
|
20
|
+
* liveness — we use `liveSession === true` to mark "this instance is
|
|
21
|
+
* backed by a live opencode session".
|
|
22
|
+
* - Event subscription: `sdk.events.subscribe({ sessionID })` yields
|
|
23
|
+
* opencode SSE envelopes which we forward to the WS bus as
|
|
24
|
+
* `bg:output` / `background:change` / `bg:tool-call` events.
|
|
20
25
|
*
|
|
21
|
-
* Public surface:
|
|
22
|
-
* - `spawnBgAgent({...})`
|
|
23
|
-
*
|
|
24
|
-
* - `
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* - `isAlive(instanceId)`
|
|
28
|
-
* - `status()`
|
|
26
|
+
* Public surface (kept identical to v5.5.0):
|
|
27
|
+
* - `spawnBgAgent({...})` — create instance + SDK session.
|
|
28
|
+
* - `killBgAgent(instanceId, …)` — abort the session.
|
|
29
|
+
* - `pauseBgAgent(instanceId)` — output pause (see note below).
|
|
30
|
+
* - `resumeBgAgent(instanceId)` — resume forwarding.
|
|
31
|
+
* - `steerBgAgent(instanceId, …)`— TRUE mid-flight prompt (no kill+respawn).
|
|
32
|
+
* - `isAlive(instanceId)` — liveness check.
|
|
33
|
+
* - `status()` — diagnostics.
|
|
34
|
+
* - `configureSpawner(ctx)` — broadcast wiring.
|
|
29
35
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
36
|
+
* Note on `pauseBgAgent`:
|
|
37
|
+
* In subprocess mode, pause sent SIGSTOP to the underlying OS process.
|
|
38
|
+
* The opencode serve child doesn't expose an HTTP pause — there's no
|
|
39
|
+
* documented "freeze the agent loop" endpoint. We therefore implement
|
|
40
|
+
* pause as "stop forwarding events to the dashboard" (output pause).
|
|
41
|
+
* The session keeps running; events that arrive while paused are
|
|
42
|
+
* buffered; resume drains the buffer. This matches the user's mental
|
|
43
|
+
* model ("pause the live output") and is cross-platform.
|
|
44
|
+
*
|
|
45
|
+
* Backwards compatibility (v5.5.0 → v5.5.1):
|
|
46
|
+
* - State-file shape unchanged: `processId` stays in the schema (now
|
|
47
|
+
* always `null`) so a v5.5.0 instance file can still be read.
|
|
48
|
+
* - REST surface unchanged: `POST /api/background`, `POST .../steer`,
|
|
49
|
+
* `POST .../pause`, `POST .../resume`, `DELETE /api/background/:id`
|
|
50
|
+
* all keep their contract.
|
|
51
|
+
* - `steerBgAgent` semantics change from "kill+respawn with [STEERED
|
|
52
|
+
* <ts>] marker" to "true mid-flight prompt"; the response shape stays
|
|
53
|
+
* `{ ok, newInstanceId? }` but `newInstanceId` is no longer returned
|
|
54
|
+
* (the same instance is reused).
|
|
33
55
|
*/
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
|
|
56
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from "node:fs";
|
|
57
|
+
import { randomBytes } from "node:crypto";
|
|
58
|
+
import { homedir } from "node:os";
|
|
59
|
+
import { resolve as pathResolve } from "node:path";
|
|
60
|
+
|
|
61
|
+
import { getOpencodeSdkOrThrow } from "./opencode-sdk.mjs";
|
|
39
62
|
|
|
40
63
|
/** Same shape the plugin uses for instance IDs. */
|
|
41
64
|
function generateInstanceId() {
|
|
42
65
|
const bytes = randomBytes(16);
|
|
43
66
|
// crockford base32: 26 chars total, take 22
|
|
44
|
-
const ALPH =
|
|
67
|
+
const ALPH = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
45
68
|
let bits = 0;
|
|
46
69
|
let value = 0;
|
|
47
|
-
let out =
|
|
70
|
+
let out = "";
|
|
48
71
|
for (let i = 0; i < bytes.length; i++) {
|
|
49
72
|
value = (value << 8) | bytes[i];
|
|
50
73
|
bits += 8;
|
|
@@ -54,19 +77,19 @@ function generateInstanceId() {
|
|
|
54
77
|
}
|
|
55
78
|
}
|
|
56
79
|
if (bits > 0) out += ALPH[(value << (5 - bits)) & 0x1f];
|
|
57
|
-
return
|
|
80
|
+
return "bgr_" + out.slice(0, 22);
|
|
58
81
|
}
|
|
59
82
|
|
|
60
83
|
// --- Configuration -------------------------------------------------------
|
|
61
84
|
|
|
62
85
|
const HOME = homedir();
|
|
63
86
|
const BG_DIR_CANDIDATES = [
|
|
64
|
-
pathResolve(HOME,
|
|
65
|
-
pathResolve(HOME,
|
|
66
|
-
pathResolve(HOME,
|
|
87
|
+
pathResolve(HOME, ".cache", "bizar", "bg"),
|
|
88
|
+
pathResolve(HOME, ".config", "opencode", "bg"),
|
|
89
|
+
pathResolve(HOME, ".bizar", "bg"),
|
|
67
90
|
];
|
|
68
91
|
const LOG_DIR_CANDIDATES = [
|
|
69
|
-
process.env.BIZAR_LOG_DIR || pathResolve(HOME,
|
|
92
|
+
process.env.BIZAR_LOG_DIR || pathResolve(HOME, ".cache", "bizar", "logs"),
|
|
70
93
|
];
|
|
71
94
|
|
|
72
95
|
function pickBgDir() {
|
|
@@ -89,26 +112,28 @@ function pickLogDir() {
|
|
|
89
112
|
return def;
|
|
90
113
|
}
|
|
91
114
|
|
|
92
|
-
// --- In-memory
|
|
115
|
+
// --- In-memory session registry ------------------------------------------
|
|
93
116
|
|
|
94
117
|
/**
|
|
95
118
|
* @typedef {object} SpawnerRecord
|
|
96
|
-
* @property {number} processId
|
|
97
119
|
* @property {string} instanceId
|
|
98
120
|
* @property {string} sessionId
|
|
99
121
|
* @property {string} logPath
|
|
100
|
-
* @property {import('node:child_process').ChildProcess} proc
|
|
101
122
|
* @property {string} state — "starting" | "running" | "paused" | "done" | "failed" | "killed"
|
|
102
123
|
* @property {number} startedAt
|
|
103
124
|
* @property {number} [endedAt]
|
|
104
|
-
* @property {number} [exitCode]
|
|
105
125
|
* @property {string} [worktree]
|
|
126
|
+
* @property {boolean} paused — v5.5.1 output-pause flag
|
|
127
|
+
* @property {Array<object>} pauseBuffer — events buffered while paused
|
|
128
|
+
* @property {{ close: () => void, stream?: AsyncIterable<unknown> } | null} sub — SDK event sub
|
|
129
|
+
* @property {number} steerCount — number of mid-flight prompts sent
|
|
130
|
+
* @property {number} lastSteerAt — epoch ms of the last steer
|
|
106
131
|
*/
|
|
107
132
|
|
|
108
133
|
/** @type {Map<string, SpawnerRecord>} — keyed by instanceId */
|
|
109
134
|
const byInstanceId = new Map();
|
|
110
|
-
/** @type {Map<
|
|
111
|
-
const
|
|
135
|
+
/** @type {Map<string, SpawnerRecord>} — keyed by sessionId (for fast lookup from event handler) */
|
|
136
|
+
const bySessionId = new Map();
|
|
112
137
|
|
|
113
138
|
// --- Public API ---------------------------------------------------------
|
|
114
139
|
|
|
@@ -125,10 +150,19 @@ export function configureSpawner(ctx) {
|
|
|
125
150
|
}
|
|
126
151
|
|
|
127
152
|
/**
|
|
128
|
-
* Spawn one opencode
|
|
129
|
-
* `bg-spawn.ts` semantics
|
|
130
|
-
*
|
|
131
|
-
*
|
|
153
|
+
* Spawn one opencode serve SDK session for the bg agent. Mirrors the
|
|
154
|
+
* v5.5.0 plugin-side `bg-spawn.ts` semantics on the input/output side:
|
|
155
|
+
*
|
|
156
|
+
* - Generates an instanceId.
|
|
157
|
+
* - Writes the initial state file (same shape as v5.5.0 — the
|
|
158
|
+
* `processId` field stays in the schema but is always `null` now).
|
|
159
|
+
* - Creates an SDK session via `sdk.sessions.create({ title })`.
|
|
160
|
+
* - Fires the prompt via `sdk.sessions.promptAsync({ sessionId, body })`.
|
|
161
|
+
* - Subscribes to `sdk.events.subscribe({ sessionID })` to forward
|
|
162
|
+
* SSE events to the dashboard's WS bus.
|
|
163
|
+
*
|
|
164
|
+
* The function returns as soon as the sessionId is known (sub-second),
|
|
165
|
+
* matching the v5.5.0 "return instanceId immediately" contract.
|
|
132
166
|
*
|
|
133
167
|
* @param {object} opts
|
|
134
168
|
* @param {string} opts.agent
|
|
@@ -143,27 +177,43 @@ export function configureSpawner(ctx) {
|
|
|
143
177
|
*/
|
|
144
178
|
export async function spawnBgAgent(opts) {
|
|
145
179
|
if (!opts || !opts.agent || !opts.prompt || !opts.worktree) {
|
|
146
|
-
return { instanceId:
|
|
180
|
+
return { instanceId: "", sessionId: null, processId: null, error: "missing_required_fields" };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Resolve the SDK early so a missing serve fails fast with a clear error.
|
|
184
|
+
let sdk;
|
|
185
|
+
try {
|
|
186
|
+
sdk = await getOpencodeSdkOrThrow();
|
|
187
|
+
} catch (err) {
|
|
188
|
+
return {
|
|
189
|
+
instanceId: "",
|
|
190
|
+
sessionId: null,
|
|
191
|
+
processId: null,
|
|
192
|
+
error: `opencode_serve_unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
|
193
|
+
};
|
|
147
194
|
}
|
|
195
|
+
|
|
148
196
|
const instanceId = generateInstanceId();
|
|
149
197
|
const logDir = pickLogDir();
|
|
150
198
|
const logPath = pathResolve(logDir, `${instanceId}.log`);
|
|
151
199
|
const now = Date.now();
|
|
152
|
-
|
|
200
|
+
|
|
201
|
+
// Initial state — same shape as v5.5.0 (processId stays for compat).
|
|
202
|
+
/** @type {any} */
|
|
153
203
|
const initial = {
|
|
154
204
|
instanceId,
|
|
155
|
-
sessionId:
|
|
205
|
+
sessionId: "",
|
|
156
206
|
agent: opts.agent,
|
|
157
207
|
model: opts.model
|
|
158
208
|
? `${opts.model.providerID}/${opts.model.modelID}`
|
|
159
|
-
:
|
|
209
|
+
: "agent-default",
|
|
160
210
|
promptPreview: String(opts.prompt).slice(0, 200),
|
|
161
211
|
prompt: String(opts.prompt),
|
|
162
|
-
parentAgent:
|
|
212
|
+
parentAgent: "dashboard",
|
|
163
213
|
logPath,
|
|
164
214
|
timeoutMs: Math.max(1000, Math.floor(opts.timeoutMs ?? 300_000)),
|
|
165
215
|
toolCallCount: 0,
|
|
166
|
-
status:
|
|
216
|
+
status: "pending",
|
|
167
217
|
startedAt: now,
|
|
168
218
|
lastEventAt: now,
|
|
169
219
|
lastToolOrTextAt: now,
|
|
@@ -171,315 +221,470 @@ export async function spawnBgAgent(opts) {
|
|
|
171
221
|
persistent: Boolean(opts.persistent),
|
|
172
222
|
maxRestarts: Math.max(1, Math.floor(opts.maxRestarts ?? 3)),
|
|
173
223
|
restartCount: 0,
|
|
174
|
-
runnerState:
|
|
224
|
+
runnerState: "starting",
|
|
175
225
|
spawnedAt: now,
|
|
176
226
|
progress: 0,
|
|
177
227
|
toolCalls: [],
|
|
178
228
|
tags: Array.isArray(opts.tags) ? opts.tags.slice(0, 10) : undefined,
|
|
179
|
-
source:
|
|
229
|
+
source: "dashboard",
|
|
230
|
+
// v5.5.1 — explicit flag so the state file carries the "this is a
|
|
231
|
+
// live opencode session, not a subprocess" marker. Dashboard
|
|
232
|
+
// reconciliation reads this to decide whether to call SDK abort
|
|
233
|
+
// vs the legacy `proc.kill` path.
|
|
234
|
+
liveSession: true,
|
|
180
235
|
};
|
|
181
236
|
|
|
182
237
|
await writeStateFile(initial);
|
|
183
|
-
broadcast({ type:
|
|
184
|
-
|
|
185
|
-
// Build argv — mirror plugins/bizar/src/opencode-runner.ts buildOpencodeRunArgs.
|
|
186
|
-
const args = [
|
|
187
|
-
'opencode',
|
|
188
|
-
'run',
|
|
189
|
-
'--dir', opts.worktree,
|
|
190
|
-
'--print-logs',
|
|
191
|
-
'--log-level', 'INFO',
|
|
192
|
-
'--title', `bgr:${opts.agent}:${instanceId}`,
|
|
193
|
-
'--agent', opts.agent,
|
|
194
|
-
];
|
|
195
|
-
if (opts.model) args.push('--model', `${opts.model.providerID}/${opts.model.modelID}`);
|
|
196
|
-
args.push('--', String(opts.prompt));
|
|
197
|
-
|
|
198
|
-
// For subagents, wrap with delegation prompt (mirroring bg-spawn.ts).
|
|
199
|
-
const PRIMARY = new Set(['odin', 'quick', 'browser-harness']);
|
|
200
|
-
let finalPrompt = String(opts.prompt);
|
|
201
|
-
let wrapperAgent = opts.agent;
|
|
202
|
-
if (!PRIMARY.has(opts.agent)) {
|
|
203
|
-
wrapperAgent = 'odin';
|
|
204
|
-
finalPrompt = [
|
|
205
|
-
'You are Odin, the BizarHarness router.',
|
|
206
|
-
'',
|
|
207
|
-
'A background agent session has been requested with a SPECIFIC subagent.',
|
|
208
|
-
'Your only job is to delegate to that subagent using the `task` tool. Do NOT',
|
|
209
|
-
'perform the work yourself. Do NOT interpret the user\'s prompt.',
|
|
210
|
-
'',
|
|
211
|
-
`Requested subagent: ${opts.agent}`,
|
|
212
|
-
'',
|
|
213
|
-
'Task prompt to pass verbatim:',
|
|
214
|
-
'--- BEGIN USER PROMPT ---',
|
|
215
|
-
String(opts.prompt),
|
|
216
|
-
'--- END USER PROMPT ---',
|
|
217
|
-
'',
|
|
218
|
-
`Use the task tool with agent="${opts.agent}" and the exact prompt above.`,
|
|
219
|
-
].join('\n');
|
|
220
|
-
args[args.indexOf('--agent') + 1] = wrapperAgent;
|
|
221
|
-
// Replace the trailing "-- <prompt>" with the wrapped prompt.
|
|
222
|
-
const dashIdx = args.indexOf('--', args.indexOf('--agent'));
|
|
223
|
-
if (dashIdx >= 0) {
|
|
224
|
-
args.splice(dashIdx + 1, 1, finalPrompt);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
let proc;
|
|
229
|
-
try {
|
|
230
|
-
proc = spawn(args[0], args.slice(1), {
|
|
231
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
232
|
-
env: { ...process.env },
|
|
233
|
-
});
|
|
234
|
-
} catch (err) {
|
|
235
|
-
await patchState(instanceId, {
|
|
236
|
-
status: 'failed',
|
|
237
|
-
error: `spawn failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
238
|
-
completedAt: Date.now(),
|
|
239
|
-
});
|
|
240
|
-
broadcast({ type: 'background:change', id: instanceId, status: 'failed', error: err.message });
|
|
241
|
-
return { instanceId, sessionId: null, processId: null, error: err.message };
|
|
242
|
-
}
|
|
238
|
+
broadcast({ type: "background:change", id: instanceId, status: "pending", source: "dashboard" });
|
|
243
239
|
|
|
240
|
+
// --- Create the SDK session ---
|
|
244
241
|
/** @type {SpawnerRecord} */
|
|
245
242
|
const rec = {
|
|
246
|
-
processId: proc.pid,
|
|
247
243
|
instanceId,
|
|
248
|
-
sessionId:
|
|
244
|
+
sessionId: "",
|
|
249
245
|
logPath,
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
startedAt: Date.now(),
|
|
246
|
+
state: "starting",
|
|
247
|
+
startedAt: now,
|
|
253
248
|
worktree: opts.worktree,
|
|
249
|
+
paused: false,
|
|
250
|
+
pauseBuffer: [],
|
|
251
|
+
sub: null,
|
|
252
|
+
steerCount: 0,
|
|
253
|
+
lastSteerAt: 0,
|
|
254
254
|
};
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
for (const line of text.split(/\r?\n/)) {
|
|
270
|
-
if (!line) continue;
|
|
271
|
-
broadcast({
|
|
272
|
-
type: 'bg:output',
|
|
273
|
-
instanceId,
|
|
274
|
-
line,
|
|
275
|
-
ts: Date.now(),
|
|
276
|
-
byteOffset: appendOffset,
|
|
277
|
-
stream: label,
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
const created = await sdk.sessions.create({
|
|
258
|
+
title: `bgr:${opts.agent}:${instanceId}`,
|
|
259
|
+
agent: opts.agent,
|
|
260
|
+
...(opts.model ? { model: opts.model } : {}),
|
|
261
|
+
});
|
|
262
|
+
const sessionId = created?.id || "";
|
|
263
|
+
if (!sessionId) {
|
|
264
|
+
const msg = "opencode sessions.create returned no id";
|
|
265
|
+
await patchState(instanceId, {
|
|
266
|
+
status: "failed",
|
|
267
|
+
error: msg,
|
|
268
|
+
completedAt: Date.now(),
|
|
278
269
|
});
|
|
270
|
+
broadcast({ type: "background:change", id: instanceId, status: "failed", error: msg });
|
|
271
|
+
return { instanceId, sessionId: null, processId: null, error: msg };
|
|
279
272
|
}
|
|
280
|
-
|
|
273
|
+
rec.sessionId = sessionId;
|
|
274
|
+
rec.state = "running";
|
|
275
|
+
byInstanceId.set(instanceId, rec);
|
|
276
|
+
bySessionId.set(sessionId, rec);
|
|
277
|
+
|
|
278
|
+
// --- Send the prompt asynchronously (fire-and-forget) ---
|
|
279
|
+
// `promptAsync` returns `null` (202-style): the prompt is queued on
|
|
280
|
+
// the server and processed in the background. We don't block the
|
|
281
|
+
// spawn on it.
|
|
282
|
+
await sdk.sessions
|
|
283
|
+
.promptAsync({ sessionId, body: { text: String(opts.prompt) } })
|
|
284
|
+
.catch((err) => {
|
|
285
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
286
|
+
broadcast({ type: "bg:error", instanceId, error: `promptAsync failed: ${msg}` });
|
|
287
|
+
});
|
|
281
288
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
sessionId: sid,
|
|
293
|
-
status: 'running',
|
|
294
|
-
runnerState: 'running',
|
|
295
|
-
processId: proc.pid,
|
|
296
|
-
sessionIdAt: Date.now(),
|
|
297
|
-
}).then(() => {
|
|
298
|
-
broadcast({ type: 'background:change', id: instanceId, status: 'running', sessionId: sid });
|
|
289
|
+
// --- Subscribe to the session's event stream ---
|
|
290
|
+
let sub = null;
|
|
291
|
+
try {
|
|
292
|
+
sub = await sdk.events.subscribe({ sessionID: sessionId });
|
|
293
|
+
} catch (err) {
|
|
294
|
+
// Non-fatal: events won't be forwarded but the session still runs.
|
|
295
|
+
broadcast({
|
|
296
|
+
type: "bg:error",
|
|
297
|
+
instanceId,
|
|
298
|
+
error: `events.subscribe failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
299
299
|
});
|
|
300
300
|
}
|
|
301
|
-
|
|
301
|
+
rec.sub = sub;
|
|
302
302
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
rec.endedAt = Date.now();
|
|
306
|
-
rec.exitCode = code ?? undefined;
|
|
307
|
-
byPid.delete(proc.pid);
|
|
308
|
-
const wasAdopted = false;
|
|
309
|
-
const update = {
|
|
310
|
-
runnerState: rec.state,
|
|
311
|
-
completedAt: rec.endedAt,
|
|
312
|
-
...(code != null ? { exitCode: code } : {}),
|
|
313
|
-
};
|
|
314
|
-
if (signal) update.exitSignal = signal;
|
|
315
|
-
// Don't clobber "steered" status.
|
|
316
|
-
const cur = readStateFile(instanceId);
|
|
317
|
-
if (cur && cur.status !== 'steered' && cur.status !== 'killed' && cur.status !== 'failed') {
|
|
318
|
-
update.status = rec.state === 'killed' ? 'killed' : rec.state === 'done' ? 'done' : 'failed';
|
|
303
|
+
if (sub && sub.stream) {
|
|
304
|
+
void forwardEvents(rec, sub.stream);
|
|
319
305
|
}
|
|
320
|
-
|
|
306
|
+
|
|
307
|
+
// --- Persist sessionId into state file ---
|
|
308
|
+
await patchState(instanceId, {
|
|
309
|
+
sessionId,
|
|
310
|
+
status: "running",
|
|
311
|
+
runnerState: "running",
|
|
312
|
+
// processId stays null — the schema field is preserved for compat
|
|
313
|
+
// but is no longer the source of truth for liveness.
|
|
314
|
+
processId: null,
|
|
315
|
+
sessionIdAt: Date.now(),
|
|
316
|
+
liveSession: true,
|
|
317
|
+
});
|
|
321
318
|
broadcast({
|
|
322
|
-
type:
|
|
319
|
+
type: "background:change",
|
|
323
320
|
id: instanceId,
|
|
324
|
-
status:
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
...(rec.sessionId ? { sessionId: rec.sessionId } : {}),
|
|
321
|
+
status: "running",
|
|
322
|
+
sessionId,
|
|
323
|
+
liveSession: true,
|
|
328
324
|
});
|
|
329
|
-
void wasAdopted;
|
|
330
|
-
});
|
|
331
325
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
326
|
+
return { instanceId, sessionId, processId: null };
|
|
327
|
+
} catch (err) {
|
|
328
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
329
|
+
await patchState(instanceId, {
|
|
330
|
+
status: "failed",
|
|
331
|
+
error: `sdk.sessions.create threw: ${msg}`,
|
|
332
|
+
completedAt: Date.now(),
|
|
333
|
+
});
|
|
334
|
+
broadcast({ type: "background:change", id: instanceId, status: "failed", error: msg });
|
|
335
|
+
return { instanceId, sessionId: null, processId: null, error: msg };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
335
338
|
|
|
336
|
-
|
|
339
|
+
/**
|
|
340
|
+
* Forward SDK SSE events to the dashboard's WS bus. Resolves when the
|
|
341
|
+
* subscription ends (the session terminated or the SSE connection died).
|
|
342
|
+
*
|
|
343
|
+
* Translates opencode event types to dashboard shapes:
|
|
344
|
+
* - `session.idle` → `background:change { status: 'done' }`
|
|
345
|
+
* - `session.error` → `background:change { status: 'failed' }`
|
|
346
|
+
* - `message.part.updated` (text/tool) → `bg:output` + tool-call bookkeeping
|
|
347
|
+
*
|
|
348
|
+
* @param {SpawnerRecord} rec
|
|
349
|
+
* @param {AsyncIterable<any>} stream
|
|
350
|
+
*/
|
|
351
|
+
async function forwardEvents(rec, stream) {
|
|
352
|
+
try {
|
|
353
|
+
for await (const ev of stream) {
|
|
354
|
+
if (!ev || typeof ev !== "object") continue;
|
|
355
|
+
|
|
356
|
+
// Output-pause: buffer events instead of forwarding, so the
|
|
357
|
+
// dashboard doesn't show live updates while paused.
|
|
358
|
+
if (rec.paused) {
|
|
359
|
+
try {
|
|
360
|
+
rec.pauseBuffer.push(ev);
|
|
361
|
+
// Cap the buffer so a long pause doesn't grow unbounded.
|
|
362
|
+
if (rec.pauseBuffer.length > 1000) rec.pauseBuffer.shift();
|
|
363
|
+
} catch {
|
|
364
|
+
/* ignore */
|
|
365
|
+
}
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const type = typeof ev.type === "string" ? ev.type : null;
|
|
370
|
+
if (!type) continue;
|
|
371
|
+
|
|
372
|
+
// Append a compact line to the log file for tail/inspection.
|
|
373
|
+
try {
|
|
374
|
+
appendFileSync(
|
|
375
|
+
rec.logPath,
|
|
376
|
+
`[sdk-event] type=${type} ts=${Date.now()}\n`,
|
|
377
|
+
);
|
|
378
|
+
} catch {
|
|
379
|
+
/* ignore */
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Tool / text parts: forward as `bg:output` + bump counters.
|
|
383
|
+
if (type === "message.part.updated") {
|
|
384
|
+
const part = ev.part || ev.data?.part;
|
|
385
|
+
const partText =
|
|
386
|
+
part?.text ||
|
|
387
|
+
(part?.type === "tool" ? `[tool ${part?.tool || part?.name || ""}]` : "");
|
|
388
|
+
if (partText) {
|
|
389
|
+
broadcast({
|
|
390
|
+
type: "bg:output",
|
|
391
|
+
instanceId: rec.instanceId,
|
|
392
|
+
line: typeof partText === "string" ? partText : JSON.stringify(partText),
|
|
393
|
+
ts: Date.now(),
|
|
394
|
+
stream: "sse",
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
if (part?.type === "tool") {
|
|
398
|
+
const tc = {
|
|
399
|
+
id: ev.partID || ev.messageID || `tc_${Date.now().toString(36)}`,
|
|
400
|
+
name: String(part.tool || part.name || "tool"),
|
|
401
|
+
status: part.state || "running",
|
|
402
|
+
startedAt: Date.now(),
|
|
403
|
+
};
|
|
404
|
+
// Best-effort persist: read-modify-write the toolCalls array.
|
|
405
|
+
try {
|
|
406
|
+
const st = readStateFile(rec.instanceId);
|
|
407
|
+
if (st) {
|
|
408
|
+
const list = Array.isArray(st.toolCalls) ? st.toolCalls.slice() : [];
|
|
409
|
+
list.push(tc);
|
|
410
|
+
if (list.length > 100) list.shift();
|
|
411
|
+
await patchState(rec.instanceId, { toolCalls: list, toolCallCount: list.length });
|
|
412
|
+
}
|
|
413
|
+
} catch {
|
|
414
|
+
/* ignore */
|
|
415
|
+
}
|
|
416
|
+
broadcast({
|
|
417
|
+
type: "bg:tool-call",
|
|
418
|
+
instanceId: rec.instanceId,
|
|
419
|
+
toolCall: tc,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
// Heartbeat for the stall checker / output-pause ticker.
|
|
423
|
+
await patchState(rec.instanceId, {
|
|
424
|
+
lastEventAt: Date.now(),
|
|
425
|
+
...(part?.type === "tool" || part?.type === "text"
|
|
426
|
+
? { lastToolOrTextAt: Date.now() }
|
|
427
|
+
: {}),
|
|
428
|
+
});
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Session-terminal events: flip state to the appropriate terminal.
|
|
433
|
+
if (type === "session.idle" || type === "session.idle.0") {
|
|
434
|
+
rec.state = "done";
|
|
435
|
+
rec.endedAt = Date.now();
|
|
436
|
+
await patchState(rec.instanceId, {
|
|
437
|
+
status: "done",
|
|
438
|
+
runnerState: "done",
|
|
439
|
+
completedAt: rec.endedAt,
|
|
440
|
+
});
|
|
441
|
+
broadcast({
|
|
442
|
+
type: "background:change",
|
|
443
|
+
id: rec.instanceId,
|
|
444
|
+
status: "done",
|
|
445
|
+
});
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
if (type === "session.error" || type === "session.error.0") {
|
|
449
|
+
rec.state = "failed";
|
|
450
|
+
rec.endedAt = Date.now();
|
|
451
|
+
const errMsg = String(ev.error || ev.data?.error || "session error");
|
|
452
|
+
await patchState(rec.instanceId, {
|
|
453
|
+
status: "failed",
|
|
454
|
+
runnerState: "failed",
|
|
455
|
+
error: errMsg,
|
|
456
|
+
completedAt: rec.endedAt,
|
|
457
|
+
});
|
|
458
|
+
broadcast({
|
|
459
|
+
type: "background:change",
|
|
460
|
+
id: rec.instanceId,
|
|
461
|
+
status: "failed",
|
|
462
|
+
error: errMsg,
|
|
463
|
+
});
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Anything else: forward as a generic bg:output so the dashboard
|
|
468
|
+
// log captures it.
|
|
469
|
+
broadcast({
|
|
470
|
+
type: "bg:output",
|
|
471
|
+
instanceId: rec.instanceId,
|
|
472
|
+
line: `[event] ${type}`,
|
|
473
|
+
ts: Date.now(),
|
|
474
|
+
stream: "sse",
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
} catch (err) {
|
|
478
|
+
broadcast({
|
|
479
|
+
type: "bg:error",
|
|
480
|
+
instanceId: rec.instanceId,
|
|
481
|
+
error: `event stream ended: ${err instanceof Error ? err.message : String(err)}`,
|
|
482
|
+
});
|
|
483
|
+
} finally {
|
|
484
|
+
// Clean up so a re-adopt / restart can re-subscribe cleanly.
|
|
485
|
+
bySessionId.delete(rec.sessionId);
|
|
486
|
+
}
|
|
337
487
|
}
|
|
338
488
|
|
|
339
489
|
/**
|
|
340
|
-
* Kill an instance's
|
|
490
|
+
* Kill an instance's opencode session. Aborts the SDK session; best-effort.
|
|
341
491
|
* @param {string} instanceId
|
|
342
|
-
* @param {{signal?: 'SIGTERM' | 'SIGKILL', reason?: string}} [
|
|
492
|
+
* @param {{signal?: 'SIGTERM' | 'SIGKILL', reason?: string}} [_opts] — `signal` accepted for compat but ignored (sessions are aborted via SDK)
|
|
343
493
|
* @returns {{ ok: boolean, error?: string }}
|
|
344
494
|
*/
|
|
345
|
-
export async function killBgAgent(instanceId,
|
|
495
|
+
export async function killBgAgent(instanceId, _opts = {}) {
|
|
346
496
|
const rec = byInstanceId.get(instanceId);
|
|
347
|
-
if (!rec) return { ok: false, error:
|
|
348
|
-
if (rec.endedAt) return { ok: true, note:
|
|
349
|
-
|
|
497
|
+
if (!rec) return { ok: false, error: "instance_not_tracked" };
|
|
498
|
+
if (rec.endedAt) return { ok: true, note: "already_exited" };
|
|
499
|
+
|
|
350
500
|
try {
|
|
351
|
-
|
|
352
|
-
rec.
|
|
353
|
-
if (rec.sessionId) {
|
|
354
|
-
await patchState(instanceId, {
|
|
355
|
-
status: 'killed',
|
|
356
|
-
runnerState: 'killed',
|
|
357
|
-
completedAt: Date.now(),
|
|
358
|
-
exitSignal: signal,
|
|
359
|
-
});
|
|
360
|
-
broadcast({ type: 'background:change', id: instanceId, status: 'killed', reason: opts.reason });
|
|
361
|
-
}
|
|
362
|
-
setTimeout(() => {
|
|
363
|
-
if (!rec.endedAt) {
|
|
364
|
-
try { rec.proc.kill('SIGKILL'); } catch { /* ignore */ }
|
|
365
|
-
}
|
|
366
|
-
}, 5_000);
|
|
367
|
-
return { ok: true };
|
|
501
|
+
const sdk = await getOpencodeSdkOrThrow();
|
|
502
|
+
await sdk.sessions.abort({ sessionId: rec.sessionId });
|
|
368
503
|
} catch (err) {
|
|
369
|
-
|
|
504
|
+
// Best-effort: even if abort fails, mark the state so the UI
|
|
505
|
+
// reflects the intent.
|
|
506
|
+
broadcast({
|
|
507
|
+
type: "bg:error",
|
|
508
|
+
instanceId,
|
|
509
|
+
error: `abort failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
510
|
+
});
|
|
370
511
|
}
|
|
512
|
+
|
|
513
|
+
// Close the event subscription so we stop forwarding events.
|
|
514
|
+
try {
|
|
515
|
+
rec.sub?.close?.();
|
|
516
|
+
} catch {
|
|
517
|
+
/* ignore */
|
|
518
|
+
}
|
|
519
|
+
rec.sub = null;
|
|
520
|
+
|
|
521
|
+
rec.state = "killed";
|
|
522
|
+
rec.endedAt = Date.now();
|
|
523
|
+
await patchState(instanceId, {
|
|
524
|
+
status: "killed",
|
|
525
|
+
runnerState: "killed",
|
|
526
|
+
completedAt: rec.endedAt,
|
|
527
|
+
});
|
|
528
|
+
broadcast({ type: "background:change", id: instanceId, status: "killed" });
|
|
529
|
+
return { ok: true };
|
|
371
530
|
}
|
|
372
531
|
|
|
373
532
|
/**
|
|
374
|
-
* Pause an instance
|
|
533
|
+
* Pause an instance's event forwarding. In v5.5.1 (SDK-based) we can't
|
|
534
|
+
* freeze the underlying opencode agent loop without a documented pause
|
|
535
|
+
* endpoint; pause is therefore an OUTPUT pause — events that arrive
|
|
536
|
+
* while paused are buffered; resume drains them.
|
|
537
|
+
*
|
|
538
|
+
* Idempotent against already-paused / already-exited instances.
|
|
539
|
+
*
|
|
375
540
|
* @param {string} instanceId
|
|
376
541
|
* @returns {{ ok: boolean, error?: string }}
|
|
377
542
|
*/
|
|
378
543
|
export function pauseBgAgent(instanceId) {
|
|
379
|
-
if (process.platform === 'win32') {
|
|
380
|
-
return { ok: false, error: 'unsupported_on_win32' };
|
|
381
|
-
}
|
|
382
544
|
const rec = byInstanceId.get(instanceId);
|
|
383
|
-
if (!rec) return { ok: false, error:
|
|
384
|
-
if (rec.endedAt) return { ok: true, note:
|
|
385
|
-
if (rec.
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
broadcast({ type: 'background:change', id: instanceId, status: 'paused' });
|
|
545
|
+
if (!rec) return { ok: false, error: "instance_not_tracked" };
|
|
546
|
+
if (rec.endedAt) return { ok: true, note: "already_exited" };
|
|
547
|
+
if (rec.paused) return { ok: true };
|
|
548
|
+
rec.paused = true;
|
|
549
|
+
patchState(instanceId, { status: "paused", pausedAt: Date.now() })
|
|
550
|
+
.then(() => {
|
|
551
|
+
broadcast({ type: "background:change", id: instanceId, status: "paused" });
|
|
552
|
+
})
|
|
553
|
+
.catch(() => {
|
|
554
|
+
/* ignore */
|
|
394
555
|
});
|
|
395
|
-
|
|
396
|
-
} catch (err) {
|
|
397
|
-
return { ok: false, error: err.message };
|
|
398
|
-
}
|
|
556
|
+
return { ok: true };
|
|
399
557
|
}
|
|
400
558
|
|
|
401
559
|
/**
|
|
402
|
-
* Resume an instance:
|
|
560
|
+
* Resume an instance: drain the buffered events to the WS bus, then
|
|
561
|
+
* resume live forwarding.
|
|
562
|
+
*
|
|
563
|
+
* Idempotent.
|
|
564
|
+
*
|
|
403
565
|
* @param {string} instanceId
|
|
566
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
404
567
|
*/
|
|
405
568
|
export function resumeBgAgent(instanceId) {
|
|
406
|
-
if (process.platform === 'win32') {
|
|
407
|
-
return { ok: false, error: 'unsupported_on_win32' };
|
|
408
|
-
}
|
|
409
569
|
const rec = byInstanceId.get(instanceId);
|
|
410
|
-
if (!rec) return { ok: false, error:
|
|
411
|
-
if (rec.endedAt) return { ok: true, note:
|
|
412
|
-
if (rec.
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
570
|
+
if (!rec) return { ok: false, error: "instance_not_tracked" };
|
|
571
|
+
if (rec.endedAt) return { ok: true, note: "already_exited" };
|
|
572
|
+
if (!rec.paused) return { ok: true };
|
|
573
|
+
rec.paused = false;
|
|
574
|
+
|
|
575
|
+
// Drain the buffer (best-effort; if a flush helper above changes,
|
|
576
|
+
// this is the only place we need to update).
|
|
577
|
+
const buffered = rec.pauseBuffer;
|
|
578
|
+
rec.pauseBuffer = [];
|
|
579
|
+
for (const ev of buffered) {
|
|
580
|
+
try {
|
|
581
|
+
const type = ev?.type;
|
|
582
|
+
const part = ev?.part || ev?.data?.part;
|
|
583
|
+
const partText =
|
|
584
|
+
part?.text ||
|
|
585
|
+
(part?.type === "tool" ? `[tool ${part?.tool || part?.name || ""}]` : "");
|
|
586
|
+
if (type === "message.part.updated" && partText) {
|
|
587
|
+
broadcast({
|
|
588
|
+
type: "bg:output",
|
|
589
|
+
instanceId,
|
|
590
|
+
line: typeof partText === "string" ? partText : JSON.stringify(partText),
|
|
591
|
+
ts: Date.now(),
|
|
592
|
+
stream: "sse-buffered",
|
|
593
|
+
});
|
|
594
|
+
} else if (typeof type === "string") {
|
|
595
|
+
broadcast({
|
|
596
|
+
type: "bg:output",
|
|
597
|
+
instanceId,
|
|
598
|
+
line: `[event] ${type}`,
|
|
599
|
+
ts: Date.now(),
|
|
600
|
+
stream: "sse-buffered",
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
} catch {
|
|
604
|
+
/* ignore */
|
|
605
|
+
}
|
|
425
606
|
}
|
|
607
|
+
patchState(instanceId, { status: "running", pausedAt: undefined })
|
|
608
|
+
.then(() => {
|
|
609
|
+
broadcast({ type: "background:change", id: instanceId, status: "running" });
|
|
610
|
+
})
|
|
611
|
+
.catch(() => {
|
|
612
|
+
/* ignore */
|
|
613
|
+
});
|
|
614
|
+
return { ok: true };
|
|
426
615
|
}
|
|
427
616
|
|
|
428
617
|
/**
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
618
|
+
* v5.5.1 — TRUE mid-flight steer. Sends a follow-up prompt to the
|
|
619
|
+
* running opencode session via the SDK. NO kill+respawn; the same
|
|
620
|
+
* instanceId and sessionId are reused.
|
|
621
|
+
*
|
|
622
|
+
* Returns `{ ok: true, instanceId, mode: 'true_midflight' }`.
|
|
432
623
|
*
|
|
433
624
|
* @param {string} instanceId
|
|
434
625
|
* @param {string} message
|
|
435
|
-
* @returns {Promise<{ ok: boolean,
|
|
626
|
+
* @returns {Promise<{ ok: boolean, instanceId?: string, mode?: string, error?: string }>}
|
|
436
627
|
*/
|
|
437
628
|
export async function steerBgAgent(instanceId, message) {
|
|
438
629
|
if (!message || !message.trim()) {
|
|
439
|
-
return { ok: false, error:
|
|
630
|
+
return { ok: false, error: "message_empty" };
|
|
631
|
+
}
|
|
632
|
+
const rec = byInstanceId.get(instanceId);
|
|
633
|
+
if (!rec) return { ok: false, error: "instance_not_found" };
|
|
634
|
+
if (rec.endedAt) return { ok: false, error: "instance_already_terminated" };
|
|
635
|
+
|
|
636
|
+
let sdk;
|
|
637
|
+
try {
|
|
638
|
+
sdk = await getOpencodeSdkOrThrow();
|
|
639
|
+
} catch (err) {
|
|
640
|
+
return {
|
|
641
|
+
ok: false,
|
|
642
|
+
error: `opencode_serve_unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
|
643
|
+
};
|
|
440
644
|
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
645
|
+
|
|
646
|
+
try {
|
|
647
|
+
// Synchronous prompt: blocks until the server accepts the message.
|
|
648
|
+
// Use promptAsync if we want fire-and-forget; the v5.5.0 spec kept
|
|
649
|
+
// this as the synchronous form so the dashboard can surface a clear
|
|
650
|
+
// error if the SDK rejects the message.
|
|
651
|
+
await sdk.sessions.prompt({
|
|
652
|
+
sessionId: rec.sessionId,
|
|
653
|
+
body: { text: String(message).slice(0, 200_000) },
|
|
654
|
+
});
|
|
655
|
+
} catch (err) {
|
|
656
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
657
|
+
return { ok: false, error: `prompt failed: ${msg}` };
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
rec.steerCount += 1;
|
|
661
|
+
rec.lastSteerAt = Date.now();
|
|
444
662
|
await patchState(instanceId, {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
663
|
+
steerCount: rec.steerCount,
|
|
664
|
+
lastSteerAt: rec.lastSteerAt,
|
|
665
|
+
interventionCount: rec.steerCount, // dashboard UI treats intervention count and steer count together
|
|
666
|
+
lastEventAt: Date.now(),
|
|
449
667
|
});
|
|
450
|
-
broadcast({
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
cur.prompt || cur.promptPreview || '',
|
|
457
|
-
'',
|
|
458
|
-
`[STEERED ${new Date().toISOString()}]`,
|
|
459
|
-
message.trim(),
|
|
460
|
-
].join('\n');
|
|
461
|
-
const res = await spawnBgAgent({
|
|
462
|
-
agent: cur.agent,
|
|
463
|
-
prompt: combined,
|
|
464
|
-
model: cur.model && cur.model !== 'agent-default'
|
|
465
|
-
? { providerID: cur.model.split('/')[0], modelID: cur.model.split('/').slice(1).join('/') }
|
|
466
|
-
: undefined,
|
|
467
|
-
worktree: cur.worktree || process.cwd(),
|
|
468
|
-
persistent: cur.persistent,
|
|
469
|
-
maxRestarts: cur.maxRestarts,
|
|
470
|
-
timeoutMs: cur.timeoutMs,
|
|
471
|
-
tags: cur.tags,
|
|
668
|
+
broadcast({
|
|
669
|
+
type: "background:change",
|
|
670
|
+
id: instanceId,
|
|
671
|
+
status: "running",
|
|
672
|
+
action: "steer",
|
|
673
|
+
steerCount: rec.steerCount,
|
|
472
674
|
});
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
675
|
+
broadcast({
|
|
676
|
+
type: "bg:output",
|
|
677
|
+
instanceId,
|
|
678
|
+
line: `[steer #${rec.steerCount}] ${String(message).slice(0, 500)}`,
|
|
679
|
+
ts: Date.now(),
|
|
680
|
+
stream: "steer",
|
|
681
|
+
});
|
|
682
|
+
return { ok: true, instanceId, mode: "true_midflight", steerCount: rec.steerCount };
|
|
479
683
|
}
|
|
480
684
|
|
|
481
685
|
/**
|
|
482
|
-
* Liveness probe.
|
|
686
|
+
* Liveness probe. With SDK sessions we can probe the server directly.
|
|
687
|
+
*
|
|
483
688
|
* @param {string} instanceId
|
|
484
689
|
* @returns {boolean}
|
|
485
690
|
*/
|
|
@@ -508,7 +713,11 @@ const state = {
|
|
|
508
713
|
* @param {object} msg
|
|
509
714
|
*/
|
|
510
715
|
function broadcast(msg) {
|
|
511
|
-
try {
|
|
716
|
+
try {
|
|
717
|
+
state.broadcast(msg);
|
|
718
|
+
} catch {
|
|
719
|
+
/* ignore */
|
|
720
|
+
}
|
|
512
721
|
}
|
|
513
722
|
|
|
514
723
|
function bgDir() {
|
|
@@ -528,12 +737,12 @@ async function writeStateFile(bgState) {
|
|
|
528
737
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
529
738
|
const file = stateFile(bgState.instanceId);
|
|
530
739
|
const tmp = `${file}.tmp`;
|
|
531
|
-
writeFileSync(tmp, JSON.stringify(bgState, null, 2),
|
|
740
|
+
writeFileSync(tmp, JSON.stringify(bgState, null, 2), "utf-8");
|
|
532
741
|
try {
|
|
533
|
-
const fs = await import(
|
|
742
|
+
const fs = await import("node:fs");
|
|
534
743
|
fs.renameSync(tmp, file);
|
|
535
744
|
} catch {
|
|
536
|
-
writeFileSync(file, JSON.stringify(bgState, null, 2),
|
|
745
|
+
writeFileSync(file, JSON.stringify(bgState, null, 2), "utf-8");
|
|
537
746
|
}
|
|
538
747
|
}
|
|
539
748
|
|
|
@@ -546,11 +755,11 @@ async function patchState(instanceId, patch) {
|
|
|
546
755
|
const file = stateFile(instanceId);
|
|
547
756
|
if (!existsSync(file)) return;
|
|
548
757
|
try {
|
|
549
|
-
const cur = JSON.parse(readFileSync(file,
|
|
758
|
+
const cur = JSON.parse(readFileSync(file, "utf-8") || "{}");
|
|
550
759
|
const next = { ...cur, ...patch };
|
|
551
|
-
writeFileSync(file, JSON.stringify(next, null, 2),
|
|
760
|
+
writeFileSync(file, JSON.stringify(next, null, 2), "utf-8");
|
|
552
761
|
} catch (err) {
|
|
553
|
-
broadcast({ type:
|
|
762
|
+
broadcast({ type: "bg:error", instanceId, error: `state_patch failed: ${err.message}` });
|
|
554
763
|
}
|
|
555
764
|
}
|
|
556
765
|
|
|
@@ -562,13 +771,21 @@ function readStateFile(instanceId) {
|
|
|
562
771
|
const file = stateFile(instanceId);
|
|
563
772
|
if (!existsSync(file)) return null;
|
|
564
773
|
try {
|
|
565
|
-
return JSON.parse(readFileSync(file,
|
|
774
|
+
return JSON.parse(readFileSync(file, "utf-8"));
|
|
566
775
|
} catch {
|
|
567
776
|
return null;
|
|
568
777
|
}
|
|
569
778
|
}
|
|
570
779
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
780
|
+
/** For tests: clear the in-memory registry. Does NOT close SDK sessions. */
|
|
781
|
+
export function _resetForTests() {
|
|
782
|
+
for (const rec of byInstanceId.values()) {
|
|
783
|
+
try {
|
|
784
|
+
rec.sub?.close?.();
|
|
785
|
+
} catch {
|
|
786
|
+
/* ignore */
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
byInstanceId.clear();
|
|
790
|
+
bySessionId.clear();
|
|
791
|
+
}
|