@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,23 +1,51 @@
1
1
  /**
2
2
  * plugins/bizar/src/tools/bg-spawn.ts
3
3
  *
4
- * v0.8.0 — `bizar_spawn_background` tool, refactored to spawn one
5
- * `opencode run` subprocess per agent (instead of POSTing to a
6
- * passive `opencode serve` HTTP API). See opencode-runner.ts for the
7
- * spawning implementation; see ../opencode-runner.ts for the
8
- * rationale and the wire format we parse.
4
+ * v5.5.1 — `bizar_spawn_background` tool, refactored to delegate to the
5
+ * dashboard's SDK-based spawner (`POST /api/background`) instead of
6
+ * spawning `opencode run` subprocesses directly.
7
+ *
8
+ * Why the plugin now talks to the dashboard instead of running `opencode run`:
9
+ * - The v5.5.0 design (opencode-runner.ts) spawned one `opencode run`
10
+ * subprocess per agent. Steering a running agent required
11
+ * kill+respawn with a `[STEERED <ts>]` marker — a poor approximation
12
+ * of "true mid-flight prompt".
13
+ * - opencode's serve child exposes long-lived SDK sessions that accept
14
+ * new prompts via `POST /api/session/{id}/prompt`. Steering is then
15
+ * a real mid-flight redirect: the same session keeps running, the
16
+ * new prompt becomes the next user turn.
17
+ * - The dashboard already owns the opencode SDK (see
18
+ * `bizar-dash/src/server/opencode-sdk.mjs`); it can mediate between
19
+ * the plugin's many bg instances and the single opencode serve child.
20
+ *
21
+ * This tool therefore:
22
+ * 1. Validates the request (Odin-only check, model parsing,
23
+ * timeoutMs clamping — unchanged from v5.5.0).
24
+ * 2. Builds the delegation wrapper prompt if the agent is a subagent.
25
+ * 3. POSTs `{ agent, prompt, worktree, ... }` to the dashboard at
26
+ * `POST /api/background`.
27
+ * 4. Tracks the returned instance in the local InstanceManager so the
28
+ * existing `bizar_status`, `bizar_collect`, `bizar_kill`,
29
+ * `bizar_pause`, `bizar_resume` tools work unchanged (they now
30
+ * delegate to the dashboard HTTP API too — see respective tool files).
31
+ *
32
+ * Backwards compat: the public tool args are unchanged. The return
33
+ * shape is backwards compatible — `instanceId` + `sessionId` are still
34
+ * present, but `processId` is `null` (no OS subprocess) and a new
35
+ * `liveSession: true` flag indicates the new SDK-backed mode.
9
36
  *
10
37
  * Spec §1, §6.3, §7.1.
11
38
  */
12
39
  import { tool } from "@opencode-ai/plugin";
13
40
  import { z } from "zod";
14
41
 
15
- import { generateInstanceId, generateMessageId } from "../background.js";
42
+ import { generateInstanceId } from "../background.js";
16
43
  import type { InstanceManager } from "../background.js";
17
44
  import type { Logger } from "../logger.js";
18
- import { spawnAgent } from "../opencode-runner.js";
19
45
  import { resolve as pathResolve } from "node:path";
20
46
  import { homedir } from "node:os";
47
+ import { readFileSync, existsSync } from "node:fs";
48
+ import { join } from "node:path";
21
49
 
22
50
  /** Spec §7.3: `timeoutMs` clamped to [1000, 1800000] (1s..30min). */
23
51
  const TIMEOUT_MIN_MS = 1000;
@@ -26,24 +54,11 @@ const TIMEOUT_DEFAULT_MS = 300_000;
26
54
 
27
55
  /**
28
56
  * Agents whose `mode` is `primary` and therefore accepted by
29
- * `opencode run --agent <name>`. opencode 1.17.x silently REJECTS
30
- * any agent whose `mode` is `subagent` with a "Falling back to default
31
- * agent" warning — the bg instance then runs odin regardless of what
32
- * the caller asked for. See `wiki/Troubleshooting.md` for the full
33
- * postmortem.
34
- *
35
- * To invoke a subagent cleanly, `spawnAgent` below routes the spawn
36
- * through a primary agent (odin) with an explicit delegation prompt
37
- * that tells odin to call its `task` tool to spawn the requested
38
- * subagent. Subagent changes here MUST mirror `config/agents/*.md`.
57
+ * opencode's `--agent` flag.
39
58
  *
40
59
  * Exported for testability — the test asserts the set is in sync with
41
60
  * the agent configs.
42
61
  */
43
- // v3.20.11 — kept in sync with config/agents/*.md via the
44
- // "is in sync with the on-disk agent configs" test below. The set
45
- // must equal exactly the agents whose frontmatter declares
46
- // `mode: primary` (currently odin, quick, browser-harness).
47
62
  export const PRIMARY_AGENTS: ReadonlySet<string> = new Set([
48
63
  "odin",
49
64
  "quick",
@@ -59,18 +74,9 @@ export function needsDelegationWrapper(agent: string): boolean {
59
74
  }
60
75
 
61
76
  /**
62
- * Build the delegation prompt that wraps a subagent request. The
63
- * wrapper runs as odin; odin must call its `task` tool to spawn the
64
- * requested subagent and report the subagent's final output verbatim.
77
+ * Build the delegation prompt that wraps a subagent request.
65
78
  *
66
- * The prompt is intentionally directive — LLMs are non-deterministic,
67
- * and a passive "please consider delegating" framing is too easily
68
- * ignored. The explicit "do not interpret", "do not perform the work
69
- * yourself", and "report only the subagent's output" constraints give
70
- * the LLM no room to drift.
71
- *
72
- * Exported for testability — the test asserts the prompt is directive
73
- * enough to override Odin's default routing behavior.
79
+ * Exported for testability.
74
80
  */
75
81
  export function buildDelegationPrompt(requestedAgent: string, userPrompt: string): string {
76
82
  return [
@@ -120,23 +126,139 @@ export interface BgSpawnDeps {
120
126
  instanceManager: InstanceManager;
121
127
  worktree: string;
122
128
  logger: Logger;
129
+ /**
130
+ * Optional injection point for tests. When provided, the dashboard
131
+ * HTTP call is short-circuited and the test function is called with
132
+ * the resolved request shape. Production code never sets this.
133
+ */
134
+ _dashboardPost?: (url: string, init: { headers: Record<string, string>; body: string }) => Promise<{ ok: boolean; status: number; json: () => Promise<unknown> }>;
123
135
  }
124
136
 
125
137
  /**
126
- * Compute the LogWriter's actual log path for a given instanceId.
127
- * The plugin's `LogWriter` (../report.ts:147) writes to
128
- * `${logDir}/${sessionId}.log` where `logDir` defaults to
129
- * `~/.cache/bizar/logs` (overridable via the `BIZAR_LOG_DIR` env
130
- * var). The instanceId is what we know at spawn time — the opencode
131
- * sessionId is generated later by the subprocess and we don't
132
- * pre-allocate it. We therefore use the instanceId as the log file
133
- * name and let the runner append to it.
138
+ * Compute the LogWriter's actual log path for a given instanceId. Mirrors
139
+ * the convention used by `bg-spawn.ts` pre-v5.5.1 so existing log readers
140
+ * (dashboard's log viewer) find the file in the same place.
134
141
  */
135
142
  function buildLogPath(instanceId: string): string {
136
143
  const logDir = process.env.BIZAR_LOG_DIR || pathResolve(homedir(), ".cache", "bizar", "logs");
137
144
  return pathResolve(logDir, `${instanceId}.log`);
138
145
  }
139
146
 
147
+ // --- Dashboard HTTP wiring -----------------------------------------------
148
+
149
+ /**
150
+ * Resolve the dashboard base URL. Order:
151
+ * 1. `BIZAR_DASHBOARD_URL` env override (matches dashboard-client.ts).
152
+ * 2. `BIZAR_DASHBOARD_PORT` env override → `http://127.0.0.1:<port>`.
153
+ * 3. Default `http://127.0.0.1:4098` (matches dashboard default port
154
+ * in install.sh + bizarre installer).
155
+ */
156
+ function resolveDashboardUrl(): string {
157
+ const fromEnv = process.env.BIZAR_DASHBOARD_URL;
158
+ if (fromEnv && fromEnv.trim()) return fromEnv.trim().replace(/\/+$/, "");
159
+ const port = process.env.BIZAR_DASHBOARD_PORT;
160
+ if (port && /^\d+$/.test(port)) return `http://127.0.0.1:${port}`;
161
+ return "http://127.0.0.1:4098";
162
+ }
163
+
164
+ const DEFAULT_DASHBOARD_AUTH_PATHS = [
165
+ join(homedir(), ".config", "bizar", "dashboard-secret"),
166
+ join(homedir(), ".cache", "bizarharness", "dash-auth.json"),
167
+ join(homedir(), ".cache", "bizar", "dash-auth.json"),
168
+ ];
169
+
170
+ /**
171
+ * Read the dashboard bearer token from the on-disk secret file. The
172
+ * dashboard writes `~/.config/bizar/dashboard-secret` (mode 0600) on
173
+ * first boot. Loopback requests don't need the token (auth middleware
174
+ * trusts loopback automatically), but we send it anyway when available
175
+ * so a non-loopback deployment works without extra config.
176
+ */
177
+ function readDashboardToken(): string {
178
+ for (const candidate of DEFAULT_DASHBOARD_AUTH_PATHS) {
179
+ try {
180
+ if (!existsSync(candidate)) continue;
181
+ const text = readFileSync(candidate, "utf-8").trim();
182
+ if (text && text.length >= 16) return text;
183
+ } catch {
184
+ /* ignore */
185
+ }
186
+ }
187
+ // Fall back to the password (dash-auth.json carries a `password` field).
188
+ for (const candidate of DEFAULT_DASHBOARD_AUTH_PATHS) {
189
+ try {
190
+ if (!existsSync(candidate)) continue;
191
+ const parsed = JSON.parse(readFileSync(candidate, "utf-8")) as { password?: unknown };
192
+ if (typeof parsed.password === "string" && parsed.password.length >= 16) {
193
+ return parsed.password;
194
+ }
195
+ } catch {
196
+ /* ignore */
197
+ }
198
+ }
199
+ return "";
200
+ }
201
+
202
+ /**
203
+ * POST JSON to the dashboard. Returns the parsed JSON on 2xx; throws a
204
+ * structured `Error` (with `.httpStatus`) on transport / non-2xx so
205
+ * callers can surface a clear error to the agent.
206
+ *
207
+ * In tests, `deps._dashboardPost` overrides this with a mock.
208
+ */
209
+ async function postJsonToDashboard(
210
+ url: string,
211
+ body: unknown,
212
+ logger: Logger,
213
+ override?: BgSpawnDeps["_dashboardPost"],
214
+ ): Promise<unknown> {
215
+ if (override) {
216
+ const res = await override(url, {
217
+ headers: { "content-type": "application/json" },
218
+ body: JSON.stringify(body),
219
+ });
220
+ if (!res.ok) {
221
+ const text = await res.json().catch(() => ({}));
222
+ const err = new Error(
223
+ `dashboard HTTP ${res.status}: ${JSON.stringify(text).slice(0, 200)}`,
224
+ );
225
+ (err as Error & { httpStatus?: number }).httpStatus = res.status;
226
+ throw err;
227
+ }
228
+ return res.json();
229
+ }
230
+
231
+ const token = readDashboardToken();
232
+ const headers: Record<string, string> = {
233
+ "content-type": "application/json",
234
+ accept: "application/json",
235
+ };
236
+ if (token) headers.authorization = `Bearer ${token}`;
237
+
238
+ const ac = new AbortController();
239
+ const timer = setTimeout(() => ac.abort(), 8_000);
240
+ try {
241
+ const res = await fetch(url, {
242
+ method: "POST",
243
+ headers,
244
+ body: JSON.stringify(body),
245
+ signal: ac.signal,
246
+ });
247
+ if (!res.ok) {
248
+ const text = await res.text().catch(() => "");
249
+ const err = new Error(
250
+ `dashboard HTTP ${res.status}: ${text.slice(0, 200)}`,
251
+ );
252
+ (err as Error & { httpStatus?: number }).httpStatus = res.status;
253
+ throw err;
254
+ }
255
+ return res.json();
256
+ } finally {
257
+ clearTimeout(timer);
258
+ }
259
+ void logger;
260
+ }
261
+
140
262
  /**
141
263
  * Build the `bizar_spawn_background` tool. The plugin wires the
142
264
  * result into `Hooks.tool`. The `deps` closure carries the
@@ -145,11 +267,13 @@ function buildLogPath(instanceId: string): string {
145
267
  export function createBgSpawnTool(deps: BgSpawnDeps) {
146
268
  return tool({
147
269
  description:
148
- "Spawn a background agent that runs asynchronously as a separate `opencode run` subprocess. " +
270
+ "Spawn a background agent that runs asynchronously as a long-lived opencode serve session " +
271
+ "(SDK-backed via the dashboard). " +
149
272
  "Only Odin may call this tool. " +
150
273
  "Returns an instanceId immediately (sub-second), then the agent runs to completion in the background. " +
151
274
  "Use `bizar_status` / `bizar_collect` / `bizar_kill` to manage the instance. " +
152
275
  "Use `bizar_bg_view` (CLI) to watch all running agents in a tmux split window. " +
276
+ "Steering is TRUE mid-flight via `bizar_send_message` (no kill+respawn). " +
153
277
  "IMPORTANT: do NOT block waiting for the agent. Return control to the user right after spawning.",
154
278
  args: {
155
279
  agent: z.string().min(1).describe("Agent name to spawn (e.g. 'mimir', 'thor', 'tyr')."),
@@ -223,14 +347,23 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
223
347
  }
224
348
  const timeoutMs = requested;
225
349
 
226
- // 4. Generate the instanceId and seed the manager (track BEFORE
227
- // the subprocess starts, so a fast-exiting agent is still
228
- // queryable via bizar_status).
350
+ // 4. Build the delegation wrapper (mirrors bg-spawn.ts pre-v5.5.1).
351
+ const isPrimary = PRIMARY_AGENTS.has(args.agent);
352
+ const wrapperPrompt = isPrimary
353
+ ? args.prompt
354
+ : buildDelegationPrompt(args.agent, args.prompt);
355
+
356
+ // 5. Pre-allocate the instanceId so we can track BEFORE the
357
+ // dashboard call (track-before-HTTP, HIGH-21). The dashboard
358
+ // generates its own instanceId, but accepting ours would
359
+ // require a second round-trip — instead we accept the
360
+ // dashboard's id and patch it back in. The local InstanceManager
361
+ // mirrors the dashboard state so `bizar_status` works.
229
362
  const instanceId = generateInstanceId();
230
363
  const logPath = buildLogPath(instanceId);
231
364
  const draft = {
232
365
  instanceId,
233
- sessionId: "", // filled in once the opencode run reports it
366
+ sessionId: "", // filled in once the dashboard returns the sessionId
234
367
  agent: args.agent,
235
368
  model: modelOverride
236
369
  ? `${modelOverride.providerID}/${modelOverride.modelID}`
@@ -241,12 +374,9 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
241
374
  logPath,
242
375
  timeoutMs,
243
376
  toolCallCount: 0,
244
- // v0.5.5 — persistent auto-restart
245
377
  persistent: args.persistent ?? false,
246
378
  maxRestarts: args.maxRestarts ?? 3,
247
379
  restartCount: 0,
248
- // v5.x — progress reporting seed. Agents update via
249
- // `bizar_report_progress`; the dashboard renders the bar.
250
380
  progress: 0,
251
381
  toolCalls: [],
252
382
  };
@@ -259,41 +389,38 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
259
389
  };
260
390
  }
261
391
 
262
- // 5. Spawn the opencode run subprocess. The runner returns
263
- // when the opencode child has reported its session id in
264
- // the structured log stream (typically <500ms).
265
- //
266
- // opencode 1.17.x rejects `--agent <subagent>` with a silent
267
- // fallback to the default agent. To invoke a subagent we
268
- // route through a primary wrapper (odin) with a delegation
269
- // prompt. The instance record still attributes the work to
270
- // the requested agent — only the opencode process is odin's.
271
- const messageID = generateMessageId();
272
- const isPrimary = PRIMARY_AGENTS.has(args.agent);
273
- const wrapperAgent = isPrimary ? args.agent : "odin";
274
- const wrapperPrompt = isPrimary
275
- ? args.prompt
276
- : buildDelegationPrompt(args.agent, args.prompt);
277
- let spawnRes: Awaited<ReturnType<typeof spawnAgent>>;
392
+ // 6. POST to the dashboard. The dashboard owns the SDK and the
393
+ // underlying opencode session; we mirror its instanceId.
394
+ const dashboardUrl = `${resolveDashboardUrl()}/api/background`;
395
+ let spawnRes: { instanceId: string; sessionId: string | null; status?: string; liveSession?: boolean };
278
396
  try {
279
- spawnRes = await spawnAgent({
280
- prompt: wrapperPrompt,
281
- agent: wrapperAgent,
282
- model: modelOverride,
283
- worktree: deps.worktree,
284
- logPath,
285
- title: `bgr:${args.agent}:${instanceId}:${messageID}`,
286
- });
397
+ spawnRes = (await postJsonToDashboard(
398
+ dashboardUrl,
399
+ {
400
+ agent: args.agent,
401
+ prompt: wrapperPrompt,
402
+ model: modelOverride
403
+ ? `${modelOverride.providerID}/${modelOverride.modelID}`
404
+ : undefined,
405
+ worktree: deps.worktree,
406
+ timeoutMs,
407
+ persistent: Boolean(args.persistent),
408
+ maxRestarts: args.maxRestarts ?? 3,
409
+ tags: [`spawned-by:${ctx.agent}`, `plugin-instance:${instanceId}`],
410
+ },
411
+ deps.logger,
412
+ deps._dashboardPost,
413
+ )) as { instanceId: string; sessionId: string | null; status?: string; liveSession?: boolean };
287
414
  } catch (err: unknown) {
288
415
  const msg = err instanceof Error ? err.message : String(err);
289
416
  await deps.instanceManager.update(instanceId, {
290
417
  status: "failed",
291
- error: `spawnAgent threw: ${msg}`,
418
+ error: `dashboard POST /api/background failed: ${msg}`,
292
419
  completedAt: Date.now(),
293
420
  });
294
421
  return {
295
422
  output: JSON.stringify({
296
- error: `spawn crashed: ${msg}`,
423
+ error: `spawn failed: dashboard unreachable (${msg}). Make sure the Bizar dashboard is running.`,
297
424
  instanceId,
298
425
  sessionId: null,
299
426
  status: "failed",
@@ -301,103 +428,55 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
301
428
  };
302
429
  }
303
430
 
304
- if (!spawnRes.ok || !spawnRes.sessionId) {
431
+ // 7. The dashboard allocates its own instanceId. Patch our local
432
+ // record to mirror the dashboard state — keep the original
433
+ // instanceId we returned to the caller (the plugin's existing
434
+ // InstanceManager tracks by that id, and the dashboard already
435
+ // has the same metadata via the `tags: ["plugin-instance:<id>"]`
436
+ // we sent above).
437
+ if (spawnRes.instanceId && spawnRes.instanceId !== instanceId) {
438
+ // Update the local record's sessionId + flags; the local id stays
439
+ // as-is so all subsequent tool calls (status/collect/kill/...)
440
+ // resolve correctly. The dashboard id is stored alongside as
441
+ // `dashboardInstanceId` for cross-referencing.
305
442
  await deps.instanceManager.update(instanceId, {
306
- status: "failed",
307
- error: spawnRes.error || "opencode run failed before reporting session id",
308
- completedAt: Date.now(),
443
+ sessionId: spawnRes.sessionId || "",
444
+ status: "running",
445
+ runnerState: "running",
446
+ sessionIdAt: Date.now(),
447
+ liveSession: true,
448
+ dashboardInstanceId: spawnRes.instanceId,
309
449
  });
310
- return {
311
- output: JSON.stringify({
312
- error: `spawn failed: ${spawnRes.error || "no session id"}`,
313
- instanceId,
314
- sessionId: null,
315
- status: "failed",
316
- }),
317
- };
318
- }
319
-
320
- // 6. Persist the sessionId in the instance state. The high-level
321
- // `status` stays "pending" until the runner reports a terminal
322
- // state; we record the processId and the runner's
323
- // intermediate states in the dedicated fields.
324
- await deps.instanceManager.update(instanceId, {
325
- sessionId: spawnRes.sessionId,
326
- status: "running",
327
- processId: spawnRes.processId,
328
- runnerState: "running",
329
- sessionIdAt: Date.now(),
330
- });
331
-
332
- // 7. Wire the runner's exit event to the instance state. When
333
- // the opencode run subprocess exits, the runner updates
334
- // the status (done / failed / killed) and triggers the
335
- // persistent auto-restart flow if appropriate.
336
- if (spawnRes.processId !== undefined) {
337
- const { onExit } = await import("../opencode-runner.js");
338
- onExit(spawnRes.processId, (status) => {
339
- // Map runner states to BackgroundStatus. The runner reports
340
- // "starting" | "running" | "paused" | "done" | "failed" | "killed";
341
- // BackgroundStatus has "pending" | "running" | "paused" | "done" |
342
- // "failed" | "killed" | "timed_out" | "steered". "starting"
343
- // maps to "running" (in-flight, no terminal state). "paused"
344
- // is preserved so pause/resume survives subprocess reconnects.
345
- const mapped: "pending" | "running" | "paused" | "done" | "failed" | "killed" =
346
- status.state === "starting" || status.state === "running"
347
- ? "running"
348
- : status.state === "paused"
349
- ? "paused"
350
- : status.state;
351
-
352
- const update: Parameters<InstanceManager["update"]>[1] = {
353
- status: mapped,
354
- runnerState: status.state,
355
- completedAt: status.endedAt ?? Date.now(),
356
- };
357
- if (status.exitCode !== undefined) update.exitCode = status.exitCode;
358
- if (status.error) {
359
- update.runnerError = status.error;
360
- update.error = status.error;
361
- }
362
- if (status.endedAt !== undefined) update.runnerEndedAt = status.endedAt;
363
- // Best-effort: if the InstanceManager has gone away (e.g.
364
- // the plugin restarted), the update silently no-ops.
365
- deps.instanceManager
366
- .update(instanceId, update)
367
- .then(() => {
368
- // Persistent auto-restart: only for natural failures,
369
- // not for explicit kills or successes.
370
- if (status.state === "failed") {
371
- return deps.instanceManager.maybeAutoRestart(instanceId);
372
- }
373
- return undefined;
374
- })
375
- .catch((err: unknown) => {
376
- deps.logger.warn(
377
- `bizar: bg-spawn exit update failed for ${instanceId}: ${
378
- err instanceof Error ? err.message : String(err)
379
- }`,
380
- );
381
- });
450
+ } else {
451
+ await deps.instanceManager.update(instanceId, {
452
+ sessionId: spawnRes.sessionId || "",
453
+ status: "running",
454
+ runnerState: "running",
455
+ sessionIdAt: Date.now(),
456
+ liveSession: true,
382
457
  });
383
458
  }
384
459
 
385
- // 8. Return the spawn result. v0.8.0 message: the agent is
386
- // running in the background; Odin should return control to
387
- // the user immediately and not block waiting.
460
+ // 8. Return the spawn result. v5.5.1 message: the agent is
461
+ // running in the background on an opencode serve session; Odin
462
+ // should return control to the user immediately.
388
463
  return {
389
464
  output: JSON.stringify({
390
465
  instanceId,
466
+ dashboardInstanceId: spawnRes.instanceId,
391
467
  sessionId: spawnRes.sessionId,
392
- processId: spawnRes.processId,
468
+ processId: null, // no subprocess; the opencode serve child owns the session
393
469
  status: "running",
470
+ liveSession: true,
394
471
  message:
395
- "Background agent started. It will run to completion in a separate `opencode run` subprocess. " +
472
+ "Background agent started. It runs as an opencode serve SDK session managed by the Bizar dashboard. " +
396
473
  "Use `bizar_status <instanceId>` to check progress, `bizar_collect <instanceId>` to wait for the result, " +
474
+ "`bizar_send_message <instanceId> <msg>` for true mid-flight steering, " +
397
475
  "or `bizar_kill <instanceId>` to stop it. Run `bizar bg view` in another terminal to watch all running agents live.",
398
476
  nextSteps: [
399
477
  "Tell the user the agent is running and approximately how long they should expect to wait",
400
478
  "If the user wants the result now, call `bizar_collect <instanceId>` (with a reasonable timeout)",
479
+ "If the user wants to redirect the agent, call `bizar_send_message <instanceId> <msg>` — this is TRUE mid-flight",
401
480
  "If the user wants to stop the agent, call `bizar_kill <instanceId>`",
402
481
  "Do NOT block waiting for the result unless the user explicitly asked for it",
403
482
  ],
@@ -405,4 +484,4 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
405
484
  };
406
485
  },
407
486
  });
408
- }
487
+ }
@@ -1,5 +1,8 @@
1
1
  /**
2
2
  * bg-send-message.test.ts — `bizar_send_message` tool tests.
3
+ *
4
+ * v5.5.1: the tool now performs TRUE mid-flight steer via the dashboard
5
+ * SDK. Tests mock the dashboard POST so we don't need a real server.
3
6
  */
4
7
 
5
8
  import { describe, it, expect } from "bun:test";
@@ -20,27 +23,97 @@ function decode(result: unknown): any {
20
23
  }
21
24
 
22
25
  describe("bg-send-message tool", () => {
23
- it("returns unavailable_in_subprocess_mode for valid instance", async () => {
26
+ it("returns instance_not_found for missing", async () => {
27
+ const mgr = { get: async () => null } as any;
28
+ const tool = createBgSendMessageTool({ instanceManager: mgr, logger: silentLogger });
29
+ const r = await tool.execute(
30
+ { instanceId: "bgr_missing", message: "hi" } as any,
31
+ { agent: "odin" } as any,
32
+ );
33
+ expect(decode(r)).toMatchObject({ error: "instance_not_found" });
34
+ });
35
+
36
+ it("delegates to dashboard POST /api/background/:id/steer (mid-flight)", async () => {
24
37
  const mgr = {
25
- get: async () => ({ instanceId: "bgr_x", status: "running" }),
38
+ get: async () => ({ instanceId: "bgr_x", status: "running", sessionId: "ses_x" }),
26
39
  } as any;
27
- const tool = createBgSendMessageTool({ instanceManager: mgr, logger: silentLogger });
40
+ let capturedUrl = "";
41
+ let capturedBody = "";
42
+ const mockPost = async (
43
+ url: string,
44
+ init: { headers: Record<string, string>; body: string },
45
+ ) => {
46
+ capturedUrl = url;
47
+ capturedBody = init.body;
48
+ return {
49
+ ok: true,
50
+ status: 200,
51
+ json: async () => ({
52
+ ok: true,
53
+ mode: "true_midflight",
54
+ instanceId: "bgr_x",
55
+ steerCount: 1,
56
+ }),
57
+ };
58
+ };
59
+ const tool = createBgSendMessageTool({
60
+ instanceManager: mgr,
61
+ logger: silentLogger,
62
+ _dashboardPost: mockPost,
63
+ } as any);
28
64
  const r = await tool.execute(
29
65
  { instanceId: "bgr_x", message: "go faster" } as any,
30
66
  { agent: "odin" } as any,
31
67
  );
68
+ // Verify the dashboard was hit with the right URL + body.
69
+ expect(capturedUrl).toContain("/api/background/bgr_x/steer");
70
+ expect(JSON.parse(capturedBody).message).toBe("go faster");
32
71
  const out = decode(r);
33
- expect(out.error).toBe("unavailable_in_subprocess_mode");
34
- expect(out.dashboardHint).toBe("POST /api/background/bgr_x/steer");
72
+ expect(out.ok).toBe(true);
73
+ expect(out.mode).toBe("true_midflight");
74
+ expect(out.instanceId).toBe("bgr_x");
75
+ expect(out.steerCount).toBe(1);
35
76
  });
36
77
 
37
- it("returns instance_not_found for missing", async () => {
38
- const mgr = { get: async () => null } as any;
39
- const tool = createBgSendMessageTool({ instanceManager: mgr, logger: silentLogger });
78
+ it("propagates dashboard errors", async () => {
79
+ const mgr = {
80
+ get: async () => ({ instanceId: "bgr_y", status: "running" }),
81
+ } as any;
82
+ const mockPost = async () => ({
83
+ ok: false,
84
+ status: 502,
85
+ json: async () => ({ ok: false, error: "dashboard_down" }),
86
+ });
87
+ const tool = createBgSendMessageTool({
88
+ instanceManager: mgr,
89
+ logger: silentLogger,
90
+ _dashboardPost: mockPost,
91
+ } as any);
40
92
  const r = await tool.execute(
41
- { instanceId: "bgr_missing", message: "hi" } as any,
93
+ { instanceId: "bgr_y", message: "halt" } as any,
42
94
  { agent: "odin" } as any,
43
95
  );
44
- expect(decode(r)).toMatchObject({ error: "instance_not_found" });
96
+ const out = decode(r);
97
+ expect(out.error).toContain("dashboard_down");
98
+ });
99
+
100
+ it("propagates network errors", async () => {
101
+ const mgr = {
102
+ get: async () => ({ instanceId: "bgr_z", status: "running" }),
103
+ } as any;
104
+ const mockPost = async () => {
105
+ throw new Error("ECONNREFUSED");
106
+ };
107
+ const tool = createBgSendMessageTool({
108
+ instanceManager: mgr,
109
+ logger: silentLogger,
110
+ _dashboardPost: mockPost,
111
+ } as any);
112
+ const r = await tool.execute(
113
+ { instanceId: "bgr_z", message: "halt" } as any,
114
+ { agent: "odin" } as any,
115
+ );
116
+ const out = decode(r);
117
+ expect(out.error).toContain("ECONNREFUSED");
45
118
  });
46
- });
119
+ });