@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,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
+ }