@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.
- package/LICENSE +21 -0
- package/README.md +448 -0
- package/bun.lock +88 -0
- package/index.ts +1113 -0
- package/package.json +42 -0
- package/scripts/check-forbidden-imports.sh +33 -0
- package/src/background-state.ts +463 -0
- package/src/background.ts +964 -0
- package/src/commands-impl.ts +369 -0
- package/src/commands.ts +880 -0
- package/src/event-stream.ts +574 -0
- package/src/fingerprint.ts +120 -0
- package/src/handoff.ts +79 -0
- package/src/http-client.ts +467 -0
- package/src/logger.ts +144 -0
- package/src/loop.ts +176 -0
- package/src/options.ts +421 -0
- package/src/plan-fs.ts +323 -0
- package/src/report.ts +178 -0
- package/src/research-prompt.ts +35 -0
- package/src/serve.ts +476 -0
- package/src/settings.ts +349 -0
- package/src/state.ts +298 -0
- package/src/tools/bg-collect.ts +104 -0
- package/src/tools/bg-get-comments.ts +239 -0
- package/src/tools/bg-kill.ts +87 -0
- package/src/tools/bg-spawn.ts +263 -0
- package/src/tools/bg-status.ts +99 -0
- package/src/tools/plan-action.ts +767 -0
- package/src/tools/wait-for-feedback.ts +402 -0
- package/tests/attach-handler-bug.test.ts +166 -0
- package/tests/background-state.test.ts +277 -0
- package/tests/background.test.ts +402 -0
- package/tests/block.test.ts +193 -0
- package/tests/canonical-key-order.test.ts +71 -0
- package/tests/commands-impl.test.ts +442 -0
- package/tests/commands.test.ts +548 -0
- package/tests/config.test.ts +122 -0
- package/tests/dispose.test.ts +336 -0
- package/tests/event-stream.test.ts +409 -0
- package/tests/event.test.ts +262 -0
- package/tests/fingerprint.test.ts +161 -0
- package/tests/http-client.test.ts +403 -0
- package/tests/init-helpers.test.ts +203 -0
- package/tests/integration/slash-command.test.ts +348 -0
- package/tests/integration/tool-routing.test.ts +314 -0
- package/tests/loop.test.ts +397 -0
- package/tests/options.test.ts +274 -0
- package/tests/serve.test.ts +335 -0
- package/tests/settings.test.ts +351 -0
- package/tests/stall-think.test.ts +749 -0
- package/tests/state.test.ts +275 -0
- package/tests/tools/bg-collect.test.ts +337 -0
- package/tests/tools/bg-get-comments.test.ts +485 -0
- package/tests/tools/bg-kill.test.ts +231 -0
- package/tests/tools/bg-spawn.test.ts +311 -0
- package/tests/tools/bg-status.test.ts +216 -0
- package/tests/tools/plan-action.test.ts +599 -0
- package/tests/tools/wait-for-feedback.test.ts +390 -0
- 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
|
+
}
|