@polderlabs/bizar-plugin 0.5.4 → 0.6.0
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/dist/index.js +29901 -0
- package/index.ts +34 -8
- package/package.json +1 -1
- package/src/background-state.ts +15 -4
- package/src/background.ts +19 -1
- package/src/commands-impl.ts +95 -0
- package/src/commands.ts +53 -0
- package/src/plan-fs.ts +2 -2
- package/src/serve-info.ts +228 -0
- package/src/serve.ts +12 -1
- package/tests/attach-handler-bug.test.ts +2 -2
- package/tests/event-stream.test.ts +2 -2
- package/tests/http-client.test.ts +6 -7
- package/tests/init-helpers.test.ts +3 -3
- package/tests/serve.test.ts +14 -10
- package/tests/tools/bg-get-comments.test.ts +2 -2
- package/tests/tools/bg-kill.test.ts +9 -5
- 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
package/index.ts
CHANGED
|
@@ -109,6 +109,7 @@ import {
|
|
|
109
109
|
} from "./src/options.js";
|
|
110
110
|
|
|
111
111
|
import { ServeLifecycle } from "./src/serve.js";
|
|
112
|
+
import { writeServeInfo, clearServeInfo } from "./src/serve-info.js";
|
|
112
113
|
import { HttpClient } from "./src/http-client.js";
|
|
113
114
|
import { EventStream } from "./src/event-stream.js";
|
|
114
115
|
import { BackgroundStateStore, type BackgroundState } from "./src/background-state.js";
|
|
@@ -216,6 +217,7 @@ let instanceManagerHandle: InstanceManager | null = null;
|
|
|
216
217
|
let serveHandle: ServeLifecycle | null = null;
|
|
217
218
|
let streamHandle: EventStream | null = null;
|
|
218
219
|
let loggerHandle: Logger | null = null;
|
|
220
|
+
const signalHandlerRefs = new Map<"SIGTERM" | "SIGINT", () => void>();
|
|
219
221
|
|
|
220
222
|
// --- Plugin entry point ---------------------------------------------------
|
|
221
223
|
|
|
@@ -359,6 +361,20 @@ async function init(
|
|
|
359
361
|
});
|
|
360
362
|
serveHandle = serve;
|
|
361
363
|
const serveInfo = await serve.start();
|
|
364
|
+
// v3.5.7 — Persist serve-info so the dashboard can talk to us
|
|
365
|
+
try {
|
|
366
|
+
writeServeInfo(options.stateDir, {
|
|
367
|
+
baseUrl: serveInfo.baseUrl,
|
|
368
|
+
port: serveInfo.port,
|
|
369
|
+
password: serveInfo.password,
|
|
370
|
+
worktree: serveInfo.worktree,
|
|
371
|
+
pid: serveInfo.pid,
|
|
372
|
+
startedAt: serveInfo.startedAt,
|
|
373
|
+
}, logger);
|
|
374
|
+
logger.info(`[bizar] wrote serve-info to ${options.stateDir}/serve.json`);
|
|
375
|
+
} catch (err) {
|
|
376
|
+
logger.warn(`[bizar] failed to write serve-info: ${err instanceof Error ? err.message : String(err)}`);
|
|
377
|
+
}
|
|
362
378
|
const http = new HttpClient({
|
|
363
379
|
baseUrl: `http://127.0.0.1:${serveInfo.port}`,
|
|
364
380
|
password: serveInfo.password,
|
|
@@ -437,7 +453,7 @@ async function init(
|
|
|
437
453
|
|
|
438
454
|
// --- Signal traps (spec §5.3) ------------------------------------------
|
|
439
455
|
|
|
440
|
-
installSignalHandlers(logger, instanceManager, serve, stream);
|
|
456
|
+
installSignalHandlers(logger, instanceManager, serve, stream, options.stateDir);
|
|
441
457
|
|
|
442
458
|
const ctx: RuntimeContext = {
|
|
443
459
|
logger,
|
|
@@ -462,6 +478,7 @@ function installSignalHandlers(
|
|
|
462
478
|
instanceManager: InstanceManager | null,
|
|
463
479
|
serve: ServeLifecycle | null,
|
|
464
480
|
stream: EventStream | null,
|
|
481
|
+
stateDir: string,
|
|
465
482
|
): void {
|
|
466
483
|
const onSignal = async (sig: "SIGTERM" | "SIGINT") => {
|
|
467
484
|
if (shuttingDown) return;
|
|
@@ -503,7 +520,10 @@ function installSignalHandlers(
|
|
|
503
520
|
}
|
|
504
521
|
}
|
|
505
522
|
|
|
506
|
-
// 4.
|
|
523
|
+
// 4. Clear serve-info so the dashboard doesn't try to talk to a dead serve.
|
|
524
|
+
clearServeInfo(stateDir, logger);
|
|
525
|
+
|
|
526
|
+
// 5. Exit. (Note: the host may keep the process alive if other work
|
|
507
527
|
// is pending, but for the plugin process this is the end.)
|
|
508
528
|
try {
|
|
509
529
|
process.exit(0);
|
|
@@ -516,14 +536,19 @@ function installSignalHandlers(
|
|
|
516
536
|
// duplicate handlers. Use `process.once` so each handler runs at most
|
|
517
537
|
// once per signal; the `shuttingDown` guard catches reentry.
|
|
518
538
|
for (const sig of ["SIGTERM", "SIGINT"] as const) {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
539
|
+
const previous = signalHandlerRefs.get(sig);
|
|
540
|
+
if (previous) {
|
|
541
|
+
try {
|
|
542
|
+
process.removeListener(sig, previous);
|
|
543
|
+
} catch {
|
|
544
|
+
// ignore
|
|
545
|
+
}
|
|
523
546
|
}
|
|
524
|
-
|
|
547
|
+
const handler = () => {
|
|
525
548
|
void onSignal(sig);
|
|
526
|
-
}
|
|
549
|
+
};
|
|
550
|
+
signalHandlerRefs.set(sig, handler);
|
|
551
|
+
process.once(sig, handler);
|
|
527
552
|
}
|
|
528
553
|
}
|
|
529
554
|
|
|
@@ -1070,6 +1095,7 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
|
|
|
1070
1095
|
);
|
|
1071
1096
|
}
|
|
1072
1097
|
}
|
|
1098
|
+
clearServeInfo(ctx.options.stateDir, ctx.logger);
|
|
1073
1099
|
},
|
|
1074
1100
|
};
|
|
1075
1101
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polderlabs/bizar-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Bizar opencode plugin — loop detection, status reporting, handoff signal, background agents, and slash commands + visual plan flow for subagent activity",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
package/src/background-state.ts
CHANGED
|
@@ -389,11 +389,22 @@ export class BackgroundStateStore {
|
|
|
389
389
|
*/
|
|
390
390
|
async save(state: BackgroundState): Promise<void> {
|
|
391
391
|
if (!this.ensureDir()) return;
|
|
392
|
+
return withLock(this.locks, state.instanceId, () => this.saveUnlocked(state));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Persist a `BackgroundState` without acquiring the per-instance mutex.
|
|
397
|
+
*
|
|
398
|
+
* Callers must already hold the lock for `state.instanceId`. This exists
|
|
399
|
+
* for internal code paths such as `InstanceManager.update()` that need to
|
|
400
|
+
* mutate in-memory state while holding the same lock; calling `save()`
|
|
401
|
+
* there would re-enter the mutex and deadlock the promise chain.
|
|
402
|
+
*/
|
|
403
|
+
saveUnlocked(state: BackgroundState): Promise<void> {
|
|
404
|
+
if (!this.ensureDir()) return Promise.resolve();
|
|
392
405
|
const filePath = backgroundStateFilePath(this.stateDir, state.instanceId);
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
return Promise.resolve();
|
|
396
|
-
});
|
|
406
|
+
writeStateAtomic(filePath, state, this.logger);
|
|
407
|
+
return Promise.resolve();
|
|
397
408
|
}
|
|
398
409
|
|
|
399
410
|
/**
|
package/src/background.ts
CHANGED
|
@@ -139,6 +139,7 @@ const STALL_CHECK_INTERVAL_MS = 15_000;
|
|
|
139
139
|
*/
|
|
140
140
|
export class InstanceManager {
|
|
141
141
|
private instances = new Map<string, BackgroundState>();
|
|
142
|
+
private eventUnsubscribers = new Map<string, () => void>();
|
|
142
143
|
private addLock: Promise<unknown> = Promise.resolve();
|
|
143
144
|
private stateStore: BackgroundStateStore;
|
|
144
145
|
private maxConcurrent: number;
|
|
@@ -193,6 +194,7 @@ export class InstanceManager {
|
|
|
193
194
|
() => void this.runStallAndLoopChecks(),
|
|
194
195
|
STALL_CHECK_INTERVAL_MS,
|
|
195
196
|
);
|
|
197
|
+
this.stallCheckerTimer.unref?.();
|
|
196
198
|
}
|
|
197
199
|
|
|
198
200
|
// --- Getters ------------------------------------------------------------
|
|
@@ -376,8 +378,11 @@ export class InstanceManager {
|
|
|
376
378
|
if (TERMINAL_STATUSES.has(patch.status ?? current.status) && !current.completedAt) {
|
|
377
379
|
current.completedAt = Date.now();
|
|
378
380
|
}
|
|
381
|
+
if (TERMINAL_STATUSES.has(current.status)) {
|
|
382
|
+
this.detachEventHandler(instanceId);
|
|
383
|
+
}
|
|
379
384
|
try {
|
|
380
|
-
await this.stateStore.
|
|
385
|
+
await this.stateStore.saveUnlocked(current);
|
|
381
386
|
} catch (err: unknown) {
|
|
382
387
|
this.logger.warn(
|
|
383
388
|
`bizar: failed to persist update for ${instanceId}: ${
|
|
@@ -704,13 +709,26 @@ export class InstanceManager {
|
|
|
704
709
|
// --- Internal: per-session event handler -------------------------------
|
|
705
710
|
|
|
706
711
|
public attachEventHandler(inst: BackgroundState): () => void {
|
|
712
|
+
this.detachEventHandler(inst.instanceId);
|
|
707
713
|
const handler: SessionEventHandler = (ev: StreamEvent) => {
|
|
708
714
|
void this.handleInstanceEvent(inst.instanceId, ev);
|
|
709
715
|
};
|
|
710
716
|
const unsubscribe = this.stream.onSessionEvent(inst.sessionId, handler);
|
|
717
|
+
this.eventUnsubscribers.set(inst.instanceId, unsubscribe);
|
|
711
718
|
return unsubscribe;
|
|
712
719
|
}
|
|
713
720
|
|
|
721
|
+
private detachEventHandler(instanceId: string): void {
|
|
722
|
+
const unsubscribe = this.eventUnsubscribers.get(instanceId);
|
|
723
|
+
if (!unsubscribe) return;
|
|
724
|
+
try {
|
|
725
|
+
unsubscribe();
|
|
726
|
+
} catch {
|
|
727
|
+
// ignore
|
|
728
|
+
}
|
|
729
|
+
this.eventUnsubscribers.delete(instanceId);
|
|
730
|
+
}
|
|
731
|
+
|
|
714
732
|
private async handleInstanceEvent(
|
|
715
733
|
instanceId: string,
|
|
716
734
|
ev: StreamEvent,
|
package/src/commands-impl.ts
CHANGED
|
@@ -112,6 +112,9 @@ export interface ExecuteResult {
|
|
|
112
112
|
* list so we can include status + lastEdited).
|
|
113
113
|
* `open_plan_url` — just returns the parser's response (the parser
|
|
114
114
|
* already built the URL). No I/O.
|
|
115
|
+
* `launch_dashboard` — spawns `bizar dashboard start` as a detached
|
|
116
|
+
* child process. Reads the port file back and
|
|
117
|
+
* appends the URL to the parser's response.
|
|
115
118
|
* `tool_invocation` — delegates to `executeToolInvocation`.
|
|
116
119
|
*
|
|
117
120
|
* Never throws. All failures become `responseOverride` strings.
|
|
@@ -131,6 +134,8 @@ export async function executeSideEffect(
|
|
|
131
134
|
// No I/O — the parser already built the URL. The chat hook
|
|
132
135
|
// uses the parser's response unchanged.
|
|
133
136
|
return {};
|
|
137
|
+
case "launch_dashboard":
|
|
138
|
+
return await executeLaunchDashboard(sideEffect.defaultPort, ctx);
|
|
134
139
|
case "tool_invocation":
|
|
135
140
|
return await executeToolInvocation(sideEffect, ctx, opts);
|
|
136
141
|
default: {
|
|
@@ -188,6 +193,96 @@ async function executeListPlans(
|
|
|
188
193
|
};
|
|
189
194
|
}
|
|
190
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Launch the Bizar dashboard as a detached child process.
|
|
198
|
+
*
|
|
199
|
+
* We spawn `bizar dashboard start` with `detached: true` and `unref()`
|
|
200
|
+
* so the child's lifetime is independent of the plugin host. We then
|
|
201
|
+
* poll the port file (written by the child) for up to ~3s and append
|
|
202
|
+
* the URL to the parser's response. If anything goes wrong we surface
|
|
203
|
+
* a clear error so the user knows where to look.
|
|
204
|
+
*
|
|
205
|
+
* Never throws — all failures become responseSuffix/Override.
|
|
206
|
+
*/
|
|
207
|
+
async function executeLaunchDashboard(
|
|
208
|
+
defaultPort: number,
|
|
209
|
+
ctx: ExecutorContext,
|
|
210
|
+
): Promise<ExecuteResult> {
|
|
211
|
+
const { spawn } = await import("node:child_process");
|
|
212
|
+
const { existsSync, readFileSync } = await import("node:fs");
|
|
213
|
+
const { join } = await import("node:path");
|
|
214
|
+
const { homedir } = await import("node:os");
|
|
215
|
+
|
|
216
|
+
const portFile = join(homedir(), ".config", "bizar", "dashboard.port");
|
|
217
|
+
|
|
218
|
+
// If a dashboard is already running, just report its URL.
|
|
219
|
+
if (existsSync(portFile)) {
|
|
220
|
+
try {
|
|
221
|
+
const port = readFileSync(portFile, "utf8").trim();
|
|
222
|
+
if (port && Number.isFinite(Number(port))) {
|
|
223
|
+
return {
|
|
224
|
+
responseSuffix:
|
|
225
|
+
`\n✓ Dashboard already running at http://localhost:${port}/`,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
/* fall through to spawn */
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
// `bizar` is on $PATH for global installs; for npx / local installs
|
|
235
|
+
// we'd want to resolve to the package's bin. Spawn `bizar` directly
|
|
236
|
+
// for now — the user's $PATH is the source of truth.
|
|
237
|
+
const child = spawn("bizar", ["dashboard", "start"], {
|
|
238
|
+
detached: true,
|
|
239
|
+
stdio: "ignore",
|
|
240
|
+
cwd: ctx.worktree,
|
|
241
|
+
});
|
|
242
|
+
child.on("error", (err) => {
|
|
243
|
+
ctx.logger.warn(`bizar: dashboard spawn error: ${err.message}`);
|
|
244
|
+
});
|
|
245
|
+
child.unref();
|
|
246
|
+
} catch (err: unknown) {
|
|
247
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
248
|
+
return {
|
|
249
|
+
responseOverride:
|
|
250
|
+
`Could not launch the Bizar dashboard: ${msg}\n` +
|
|
251
|
+
`Try running \`bizar dashboard start\` in your terminal.`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Poll the port file briefly so the response carries the live URL.
|
|
256
|
+
const deadline = Date.now() + 3000;
|
|
257
|
+
let resolvedPort: number | null = null;
|
|
258
|
+
while (Date.now() < deadline) {
|
|
259
|
+
if (existsSync(portFile)) {
|
|
260
|
+
try {
|
|
261
|
+
const port = Number(readFileSync(portFile, "utf8").trim());
|
|
262
|
+
if (Number.isFinite(port) && port > 0) {
|
|
263
|
+
resolvedPort = port;
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
/* ignore — keep polling */
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (resolvedPort === null) {
|
|
274
|
+
return {
|
|
275
|
+
responseSuffix:
|
|
276
|
+
`\n✓ Dashboard launching… (preferred port ${defaultPort}, ` +
|
|
277
|
+
`fallback to a free port). The browser will open shortly.`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return {
|
|
282
|
+
responseSuffix: `\n✓ Dashboard running at http://localhost:${resolvedPort}/`,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
191
286
|
// --- executeToolInvocation -----------------------------------------------
|
|
192
287
|
|
|
193
288
|
/**
|
package/src/commands.ts
CHANGED
|
@@ -60,6 +60,10 @@ export type SideEffect =
|
|
|
60
60
|
| {
|
|
61
61
|
kind: "list_plans";
|
|
62
62
|
}
|
|
63
|
+
| {
|
|
64
|
+
kind: "launch_dashboard";
|
|
65
|
+
defaultPort: number;
|
|
66
|
+
}
|
|
63
67
|
| {
|
|
64
68
|
kind: "tool_invocation";
|
|
65
69
|
toolName: string;
|
|
@@ -264,6 +268,8 @@ export function parseSlashCommand(
|
|
|
264
268
|
return handleVisualPlan(rest, ctx);
|
|
265
269
|
case "plan":
|
|
266
270
|
return handlePlan(rest, ctx);
|
|
271
|
+
case "bizar":
|
|
272
|
+
return handleBizar(rest, ctx);
|
|
267
273
|
case "help":
|
|
268
274
|
case "commands":
|
|
269
275
|
return helpResult();
|
|
@@ -845,6 +851,53 @@ function handlePlanWait(args: string[]): SlashCommandResult {
|
|
|
845
851
|
};
|
|
846
852
|
}
|
|
847
853
|
|
|
854
|
+
// --- /bizar --------------------------------------------------------------
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* v2.5.0 — `/bizar [args]` launches the dashboard or routes a sub-request.
|
|
858
|
+
*
|
|
859
|
+
* Behavior:
|
|
860
|
+
* - `/bizar` (no args) — emits a `launch_dashboard` side-effect. The
|
|
861
|
+
* executor spawns `bizar dashboard start` as a detached child
|
|
862
|
+
* process, then the host surfaces the URL in the response.
|
|
863
|
+
* - `/bizar <args>` — passes the args to the menu command file. Today
|
|
864
|
+
* the menu routes intent (`/explain`, `/plan`, `/audit`, etc.); the
|
|
865
|
+
* response is the menu's natural-language routing advice.
|
|
866
|
+
*
|
|
867
|
+
* Note: the menu text lives in `config/commands/bizar.md` and is shipped
|
|
868
|
+
* via the CLI package. The plugin only handles the no-arg case for the
|
|
869
|
+
* side-effect; with args we return a short pointer so the user knows
|
|
870
|
+
* where the routing table lives.
|
|
871
|
+
*/
|
|
872
|
+
function handleBizar(arg: string, ctx: ParseContext): SlashCommandResult {
|
|
873
|
+
const trimmed = arg.trim();
|
|
874
|
+
|
|
875
|
+
if (trimmed === "") {
|
|
876
|
+
const port = ctx.defaultPort ?? 4321;
|
|
877
|
+
return {
|
|
878
|
+
handled: true,
|
|
879
|
+
response:
|
|
880
|
+
`🪩 Bizar dashboard launching in the background.\n` +
|
|
881
|
+
`Visit http://localhost:${port}/ once the server is ready.\n` +
|
|
882
|
+
`(If the browser did not open automatically, click the URL above.)`,
|
|
883
|
+
sideEffect: {
|
|
884
|
+
kind: "launch_dashboard",
|
|
885
|
+
defaultPort: port,
|
|
886
|
+
},
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// With args, defer to the menu command file shipped with the CLI.
|
|
891
|
+
return {
|
|
892
|
+
handled: true,
|
|
893
|
+
response:
|
|
894
|
+
`🪩 Bizar routing your request: "${trimmed}"\n` +
|
|
895
|
+
`The menu command file (config/commands/bizar.md) maps intents like\n` +
|
|
896
|
+
`"explain X", "plan Y", "review PR", "audit", "learn", and "init"\n` +
|
|
897
|
+
`to the right Bizar action. For the dashboard, use \`/bizar\` with no args.`,
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
|
|
848
901
|
// --- /help ----------------------------------------------------------------
|
|
849
902
|
|
|
850
903
|
function helpResult(): SlashCommandResult {
|
package/src/plan-fs.ts
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
rmSync,
|
|
44
44
|
writeFileSync,
|
|
45
45
|
} from "node:fs";
|
|
46
|
-
import { join } from "node:path";
|
|
46
|
+
import { dirname, join } from "node:path";
|
|
47
47
|
|
|
48
48
|
import type { Logger } from "./logger.js";
|
|
49
49
|
|
|
@@ -146,7 +146,7 @@ function writeJsonAtomic(
|
|
|
146
146
|
): { ok: true } | { ok: false; error: string } {
|
|
147
147
|
const tmp = `${filePath}.tmp`;
|
|
148
148
|
try {
|
|
149
|
-
mkdirSync(
|
|
149
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
150
150
|
writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
|
|
151
151
|
renameSync(tmp, filePath);
|
|
152
152
|
return { ok: true };
|
|
@@ -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,11 +210,19 @@ 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 ---------------------------------------------------------------
|
|
@@ -76,8 +76,8 @@ class InMemoryStateStore {
|
|
|
76
76
|
|
|
77
77
|
// We import the real InstanceManager after the stubs are defined so the
|
|
78
78
|
// test file fails fast if the real signature changes.
|
|
79
|
-
import { InstanceManager } from "../src/background.
|
|
80
|
-
import type { BackgroundState } from "../src/background-state.
|
|
79
|
+
import { InstanceManager } from "../src/background.js";
|
|
80
|
+
import type { BackgroundState } from "../src/background-state.js";
|
|
81
81
|
|
|
82
82
|
function makeDraft(overrides: Partial<BackgroundState> = {}): BackgroundState {
|
|
83
83
|
return {
|
|
@@ -65,7 +65,7 @@ type Event = EventSessionIdle | EventSessionError | EventMessagePartUpdated;
|
|
|
65
65
|
class FakeEventStream {
|
|
66
66
|
private handlers = new Map<string, Array<(event: Event) => void>>();
|
|
67
67
|
private sessions = new Map<string, string>(); // instanceId → sessionId
|
|
68
|
-
|
|
68
|
+
public instances = new Map<string, BackgroundState>();
|
|
69
69
|
private closed = false;
|
|
70
70
|
|
|
71
71
|
/** Register an instance's sessionId for event routing */
|
|
@@ -406,4 +406,4 @@ describe("EventStream interface contract", () => {
|
|
|
406
406
|
expect(typeof stream.onSessionEvent).toBe("function");
|
|
407
407
|
expect(typeof stream.close).toBe("function");
|
|
408
408
|
});
|
|
409
|
-
});
|
|
409
|
+
});
|