@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/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. Exit. (Note: the host may keep the process alive if other work
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
- try {
520
- process.removeAllListeners(sig);
521
- } catch {
522
- // ignore
539
+ const previous = signalHandlerRefs.get(sig);
540
+ if (previous) {
541
+ try {
542
+ process.removeListener(sig, previous);
543
+ } catch {
544
+ // ignore
545
+ }
523
546
  }
524
- process.on(sig, () => {
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.5.4",
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",
@@ -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
- return withLock(this.locks, state.instanceId, () => {
394
- writeStateAtomic(filePath, state, this.logger);
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.save(current);
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,
@@ -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(join(filePath, ".."), { recursive: true });
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 { pid: proc.pid, port: boundPort, password };
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.ts";
80
- import type { BackgroundState } from "../src/background-state.ts";
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
- private instances = new Map<string, BackgroundState>();
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
+ });