@polderlabs/bizar-plugin 0.5.4

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 (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +448 -0
  3. package/bun.lock +88 -0
  4. package/index.ts +1113 -0
  5. package/package.json +42 -0
  6. package/scripts/check-forbidden-imports.sh +33 -0
  7. package/src/background-state.ts +463 -0
  8. package/src/background.ts +964 -0
  9. package/src/commands-impl.ts +369 -0
  10. package/src/commands.ts +880 -0
  11. package/src/event-stream.ts +574 -0
  12. package/src/fingerprint.ts +120 -0
  13. package/src/handoff.ts +79 -0
  14. package/src/http-client.ts +467 -0
  15. package/src/logger.ts +144 -0
  16. package/src/loop.ts +176 -0
  17. package/src/options.ts +421 -0
  18. package/src/plan-fs.ts +323 -0
  19. package/src/report.ts +178 -0
  20. package/src/research-prompt.ts +35 -0
  21. package/src/serve.ts +476 -0
  22. package/src/settings.ts +349 -0
  23. package/src/state.ts +298 -0
  24. package/src/tools/bg-collect.ts +104 -0
  25. package/src/tools/bg-get-comments.ts +239 -0
  26. package/src/tools/bg-kill.ts +87 -0
  27. package/src/tools/bg-spawn.ts +263 -0
  28. package/src/tools/bg-status.ts +99 -0
  29. package/src/tools/plan-action.ts +767 -0
  30. package/src/tools/wait-for-feedback.ts +402 -0
  31. package/tests/attach-handler-bug.test.ts +166 -0
  32. package/tests/background-state.test.ts +277 -0
  33. package/tests/background.test.ts +402 -0
  34. package/tests/block.test.ts +193 -0
  35. package/tests/canonical-key-order.test.ts +71 -0
  36. package/tests/commands-impl.test.ts +442 -0
  37. package/tests/commands.test.ts +548 -0
  38. package/tests/config.test.ts +122 -0
  39. package/tests/dispose.test.ts +336 -0
  40. package/tests/event-stream.test.ts +409 -0
  41. package/tests/event.test.ts +262 -0
  42. package/tests/fingerprint.test.ts +161 -0
  43. package/tests/http-client.test.ts +403 -0
  44. package/tests/init-helpers.test.ts +203 -0
  45. package/tests/integration/slash-command.test.ts +348 -0
  46. package/tests/integration/tool-routing.test.ts +314 -0
  47. package/tests/loop.test.ts +397 -0
  48. package/tests/options.test.ts +274 -0
  49. package/tests/serve.test.ts +335 -0
  50. package/tests/settings.test.ts +351 -0
  51. package/tests/stall-think.test.ts +749 -0
  52. package/tests/state.test.ts +275 -0
  53. package/tests/tools/bg-collect.test.ts +337 -0
  54. package/tests/tools/bg-get-comments.test.ts +485 -0
  55. package/tests/tools/bg-kill.test.ts +231 -0
  56. package/tests/tools/bg-spawn.test.ts +311 -0
  57. package/tests/tools/bg-status.test.ts +216 -0
  58. package/tests/tools/plan-action.test.ts +599 -0
  59. package/tests/tools/wait-for-feedback.test.ts +390 -0
  60. package/tsconfig.json +29 -0
@@ -0,0 +1,104 @@
1
+ /**
2
+ * bg-collect.ts
3
+ *
4
+ * `bizar_collect` tool (v0.4.2 spec §7.1, §4.4).
5
+ *
6
+ * Odin-only — only Odin may collect. Blocks until the instance reaches a
7
+ * terminal state or `timeoutMs` elapses. On timeout, returns
8
+ * `status: "running"` with the last known `resultPreview`.
9
+ *
10
+ * Returns:
11
+ * `{ instanceId, status, result, toolCallCount, durationMs, error? }`
12
+ *
13
+ * The `result` field is the concatenated text of all assistant messages
14
+ * (TextPart only). If the instance ended with a threshold-12 loop-guard
15
+ * error, the marker `[loop guard: 12 identical calls to <tool>]` is
16
+ * prepended (MEDIUM-30).
17
+ */
18
+
19
+ import { tool } from "@opencode-ai/plugin";
20
+ import { z } from "zod";
21
+
22
+ import type { InstanceManager } from "../background.js";
23
+ import type { Logger } from "../logger.js";
24
+
25
+ const TIMEOUT_MIN_MS = 1000;
26
+ const TIMEOUT_MAX_MS = 1_800_000;
27
+ const TIMEOUT_DEFAULT_MS = 60_000;
28
+
29
+ export interface BgCollectDeps {
30
+ instanceManager: InstanceManager;
31
+ logger: Logger;
32
+ }
33
+
34
+ /**
35
+ * Build the `bizar_collect` tool.
36
+ */
37
+ export function createBgCollectTool(deps: BgCollectDeps) {
38
+ return tool({
39
+ description:
40
+ "Wait for a background instance to complete and return its result. " +
41
+ "Only Odin may collect. Blocks until the instance reaches a terminal state or the timeout fires.",
42
+ args: {
43
+ instanceId: z
44
+ .string()
45
+ .min(1)
46
+ .describe("Instance id returned by bizar_spawn_background."),
47
+ timeoutMs: z
48
+ .number()
49
+ .int()
50
+ .positive()
51
+ .optional()
52
+ .describe("Collect-time timeout in ms (1s..30min, default 60s)."),
53
+ },
54
+ execute: async (rawArgs, ctx) => {
55
+ // Odin-only (§6.3).
56
+ if (ctx.agent !== "odin") {
57
+ return {
58
+ output: JSON.stringify({
59
+ error:
60
+ "Only Odin can collect background agent results. Use bizar_status to inspect or ask Odin to collect.",
61
+ }),
62
+ };
63
+ }
64
+ const args = rawArgs as { instanceId: string; timeoutMs?: number };
65
+
66
+ // Clamp timeoutMs (MEDIUM-33 / §7.3).
67
+ const requested = args.timeoutMs ?? TIMEOUT_DEFAULT_MS;
68
+ if (requested < TIMEOUT_MIN_MS || requested > TIMEOUT_MAX_MS) {
69
+ return {
70
+ output: JSON.stringify({
71
+ error: `timeoutMs must be between ${TIMEOUT_MIN_MS} (1s) and ${TIMEOUT_MAX_MS} (30min). Got ${requested}.`,
72
+ }),
73
+ };
74
+ }
75
+ const timeoutMs = requested;
76
+
77
+ try {
78
+ const result = await deps.instanceManager.collect(args.instanceId, timeoutMs);
79
+ return {
80
+ output: JSON.stringify({
81
+ instanceId: args.instanceId,
82
+ status: result.status,
83
+ result: result.result,
84
+ toolCallCount: result.toolCallCount,
85
+ durationMs: result.durationMs,
86
+ ...(result.error !== undefined ? { error: result.error } : {}),
87
+ }),
88
+ };
89
+ } catch (err: unknown) {
90
+ deps.logger.warn(
91
+ `bizar: collect(${args.instanceId}) failed: ${
92
+ err instanceof Error ? err.message : String(err)
93
+ }`,
94
+ );
95
+ return {
96
+ output: JSON.stringify({
97
+ error: `collect failed: ${err instanceof Error ? err.message : String(err)}`,
98
+ instanceId: args.instanceId,
99
+ }),
100
+ };
101
+ }
102
+ },
103
+ });
104
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * bg-get-comments.ts
3
+ *
4
+ * `bizar_get_plan_comments` tool — read-only access to the comments on a
5
+ * Bizar Plan canvas.
6
+ *
7
+ * Background agents (Thor, Tyr, Mimir, etc.) call this to pick up user
8
+ * feedback pinned to specific elements they are working on. The tool
9
+ * reads `plans/<slug>/plan.json` from disk and returns the comments
10
+ * array, optionally filtered to a specific element.
11
+ *
12
+ * The schema on disk is the v2 canvas shape:
13
+ *
14
+ * {
15
+ * schemaVersion: 2,
16
+ * title: "...",
17
+ * elements: [...],
18
+ * connections: [...],
19
+ * comments: [
20
+ * {
21
+ * id: "c_xxx",
22
+ * x: 100, y: 200,
23
+ * elementId: "el_yyy" | null,
24
+ * author: "DrB0rk",
25
+ * text: "Make this button bigger",
26
+ * created: "2026-06-18T...",
27
+ * thread: [
28
+ * { id: "r_xxx", author: "ai", text: "Done", created: "..." }
29
+ * ]
30
+ * }
31
+ * ],
32
+ * viewport: { x, y, zoom }
33
+ * }
34
+ *
35
+ * If the file does not exist, has an older shape, or the elementId filter
36
+ * yields zero results, the tool returns an empty array — never an error.
37
+ * This matches the principle that a missing plan is not the agent's
38
+ * problem; it just means "no feedback on file".
39
+ *
40
+ * Returns (on success):
41
+ * `Array<{ id, x, y, elementId, author, text, created, thread: [...] }>`
42
+ *
43
+ * Returns (on error):
44
+ * `{ error: string, planSlug: string }`
45
+ *
46
+ * Read-only — available to ALL agents (Vör, Frigg, Mimir, Odin, Thor,
47
+ * Tyr, Heimdall, etc.). The function is pure: no side effects, no
48
+ * network calls, no process spawning.
49
+ *
50
+ * The "worktree" passed in via deps is the directory the plugin was
51
+ * loaded for; `plans/` lives at the root of the project.
52
+ */
53
+
54
+ import { tool } from "@opencode-ai/plugin";
55
+ import { z } from "zod";
56
+ import { existsSync, readFileSync } from "node:fs";
57
+ import { join } from "node:path";
58
+
59
+ import type { Logger } from "../logger.js";
60
+
61
+ export interface BgGetCommentsDeps {
62
+ /** The project's working directory (e.g. /home/user/proj). `plans/` is here. */
63
+ worktree: string;
64
+ logger: Logger;
65
+ }
66
+
67
+ // --- On-disk shapes ---------------------------------------------------------
68
+
69
+ /** A single thread reply on a canvas comment. */
70
+ interface PlanCommentReply {
71
+ id?: string;
72
+ author?: string;
73
+ text?: string;
74
+ created?: string;
75
+ }
76
+
77
+ /** A single canvas comment as stored in plan.json. */
78
+ interface PlanComment {
79
+ id: string;
80
+ x?: number;
81
+ y?: number;
82
+ elementId?: string | null;
83
+ author?: string;
84
+ text?: string;
85
+ created?: string;
86
+ thread?: PlanCommentReply[];
87
+ }
88
+
89
+ /** The minimum subset of plan.json the tool reads. */
90
+ interface PlanCanvasFile {
91
+ schemaVersion?: number;
92
+ title?: string;
93
+ elements?: Array<{ id?: string; title?: string }>;
94
+ connections?: unknown[];
95
+ comments?: PlanComment[];
96
+ viewport?: unknown;
97
+ }
98
+
99
+ // --- Validation helpers -----------------------------------------------------
100
+
101
+ /** Same slug rule used by `cli/plan.mjs`. Lowercase, hyphens, 1–64 chars. */
102
+ const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/;
103
+
104
+ function isValidSlug(s: string): boolean {
105
+ return SLUG_REGEX.test(s);
106
+ }
107
+
108
+ // --- Core read function (extracted for testability) --------------------------
109
+
110
+ /**
111
+ * Read the plan.json from disk, parse it, and return the filtered comments.
112
+ *
113
+ * Exported (named) so the test file can drive the same code path the
114
+ * real tool uses, without needing a tool framework.
115
+ *
116
+ * Errors are swallowed and returned as a result — a missing plan should
117
+ * never break an agent session. The only fatal error is "invalid slug",
118
+ * which is a programming error rather than a runtime condition.
119
+ */
120
+ export function readPlanComments(
121
+ worktree: string,
122
+ planSlug: string,
123
+ elementId: string | undefined,
124
+ ): { ok: true; comments: PlanComment[]; planSlug: string } | { ok: false; error: string; planSlug: string } {
125
+ if (!isValidSlug(planSlug)) {
126
+ return { ok: false, error: `Invalid planSlug: "${planSlug}". Must match ^[a-z0-9][a-z0-9-]{0,63}$.`, planSlug };
127
+ }
128
+
129
+ const planPath = join(worktree, "plans", planSlug, "plan.json");
130
+ if (!existsSync(planPath)) {
131
+ // No plan.json — this is a v1 plan, or the plan was never created.
132
+ // We return an empty array; the agent should treat it as "no feedback".
133
+ return { ok: true, comments: [], planSlug };
134
+ }
135
+
136
+ let parsed: PlanCanvasFile;
137
+ try {
138
+ const raw = readFileSync(planPath, "utf-8");
139
+ parsed = JSON.parse(raw) as PlanCanvasFile;
140
+ } catch (err: unknown) {
141
+ const msg = err instanceof Error ? err.message : String(err);
142
+ return { ok: false, error: `Failed to read plan.json: ${msg}`, planSlug };
143
+ }
144
+
145
+ // Guard against the JSON being null or a non-object (e.g. "null" or "[]"
146
+ // at the top level). The v2 schema requires an object; anything else is
147
+ // a programmer error rather than a runtime condition.
148
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
149
+ return {
150
+ ok: false,
151
+ error: `plan.json is not a v2 canvas object (got ${Array.isArray(parsed) ? "array" : typeof parsed})`,
152
+ planSlug,
153
+ };
154
+ }
155
+
156
+ const all = Array.isArray(parsed.comments) ? parsed.comments : [];
157
+
158
+ // Filter by elementId if requested. Note: we keep elementId === null
159
+ // (canvas-pinned, no element) separate from elementId === undefined
160
+ // (malformed comment). The latter is filtered out as "not a comment".
161
+ let filtered: PlanComment[];
162
+ if (elementId === undefined) {
163
+ filtered = all.filter((c) => c && typeof c === "object" && typeof c.id === "string");
164
+ } else if (elementId === "nil" || elementId === "null") {
165
+ // explicit "canvas-pinned only"
166
+ filtered = all.filter(
167
+ (c) => c && typeof c === "object" && typeof c.id === "string" && c.elementId == null,
168
+ );
169
+ } else if (elementId === "") {
170
+ // empty string means "all comments" (used by the v2 viewer's
171
+ // "show me everything" GET with ?elementId=)
172
+ filtered = all.filter((c) => c && typeof c === "object" && typeof c.id === "string");
173
+ } else {
174
+ filtered = all.filter(
175
+ (c) => c && typeof c === "object" && typeof c.id === "string" && c.elementId === elementId,
176
+ );
177
+ }
178
+
179
+ // Sort by created time (oldest first) so the agent sees a chronological
180
+ // thread. Missing/empty timestamps are pushed to the end.
181
+ filtered.sort((a, b) => {
182
+ const at = String(a.created || "");
183
+ const bt = String(b.created || "");
184
+ if (at === bt) return 0;
185
+ if (at === "") return 1;
186
+ if (bt === "") return -1;
187
+ return at.localeCompare(bt);
188
+ });
189
+
190
+ return { ok: true, comments: filtered, planSlug };
191
+ }
192
+
193
+ // --- Tool factory -----------------------------------------------------------
194
+
195
+ /**
196
+ * Build the `bizar_get_plan_comments` tool. The plugin wires the result
197
+ * into `Hooks.tool`. The `deps` closure carries the worktree and logger.
198
+ */
199
+ export function createBgGetCommentsTool(deps: BgGetCommentsDeps) {
200
+ return tool({
201
+ description:
202
+ "Read the comments pinned to elements on a Bizar Plan canvas. " +
203
+ "Use this to pick up user feedback while implementing changes. " +
204
+ "Read-only; available to all agents. " +
205
+ "Returns an array of comment objects with id, x, y, elementId, " +
206
+ "author, text, created, and a thread of replies.",
207
+ args: {
208
+ planSlug: z
209
+ .string()
210
+ .min(1)
211
+ .max(64)
212
+ .describe(
213
+ "The plan's slug (lowercase, hyphens, e.g. 'my-feature'). " +
214
+ "Matches the directory under plans/<slug>/.",
215
+ ),
216
+ elementId: z
217
+ .string()
218
+ .optional()
219
+ .describe(
220
+ "Optional element id (e.g. 'el_abc123'). If provided, only " +
221
+ "comments pinned to that element are returned. Omit to " +
222
+ "get all comments on the plan. Pass an empty string, " +
223
+ "'nil', or 'null' to get comments pinned to the canvas " +
224
+ "(no element).",
225
+ ),
226
+ },
227
+ execute: async (rawArgs) => {
228
+ const args = rawArgs as { planSlug: string; elementId?: string };
229
+ const result = readPlanComments(deps.worktree, args.planSlug, args.elementId);
230
+ if (!result.ok) {
231
+ deps.logger.warn(
232
+ `bizar: get_plan_comments(${args.planSlug}) failed: ${result.error}`,
233
+ );
234
+ return { output: JSON.stringify({ error: result.error, planSlug: args.planSlug }) };
235
+ }
236
+ return { output: JSON.stringify(result.comments) };
237
+ },
238
+ });
239
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * bg-kill.ts
3
+ *
4
+ * `bizar_kill` tool (v0.4.2 spec §1.5, §7.1, HIGH-4, MEDIUM-40).
5
+ *
6
+ * Odin-only — only Odin may kill background instances.
7
+ *
8
+ * Calls `POST /session/{id}/abort` (NOT `DELETE /session/{id}`). The
9
+ * session record is preserved in opencode for history; the running
10
+ * loop is stopped.
11
+ *
12
+ * After abort, the next event for that session is `EventSessionIdle`
13
+ * or `EventSessionError`. The plugin's event handler updates the
14
+ * instance status; `bizar_kill` itself also stamps `status: "killed"`
15
+ * synchronously so the caller gets immediate feedback.
16
+ *
17
+ * No-op on already-terminal instances (returns the current status).
18
+ */
19
+
20
+ import { tool } from "@opencode-ai/plugin";
21
+ import { z } from "zod";
22
+
23
+ import type { InstanceManager } from "../background.js";
24
+ import type { Logger } from "../logger.js";
25
+
26
+ export interface BgKillDeps {
27
+ instanceManager: InstanceManager;
28
+ logger: Logger;
29
+ }
30
+
31
+ export function createBgKillTool(deps: BgKillDeps) {
32
+ return tool({
33
+ description:
34
+ "Kill a running background instance via POST /session/{id}/abort. " +
35
+ "Only Odin may kill. No-op on already-terminal instances.",
36
+ args: {
37
+ instanceId: z
38
+ .string()
39
+ .min(1)
40
+ .describe("Instance id returned by bizar_spawn_background."),
41
+ },
42
+ execute: async (rawArgs, ctx) => {
43
+ if (ctx.agent !== "odin") {
44
+ return {
45
+ output: JSON.stringify({
46
+ error:
47
+ "Only Odin can kill background agents. Use bizar_status to inspect or ask Odin to kill.",
48
+ }),
49
+ };
50
+ }
51
+ const args = rawArgs as { instanceId: string };
52
+ const inst = await deps.instanceManager.get(args.instanceId);
53
+ if (!inst) {
54
+ return {
55
+ output: JSON.stringify({
56
+ error: `instance not found`,
57
+ instanceId: args.instanceId,
58
+ }),
59
+ };
60
+ }
61
+ try {
62
+ await deps.instanceManager.kill(args.instanceId);
63
+ // After kill, the in-memory state should be "killed". Re-read so
64
+ // the caller sees the final status.
65
+ const after = await deps.instanceManager.get(args.instanceId);
66
+ return {
67
+ output: JSON.stringify({
68
+ instanceId: args.instanceId,
69
+ status: after?.status ?? "killed",
70
+ }),
71
+ };
72
+ } catch (err: unknown) {
73
+ deps.logger.warn(
74
+ `bizar: kill(${args.instanceId}) failed: ${
75
+ err instanceof Error ? err.message : String(err)
76
+ }`,
77
+ );
78
+ return {
79
+ output: JSON.stringify({
80
+ error: `kill failed: ${err instanceof Error ? err.message : String(err)}`,
81
+ instanceId: args.instanceId,
82
+ }),
83
+ };
84
+ }
85
+ },
86
+ });
87
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * bg-spawn.ts
3
+ *
4
+ * `bizar_spawn_background` tool (v0.4.2 spec §1, §6.3, §7.1).
5
+ *
6
+ * Odin-only — any other agent calling this tool receives a clear error
7
+ * (MEDIUM-26). The caller's `ctx.agent` is the source of truth.
8
+ *
9
+ * Args:
10
+ * - `agent: string` — the agent to spawn (e.g. "mimir", "thor", "tyr").
11
+ * - `prompt: string` — the user prompt; sent verbatim via
12
+ * `parts: [{ type: "text", text: prompt }]`.
13
+ * - `model?: string` — `"<providerID>/<modelID>"` override (LOW-34).
14
+ * - `timeoutMs?: number` — collect-time timeout, clamped to [1000, 1800000].
15
+ *
16
+ * Returns on success:
17
+ * `{ instanceId, sessionId, status: "pending" }`
18
+ *
19
+ * The "Track BEFORE HTTP" invariant (spec §2.2 / HIGH-21):
20
+ * 1. Validate inputs.
21
+ * 2. Generate `instanceId` and `messageID`.
22
+ * 3. `InstanceManager.add()` — atomic cap check + insert; map entry
23
+ * exists BEFORE any HTTP call.
24
+ * 4. `POST /session` — returns the opencode `sessionId`.
25
+ * 5. `POST /session/{id}/prompt_async` — fire the prompt.
26
+ * 6. On either HTTP failure, mark the instance `failed` and return the
27
+ * error. The map is never left in a half-state.
28
+ */
29
+
30
+ import { tool } from "@opencode-ai/plugin";
31
+ import { z } from "zod";
32
+
33
+ import type { InstanceManager } from "../background.js";
34
+ import { generateInstanceId, generateMessageId } from "../background.js";
35
+ import type { HttpClient, ModelOverride } from "../http-client.js";
36
+ import type { Logger } from "../logger.js";
37
+
38
+ // --- Spec §1.4 / LOW-34: model parameter parsing -------------------------
39
+
40
+ /**
41
+ * Parse the `model?: string` argument.
42
+ * - "provider/model" → { providerID, modelID }
43
+ * - "model" → null (no slash)
44
+ * - "a/b/c" → null (multiple slashes)
45
+ * - "provider/" → null (empty half)
46
+ * - "/model" → null (empty half)
47
+ */
48
+ function parseModel(raw: string): ModelOverride | null {
49
+ if (raw === "") return null;
50
+ const parts = raw.split("/");
51
+ if (parts.length !== 2) return null;
52
+ const providerID = parts[0];
53
+ const modelID = parts[1];
54
+ if (!providerID || !modelID) return null;
55
+ return { providerID, modelID };
56
+ }
57
+
58
+ // --- Tool factory ---------------------------------------------------------
59
+
60
+ /** Spec §7.3: `timeoutMs` clamped to [1000, 1800000] (1s..30min). */
61
+ const TIMEOUT_MIN_MS = 1000;
62
+ const TIMEOUT_MAX_MS = 1_800_000;
63
+ const TIMEOUT_DEFAULT_MS = 300_000;
64
+
65
+ export interface BgSpawnDeps {
66
+ instanceManager: InstanceManager;
67
+ http: HttpClient;
68
+ worktree: string;
69
+ logger: Logger;
70
+ }
71
+
72
+ /**
73
+ * Build the `bizar_spawn_background` tool. The plugin wires the result
74
+ * into `Hooks.tool`. The `deps` closure carries the per-process state
75
+ * (InstanceManager, HttpClient, worktree, logger).
76
+ */
77
+ export function createBgSpawnTool(deps: BgSpawnDeps) {
78
+ return tool({
79
+ description:
80
+ "Spawn a background agent that runs asynchronously. Only Odin may call this tool. " +
81
+ "Returns an instanceId; use bizar_status / bizar_collect / bizar_kill to manage the instance.",
82
+ args: {
83
+ agent: z.string().min(1).describe("Agent name to spawn (e.g. 'mimir', 'thor', 'tyr')."),
84
+ prompt: z.string().min(1).describe("User prompt for the background session."),
85
+ model: z
86
+ .string()
87
+ .optional()
88
+ .describe("Optional model override in 'providerID/modelID' format."),
89
+ timeoutMs: z
90
+ .number()
91
+ .int()
92
+ .positive()
93
+ .optional()
94
+ .describe("Collect-time timeout in ms (1s..30min, default 5min)."),
95
+ },
96
+ execute: async (rawArgs, ctx) => {
97
+ // 1. Odin-only (MEDIUM-26).
98
+ if (ctx.agent !== "odin") {
99
+ return {
100
+ output: JSON.stringify({
101
+ error:
102
+ "Only Odin can spawn background agents. Use the task tool for sync work, or ask Odin to spawn a background agent.",
103
+ }),
104
+ };
105
+ }
106
+
107
+ const args = rawArgs as {
108
+ agent: string;
109
+ prompt: string;
110
+ model?: string;
111
+ timeoutMs?: number;
112
+ };
113
+
114
+ // 2. Validate the model parameter (LOW-34 / §1.4).
115
+ let modelOverride: ModelOverride | undefined;
116
+ if (args.model !== undefined && args.model !== "") {
117
+ const m = parseModel(args.model);
118
+ if (m === null) {
119
+ return {
120
+ output: JSON.stringify({
121
+ error: `model must be in "providerID/modelID" format (e.g. "minimax/MiniMax-M3"). Omit to use the agent's default.`,
122
+ }),
123
+ };
124
+ }
125
+ modelOverride = m;
126
+ }
127
+
128
+ // 3. Clamp timeoutMs (MEDIUM-33 / §7.3).
129
+ const requested = args.timeoutMs ?? TIMEOUT_DEFAULT_MS;
130
+ if (requested < TIMEOUT_MIN_MS || requested > TIMEOUT_MAX_MS) {
131
+ return {
132
+ output: JSON.stringify({
133
+ error: `timeoutMs must be between ${TIMEOUT_MIN_MS} (1s) and ${TIMEOUT_MAX_MS} (30min). Got ${requested}.`,
134
+ }),
135
+ };
136
+ }
137
+ const timeoutMs = requested;
138
+
139
+ // 4. Generate the instanceId and seed the manager (track BEFORE HTTP).
140
+ const instanceId = generateInstanceId();
141
+ const draft = {
142
+ instanceId,
143
+ sessionId: "", // filled in by POST /session response
144
+ agent: args.agent,
145
+ model: modelOverride
146
+ ? `${modelOverride.providerID}/${modelOverride.modelID}`
147
+ : "agent-default",
148
+ promptPreview: args.prompt.slice(0, 200),
149
+ parentAgent: ctx.agent,
150
+ logPath: buildLogPath(deps.worktree, instanceId),
151
+ timeoutMs,
152
+ toolCallCount: 0,
153
+ };
154
+ const addRes = await deps.instanceManager.add(draft);
155
+ if (addRes === "cap_reached") {
156
+ return {
157
+ output: JSON.stringify({
158
+ error: `Max concurrent instances reached. Wait for one to finish or call bizar_kill.`,
159
+ }),
160
+ };
161
+ }
162
+
163
+ // 5. POST /session. The in-memory entry already exists.
164
+ const sessionRes = await deps.http.createSession(
165
+ {
166
+ parentID: ctx.sessionID,
167
+ title: `bgr:${args.agent}:${instanceId}`,
168
+ agent: args.agent,
169
+ ...(modelOverride ? { model: modelOverride } : {}),
170
+ },
171
+ deps.worktree,
172
+ );
173
+ if (!sessionRes.ok) {
174
+ await deps.instanceManager.update(instanceId, {
175
+ status: "failed",
176
+ error: `POST /session failed: ${sessionRes.error}`,
177
+ completedAt: Date.now(),
178
+ });
179
+ return {
180
+ output: JSON.stringify({
181
+ error: `spawn failed: ${sessionRes.error}`,
182
+ instanceId,
183
+ sessionId: null,
184
+ status: "failed",
185
+ }),
186
+ };
187
+ }
188
+
189
+ // 6. Persist the sessionId in the in-memory state.
190
+ await deps.instanceManager.update(instanceId, {
191
+ sessionId: sessionRes.value.id,
192
+ status: "running",
193
+ });
194
+
195
+ // 6b. BUGFIX (v0.5.1): Now that the real sessionId is known,
196
+ // attach the SSE event handler for this instance. The track-BEFORE-
197
+ // HTTP invariant is preserved (instance is in the map from step 4),
198
+ // but the per-session event subscription is deferred to here so it
199
+ // can be registered against the real sessionId rather than "".
200
+ // Re-read the instance so the SSE handler sees the updated sessionId.
201
+ const freshInstance = await deps.instanceManager.get(instanceId);
202
+ if (freshInstance) {
203
+ try {
204
+ deps.instanceManager.attachEventHandler(freshInstance);
205
+ } catch (err: unknown) {
206
+ // Event handler attachment is best-effort. If it fails (e.g. the
207
+ // SSE stream is disconnected) the instance is still tracked and
208
+ // can be re-attached later via a reconnect.
209
+ deps.logger.warn(
210
+ `bizar: attachEventHandler failed for ${instanceId}: ${
211
+ err instanceof Error ? err.message : String(err)
212
+ }`,
213
+ );
214
+ }
215
+ }
216
+
217
+ // 7. POST /session/{id}/prompt_async.
218
+ const messageID = generateMessageId();
219
+ const sendRes = await deps.http.sendPrompt(
220
+ {
221
+ sessionId: sessionRes.value.id,
222
+ messageID,
223
+ agent: args.agent,
224
+ ...(modelOverride ? { model: modelOverride } : {}),
225
+ parts: [{ type: "text", text: args.prompt }],
226
+ },
227
+ deps.worktree,
228
+ );
229
+ if (!sendRes.ok) {
230
+ await deps.instanceManager.update(instanceId, {
231
+ status: "failed",
232
+ error: `POST /session/{id}/prompt_async failed: ${sendRes.error}`,
233
+ completedAt: Date.now(),
234
+ });
235
+ return {
236
+ output: JSON.stringify({
237
+ error: `spawn failed: ${sendRes.error}`,
238
+ instanceId,
239
+ sessionId: sessionRes.value.id,
240
+ status: "failed",
241
+ }),
242
+ };
243
+ }
244
+
245
+ return {
246
+ output: JSON.stringify({
247
+ instanceId,
248
+ sessionId: sessionRes.value.id,
249
+ status: "pending",
250
+ }),
251
+ };
252
+ },
253
+ });
254
+ }
255
+
256
+ // --- Helpers --------------------------------------------------------------
257
+
258
+ function buildLogPath(worktree: string, instanceId: string): string {
259
+ // The log file is owned by the opencode serve child (not by us). The
260
+ // plugin doesn't write to it. We still record the conventional path so
261
+ // the user can `cat` the per-session log for diagnostics.
262
+ return `${worktree}/.opencode/log/${instanceId}.log`;
263
+ }