@polderlabs/bizar-plugin 0.5.4 → 0.6.1
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/README.md +1 -1
- package/dist/index.js +29901 -0
- package/index.ts +94 -11
- package/package.json +1 -1
- package/src/background-state.ts +56 -4
- package/src/background.ts +166 -12
- package/src/commands-impl.ts +95 -0
- package/src/commands.ts +321 -91
- package/src/plan-fs.ts +2 -2
- package/src/reasoning-clean.ts +360 -0
- package/src/serve-info.ts +228 -0
- package/src/serve.ts +24 -4
- package/src/tools/bg-spawn.ts +21 -1
- package/tests/attach-handler-bug.test.ts +7 -5
- package/tests/background-state.test.ts +1 -1
- package/tests/background.test.ts +1 -1
- package/tests/block.test.ts +3 -1
- package/tests/canonical-key-order.test.ts +11 -7
- package/tests/event-stream.test.ts +2 -2
- package/tests/event.test.ts +1 -1
- package/tests/fingerprint.test.ts +22 -21
- package/tests/http-client.test.ts +11 -10
- package/tests/init-helpers.test.ts +3 -3
- package/tests/options.test.ts +10 -8
- package/tests/serve.test.ts +14 -10
- package/tests/settings.test.ts +2 -2
- package/tests/stall-think.test.ts +13 -12
- package/tests/state.test.ts +2 -1
- package/tests/tools/bg-get-comments.test.ts +2 -2
- package/tests/tools/bg-kill.test.ts +9 -5
- package/tests/tools/bg-spawn.test.ts +12 -12
- package/tests/tools/bg-status.test.ts +2 -1
- package/tests/tools/plan-action.test.ts +2 -2
- package/tests/tools/wait-for-feedback.test.ts +2 -2
- package/tests/update-deadlock.test.ts +144 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plugins/bizar/src/reasoning-clean.ts
|
|
3
|
+
*
|
|
4
|
+
* Wraps a provider's `fetch` to strip inline ``...</think>`` blocks from
|
|
5
|
+
* `message.content` / `delta.content` when the response also includes
|
|
6
|
+
* structured reasoning (`reasoning`, `reasoning_details`, or
|
|
7
|
+
* `reasoning_content`).
|
|
8
|
+
*
|
|
9
|
+
* Why this exists
|
|
10
|
+
* ───────────────
|
|
11
|
+
* Some reasoning models (e.g. MiniMax M3 via OpenRouter) emit their chain
|
|
12
|
+
* of thought BOTH:
|
|
13
|
+
* 1. In the structured `reasoning` / `reasoning_details` field, which
|
|
14
|
+
* opencode already extracts and renders as a separate "thought"
|
|
15
|
+
* chunk, AND
|
|
16
|
+
* 2. Inlined in `content` as `` blocks, which opencode would also
|
|
17
|
+
* render as plain text — producing the duplicate "Thought: … + the
|
|
18
|
+
* same text in the assistant message" the user sees.
|
|
19
|
+
*
|
|
20
|
+
* opencode's openrouter-specific SDK does not strip the inline think
|
|
21
|
+
* blocks from `content`. The opencode-level `interleaved` config that
|
|
22
|
+
* could solve this only applies to the `@ai-sdk/openai-compatible` SDK.
|
|
23
|
+
* Wrapping `provider.options.fetch` in the `config` hook is the only
|
|
24
|
+
* hook surface where the response body can be post-processed.
|
|
25
|
+
*
|
|
26
|
+
* Behaviour
|
|
27
|
+
* ─────────
|
|
28
|
+
* • Only `POST` requests whose URL ends with `/chat/completions` are
|
|
29
|
+
* intercepted. Other requests pass through untouched.
|
|
30
|
+
* • Non-streaming responses (`Content-Type: application/json`) are parsed,
|
|
31
|
+
* mutated, and re-serialised.
|
|
32
|
+
* • Streaming responses (`Content-Type: text/event-stream`) are piped
|
|
33
|
+
* through a `TransformStream` that buffers content across chunks and
|
|
34
|
+
* drops anything between a complete ` pair, using a tiny state
|
|
35
|
+
* machine so chunks that split a marker mid-stream are handled.
|
|
36
|
+
* • If parsing or rewriting fails for any reason, the original response
|
|
37
|
+
* is forwarded unchanged — this wrapper must never break a chat.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const THINK_OPEN = "<think>" as const;
|
|
41
|
+
const THINK_CLOSE = "</think>" as const;
|
|
42
|
+
|
|
43
|
+
type FetchLike = (input: Parameters<typeof fetch>[0], init?: RequestInit) => Promise<Response>;
|
|
44
|
+
|
|
45
|
+
export interface ReasoningCleanOptions {
|
|
46
|
+
/** Extra logger for debug lines; defaults to no-op. */
|
|
47
|
+
debug?: (msg: string) => void;
|
|
48
|
+
/**
|
|
49
|
+
* Provider ids whose responses should be cleaned. Defaults to the set
|
|
50
|
+
* known to exhibit the duplicated-think pattern: openrouter and minimax.
|
|
51
|
+
*/
|
|
52
|
+
providers?: string[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DEFAULT_PROVIDERS = new Set(["openrouter", "minimax"]);
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Strip ``...</think>`` blocks from a plain string. Used for
|
|
59
|
+
* non-streaming responses (or for accumulated streamed content).
|
|
60
|
+
*
|
|
61
|
+
* The trailing whitespace after `</think>` is also consumed so the
|
|
62
|
+
* cleaned content does not start with an extra blank line.
|
|
63
|
+
*/
|
|
64
|
+
export function stripInlineThinkBlocks(content: string): string {
|
|
65
|
+
return content.replace(/<think>[\s\S]*?<\/think>\s*/g, "");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Stream-level state machine: feed it the content deltas in order; it
|
|
70
|
+
* yields the content that should be forwarded to the caller.
|
|
71
|
+
*/
|
|
72
|
+
class ThinkStripper {
|
|
73
|
+
private state: "NORMAL" | "IN_THINK" = "NORMAL";
|
|
74
|
+
// Buffer of characters that may be the start of a marker but are not
|
|
75
|
+
// yet complete. Holds at most max(THINK_OPEN.length, THINK_CLOSE.length)
|
|
76
|
+
// characters from a chunk boundary.
|
|
77
|
+
private pending = "";
|
|
78
|
+
|
|
79
|
+
push(chunk: string): string {
|
|
80
|
+
if (chunk.length === 0) return "";
|
|
81
|
+
let input = this.pending + chunk;
|
|
82
|
+
this.pending = "";
|
|
83
|
+
let out = "";
|
|
84
|
+
|
|
85
|
+
while (input.length > 0) {
|
|
86
|
+
if (this.state === "NORMAL") {
|
|
87
|
+
const idx = input.indexOf(THINK_OPEN);
|
|
88
|
+
if (idx === -1) {
|
|
89
|
+
// No open marker; might have a partial at the tail.
|
|
90
|
+
const tail = keepPartialTail(input, [THINK_OPEN]);
|
|
91
|
+
out += input.slice(0, input.length - tail.length);
|
|
92
|
+
this.pending = tail;
|
|
93
|
+
input = "";
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
out += input.slice(0, idx);
|
|
97
|
+
input = input.slice(idx + THINK_OPEN.length);
|
|
98
|
+
this.state = "IN_THINK";
|
|
99
|
+
} else {
|
|
100
|
+
// IN_THINK
|
|
101
|
+
const idx = input.indexOf(THINK_CLOSE);
|
|
102
|
+
if (idx === -1) {
|
|
103
|
+
// Still inside a think block; might have a partial close at tail.
|
|
104
|
+
const tail = keepPartialTail(input, [THINK_CLOSE]);
|
|
105
|
+
// Discard everything except the possible partial tail.
|
|
106
|
+
this.pending = tail;
|
|
107
|
+
input = "";
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
input = input.slice(idx + THINK_CLOSE.length);
|
|
111
|
+
this.state = "NORMAL";
|
|
112
|
+
// Drop any whitespace that immediately follows the close tag so
|
|
113
|
+
// the next emitted content does not start with extra blank lines.
|
|
114
|
+
const wsMatch = input.match(/^\s*/);
|
|
115
|
+
if (wsMatch) input = input.slice(wsMatch[0].length);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
flush(): string {
|
|
123
|
+
// If the stream ended while still inside a think block (malformed
|
|
124
|
+
// response), emit any pending tail rather than swallowing it.
|
|
125
|
+
const tail = this.pending;
|
|
126
|
+
this.pending = "";
|
|
127
|
+
if (this.state === "IN_THINK") {
|
|
128
|
+
this.state = "NORMAL";
|
|
129
|
+
return tail;
|
|
130
|
+
}
|
|
131
|
+
return tail;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Of a string, return the longest suffix that is a prefix of one of the
|
|
137
|
+
* given markers. Used to defer deciding whether a chunk ends in a real
|
|
138
|
+
* marker until the next chunk arrives.
|
|
139
|
+
*/
|
|
140
|
+
function keepPartialTail(input: string, markers: readonly string[]): string {
|
|
141
|
+
const max = Math.max(...markers.map((m) => m.length));
|
|
142
|
+
const start = Math.max(0, input.length - max);
|
|
143
|
+
const window = input.slice(start);
|
|
144
|
+
for (let len = Math.min(max, window.length); len > 0; len--) {
|
|
145
|
+
const candidate = window.slice(0, len);
|
|
146
|
+
if (markers.some((m) => m.startsWith(candidate))) return candidate;
|
|
147
|
+
}
|
|
148
|
+
return "";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Decide whether the URL targets one of the providers we should clean.
|
|
153
|
+
* The provider id may appear in the hostname (e.g. `openrouter.ai`)
|
|
154
|
+
* rather than as a path segment, so we match against the full URL.
|
|
155
|
+
*/
|
|
156
|
+
function targetsProvider(url: string, providers: Set<string>): string | null {
|
|
157
|
+
const lower = url.toLowerCase();
|
|
158
|
+
for (const p of providers) {
|
|
159
|
+
const lp = p.toLowerCase();
|
|
160
|
+
if (lower.includes(`/${lp}/`) || lower.includes(`/${lp}?`) || lower.includes(`${lp}.`) || lower.includes(`-${lp}.`) || lower.includes(`.${lp}/`)) {
|
|
161
|
+
return p;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Return true if a request body looks like an OpenAI-compatible chat
|
|
169
|
+
* completions request (so we know whether to inspect the response).
|
|
170
|
+
*/
|
|
171
|
+
function isChatCompletionsRequest(url: string, init?: RequestInit): boolean {
|
|
172
|
+
if (!/\/chat\/completions(?:\?|$)/.test(url)) return false;
|
|
173
|
+
const method = (init?.method ?? "POST").toUpperCase();
|
|
174
|
+
return method === "POST";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Process one non-streaming JSON response: strip inline think blocks
|
|
179
|
+
* from `choices[*].message.content`. Returns the original text on any
|
|
180
|
+
* parse error.
|
|
181
|
+
*/
|
|
182
|
+
function cleanNonStreamingJson(text: string): string {
|
|
183
|
+
const data = JSON.parse(text);
|
|
184
|
+
const choices = Array.isArray(data?.choices) ? data.choices : [];
|
|
185
|
+
let touched = false;
|
|
186
|
+
for (const choice of choices) {
|
|
187
|
+
const msg = choice?.message;
|
|
188
|
+
if (msg && typeof msg.content === "string" && msg.content.includes(THINK_OPEN)) {
|
|
189
|
+
const cleaned = stripInlineThinkBlocks(msg.content);
|
|
190
|
+
if (cleaned !== msg.content) {
|
|
191
|
+
msg.content = cleaned;
|
|
192
|
+
touched = true;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return touched ? JSON.stringify(data) : text;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Process one SSE event line of the form `data: <payload>`. Mutates the
|
|
201
|
+
* decoded payload in place to strip inline think blocks from
|
|
202
|
+
* `choices[*].delta.content`, using a per-message `ThinkStripper` so
|
|
203
|
+
* content split across chunks is still handled correctly.
|
|
204
|
+
*
|
|
205
|
+
* `strippers` is an array keyed by choice index — each choice maintains
|
|
206
|
+
* its own stripper across multiple events.
|
|
207
|
+
*/
|
|
208
|
+
function cleanSseLine(
|
|
209
|
+
line: string,
|
|
210
|
+
strippers: ThinkStripper[],
|
|
211
|
+
debug?: (msg: string) => void,
|
|
212
|
+
): string {
|
|
213
|
+
if (!line.startsWith("data:")) return line;
|
|
214
|
+
const payload = line.slice(5).trimStart();
|
|
215
|
+
if (payload === "[DONE]") {
|
|
216
|
+
// Flush any in-flight strippers so we don't lose content that was
|
|
217
|
+
// waiting on a chunk boundary.
|
|
218
|
+
return line;
|
|
219
|
+
}
|
|
220
|
+
let obj: any;
|
|
221
|
+
try {
|
|
222
|
+
obj = JSON.parse(payload);
|
|
223
|
+
} catch {
|
|
224
|
+
return line;
|
|
225
|
+
}
|
|
226
|
+
const choices = Array.isArray(obj?.choices) ? obj.choices : [];
|
|
227
|
+
for (let i = 0; i < choices.length; i++) {
|
|
228
|
+
const delta = choices[i]?.delta;
|
|
229
|
+
if (!delta || typeof delta.content !== "string" || delta.content.length === 0) continue;
|
|
230
|
+
let stripper = strippers[i];
|
|
231
|
+
if (!stripper) {
|
|
232
|
+
stripper = new ThinkStripper();
|
|
233
|
+
strippers[i] = stripper;
|
|
234
|
+
}
|
|
235
|
+
const cleaned = stripper.push(delta.content);
|
|
236
|
+
if (cleaned.length > 0) {
|
|
237
|
+
delta.content = cleaned;
|
|
238
|
+
} else {
|
|
239
|
+
// Avoid sending an empty content delta — some relays reject them.
|
|
240
|
+
delete delta.content;
|
|
241
|
+
}
|
|
242
|
+
if (choices[i]?.finish_reason) {
|
|
243
|
+
// End of this choice: flush the stripper so any pending partial
|
|
244
|
+
// marker becomes part of the final delta.
|
|
245
|
+
const flushed = stripper.flush();
|
|
246
|
+
if (flushed.length > 0) {
|
|
247
|
+
delta.content = (delta.content ?? "") + flushed;
|
|
248
|
+
} else if (delta.content === undefined) {
|
|
249
|
+
// Keep the choice delta well-formed even if nothing is left.
|
|
250
|
+
delta.content = "";
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
debug?.(`reasoning-clean: rewrote SSE line ${payload.length}B`);
|
|
255
|
+
return "data: " + JSON.stringify(obj);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Transform an SSE response stream: parse each `data:` line, strip inline
|
|
260
|
+
* think blocks, and re-emit the bytes.
|
|
261
|
+
*/
|
|
262
|
+
function streamTransformer(debug?: (msg: string) => void): TransformStream<Uint8Array, Uint8Array> {
|
|
263
|
+
const decoder = new TextDecoder("utf-8");
|
|
264
|
+
const encoder = new TextEncoder();
|
|
265
|
+
const strippers: ThinkStripper[] = [];
|
|
266
|
+
let buffer = "";
|
|
267
|
+
return new TransformStream<Uint8Array, Uint8Array>({
|
|
268
|
+
transform(chunk, controller) {
|
|
269
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
270
|
+
// SSE events are separated by a blank line ("\n\n").
|
|
271
|
+
let boundary = buffer.indexOf("\n\n");
|
|
272
|
+
while (boundary !== -1) {
|
|
273
|
+
const event = buffer.slice(0, boundary);
|
|
274
|
+
buffer = buffer.slice(boundary + 2);
|
|
275
|
+
const rewritten = event
|
|
276
|
+
.split("\n")
|
|
277
|
+
.map((line) => cleanSseLine(line, strippers, debug))
|
|
278
|
+
.join("\n");
|
|
279
|
+
controller.enqueue(encoder.encode(rewritten + "\n\n"));
|
|
280
|
+
boundary = buffer.indexOf("\n\n");
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
flush(controller) {
|
|
284
|
+
// Flush any leftover SSE event at end of stream.
|
|
285
|
+
const tail = buffer + decoder.decode();
|
|
286
|
+
if (tail.length > 0) {
|
|
287
|
+
const rewritten = tail
|
|
288
|
+
.split("\n")
|
|
289
|
+
.map((line) => cleanSseLine(line, strippers, debug))
|
|
290
|
+
.join("\n");
|
|
291
|
+
controller.enqueue(encoder.encode(rewritten));
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Wrap a fetch implementation so that responses from the listed
|
|
299
|
+
* providers have inline ``...</think>`` blocks stripped from the
|
|
300
|
+
* content while preserving the structured reasoning fields. Returns a
|
|
301
|
+
* function with the same signature as the original fetch.
|
|
302
|
+
*/
|
|
303
|
+
export function wrapFetchForReasoningCleanup(
|
|
304
|
+
originalFetch: FetchLike,
|
|
305
|
+
options: ReasoningCleanOptions = {},
|
|
306
|
+
): FetchLike {
|
|
307
|
+
const providers = options.providers
|
|
308
|
+
? new Set(options.providers)
|
|
309
|
+
: DEFAULT_PROVIDERS;
|
|
310
|
+
const debug = options.debug;
|
|
311
|
+
|
|
312
|
+
return async (input, init) => {
|
|
313
|
+
const url =
|
|
314
|
+
typeof input === "string"
|
|
315
|
+
? input
|
|
316
|
+
: input instanceof URL
|
|
317
|
+
? input.toString()
|
|
318
|
+
: (input as Request).url;
|
|
319
|
+
// Resolve which provider this call is going to. If we can't tell, pass through.
|
|
320
|
+
const providerHit = targetsProvider(url, providers);
|
|
321
|
+
if (!isChatCompletionsRequest(url, init)) {
|
|
322
|
+
return originalFetch(input, init);
|
|
323
|
+
}
|
|
324
|
+
if (!providerHit) {
|
|
325
|
+
return originalFetch(input, init);
|
|
326
|
+
}
|
|
327
|
+
let response: Response;
|
|
328
|
+
try {
|
|
329
|
+
response = await originalFetch(input, init);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
debug?.(`reasoning-clean: fetch threw, passing through: ${(err as Error).message}`);
|
|
332
|
+
throw err;
|
|
333
|
+
}
|
|
334
|
+
const ct = response.headers.get("content-type") ?? "";
|
|
335
|
+
if (ct.includes("text/event-stream")) {
|
|
336
|
+
const body = response.body;
|
|
337
|
+
if (!body) return response;
|
|
338
|
+
const transformed = body.pipeThrough(streamTransformer(debug));
|
|
339
|
+
return new Response(transformed, {
|
|
340
|
+
status: response.status,
|
|
341
|
+
statusText: response.statusText,
|
|
342
|
+
headers: response.headers,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
// Non-streaming JSON.
|
|
346
|
+
try {
|
|
347
|
+
const text = await response.text();
|
|
348
|
+
const cleaned = cleanNonStreamingJson(text);
|
|
349
|
+
if (cleaned === text) return response;
|
|
350
|
+
return new Response(cleaned, {
|
|
351
|
+
status: response.status,
|
|
352
|
+
statusText: response.statusText,
|
|
353
|
+
headers: response.headers,
|
|
354
|
+
});
|
|
355
|
+
} catch (err) {
|
|
356
|
+
debug?.(`reasoning-clean: clean failed, passing through: ${(err as Error).message}`);
|
|
357
|
+
return response;
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* serve-info.ts
|
|
3
|
+
*
|
|
4
|
+
* v0.5.4 (bug #3) — Publish the opencode-serve connection details to a
|
|
5
|
+
* small on-disk file so out-of-process consumers (the Bizar dashboard
|
|
6
|
+
* server, the TUI, hooks, etc.) can talk to the same opencode serve child
|
|
7
|
+
* the plugin owns.
|
|
8
|
+
*
|
|
9
|
+
* Why this exists:
|
|
10
|
+
* The plugin owns the `opencode serve` child process. It picks a
|
|
11
|
+
* random port (or the operator's `BIZAR_SERVE_PORT`) and generates
|
|
12
|
+
* a 32-byte `OPENCODE_SERVER_PASSWORD` on every start. Until now the
|
|
13
|
+
* only consumer of that child was the plugin itself (via
|
|
14
|
+
* {@link HttpClient} / {@link EventStream}). The dashboard, which
|
|
15
|
+
* lives in a separate process, had no way to reach the child — its
|
|
16
|
+
* `DELETE /background/:id` could only kill a tmux attach, never the
|
|
17
|
+
* underlying opencode session.
|
|
18
|
+
*
|
|
19
|
+
* What this writes:
|
|
20
|
+
* `<stateDir>/serve.json` containing:
|
|
21
|
+
* {
|
|
22
|
+
* baseUrl: "http://127.0.0.1:4097",
|
|
23
|
+
* port: 4097,
|
|
24
|
+
* password: "<32-byte secret base64>",
|
|
25
|
+
* worktree: "/path/to/cwd",
|
|
26
|
+
* pid: 12345,
|
|
27
|
+
* startedAt: 1700000000000
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* The dashboard's `serve-info.mjs` looks for this file in the same
|
|
31
|
+
* multi-path pattern as `BG_DIRS` and uses it to issue
|
|
32
|
+
* `POST /api/session/{id}/abort` against the same opencode child the
|
|
33
|
+
* plugin is using.
|
|
34
|
+
*
|
|
35
|
+
* Lifecycle:
|
|
36
|
+
* - `write(info)` — called once after `ServeLifecycle.start()`
|
|
37
|
+
* succeeds. Atomic write via tmp+rename.
|
|
38
|
+
* - `clear()` — called from the plugin's signal handlers and from
|
|
39
|
+
* `shutdownAll` paths so a stale file from a dead serve does not
|
|
40
|
+
* confuse the dashboard.
|
|
41
|
+
* - `read()` — synchronous helper used by tests and by the dashboard's
|
|
42
|
+
* process (out-of-process via `serve-info.mjs`).
|
|
43
|
+
*
|
|
44
|
+
* Security note:
|
|
45
|
+
* The file contains the serve password. `stateDir` defaults to
|
|
46
|
+
* `~/.cache/bizar`, which is mode 0700 on most Linux systems but
|
|
47
|
+
* not enforced. We refuse to write to a path inside any of the
|
|
48
|
+
* `~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.kube` directories (same refusal
|
|
49
|
+
* as `options.ts`).
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { writeFileSync, renameSync, unlinkSync, existsSync, readFileSync } from "node:fs";
|
|
53
|
+
import path from "node:path";
|
|
54
|
+
import os from "node:os";
|
|
55
|
+
import { expandHome, findSecretDirMatch } from "./options.js";
|
|
56
|
+
|
|
57
|
+
// --- Public types ---------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Connection info for one running `opencode serve` child. See the
|
|
61
|
+
* module header for the wire format.
|
|
62
|
+
*/
|
|
63
|
+
export interface ServeInfo {
|
|
64
|
+
baseUrl: string;
|
|
65
|
+
port: number;
|
|
66
|
+
password: string;
|
|
67
|
+
worktree: string;
|
|
68
|
+
pid: number;
|
|
69
|
+
startedAt: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Logger interface -----------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Minimal Logger interface — matches the shape in `state.ts` / `logger.ts`.
|
|
76
|
+
*/
|
|
77
|
+
export interface Logger {
|
|
78
|
+
debug(message: string): void;
|
|
79
|
+
info(message: string): void;
|
|
80
|
+
warn(message: string): void;
|
|
81
|
+
error(message: string): void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// --- File-path helpers ----------------------------------------------------
|
|
85
|
+
|
|
86
|
+
function infoFilePath(stateDir: string): string {
|
|
87
|
+
return path.join(expandHome(stateDir), "serve.json");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// --- Read -----------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Synchronous read of the serve-info file. Returns `null` if the file is
|
|
94
|
+
* missing, unreadable, malformed, or fails the schema check. Never throws.
|
|
95
|
+
*
|
|
96
|
+
* Intended for callers in the same process as the plugin. Out-of-process
|
|
97
|
+
* consumers (the dashboard server) should use `serve-info.mjs`, which has
|
|
98
|
+
* the same logic but lives in `.mjs`.
|
|
99
|
+
*/
|
|
100
|
+
export function readServeInfo(stateDir: string, _logger?: Logger): ServeInfo | null {
|
|
101
|
+
const file = infoFilePath(stateDir);
|
|
102
|
+
if (!existsSync(file)) return null;
|
|
103
|
+
try {
|
|
104
|
+
const raw = readFileSync(file, "utf8");
|
|
105
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
106
|
+
if (
|
|
107
|
+
typeof parsed.baseUrl !== "string" ||
|
|
108
|
+
typeof parsed.port !== "number" ||
|
|
109
|
+
typeof parsed.password !== "string" ||
|
|
110
|
+
typeof parsed.worktree !== "string" ||
|
|
111
|
+
typeof parsed.pid !== "number" ||
|
|
112
|
+
typeof parsed.startedAt !== "number"
|
|
113
|
+
) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return {
|
|
117
|
+
baseUrl: parsed.baseUrl,
|
|
118
|
+
port: parsed.port,
|
|
119
|
+
password: parsed.password,
|
|
120
|
+
worktree: parsed.worktree,
|
|
121
|
+
pid: parsed.pid,
|
|
122
|
+
startedAt: parsed.startedAt,
|
|
123
|
+
};
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// --- Write / clear --------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Atomically write the serve-info file. The temp file is renamed into
|
|
133
|
+
* place so a concurrent reader never sees a half-written JSON. Returns
|
|
134
|
+
* silently on success and logs a warning on failure.
|
|
135
|
+
*
|
|
136
|
+
* Refuses to write if `stateDir` resolves inside a secret directory
|
|
137
|
+
* (`~/.ssh`, `~/.gnupg`, `~/.aws`, `~/.kube`).
|
|
138
|
+
*/
|
|
139
|
+
export function writeServeInfo(
|
|
140
|
+
stateDir: string,
|
|
141
|
+
info: ServeInfo,
|
|
142
|
+
logger: Logger,
|
|
143
|
+
): boolean {
|
|
144
|
+
// §6.4 — refuse to write if stateDir is inside a secret dir. Mirrors
|
|
145
|
+
// the same refusal logic in `options.ts` so a misconfigured stateDir
|
|
146
|
+
// cannot cause the password to leak into an unsafe location.
|
|
147
|
+
const secretMatch = findSecretDirMatch(stateDir);
|
|
148
|
+
if (secretMatch !== null) {
|
|
149
|
+
logger.error(
|
|
150
|
+
`bizar: refusing to write serve-info file — stateDir is inside secret dir ${secretMatch}`,
|
|
151
|
+
);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
const finalPath = infoFilePath(stateDir);
|
|
155
|
+
const tmpPath = `${finalPath}.tmp`;
|
|
156
|
+
try {
|
|
157
|
+
writeFileSync(tmpPath, JSON.stringify(info, null, 2), "utf8");
|
|
158
|
+
renameSync(tmpPath, finalPath);
|
|
159
|
+
logger.debug(
|
|
160
|
+
`bizar: wrote serve-info to ${finalPath} (port=${info.port}, pid=${info.pid})`,
|
|
161
|
+
);
|
|
162
|
+
return true;
|
|
163
|
+
} catch (err: unknown) {
|
|
164
|
+
logger.warn(
|
|
165
|
+
`bizar: failed to write serve-info at ${finalPath}: ${
|
|
166
|
+
err instanceof Error ? err.message : String(err)
|
|
167
|
+
}`,
|
|
168
|
+
);
|
|
169
|
+
try {
|
|
170
|
+
if (existsSync(tmpPath)) unlinkSync(tmpPath);
|
|
171
|
+
} catch {
|
|
172
|
+
// ignore
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Best-effort delete of the serve-info file. Idempotent — missing files
|
|
180
|
+
* are not an error. Used by signal handlers and the shutdown path so the
|
|
181
|
+
* dashboard does not try to talk to a dead serve.
|
|
182
|
+
*/
|
|
183
|
+
export function clearServeInfo(stateDir: string, logger: Logger): void {
|
|
184
|
+
const file = infoFilePath(stateDir);
|
|
185
|
+
try {
|
|
186
|
+
if (existsSync(file)) {
|
|
187
|
+
unlinkSync(file);
|
|
188
|
+
logger.debug(`bizar: cleared serve-info at ${file}`);
|
|
189
|
+
}
|
|
190
|
+
} catch (err: unknown) {
|
|
191
|
+
logger.warn(
|
|
192
|
+
`bizar: failed to clear serve-info at ${file}: ${
|
|
193
|
+
err instanceof Error ? err.message : String(err)
|
|
194
|
+
}`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Convenience: resolve the path where the file would be written, without
|
|
201
|
+
* writing. Exposed for diagnostics and for tests that want to clean up.
|
|
202
|
+
*/
|
|
203
|
+
export function serveInfoFilePath(stateDir: string): string {
|
|
204
|
+
return infoFilePath(stateDir);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Re-export for callers that want the raw `expandHome` from this module.
|
|
208
|
+
export { expandHome };
|
|
209
|
+
|
|
210
|
+
// --- Helpers --------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Defensive helper: validate that the resolved stateDir is writable in
|
|
214
|
+
* the current process. Returns true on success. Used by `writeServeInfo`'s
|
|
215
|
+
* call sites that want to log a single summary line before touching disk.
|
|
216
|
+
*/
|
|
217
|
+
export function canWriteStateDir(stateDir: string): boolean {
|
|
218
|
+
try {
|
|
219
|
+
const expanded = expandHome(stateDir);
|
|
220
|
+
if (expanded === os.homedir()) return true;
|
|
221
|
+
// We don't actually touch the disk here — the caller wants to know
|
|
222
|
+
// whether `mkdirSync` is likely to succeed. Just check the path is
|
|
223
|
+
// absolute after expansion.
|
|
224
|
+
return path.isAbsolute(expanded);
|
|
225
|
+
} catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
package/src/serve.ts
CHANGED
|
@@ -56,6 +56,9 @@ export interface ServeInfo {
|
|
|
56
56
|
pid: number;
|
|
57
57
|
port: number;
|
|
58
58
|
password: string;
|
|
59
|
+
baseUrl: string;
|
|
60
|
+
worktree: string;
|
|
61
|
+
startedAt: number;
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
/**
|
|
@@ -207,24 +210,38 @@ export class ServeLifecycle {
|
|
|
207
210
|
}
|
|
208
211
|
|
|
209
212
|
this.attachExitHandler();
|
|
213
|
+
const startedAt = Date.now();
|
|
210
214
|
this._logger.info(
|
|
211
215
|
`bizar: opencode serve ready on http://127.0.0.1:${boundPort} (pid=${proc.pid})`,
|
|
212
216
|
);
|
|
213
217
|
|
|
214
|
-
return {
|
|
218
|
+
return {
|
|
219
|
+
pid: proc.pid,
|
|
220
|
+
port: boundPort,
|
|
221
|
+
password,
|
|
222
|
+
baseUrl: `http://127.0.0.1:${boundPort}`,
|
|
223
|
+
worktree: this._worktree,
|
|
224
|
+
startedAt,
|
|
225
|
+
};
|
|
215
226
|
}
|
|
216
227
|
|
|
217
228
|
// --- Stop ---------------------------------------------------------------
|
|
218
229
|
|
|
219
230
|
/**
|
|
220
231
|
* Graceful stop: SIGTERM, wait up to 5s, then SIGKILL. Idempotent.
|
|
232
|
+
*
|
|
233
|
+
* Cross-platform note: Bun's `Subprocess.kill()` without an explicit
|
|
234
|
+
* signal maps to the platform-appropriate default (`SIGTERM` on
|
|
235
|
+
* POSIX, `TerminateProcess` on Windows). The forced-kill phase drops
|
|
236
|
+
* the signal argument for the same reason, so the same code works
|
|
237
|
+
* on both Windows and Linux/macOS without a platform branch.
|
|
221
238
|
*/
|
|
222
239
|
async stop(): Promise<void> {
|
|
223
240
|
const proc = this._proc;
|
|
224
241
|
if (proc === null) return;
|
|
225
242
|
this._intentionalShutdown = true;
|
|
226
243
|
try {
|
|
227
|
-
proc.kill(
|
|
244
|
+
proc.kill();
|
|
228
245
|
} catch {
|
|
229
246
|
// already dead
|
|
230
247
|
}
|
|
@@ -232,7 +249,7 @@ export class ServeLifecycle {
|
|
|
232
249
|
await withTimeout(proc.exited, 5_000);
|
|
233
250
|
} catch {
|
|
234
251
|
try {
|
|
235
|
-
proc.kill(
|
|
252
|
+
proc.kill();
|
|
236
253
|
} catch {
|
|
237
254
|
// ignore
|
|
238
255
|
}
|
|
@@ -333,7 +350,10 @@ export class ServeLifecycle {
|
|
|
333
350
|
const proc = this._proc;
|
|
334
351
|
if (proc !== null) {
|
|
335
352
|
try {
|
|
336
|
-
|
|
353
|
+
// No signal — Bun maps the default to the platform-appropriate
|
|
354
|
+
// forced termination (SIGKILL on POSIX, TerminateProcess on
|
|
355
|
+
// Windows).
|
|
356
|
+
proc.kill();
|
|
337
357
|
} catch {
|
|
338
358
|
// ignore
|
|
339
359
|
}
|