@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
package/src/handoff.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Handoff — static message templates for the loop-guard handoff mechanism.
3
+ *
4
+ * Spec requirements satisfied here:
5
+ * - §5.4 — the three canonical messages emitted at thresholds 5, 8, and 12.
6
+ * - §7.2 — prompt-injection invariant. The ONLY value interpolated into any
7
+ * of these strings is the tool name (`<tool>`), which comes from the
8
+ * opencode tool registry and is a short, well-known identifier (e.g.
9
+ * `read`, `bash`, `edit`). It is never user content, never LLM output,
10
+ * never an agent-controlled string. No other interpolation is permitted
11
+ * in this file. A PR that adds any other interpolation is rejected.
12
+ * - §11.1, §11.2 — the literal substrings emitted here are the contract
13
+ * that Odin's loop-guard recogniser and every subagent's canonical
14
+ * `## Loop Guard Handling` section match against. Pinning the exact text
15
+ * is the whole point of this module.
16
+ *
17
+ * The threshold numbers ("5", "8", "12") are PART OF THE STATIC TEMPLATE.
18
+ * They are NOT interpolated from the configured threshold or the actual
19
+ * repetition count. This is deliberate: the agent prompts in §11.2 have
20
+ * these literal numbers in their recognition patterns, and any change here
21
+ * would silently break the loop-guard handoff in every subagent. If a user
22
+ * configures non-default thresholds, the messages still emit the canonical
23
+ * text — the action simply fires at different counts than the message
24
+ * implies. This is a known limitation; see README "Limitations" §13.
25
+ */
26
+
27
+ const WARN_TEMPLATE =
28
+ "[loop guard: 5 identical calls to %TOOL%]. Consider using the task tool to report back to your parent with what you've learned and what you need.";
29
+
30
+ const ESCALATE_TEMPLATE =
31
+ "[loop guard: 8 identical calls to %TOOL%]. Consider using the task tool to report back to your parent with what you've learned and what you need.";
32
+
33
+ const BLOCK_TEMPLATE =
34
+ "Loop protection: 12 identical calls to %TOOL%. Use task to escalate.";
35
+
36
+ /**
37
+ * Replace the `%TOOL%` placeholder in a static template with the given tool
38
+ * name. The placeholder is intentionally not a typical `${...}` expression
39
+ * so it cannot collide with anything inside a tool name. `%TOOL%` is the
40
+ * ONLY substitution performed.
41
+ */
42
+ function interpolate(template: string, tool: string): string {
43
+ return template.split("%TOOL%").join(tool);
44
+ }
45
+
46
+ /**
47
+ * Threshold-5 message: system-prompt injection via
48
+ * `experimental.chat.system.transform`. Subagent sees this as a
49
+ * system reminder on its next turn.
50
+ */
51
+ export function warnMessage(tool: string): string {
52
+ return interpolate(WARN_TEMPLATE, tool);
53
+ }
54
+
55
+ /**
56
+ * Threshold-8 message: stronger system-prompt injection via
57
+ * `experimental.chat.system.transform`. Same mechanism as `warnMessage`,
58
+ * different template.
59
+ */
60
+ export function escalateMessage(tool: string): string {
61
+ return interpolate(ESCALATE_TEMPLATE, tool);
62
+ }
63
+
64
+ /**
65
+ * Threshold-12 message: thrown from `tool.execute.before`. Surfaces in the
66
+ * TUI as a tool error. The plugin's hard-block runs BEFORE opencode's
67
+ * `doom_loop` recovery (spec §3.3), so the plugin wins.
68
+ */
69
+ export function blockMessage(tool: string): string {
70
+ return interpolate(BLOCK_TEMPLATE, tool);
71
+ }
72
+
73
+ // Exported for test verification. Tests should assert the templates have not
74
+ // drifted from the spec's canonical text.
75
+ export const CANONICAL_TEMPLATES = {
76
+ warn: WARN_TEMPLATE,
77
+ escalate: ESCALATE_TEMPLATE,
78
+ block: BLOCK_TEMPLATE,
79
+ } as const;
@@ -0,0 +1,467 @@
1
+ /**
2
+ * http-client.ts
3
+ *
4
+ * Typed fetch wrapper for plugin → opencode serve calls (v0.4.2 spec §1, §2.3).
5
+ *
6
+ * Responsibilities:
7
+ * - Auth header on every call: `Authorization: Basic base64("opencode:<password>")`
8
+ * (spec §6.1; this is the spec's best understanding of opencode serve's
9
+ * auth scheme and is verified by integration test in BizarHarness-dev).
10
+ * - `directory` query param on every per-instance call (spec §1.7).
11
+ * - 30s default timeout via `AbortController` (spec §2.3 / §6.1 env
12
+ * `BIZAR_HTTP_TIMEOUT_MS`, option `httpTimeoutMs`).
13
+ * - Never throws on transport errors; returns a discriminated result so
14
+ * callers can log + surface a clear error to the agent without an
15
+ * unhandled rejection (spec §2.3 last paragraph).
16
+ *
17
+ * v0.4.3 — v2 API migration (see `.bizar/opencode-sse-investigation.md`):
18
+ * - The v1 session routes (`/session`, `/session/{id}/prompt_async`,
19
+ * `/session/{id}/abort`, `/session/{id}/message`) hang indefinitely
20
+ * against opencode serve 1.17.7. We migrated to the v2 API:
21
+ *
22
+ * | Method | Old (v1, hangs) | New (v2) |
23
+ * |------------|----------------------------|------------------------------------|
24
+ * | create | POST /session | POST /api/session |
25
+ * | sendPrompt | POST /session/{id}/prompt_async | POST /api/session/{id}/prompt |
26
+ * | abort | POST /session/{id}/abort | POST /api/session/{id}/abort |
27
+ * | listMsgs | GET /session/{id}/message | GET /api/session/{id}/message |
28
+ *
29
+ * - v2 wraps responses in `{data: ...}`. This client unwraps internally
30
+ * so the public interface (e.g. `{id: string}` from createSession,
31
+ * `ListMessagesResult[]` from listMessages) is unchanged.
32
+ * - v2 prompt body shape is `{id, prompt: {text}, agent, model?}` — we
33
+ * extract the text from the first `parts` text entry.
34
+ * - v2 abort: the OpenAPI investigation found no documented v2 abort
35
+ * route. We try `/api/session/{id}/abort` as a best-effort; if it
36
+ * fails, the in-memory instance state is still marked `killed` for
37
+ * immediate caller feedback, and the next SSE event will finalize it.
38
+ * - The SSE endpoint `GET /event?directory=...` (v1) still works for
39
+ * the event subscription (v1 SSE connects fine; only the session
40
+ * routes hang). We keep using it; the v2 `/api/event` endpoint with
41
+ * `location[directory]=...` query syntax is a known alternative.
42
+ *
43
+ * Boundary policy: the only `node:` import allowed in this file is
44
+ * implicit (none). We use the global `fetch` / `AbortController` /
45
+ * `ReadableStream` provided by Bun's runtime. If the test runtime is pure
46
+ * Node, those are available as globals in Node 20+; in Bun they are
47
+ * always available.
48
+ *
49
+ * Note on auth scheme: opencode serve's exact auth scheme is opencode-
50
+ * dependent. The current best understanding, based on v0.4 research, is
51
+ * `Authorization: Basic` with username `opencode`. This must be verified
52
+ * by reading opencode's serve-side code or by integration test. If
53
+ * opencode uses a different scheme (e.g. a custom `x-opencode-password`
54
+ * header), this file is the single place to change.
55
+ */
56
+
57
+ // --- Logger interface -----------------------------------------------------
58
+
59
+ /**
60
+ * Minimal Logger interface — matches the shape in `state.ts` / `logger.ts`.
61
+ */
62
+ export interface Logger {
63
+ log(opts: { level: "debug" | "info" | "warn" | "error"; message: string }): void;
64
+ debug(message: string): void;
65
+ info(message: string): void;
66
+ warn(message: string): void;
67
+ error(message: string): void;
68
+ }
69
+
70
+ // --- Public surface -------------------------------------------------------
71
+
72
+ /**
73
+ * Per-call request options for the message-listing endpoint.
74
+ * Used by `bizar_collect` to reconstruct the result text.
75
+ */
76
+ export interface ListMessagesResult {
77
+ id: string;
78
+ role: string;
79
+ parts: Array<{
80
+ type: string;
81
+ text?: string;
82
+ error?: string;
83
+ }>;
84
+ }
85
+
86
+ /**
87
+ * A model override for createSession / sendPrompt. Both fields required.
88
+ */
89
+ export interface ModelOverride {
90
+ providerID: string;
91
+ modelID: string;
92
+ }
93
+
94
+ export interface CreateSessionOptions {
95
+ parentID?: string;
96
+ title: string;
97
+ agent: string;
98
+ model?: ModelOverride;
99
+ }
100
+
101
+ export interface SendPromptOptions {
102
+ sessionId: string;
103
+ messageID: string;
104
+ agent: string;
105
+ model?: ModelOverride;
106
+ parts: Array<{ type: "text"; text: string }>;
107
+ }
108
+
109
+ /**
110
+ * Discriminated result of an HTTP call. We never throw across the
111
+ * client boundary — callers pattern-match on `ok`.
112
+ */
113
+ export type HttpResult<T> =
114
+ | { ok: true; value: T; status: number }
115
+ | { ok: false; error: string; status?: number };
116
+
117
+ /**
118
+ * Typed HTTP client for the opencode serve child. All methods take a
119
+ * `directory` argument (spec §1.7) so the same client works for the
120
+ * plugin's own worktree and any future multi-worktree setups.
121
+ */
122
+ export class HttpClient {
123
+ private baseUrl: string;
124
+ private authHeader: string;
125
+ private logger: Logger;
126
+ private timeoutMs: number;
127
+
128
+ constructor(opts: {
129
+ baseUrl: string;
130
+ password: string;
131
+ logger: Logger;
132
+ timeoutMs?: number;
133
+ }) {
134
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
135
+ this.logger = opts.logger;
136
+ this.timeoutMs = Math.max(1000, Math.floor(opts.timeoutMs ?? 30_000));
137
+ // Basic auth: "opencode:<password>" base64-encoded.
138
+ // (spec §6.1; see module-level note re: scheme verification.)
139
+ const credentials = `opencode:${opts.password}`;
140
+ this.authHeader = `Basic ${btoa(credentials)}`;
141
+ }
142
+
143
+ // --- Public API ---------------------------------------------------------
144
+
145
+ /**
146
+ * POST /api/session — create a new background session (v2 route).
147
+ *
148
+ * v0.4.3 migration: v1 `POST /session` hangs against opencode 1.17.7.
149
+ * The v2 endpoint returns `{data: {id, ...}}`; we unwrap `.data` so
150
+ * the public interface stays `{id: string}`.
151
+ *
152
+ * Body (verified via OpenAPI spec):
153
+ * { parentID?, title, agent, model? }
154
+ *
155
+ * The `agent` field is REQUIRED — without it opencode spawns the
156
+ * default agent instead of the requested one.
157
+ */
158
+ async createSession(
159
+ opts: CreateSessionOptions,
160
+ directory: string,
161
+ ): Promise<HttpResult<{ id: string }>> {
162
+ const body: Record<string, unknown> = {
163
+ title: opts.title,
164
+ agent: opts.agent,
165
+ };
166
+ if (opts.parentID !== undefined) body.parentID = opts.parentID;
167
+ if (opts.model !== undefined) body.model = opts.model;
168
+
169
+ const res = await this.request<{ data?: { id?: string } }>(
170
+ "POST",
171
+ `/api/session?directory=${encodeURIComponent(directory)}`,
172
+ body,
173
+ );
174
+ if (!res.ok) return res;
175
+ // v2 wraps the session in `{data: {...}}`. Defensive: if the server
176
+ // ever returns the session at top level, fall back to that shape.
177
+ const id = res.value.data?.id ?? (res.value as unknown as { id?: string }).id;
178
+ if (typeof id !== "string" || id.length === 0) {
179
+ return {
180
+ ok: false,
181
+ error: "POST /api/session: response missing `data.id`",
182
+ status: res.status,
183
+ };
184
+ }
185
+ return { ok: true, value: { id }, status: res.status };
186
+ }
187
+
188
+ /**
189
+ * POST /api/session/{id}/prompt — fire the prompt (v2 route).
190
+ *
191
+ * v0.4.3 migration: v1 `POST /session/{id}/prompt_async` hangs. The v2
192
+ * endpoint is synchronous and uses a different body shape:
193
+ *
194
+ * OLD: { messageID, parts: [{type:"text", text}], agent, model? }
195
+ * NEW: { id, prompt: {text: "..."}, agent, model? }
196
+ *
197
+ * The text is extracted from the first text-type part. `id` is the
198
+ * plugin-generated `messageID` (renamed from `messageID`).
199
+ *
200
+ * Response shape is not fully documented in the OpenAPI spec; we
201
+ * accept any JSON (or empty) body and return the raw parsed value.
202
+ * Callers that need the response data should check `value`.
203
+ */
204
+ async sendPrompt(
205
+ opts: SendPromptOptions,
206
+ directory: string,
207
+ ): Promise<HttpResult<unknown>> {
208
+ // v2 takes the prompt text in `prompt.text`, not in `parts[]`.
209
+ // Concatenate all text parts in order; fall back to empty string.
210
+ const text = opts.parts
211
+ .filter((p) => p.type === "text" && typeof p.text === "string")
212
+ .map((p) => p.text)
213
+ .join("");
214
+ const body: Record<string, unknown> = {
215
+ id: opts.messageID,
216
+ prompt: { text },
217
+ agent: opts.agent,
218
+ };
219
+ if (opts.model) body.model = opts.model;
220
+
221
+ return this.request<unknown>(
222
+ "POST",
223
+ `/api/session/${encodeURIComponent(opts.sessionId)}/prompt?directory=${encodeURIComponent(directory)}`,
224
+ body,
225
+ );
226
+ }
227
+
228
+ /**
229
+ * POST /api/session/{id}/abort — kill a running session (v2 route).
230
+ *
231
+ * v0.4.3 migration: v1 `POST /session/{id}/abort` likely hangs (the
232
+ * v1 session routes all hang on opencode 1.17.7). The OpenAPI
233
+ * investigation did not surface a documented v2 abort endpoint, so
234
+ * this is a best-effort call against the v2-mirrored path. If the
235
+ * server returns a 404, we log a warning via the result `error`
236
+ * field and the in-memory state is still marked `killed` for
237
+ * immediate caller feedback. The next SSE `session.idle` or
238
+ * `session.error` for the session will finalize the state.
239
+ *
240
+ * This is what `bizar_kill` and the shutdown path call.
241
+ * NOT `DELETE /session/{id}`.
242
+ */
243
+ async abortSession(
244
+ sessionId: string,
245
+ directory: string,
246
+ ): Promise<HttpResult<boolean>> {
247
+ const res = await this.request<unknown>(
248
+ "POST",
249
+ `/api/session/${encodeURIComponent(sessionId)}/abort?directory=${encodeURIComponent(directory)}`,
250
+ null,
251
+ );
252
+ if (!res.ok) return res;
253
+ // The server may return `true`, a `{data: true}` wrapper, or nothing.
254
+ // We treat any 2xx with a body or no body as "ok".
255
+ const value: unknown = res.value;
256
+ if (value === undefined || value === null) {
257
+ return { ok: true, value: true, status: res.status };
258
+ }
259
+ if (typeof value === "object") {
260
+ const v = value as { data?: unknown; result?: unknown };
261
+ if (v.data === true) return { ok: true, value: true, status: res.status };
262
+ if (v.result === true) return { ok: true, value: true, status: res.status };
263
+ }
264
+ if (value === true) return { ok: true, value: true, status: res.status };
265
+ // Any other truthy/falsey body: treat as best-effort success.
266
+ return { ok: true, value: true, status: res.status };
267
+ }
268
+
269
+ /**
270
+ * GET /api/session/{id}/message — list the messages of a session (v2 route).
271
+ *
272
+ * v0.4.3 migration: v1 `GET /session/{id}/message` likely hangs. The
273
+ * v2 endpoint returns `{data: Array<{info, parts}>}`; we unwrap
274
+ * `.data` so the public interface stays `ListMessagesResult[]`.
275
+ *
276
+ * Each message is normalized to the flattened
277
+ * {@link ListMessagesResult} shape for the tool layer.
278
+ */
279
+ async listMessages(
280
+ sessionId: string,
281
+ directory: string,
282
+ ): Promise<HttpResult<ListMessagesResult[]>> {
283
+ type RawMessage = {
284
+ info?: { id?: string; role?: string };
285
+ parts?: Array<{ type?: string; text?: string; error?: string }>;
286
+ };
287
+ const res = await this.request<{ data?: RawMessage[] } | RawMessage[]>(
288
+ "GET",
289
+ `/api/session/${encodeURIComponent(sessionId)}/message?directory=${encodeURIComponent(directory)}`,
290
+ );
291
+ if (!res.ok) return res;
292
+ // v2 wraps the array in `{data: [...]}`. Defensive: fall back to
293
+ // top-level array if the server returns the bare array.
294
+ const arr: RawMessage[] = Array.isArray(res.value)
295
+ ? res.value
296
+ : (res.value.data ?? []);
297
+ const normalized: ListMessagesResult[] = arr.map((m) => ({
298
+ id: m.info?.id ?? "",
299
+ role: m.info?.role ?? "",
300
+ parts: (m.parts ?? []).map((p) => {
301
+ const part: ListMessagesResult["parts"][number] = {
302
+ type: p.type ?? "unknown",
303
+ };
304
+ if (p.text !== undefined) part.text = p.text;
305
+ if (p.error !== undefined) part.error = p.error;
306
+ return part;
307
+ }),
308
+ }));
309
+ return { ok: true, value: normalized, status: res.status };
310
+ }
311
+
312
+ /**
313
+ * GET /event?directory=... — open the SSE event stream.
314
+ *
315
+ * We return the raw `ReadableStream` so the caller (EventStream) can
316
+ * parse the SSE wire format. The HTTP response is the underlying
317
+ * `Response`; only the body stream is consumed here.
318
+ *
319
+ * Note: the SSE response uses `Content-Type: text/event-stream` and
320
+ * may stay open indefinitely. The AbortController we attach kills
321
+ * the connection on `disconnect()`.
322
+ */
323
+ async fetchEventStream(directory: string, signal?: AbortSignal): Promise<HttpResult<ReadableStream>> {
324
+ const url = `${this.baseUrl}/api/event?location[directory]=${encodeURIComponent(directory)}`;
325
+ const ac = new AbortController();
326
+ const timer = setTimeout(() => ac.abort(), this.timeoutMs);
327
+
328
+ // Bridge caller-provided signal (disconnect) into our internal ac.
329
+ const onCallerAbort = () => ac.abort();
330
+ if (signal) {
331
+ if (signal.aborted) {
332
+ ac.abort();
333
+ } else {
334
+ signal.addEventListener("abort", onCallerAbort, { once: true });
335
+ }
336
+ }
337
+
338
+ try {
339
+ const response = await fetch(url, {
340
+ method: "GET",
341
+ headers: {
342
+ Authorization: this.authHeader,
343
+ Accept: "text/event-stream",
344
+ },
345
+ signal: ac.signal,
346
+ });
347
+ if (!response.ok) {
348
+ // Drain the body so the connection is released.
349
+ try {
350
+ await response.arrayBuffer();
351
+ } catch {
352
+ // ignore
353
+ }
354
+ return {
355
+ ok: false,
356
+ error: `GET /event failed: ${response.status} ${response.statusText}`,
357
+ status: response.status,
358
+ };
359
+ }
360
+ if (!response.body) {
361
+ return { ok: false, error: "GET /event: no response body" };
362
+ }
363
+ return { ok: true, value: response.body, status: response.status };
364
+ } catch (err: unknown) {
365
+ const msg = err instanceof Error ? err.message : String(err);
366
+ const isAbort = err instanceof Error && err.name === "AbortError";
367
+ const finalMsg = isAbort
368
+ ? `GET /event aborted after ${this.timeoutMs}ms`
369
+ : `GET /event network error: ${msg}`;
370
+ this.logger.log({ level: "warn", message: `bizar: ${finalMsg}` });
371
+ return { ok: false, error: finalMsg };
372
+ } finally {
373
+ clearTimeout(timer);
374
+ if (signal) signal.removeEventListener("abort", onCallerAbort);
375
+ }
376
+ }
377
+
378
+ /**
379
+ * GET /health — used by ServeLifecycle to confirm the server is up.
380
+ */
381
+ async healthCheck(): Promise<boolean> {
382
+ const ac = new AbortController();
383
+ const timer = setTimeout(() => ac.abort(), this.timeoutMs);
384
+ try {
385
+ const response = await fetch(`${this.baseUrl}/health`, {
386
+ method: "GET",
387
+ headers: { Authorization: this.authHeader },
388
+ signal: ac.signal,
389
+ });
390
+ return response.ok;
391
+ } catch {
392
+ return false;
393
+ } finally {
394
+ clearTimeout(timer);
395
+ }
396
+ }
397
+
398
+ // --- Internal request helper -------------------------------------------
399
+
400
+ private async request<T>(
401
+ method: "GET" | "POST" | "DELETE",
402
+ pathAndQuery: string,
403
+ body?: unknown,
404
+ opts: { expectNoBody?: boolean } = {},
405
+ ): Promise<HttpResult<T>> {
406
+ const url = `${this.baseUrl}${pathAndQuery}`;
407
+ const ac = new AbortController();
408
+ const timer = setTimeout(() => ac.abort(), this.timeoutMs);
409
+ try {
410
+ const init: RequestInit = {
411
+ method,
412
+ headers: {
413
+ Authorization: this.authHeader,
414
+ "Content-Type": "application/json",
415
+ Accept: "application/json",
416
+ },
417
+ signal: ac.signal,
418
+ };
419
+ if (body !== null && body !== undefined) {
420
+ init.body = JSON.stringify(body);
421
+ }
422
+ const response = await fetch(url, init);
423
+
424
+ if (!response.ok) {
425
+ // Capture the error body for the caller but do not throw.
426
+ let detail = "";
427
+ try {
428
+ detail = await response.text();
429
+ } catch {
430
+ // ignore
431
+ }
432
+ if (detail.length > 500) detail = detail.slice(0, 500) + "…";
433
+ return {
434
+ ok: false,
435
+ error: `${method} ${pathAndQuery} failed: ${response.status} ${response.statusText}${detail ? ` — ${detail}` : ""}`,
436
+ status: response.status,
437
+ };
438
+ }
439
+
440
+ if (opts.expectNoBody || response.status === 204) {
441
+ return { ok: true, value: undefined as T, status: response.status };
442
+ }
443
+
444
+ // 200 with body — try to parse as JSON.
445
+ try {
446
+ const parsed = (await response.json()) as T;
447
+ return { ok: true, value: parsed, status: response.status };
448
+ } catch (err: unknown) {
449
+ return {
450
+ ok: false,
451
+ error: `${method} ${pathAndQuery} returned ${response.status} with non-JSON body: ${err instanceof Error ? err.message : String(err)}`,
452
+ status: response.status,
453
+ };
454
+ }
455
+ } catch (err: unknown) {
456
+ const msg = err instanceof Error ? err.message : String(err);
457
+ const isAbort = err instanceof Error && err.name === "AbortError";
458
+ const finalMsg = isAbort
459
+ ? `Request to ${url} timed out after ${this.timeoutMs}ms`
460
+ : `Request to ${url} failed: ${msg}`;
461
+ this.logger.log({ level: "warn", message: `bizar: ${finalMsg}` });
462
+ return { ok: false, error: finalMsg };
463
+ } finally {
464
+ clearTimeout(timer);
465
+ }
466
+ }
467
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Logger — thin wrapper over `client.app.log`.
3
+ *
4
+ * Spec requirements satisfied here:
5
+ * - §6.5 — honors `BIZAR_LOG_LEVEL` env var (`debug` | `info` | `warn` | `error`,
6
+ * default `info`, invalid values fall back to `info` with a warning).
7
+ * - §7.6 — never accepts or emits raw tool args / session content. Every call
8
+ * to `client.app.log` is `{ level, message }` (or the SDK's wrapped
9
+ * `{ body: { service, level, message } }` shape) where `message` is a
10
+ * static string the caller constructed. There is no second argument,
11
+ * no metadata object, no structured logging payload.
12
+ *
13
+ * The logger swallows all errors from the underlying `client.app.log` call.
14
+ * A failed log MUST NOT crash the plugin or block the tool hook.
15
+ *
16
+ * The interface is intentionally compatible with the `Logger` shape that
17
+ * Thor's `StateStore` and `LogWriter` accept: a single `log(opts)` method
18
+ * returning `void` (synchronous). We also expose level-specific convenience
19
+ * methods (`debug` / `info` / `warn` / `error`) that all delegate to `log`.
20
+ */
21
+
22
+ export type LogLevel = "debug" | "info" | "warn" | "error";
23
+
24
+ const LEVEL_ORDER: Record<LogLevel, number> = {
25
+ debug: 0,
26
+ info: 1,
27
+ warn: 2,
28
+ error: 3,
29
+ };
30
+
31
+ /**
32
+ * Minimal client surface the logger depends on. The real opencode client's
33
+ * `app.log` is a generated SDK method with a complex Options<T> type; we
34
+ * accept any object with a compatible `app.log` method and treat the body
35
+ * as an opaque `unknown` at the boundary. The logger internally shapes the
36
+ * call to the SDK's expected `{ body: { service, level, message } }` form.
37
+ */
38
+ export interface LoggerClient {
39
+ app: {
40
+ log: (input: unknown) => unknown;
41
+ };
42
+ }
43
+
44
+ /**
45
+ * The Logger interface matches the one defined in `state.ts` and `report.ts`
46
+ * (Thor's modules). A single `log(opts)` method, synchronous, returns `void`.
47
+ * The convenience methods are additive.
48
+ */
49
+ export interface Logger {
50
+ log(opts: { level: LogLevel; message: string }): void;
51
+ debug(message: string): void;
52
+ info(message: string): void;
53
+ warn(message: string): void;
54
+ error(message: string): void;
55
+ }
56
+
57
+ interface ParsedLogLevel {
58
+ level: LogLevel;
59
+ wasInvalid: boolean;
60
+ original: string | undefined;
61
+ }
62
+
63
+ function parseLogLevel(raw: string | undefined): ParsedLogLevel {
64
+ if (raw === undefined || raw === "") {
65
+ return { level: "info", wasInvalid: false, original: raw };
66
+ }
67
+ const lower = raw.toLowerCase();
68
+ if (lower === "debug" || lower === "info" || lower === "warn" || lower === "error") {
69
+ return { level: lower, wasInvalid: false, original: raw };
70
+ }
71
+ return { level: "info", wasInvalid: true, original: raw };
72
+ }
73
+
74
+ function readEnvLevel(): string | undefined {
75
+ try {
76
+ return typeof process !== "undefined" && process.env
77
+ ? process.env.BIZAR_LOG_LEVEL
78
+ : undefined;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Service name stamped onto every log entry. Lets users filter app logs
86
+ * by service (e.g. `bizar`) in their diagnostic tooling.
87
+ */
88
+ const SERVICE_NAME = "bizar";
89
+
90
+ /**
91
+ * Construct a Logger bound to the opencode client.
92
+ *
93
+ * @param client The opencode client (we use only `client.app.log`).
94
+ * @param envLevel Optional override for `BIZAR_LOG_LEVEL`. If omitted, the
95
+ * env var is read once. Env vars are read at plugin init;
96
+ * mid-session changes are ignored (spec §6.5).
97
+ */
98
+ export function createLogger(client: LoggerClient, envLevel?: string): Logger {
99
+ const raw = envLevel ?? readEnvLevel();
100
+ const parsed = parseLogLevel(raw);
101
+ const threshold = parsed.level;
102
+
103
+ if (parsed.wasInvalid) {
104
+ // Best-effort: warn about the invalid value, then proceed with "info".
105
+ // We call `client.app.log` directly because the logger is not yet wired
106
+ // to the threshold.
107
+ try {
108
+ client.app.log({
109
+ body: { service: SERVICE_NAME, level: "warn", message: `bizar: invalid BIZAR_LOG_LEVEL "${parsed.original ?? ""}", falling back to "info"` },
110
+ });
111
+ } catch {
112
+ // ignore — never let a warning crash init
113
+ }
114
+ }
115
+
116
+ function emit(msgLevel: LogLevel, message: string): void {
117
+ if (LEVEL_ORDER[msgLevel] < LEVEL_ORDER[threshold]) return;
118
+ try {
119
+ client.app.log({
120
+ body: { service: SERVICE_NAME, level: msgLevel, message },
121
+ });
122
+ } catch {
123
+ // Swallow — a failed log MUST NOT crash the plugin.
124
+ }
125
+ }
126
+
127
+ return {
128
+ log(opts) {
129
+ emit(opts.level, opts.message);
130
+ },
131
+ debug(message) {
132
+ emit("debug", message);
133
+ },
134
+ info(message) {
135
+ emit("info", message);
136
+ },
137
+ warn(message) {
138
+ emit("warn", message);
139
+ },
140
+ error(message) {
141
+ emit("error", message);
142
+ },
143
+ };
144
+ }