@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,23 +1,51 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* plugins/bizar/src/tools/bg-spawn.ts
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
128
|
-
*
|
|
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
|
|
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.
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
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
|
-
//
|
|
263
|
-
//
|
|
264
|
-
|
|
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
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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: `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
443
|
+
sessionId: spawnRes.sessionId || "",
|
|
444
|
+
status: "running",
|
|
445
|
+
runnerState: "running",
|
|
446
|
+
sessionIdAt: Date.now(),
|
|
447
|
+
liveSession: true,
|
|
448
|
+
dashboardInstanceId: spawnRes.instanceId,
|
|
309
449
|
});
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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.
|
|
386
|
-
// running in the background
|
|
387
|
-
//
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
34
|
-
expect(out.
|
|
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("
|
|
38
|
-
const mgr = {
|
|
39
|
-
|
|
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: "
|
|
93
|
+
{ instanceId: "bgr_y", message: "halt" } as any,
|
|
42
94
|
{ agent: "odin" } as any,
|
|
43
95
|
);
|
|
44
|
-
|
|
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
|
+
});
|