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