@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,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands-impl.ts
|
|
3
|
+
*
|
|
4
|
+
* v0.5.0 — Side-effect executor for slash commands.
|
|
5
|
+
*
|
|
6
|
+
* The `commands.ts` module is a pure parser: given a user message, it
|
|
7
|
+
* returns a `SlashCommandResult` describing what the chat hook should
|
|
8
|
+
* do. This module implements the "do" half:
|
|
9
|
+
*
|
|
10
|
+
* - `executeSideEffect` — dispatches `create_plan`,
|
|
11
|
+
* `list_plans`, `open_plan_url`, and `tool_invocation` to the
|
|
12
|
+
* right handler.
|
|
13
|
+
* - `executeToolInvocation` — builds a synthetic `ToolContext`,
|
|
14
|
+
* pre-validates args against the tool's Zod schema, and invokes
|
|
15
|
+
* the tool's `execute()` directly.
|
|
16
|
+
* - `buildSyntheticToolContext` — constructs the `ToolContext` shape
|
|
17
|
+
* that opencode's tool framework requires (per R6).
|
|
18
|
+
* - `validateToolArgs` — wraps the tool's `ZodRawShape` into
|
|
19
|
+
* a `z.object(...)` and runs `safeParse` (per C2).
|
|
20
|
+
*
|
|
21
|
+
* The split keeps the parser pure (no I/O, no tool imports, no
|
|
22
|
+
* ToolContext) and concentrates the runtime dependencies in one
|
|
23
|
+
* module that the chat hook imports. The parser remains testable
|
|
24
|
+
* without Bun, the opencode SDK, or a real worktree.
|
|
25
|
+
*
|
|
26
|
+
* Error handling:
|
|
27
|
+
* - All functions return a structured `ExecuteResult` and NEVER
|
|
28
|
+
* throw. The chat hook wraps calls in a try/catch as a
|
|
29
|
+
* defense-in-depth measure, but normal failures land in
|
|
30
|
+
* `responseOverride` so the user sees them.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { z } from "zod";
|
|
34
|
+
import type { ToolContext, ToolDefinition } from "@opencode-ai/plugin";
|
|
35
|
+
|
|
36
|
+
import type { Logger } from "./logger.js";
|
|
37
|
+
import type { DefaultTemplate } from "./settings.js";
|
|
38
|
+
import type { SideEffect } from "./commands.js";
|
|
39
|
+
import {
|
|
40
|
+
createPlan as fsCreatePlan,
|
|
41
|
+
listPlans as fsListPlans,
|
|
42
|
+
getPlanMeta as fsGetPlanMeta,
|
|
43
|
+
} from "./plan-fs.js";
|
|
44
|
+
|
|
45
|
+
// --- Public types --------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Minimal context the executor needs. The full `RuntimeContext` from
|
|
49
|
+
* `index.ts` has more fields, but this module only depends on a
|
|
50
|
+
* subset; using a narrower interface keeps the executor decoupled
|
|
51
|
+
* and makes it trivial to test (pass a plain object).
|
|
52
|
+
*/
|
|
53
|
+
export interface ExecutorContext {
|
|
54
|
+
/** Project worktree root (where `plans/` lives). */
|
|
55
|
+
worktree: string;
|
|
56
|
+
/** Project directory (often equal to `worktree`; the viewer / TUI
|
|
57
|
+
* may use this to display paths differently from the worktree). */
|
|
58
|
+
directory: string;
|
|
59
|
+
logger: Logger;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ExecuteOptions {
|
|
63
|
+
/**
|
|
64
|
+
* The registered `Hooks.tool` map. The executor looks up tools by
|
|
65
|
+
* name when handling `tool_invocation` side-effects. In tests, the
|
|
66
|
+
* caller can pass a minimal map containing only the tools under test.
|
|
67
|
+
*/
|
|
68
|
+
tools: Record<string, ToolDefinition>;
|
|
69
|
+
/**
|
|
70
|
+
* The current default template (from `PlanSettings`). Used to
|
|
71
|
+
* resolve `template: null` in `create_plan` side-effects.
|
|
72
|
+
*/
|
|
73
|
+
defaultTemplate: DefaultTemplate;
|
|
74
|
+
/**
|
|
75
|
+
* The default port for `/plan open` URLs. The executor builds the
|
|
76
|
+
* URL the same way the parser does, so the value should be the
|
|
77
|
+
* same one the parser was given.
|
|
78
|
+
*/
|
|
79
|
+
defaultPort: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Result of executing a side effect. The chat hook uses this to
|
|
84
|
+
* decide whether to override the parser's response (e.g. with a
|
|
85
|
+
* tool-call result or a plan-creation error).
|
|
86
|
+
*/
|
|
87
|
+
export interface ExecuteResult {
|
|
88
|
+
/**
|
|
89
|
+
* If set, replaces the parser's `response` text. Used for:
|
|
90
|
+
* - plan-create failures (refusing to clobber existing slugs)
|
|
91
|
+
* - tool-call output (so the user sees the result)
|
|
92
|
+
* - arg-validation failures
|
|
93
|
+
* - any caught exception
|
|
94
|
+
*/
|
|
95
|
+
responseOverride?: string;
|
|
96
|
+
/**
|
|
97
|
+
* Optional follow-up text to APPEND to the parser's response.
|
|
98
|
+
* Used for `/plan list` where the parser already returns a
|
|
99
|
+
* formatted list and the executor may add status info.
|
|
100
|
+
*/
|
|
101
|
+
responseSuffix?: string;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// --- executeSideEffect ---------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Dispatch a `SideEffect` to the appropriate handler. The chat hook
|
|
108
|
+
* calls this for every slash command that returns `sideEffect`.
|
|
109
|
+
*
|
|
110
|
+
* `create_plan` — calls `plan-fs.createPlan`.
|
|
111
|
+
* `list_plans` — calls `plan-fs.listPlans` (overrides the parser's
|
|
112
|
+
* list so we can include status + lastEdited).
|
|
113
|
+
* `open_plan_url` — just returns the parser's response (the parser
|
|
114
|
+
* already built the URL). No I/O.
|
|
115
|
+
* `tool_invocation` — delegates to `executeToolInvocation`.
|
|
116
|
+
*
|
|
117
|
+
* Never throws. All failures become `responseOverride` strings.
|
|
118
|
+
*/
|
|
119
|
+
export async function executeSideEffect(
|
|
120
|
+
sideEffect: SideEffect,
|
|
121
|
+
ctx: ExecutorContext,
|
|
122
|
+
opts: ExecuteOptions,
|
|
123
|
+
): Promise<ExecuteResult> {
|
|
124
|
+
try {
|
|
125
|
+
switch (sideEffect.kind) {
|
|
126
|
+
case "create_plan":
|
|
127
|
+
return await executeCreatePlan(sideEffect.slug, sideEffect.template, ctx, opts);
|
|
128
|
+
case "list_plans":
|
|
129
|
+
return await executeListPlans(ctx, opts);
|
|
130
|
+
case "open_plan_url":
|
|
131
|
+
// No I/O — the parser already built the URL. The chat hook
|
|
132
|
+
// uses the parser's response unchanged.
|
|
133
|
+
return {};
|
|
134
|
+
case "tool_invocation":
|
|
135
|
+
return await executeToolInvocation(sideEffect, ctx, opts);
|
|
136
|
+
default: {
|
|
137
|
+
// Exhaustiveness check — TS errors here if a new kind is added.
|
|
138
|
+
const _exhaustive: never = sideEffect;
|
|
139
|
+
return { responseOverride: `Unknown side-effect: ${String(_exhaustive)}` };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch (err: unknown) {
|
|
143
|
+
ctx.logger.warn(
|
|
144
|
+
`bizar: side-effect execution failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
145
|
+
);
|
|
146
|
+
return {
|
|
147
|
+
responseOverride: `Side-effect failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function executeCreatePlan(
|
|
153
|
+
slug: string,
|
|
154
|
+
template: DefaultTemplate | null,
|
|
155
|
+
ctx: ExecutorContext,
|
|
156
|
+
opts: ExecuteOptions,
|
|
157
|
+
): Promise<ExecuteResult> {
|
|
158
|
+
const result = await fsCreatePlan(ctx.worktree, slug, {
|
|
159
|
+
template: template ?? opts.defaultTemplate,
|
|
160
|
+
logger: ctx.logger,
|
|
161
|
+
});
|
|
162
|
+
if (!result.ok) {
|
|
163
|
+
return { responseOverride: `Failed to create plan: ${result.error}` };
|
|
164
|
+
}
|
|
165
|
+
// Success: the parser's "will be created" message is now confirmed.
|
|
166
|
+
// Append a short note so the user knows it landed.
|
|
167
|
+
return {
|
|
168
|
+
responseSuffix: `\n✓ Created plans/${slug}/ (meta.json + plan.json).`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function executeListPlans(
|
|
173
|
+
ctx: ExecutorContext,
|
|
174
|
+
_opts: ExecuteOptions,
|
|
175
|
+
): Promise<ExecuteResult> {
|
|
176
|
+
const list = await fsListPlans(ctx.worktree, ctx.logger);
|
|
177
|
+
if (list.length === 0) {
|
|
178
|
+
return {
|
|
179
|
+
responseOverride:
|
|
180
|
+
"No plans found in this worktree. Use /plan new <slug> to create one.",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
const lines = list.map(
|
|
184
|
+
(p) => ` - ${p.slug} (status: ${p.status}, last edited: ${p.lastEdited || "—"})`,
|
|
185
|
+
);
|
|
186
|
+
return {
|
|
187
|
+
responseOverride: `Plans in this worktree (${list.length}):\n${lines.join("\n")}`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- executeToolInvocation -----------------------------------------------
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Invoke a registered tool from a slash-command side-effect. The
|
|
195
|
+
* `tool_invocation` kind is the parser's way of saying "do whatever
|
|
196
|
+
* `/plan <sub>` would do, but using the actual tool so the result
|
|
197
|
+
* and the LLM see the same thing".
|
|
198
|
+
*
|
|
199
|
+
* Steps:
|
|
200
|
+
* 1. Look up the tool by name in `opts.tools`.
|
|
201
|
+
* 2. Pre-validate the args against the tool's Zod schema (C2).
|
|
202
|
+
* Validation failure → responseOverride with a clear error.
|
|
203
|
+
* 3. Build a synthetic `ToolContext` (R6).
|
|
204
|
+
* 4. Call `tool.execute(args, syntheticCtx)`.
|
|
205
|
+
* 5. Stringify the result and return it as `responseOverride`.
|
|
206
|
+
*
|
|
207
|
+
* Never throws.
|
|
208
|
+
*/
|
|
209
|
+
export async function executeToolInvocation(
|
|
210
|
+
sideEffect: Extract<SideEffect, { kind: "tool_invocation" }>,
|
|
211
|
+
ctx: ExecutorContext,
|
|
212
|
+
opts: ExecuteOptions,
|
|
213
|
+
): Promise<ExecuteResult> {
|
|
214
|
+
const { toolName, args } = sideEffect;
|
|
215
|
+
const tool = opts.tools[toolName];
|
|
216
|
+
if (tool === undefined) {
|
|
217
|
+
const known = Object.keys(opts.tools).sort().join(", ");
|
|
218
|
+
return {
|
|
219
|
+
responseOverride:
|
|
220
|
+
`Tool "${toolName}" is not registered in this plugin. ` +
|
|
221
|
+
`Available tools: ${known || "(none)"}.`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Pre-validate args against the tool's Zod schema (C2).
|
|
226
|
+
const validation = validateToolArgs(tool, args);
|
|
227
|
+
if (!validation.ok) {
|
|
228
|
+
return {
|
|
229
|
+
responseOverride:
|
|
230
|
+
`Invalid arguments for ${toolName}: ${validation.error}`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Build the synthetic context (R6) and invoke.
|
|
235
|
+
const syntheticCtx = buildSyntheticToolContext(ctx);
|
|
236
|
+
try {
|
|
237
|
+
const result = await tool.execute(validation.args, syntheticCtx);
|
|
238
|
+
const outputStr = stringifyToolResult(result);
|
|
239
|
+
// The tool output replaces the parser's "X-ing…" placeholder with
|
|
240
|
+
// a real outcome. This is the entire point of routing through the
|
|
241
|
+
// tool — the user sees the actual canvas/comments/etc.
|
|
242
|
+
return {
|
|
243
|
+
responseOverride: outputStr,
|
|
244
|
+
};
|
|
245
|
+
} catch (err: unknown) {
|
|
246
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
247
|
+
ctx.logger.warn(`bizar: tool ${toolName} crashed: ${msg}`);
|
|
248
|
+
return {
|
|
249
|
+
responseOverride: `Tool ${toolName} failed: ${msg}`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// --- buildSyntheticToolContext -------------------------------------------
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Build a `ToolContext` suitable for invoking a tool from a slash
|
|
258
|
+
* command. The values are deliberately synthetic — a slash command
|
|
259
|
+
* is not a real tool call from the agent's perspective, so we cannot
|
|
260
|
+
* use the chat-message's `sessionID` / `agent` / `messageID` (per R6).
|
|
261
|
+
*
|
|
262
|
+
* Fields:
|
|
263
|
+
* - `sessionID` — `"slash-command"` sentinel. Downstream code that
|
|
264
|
+
* branches on session ID (e.g. state seeding) will see this and
|
|
265
|
+
* treat the call as out-of-band.
|
|
266
|
+
* - `messageID` — `"slash-command-<timestamp>"` so a single chat
|
|
267
|
+
* turn that triggers multiple slash commands still gets unique
|
|
268
|
+
* IDs.
|
|
269
|
+
* - `agent` — `""` (empty string). The agent is the user; the
|
|
270
|
+
* tool should not assume a specific agent role.
|
|
271
|
+
* - `directory` — `ctx.directory` (R6).
|
|
272
|
+
* - `worktree` — `ctx.worktree` (R6).
|
|
273
|
+
* - `abort` — fresh `AbortSignal`. The slash-command flow has
|
|
274
|
+
* no cancellation source of its own; the chat hook's lifetime
|
|
275
|
+
* bounds the call. If a follow-up needs cancellation, the host
|
|
276
|
+
* would need to wire it through (not in MVP scope).
|
|
277
|
+
* - `metadata()` — no-op.
|
|
278
|
+
* - `ask()` — no-op (auto-approves). The user has already
|
|
279
|
+
* authorized by typing the slash command.
|
|
280
|
+
*
|
|
281
|
+
* The return value is exported for tests; production callers should
|
|
282
|
+
* use `executeToolInvocation` which calls this internally.
|
|
283
|
+
*/
|
|
284
|
+
export function buildSyntheticToolContext(ctx: ExecutorContext): ToolContext {
|
|
285
|
+
return {
|
|
286
|
+
sessionID: "slash-command",
|
|
287
|
+
messageID: `slash-command-${Date.now()}`,
|
|
288
|
+
agent: "",
|
|
289
|
+
directory: ctx.directory,
|
|
290
|
+
worktree: ctx.worktree,
|
|
291
|
+
abort: new AbortController().signal,
|
|
292
|
+
metadata: () => {
|
|
293
|
+
// No-op. A slash command has no "progress" UX to update.
|
|
294
|
+
},
|
|
295
|
+
ask: async () => {
|
|
296
|
+
// No-op. The user has already authorized by typing the command;
|
|
297
|
+
// an interactive "ask" prompt would block the chat hook past the
|
|
298
|
+
// 60-second limit and isn't appropriate for a slash command.
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --- validateToolArgs ----------------------------------------------------
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Pre-validate `args` against a tool's Zod schema. The tool's
|
|
307
|
+
* `args` field is a `ZodRawShape` (a record of Zod types, not a
|
|
308
|
+
* Zod object). We wrap it in `z.object(...)` to get a parseable
|
|
309
|
+
* schema, then call `safeParse`.
|
|
310
|
+
*
|
|
311
|
+
* Returns either:
|
|
312
|
+
* - `{ ok: true, args: T }` where `T` is the inferred type, OR
|
|
313
|
+
* - `{ ok: false, error: string }` with a human-readable error.
|
|
314
|
+
*
|
|
315
|
+
* The `T` is typed loosely (`Record<string, unknown>`) because the
|
|
316
|
+
* tool factories use a generic constraint that we can't easily express
|
|
317
|
+
* here. The downstream `tool.execute` re-validates against its own
|
|
318
|
+
* schema, so a too-loose type at this layer is safe.
|
|
319
|
+
*/
|
|
320
|
+
export function validateToolArgs(
|
|
321
|
+
tool: ToolDefinition,
|
|
322
|
+
args: unknown,
|
|
323
|
+
): { ok: true; args: Record<string, unknown> } | { ok: false; error: string } {
|
|
324
|
+
try {
|
|
325
|
+
// The tool's `args` is a ZodRawShape; wrap in z.object to get a schema.
|
|
326
|
+
const schema = z.object(tool.args as z.ZodRawShape);
|
|
327
|
+
const result = schema.safeParse(args);
|
|
328
|
+
if (result.success) {
|
|
329
|
+
return { ok: true, args: result.data as Record<string, unknown> };
|
|
330
|
+
}
|
|
331
|
+
// Format the issues into a single readable line. We don't dump the
|
|
332
|
+
// full Zod tree — the user just needs the first thing that went
|
|
333
|
+
// wrong and the field name.
|
|
334
|
+
const first = result.error.issues[0];
|
|
335
|
+
const where = first?.path && first.path.length > 0 ? first.path.join(".") : "(root)";
|
|
336
|
+
const what = first?.message ?? "unknown validation error";
|
|
337
|
+
return { ok: false, error: `${where}: ${what}` };
|
|
338
|
+
} catch (err: unknown) {
|
|
339
|
+
// We should never get here — z.object(anything) always works.
|
|
340
|
+
// But if we do (e.g. the tool's schema is broken), surface it.
|
|
341
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
342
|
+
return { ok: false, error: `internal: failed to construct schema (${msg})` };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- Helpers -------------------------------------------------------------
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Stringify a tool's result. The opencode tool contract allows
|
|
350
|
+
* either a plain string or a `{ title?, output, metadata?, attachments? }`
|
|
351
|
+
* object. We always return the `output` field (or the string itself).
|
|
352
|
+
*
|
|
353
|
+
* We DO NOT pretty-print JSON — the user pasted a slash command and
|
|
354
|
+
* wants to see the result. If the tool returned JSON, dump it.
|
|
355
|
+
*/
|
|
356
|
+
function stringifyToolResult(result: unknown): string {
|
|
357
|
+
if (typeof result === "string") return result;
|
|
358
|
+
if (result === null || result === undefined) return "";
|
|
359
|
+
if (typeof result === "object") {
|
|
360
|
+
const obj = result as { output?: unknown; title?: unknown };
|
|
361
|
+
if (typeof obj.output === "string") return obj.output;
|
|
362
|
+
try {
|
|
363
|
+
return JSON.stringify(result, null, 2);
|
|
364
|
+
} catch {
|
|
365
|
+
return String(result);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return String(result);
|
|
369
|
+
}
|