@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.
Files changed (71) hide show
  1. package/bizar-dash/dist/assets/{EnvVarsSection-L413CQEI.js → EnvVarsSection-DoIzqrlb.js} +3 -3
  2. package/bizar-dash/dist/assets/{EnvVarsSection-L413CQEI.js.map → EnvVarsSection-DoIzqrlb.js.map} +1 -1
  3. package/bizar-dash/dist/assets/{MobileChat-0kGQb1S6.js → MobileChat-CoUCUsbr.js} +1 -1
  4. package/bizar-dash/dist/assets/{MobileChat-0kGQb1S6.js.map → MobileChat-CoUCUsbr.js.map} +1 -1
  5. package/bizar-dash/dist/assets/MobileSettings-BeO04g52.js +1 -0
  6. package/bizar-dash/dist/assets/MobileSettings-BeO04g52.js.map +1 -0
  7. package/bizar-dash/dist/assets/{Toast-C6f1zFJ2.js → Toast-D9nv5N6m.js} +1 -1
  8. package/bizar-dash/dist/assets/{Toast-C6f1zFJ2.js.map → Toast-D9nv5N6m.js.map} +1 -1
  9. package/bizar-dash/dist/assets/{icons-BsWXWkEF.js → icons-Btipv2pp.js} +100 -90
  10. package/bizar-dash/dist/assets/icons-Btipv2pp.js.map +1 -0
  11. package/bizar-dash/dist/assets/main-DHXPuddY.js +18 -0
  12. package/bizar-dash/dist/assets/main-DHXPuddY.js.map +1 -0
  13. package/bizar-dash/dist/assets/{main-BklYy02e.css → main-UG4jKK-F.css} +1 -1
  14. package/bizar-dash/dist/assets/mobile-H5KH9cTL.js +1 -0
  15. package/bizar-dash/dist/assets/{mobile-CsaN4CxH.js.map → mobile-H5KH9cTL.js.map} +1 -1
  16. package/bizar-dash/dist/assets/{mobile-layout-iFBdjEE4.js → mobile-layout-BqHxVb6_.js} +2 -2
  17. package/bizar-dash/dist/assets/{mobile-layout-iFBdjEE4.js.map → mobile-layout-BqHxVb6_.js.map} +1 -1
  18. package/bizar-dash/dist/assets/{useSlashCommands-DGJcC9iQ.js → useSlashCommands-C24-O1e7.js} +2 -2
  19. package/bizar-dash/dist/assets/{useSlashCommands-DGJcC9iQ.js.map → useSlashCommands-C24-O1e7.js.map} +1 -1
  20. package/bizar-dash/dist/index.html +7 -7
  21. package/bizar-dash/dist/mobile.html +3 -3
  22. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  23. package/bizar-dash/src/server/bg-spawner.mjs +522 -305
  24. package/bizar-dash/src/server/headroom.mjs +3 -1
  25. package/bizar-dash/src/server/memory-lightrag.mjs +26 -2
  26. package/bizar-dash/src/server/memory-store.mjs +74 -28
  27. package/bizar-dash/src/server/opencode-sdk.mjs +63 -3
  28. package/bizar-dash/src/server/plugins/registry.mjs +1 -1
  29. package/bizar-dash/src/server/routes/activity.mjs +85 -0
  30. package/bizar-dash/src/server/routes/background.mjs +31 -13
  31. package/bizar-dash/src/server/routes/memory.mjs +91 -38
  32. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +1 -1
  33. package/bizar-dash/src/server/server.mjs +4 -1
  34. package/bizar-dash/src/web/components/Sidebar.tsx +0 -1
  35. package/bizar-dash/src/web/styles/main.css +52 -13
  36. package/bizar-dash/src/web/styles/memory.css +17 -0
  37. package/bizar-dash/src/web/styles/settings.css +6 -56
  38. package/bizar-dash/src/web/views/Marketplace.tsx +192 -31
  39. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +157 -7
  40. package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +11 -0
  41. package/bizar-dash/src/web/views/settings/MemorySection.tsx +65 -11
  42. package/bizar-dash/tests/background-sdk-session.test.mjs +94 -0
  43. package/bizar-dash/tests/background-session-events.test.mjs +74 -0
  44. package/bizar-dash/tests/background-steer-sdk.test.mjs +71 -0
  45. package/bizar-dash/tests/background-steer.test.mjs +25 -7
  46. package/bizar-dash/tests/memory-namespace.test.mjs +2 -2
  47. package/bizar-dash/tests/memory-status-root.test.mjs +144 -0
  48. package/bizar-dash/tests/memory-store.test.mjs +5 -4
  49. package/bizar-dash/tests/memory-vault-config-endpoint.test.mjs +173 -0
  50. package/bizar-dash/tests/settings-mode-wiring.test.tsx +1 -1
  51. package/bizar-dash/tests/settings-nav.test.tsx +0 -1
  52. package/bizar-dash/tests/views/Marketplace.test.tsx +200 -0
  53. package/bizar-dash/tests/views/Memory.test.tsx +274 -0
  54. package/bizar-dash/tests/views/sidebar.test.tsx +116 -0
  55. package/bizar-dash/tests/views/topbar.test.tsx +87 -0
  56. package/cli/commands/marketplace.mjs +1 -1
  57. package/cli/memory.mjs +1 -1
  58. package/package.json +1 -1
  59. package/plugins/bizar/src/background-state.ts +11 -0
  60. package/plugins/bizar/src/background.ts +212 -34
  61. package/plugins/bizar/src/opencode-runner.ts +80 -353
  62. package/plugins/bizar/src/tools/bg-send-message.ts +160 -61
  63. package/plugins/bizar/src/tools/bg-spawn.ts +235 -156
  64. package/plugins/bizar/tests/tools/bg-send-message.test.ts +84 -11
  65. package/plugins/bizar/tests/tools/bg-spawn-http.test.ts +223 -0
  66. package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js +0 -1
  67. package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js.map +0 -1
  68. package/bizar-dash/dist/assets/icons-BsWXWkEF.js.map +0 -1
  69. package/bizar-dash/dist/assets/main-CBTgYSeG.js +0 -18
  70. package/bizar-dash/dist/assets/main-CBTgYSeG.js.map +0 -1
  71. 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.x — Dashboard-side background agent spawner.
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
- * Bridges "spawn from UI" without requiring an LLM round-trip. Mirrors
7
- * the plugin's `opencode-runner.ts` semantics as a Node-flavoured
8
- * equivalent: spawns one `opencode run` subprocess per agent, captures
9
- * stdout/stderr to a log file, tracks the PID, and broadcasts WS events
10
- * for each new output line.
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
- * Why the dashboard has its own spawner:
13
- * - The plugin process (the opencode plugin) isn't always reachable
14
- * from the dashboard. Spawning from UI must work even when the
15
- * user is operating the dashboard before/after an opencode session.
16
- * - The dashboard already has its own pid space and state directory;
17
- * this module inherits the existing `~/.cache/bizar/bg/*.json`
18
- * layout so the dashboard list view picks up the new instance
19
- * immediately.
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({...})` — create instance + subprocess, return
23
- * `{ instanceId, sessionId?, processId? }`.
24
- * - `killBgAgent(instanceId, { signal })`, `pauseBgAgent`,
25
- * `resumeBgAgent` control signals. POSIX-only for signal
26
- * variants; Windows returns an explicit error.
27
- * - `isAlive(instanceId)` — liveness check for an instance PID.
28
- * - `status()` diagnostic dump (process count, ids).
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
- * State file: `~/.cache/bizar/bg/<instanceId>.json` — same shape
31
- * the plugin uses, kept compatible. We update the file as the
32
- * subprocess progresses (status flips, tool calls recorded, etc.).
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 { spawn } from 'node:child_process';
35
- import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, statSync, readdirSync, unlinkSync } from 'node:fs';
36
- import { randomBytes } from 'node:crypto';
37
- import { homedir } from 'node:os';
38
- import { resolve as pathResolve, dirname } from 'node:path';
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 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
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 'bgr_' + out.slice(0, 22);
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, '.cache', 'bizar', 'bg'),
65
- pathResolve(HOME, '.config', 'opencode', 'bg'),
66
- pathResolve(HOME, '.bizar', 'bg'),
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, '.cache', 'bizar', 'logs'),
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 subprocess registry --------------------------------------
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<number, SpawnerRecord>} — keyed by processId */
111
- const byPid = new Map();
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 run subprocess. Mirrors the plugin's
129
- * `bg-spawn.ts` semantics. Writes the initial BackgroundState JSON,
130
- * starts the subprocess, then patches the JSON when the session id
131
- * appears in stderr.
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: '', sessionId: null, processId: null, error: 'missing_required_fields' };
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
- /** @type {BackgroundState} */
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
- : 'agent-default',
209
+ : "agent-default",
160
210
  promptPreview: String(opts.prompt).slice(0, 200),
161
211
  prompt: String(opts.prompt),
162
- parentAgent: 'dashboard',
212
+ parentAgent: "dashboard",
163
213
  logPath,
164
214
  timeoutMs: Math.max(1000, Math.floor(opts.timeoutMs ?? 300_000)),
165
215
  toolCallCount: 0,
166
- status: 'pending',
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: 'starting',
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: 'dashboard',
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: 'background:change', id: instanceId, status: 'pending', source: 'dashboard' });
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
- proc,
251
- state: 'starting',
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
- byInstanceId.set(instanceId, rec);
256
- byPid.set(proc.pid, rec);
257
-
258
- const sessionIdRegex = /message=created id=(ses_[A-Za-z0-9_]+)/;
259
- let buf = '';
260
- let appendOffset = 0;
261
-
262
- // Helper: append a line to the log file AND broadcast ws event.
263
- const emit = (label, chunk) => {
264
- if (!chunk) return;
265
- const text = chunk.toString('utf-8');
266
- appendFileSync(logPath, `[${label}] ${text}`);
267
- appendOffset += Buffer.byteLength(text, 'utf-8');
268
- // Per-line broadcast: split on newlines.
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
- proc.stdout.on('data', (chunk) => emit('stdout', chunk));
283
- proc.stderr.on('data', (chunk) => {
284
- emit('stderr', chunk);
285
- const text = chunk.toString('utf-8');
286
- buf += text;
287
- const m = buf.match(sessionIdRegex);
288
- if (m && m[1] && !rec.sessionId) {
289
- rec.sessionId = m[1];
290
- const sid = m[1];
291
- patchState(instanceId, {
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
- proc.on('exit', async (code, signal) => {
304
- rec.state = signal === 'SIGTERM' || signal === 'SIGKILL' ? 'killed' : (code === 0 ? 'done' : 'failed');
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
- await patchState(instanceId, update);
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: 'background:change',
319
+ type: "background:change",
323
320
  id: instanceId,
324
- status: update.status,
325
- processId: proc.pid,
326
- exitCode: code,
327
- ...(rec.sessionId ? { sessionId: rec.sessionId } : {}),
321
+ status: "running",
322
+ sessionId,
323
+ liveSession: true,
328
324
  });
329
- void wasAdopted;
330
- });
331
325
 
332
- proc.on('error', (err) => {
333
- broadcast({ type: 'bg:error', instanceId, error: err.message });
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
- return { instanceId, sessionId: null, processId: proc.pid };
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 subprocess. Sends SIGTERM, then SIGKILL after 5s.
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}} [opts]
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, opts = {}) {
495
+ export async function killBgAgent(instanceId, _opts = {}) {
346
496
  const rec = byInstanceId.get(instanceId);
347
- if (!rec) return { ok: false, error: 'instance_not_tracked' };
348
- if (rec.endedAt) return { ok: true, note: 'already_exited' };
349
- const signal = opts.signal || 'SIGTERM';
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
- rec.proc.kill(signal);
352
- rec.state = 'killed';
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
- return { ok: false, error: err instanceof Error ? err.message : String(err) };
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: SIGSTOP the subprocess.
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: 'instance_not_tracked' };
384
- if (rec.endedAt) return { ok: true, note: 'already_exited' };
385
- if (rec.state === 'paused') return { ok: true };
386
- try {
387
- rec.proc.kill('SIGSTOP');
388
- rec.state = 'paused';
389
- patchState(instanceId, {
390
- status: 'paused',
391
- pausedAt: Date.now(),
392
- }).then(() => {
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
- return { ok: true };
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: SIGCONT.
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: 'instance_not_tracked' };
411
- if (rec.endedAt) return { ok: true, note: 'already_exited' };
412
- if (rec.state !== 'paused') return { ok: true };
413
- try {
414
- rec.proc.kill('SIGCONT');
415
- rec.state = 'running';
416
- patchState(instanceId, {
417
- status: 'running',
418
- pausedAt: undefined,
419
- }).then(() => {
420
- broadcast({ type: 'background:change', id: instanceId, status: 'running' });
421
- });
422
- return { ok: true };
423
- } catch (err) {
424
- return { ok: false, error: err.message };
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
- * Steer an instance by killing it and spawning a new one with the
430
- * original prompt + a [STEERED ...] marker + the user's message
431
- * appended. Returns the new instance id.
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, newInstanceId?: string, error?: string }>}
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: 'message_empty' };
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
- const cur = readStateFile(instanceId);
442
- if (!cur) return { ok: false, error: 'instance_not_found' };
443
- // Persist the steered-from state.
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
- status: 'steered',
446
- completedAt: Date.now(),
447
- steeredFromMessage: message.slice(0, 500),
448
- steeredAt: Date.now(),
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({ type: 'background:change', id: instanceId, status: 'steered' });
451
- // Kill the current subprocess (best-effort — the new one will
452
- // continue regardless; we don't want to block).
453
- void killBgAgent(instanceId, { signal: 'SIGTERM', reason: 'steered' });
454
- // Spawn the steered replacement.
455
- const combined = [
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
- if (res.error) return { ok: false, error: res.error };
474
- // Link the new instance to the old one in the state file.
475
- if (res.instanceId) {
476
- patchState(res.instanceId, { parentInstanceId: instanceId }).catch(() => {});
477
- }
478
- return { ok: true, newInstanceId: res.instanceId, processId: res.processId };
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 { state.broadcast(msg); } catch { /* ignore */ }
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), 'utf-8');
740
+ writeFileSync(tmp, JSON.stringify(bgState, null, 2), "utf-8");
532
741
  try {
533
- const fs = await import('node:fs');
742
+ const fs = await import("node:fs");
534
743
  fs.renameSync(tmp, file);
535
744
  } catch {
536
- writeFileSync(file, JSON.stringify(bgState, null, 2), 'utf-8');
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, 'utf-8') || '{}');
758
+ const cur = JSON.parse(readFileSync(file, "utf-8") || "{}");
550
759
  const next = { ...cur, ...patch };
551
- writeFileSync(file, JSON.stringify(next, null, 2), 'utf-8');
760
+ writeFileSync(file, JSON.stringify(next, null, 2), "utf-8");
552
761
  } catch (err) {
553
- broadcast({ type: 'bg:error', instanceId, error: `state_patch failed: ${err.message}` });
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, 'utf-8'));
774
+ return JSON.parse(readFileSync(file, "utf-8"));
566
775
  } catch {
567
776
  return null;
568
777
  }
569
778
  }
570
779
 
571
- void dirname;
572
- void statSync;
573
- void readdirSync;
574
- void unlinkSync;
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
+ }