@polderlabs/bizar-plugin 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +448 -0
  3. package/bun.lock +88 -0
  4. package/index.ts +1113 -0
  5. package/package.json +42 -0
  6. package/scripts/check-forbidden-imports.sh +33 -0
  7. package/src/background-state.ts +463 -0
  8. package/src/background.ts +964 -0
  9. package/src/commands-impl.ts +369 -0
  10. package/src/commands.ts +880 -0
  11. package/src/event-stream.ts +574 -0
  12. package/src/fingerprint.ts +120 -0
  13. package/src/handoff.ts +79 -0
  14. package/src/http-client.ts +467 -0
  15. package/src/logger.ts +144 -0
  16. package/src/loop.ts +176 -0
  17. package/src/options.ts +421 -0
  18. package/src/plan-fs.ts +323 -0
  19. package/src/report.ts +178 -0
  20. package/src/research-prompt.ts +35 -0
  21. package/src/serve.ts +476 -0
  22. package/src/settings.ts +349 -0
  23. package/src/state.ts +298 -0
  24. package/src/tools/bg-collect.ts +104 -0
  25. package/src/tools/bg-get-comments.ts +239 -0
  26. package/src/tools/bg-kill.ts +87 -0
  27. package/src/tools/bg-spawn.ts +263 -0
  28. package/src/tools/bg-status.ts +99 -0
  29. package/src/tools/plan-action.ts +767 -0
  30. package/src/tools/wait-for-feedback.ts +402 -0
  31. package/tests/attach-handler-bug.test.ts +166 -0
  32. package/tests/background-state.test.ts +277 -0
  33. package/tests/background.test.ts +402 -0
  34. package/tests/block.test.ts +193 -0
  35. package/tests/canonical-key-order.test.ts +71 -0
  36. package/tests/commands-impl.test.ts +442 -0
  37. package/tests/commands.test.ts +548 -0
  38. package/tests/config.test.ts +122 -0
  39. package/tests/dispose.test.ts +336 -0
  40. package/tests/event-stream.test.ts +409 -0
  41. package/tests/event.test.ts +262 -0
  42. package/tests/fingerprint.test.ts +161 -0
  43. package/tests/http-client.test.ts +403 -0
  44. package/tests/init-helpers.test.ts +203 -0
  45. package/tests/integration/slash-command.test.ts +348 -0
  46. package/tests/integration/tool-routing.test.ts +314 -0
  47. package/tests/loop.test.ts +397 -0
  48. package/tests/options.test.ts +274 -0
  49. package/tests/serve.test.ts +335 -0
  50. package/tests/settings.test.ts +351 -0
  51. package/tests/stall-think.test.ts +749 -0
  52. package/tests/state.test.ts +275 -0
  53. package/tests/tools/bg-collect.test.ts +337 -0
  54. package/tests/tools/bg-get-comments.test.ts +485 -0
  55. package/tests/tools/bg-kill.test.ts +231 -0
  56. package/tests/tools/bg-spawn.test.ts +311 -0
  57. package/tests/tools/bg-status.test.ts +216 -0
  58. package/tests/tools/plan-action.test.ts +599 -0
  59. package/tests/tools/wait-for-feedback.test.ts +390 -0
  60. package/tsconfig.json +29 -0
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@polderlabs/bizar-plugin",
3
+ "version": "0.5.4",
4
+ "description": "Bizar opencode plugin — loop detection, status reporting, handoff signal, background agents, and slash commands + visual plan flow for subagent activity",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "types": "./index.ts",
8
+ "exports": {
9
+ ".": "./index.ts"
10
+ },
11
+ "scripts": {
12
+ "check:imports": "bash scripts/check-forbidden-imports.sh",
13
+ "typecheck": "tsc --noEmit",
14
+ "test": "npm run check:imports && bun test tests/loop.test.ts tests/block.test.ts tests/stall-think.test.ts tests/tools/bg-get-comments.test.ts tests/settings.test.ts tests/commands.test.ts tests/commands-impl.test.ts tests/tools/plan-action.test.ts tests/tools/wait-for-feedback.test.ts"
15
+ },
16
+ "keywords": [
17
+ "opencode",
18
+ "plugin",
19
+ "loop-detection",
20
+ "agent-orchestration",
21
+ "visual-plan",
22
+ "slash-commands"
23
+ ],
24
+ "license": "MIT",
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "dependencies": {
29
+ "zod": "4.1.8"
30
+ },
31
+ "devDependencies": {
32
+ "@opencode-ai/plugin": "^1.17.7",
33
+ "@types/bun": "latest",
34
+ "typescript": "^5.6.0"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "peerDependencies": {
40
+ "@opencode-ai/plugin": ">=1.17.0"
41
+ }
42
+ }
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bash
2
+ # CI: forbid network-bearing node: imports in plugin source (bizar spec §7.5)
3
+ #
4
+ # Fails fast (exit 1) if any source file in src/ imports a network-bearing
5
+ # node: module. Network-bearing modules: dns, net, http, https.
6
+ # Other node: modules (fs, path, os, util, crypto, etc.) are allowed.
7
+ #
8
+ # Exception (NEW-H1 / HIGH-24): `node:crypto` is allowed ONLY in
9
+ # `src/serve.ts`, where the 32-byte password is generated for the
10
+ # opencode serve child's authentication. The legacy `src/fingerprint.ts`
11
+ # also uses `node:crypto` (for SHA-256 hashing) and is allowed as a
12
+ # pre-existing exception; any NEW use of `node:crypto` outside serve.ts
13
+ # and fingerprint.ts fails the check.
14
+ #
15
+ # grep -E (extended regex) is used rather than grep -F (fixed string) so the
16
+ # alternation works on minimal BusyBox / Alpine CI images where extended
17
+ # regex is in the default grep build.
18
+ set -euo pipefail
19
+
20
+ # Check 1: network-bearing imports are forbidden everywhere.
21
+ if grep -rE 'from "node:(dns|net|http|https)"' src/; then
22
+ echo "FAIL: src/ contains a forbidden node: import (dns|net|http|https)" >&2
23
+ exit 1
24
+ fi
25
+
26
+ # Check 2: `node:crypto` is allowed ONLY in src/serve.ts and src/fingerprint.ts.
27
+ # We grep for `from "node:crypto"` in src/, then exclude those two files.
28
+ # Any other file with `node:crypto` fails.
29
+ if grep -rE 'from "node:crypto"' src/ | grep -vE '^src/(serve|fingerprint)\.ts:'; then
30
+ echo "FAIL: src/ contains 'from \"node:crypto\"' outside src/serve.ts and src/fingerprint.ts" >&2
31
+ echo " (node:crypto is allowed only in src/serve.ts and src/fingerprint.ts; see spec §6.1 / NEW-H1)" >&2
32
+ exit 1
33
+ fi
@@ -0,0 +1,463 @@
1
+ /**
2
+ * background-state.ts
3
+ *
4
+ * Per-instance state management for background agents (v0.4.2 spec §3).
5
+ *
6
+ * Why a separate file from `state.ts` (spec §3.1):
7
+ * - The existing per-session `SessionState` is keyed on `sessionId` and
8
+ * contains loop-detection fields tied to the opencode session lifecycle.
9
+ * - Background instances have a different lifecycle: `instanceId`
10
+ * (plugin-generated `bgr_<ulid>`), spawn source, parent, model override,
11
+ * terminal status separate from session status.
12
+ * - Coupling these into `SessionState` would require 8 new fields on a
13
+ * schema that already has 8 existing fields — exactly the field
14
+ * proliferation Forseti flagged.
15
+ * - The new `BackgroundState` lives at
16
+ * `~/.cache/bizar/bg/<instanceId>.json`. The existing `state.ts`
17
+ * is unchanged.
18
+ *
19
+ * Concurrency model (spec §3.3):
20
+ * - Per-instance async mutex, keyed on `instanceId` (NOT on `sessionId`).
21
+ * - Independent of the per-session mutex in `state.ts` — they do not
22
+ * block each other.
23
+ *
24
+ * File I/O:
25
+ * - Atomic write via `writeFile(tmp)` + `renameSync(tmp, final)`.
26
+ * - Best-effort cleanup of `.tmp` on failure.
27
+ * - Corrupt JSON → `load()` returns null; caller decides what to do
28
+ * (logger warning, skip, etc.).
29
+ */
30
+
31
+ import {
32
+ readFileSync,
33
+ writeFileSync,
34
+ renameSync,
35
+ unlinkSync,
36
+ existsSync,
37
+ mkdirSync,
38
+ readdirSync,
39
+ statSync,
40
+ } from "node:fs";
41
+ import path from "node:path";
42
+ import os from "node:os";
43
+
44
+ // --- Public types (spec §3.2) ---------------------------------------------
45
+
46
+ /**
47
+ * Background instance status. Maps opencode events and lifecycle transitions
48
+ * to a small, stable set of terminal and in-flight states.
49
+ */
50
+ export type BackgroundStatus =
51
+ | "pending"
52
+ | "running"
53
+ | "done"
54
+ | "failed"
55
+ | "killed"
56
+ | "timed_out";
57
+
58
+ /**
59
+ * Per-instance state for a background agent.
60
+ * Field-by-field notes (spec §3.2):
61
+ * - `instanceId` — plugin identifier, `bgr_<ulid>`. Returned to callers.
62
+ * Used as the filename stem.
63
+ * - `sessionId` — opencode session ID returned from POST /session. Used
64
+ * for all HTTP calls. Also the key for the existing per-session state,
65
+ * log, and loop detection.
66
+ * - `status` — see {@link BackgroundStatus} for the lifecycle.
67
+ * - `startedAt` — epoch ms when the instance was added.
68
+ * - `completedAt` — epoch ms when the instance reached a terminal state.
69
+ * - `model` — resolved model ID, `"<providerID>/<modelID>"` format.
70
+ * - `promptPreview` — first 200 chars of the prompt.
71
+ * - `resultPreview` — last 200 chars of result, refreshed on
72
+ * `EventMessagePartUpdated` for assistant text parts.
73
+ * - `resultMessageIds` — incrementally populated as
74
+ * `EventMessagePartUpdated` events arrive. The full text is NOT
75
+ * stored; reconstructed on `bizar_collect`.
76
+ * - `error` — set on terminal failure; cleared on retry from
77
+ * `failed` to `running` (retry is not in v0.4).
78
+ * - `parentAgent` — who spawned it (always "odin" in v0.4 per §6.3).
79
+ * - `parentInstanceId` — for nested spawns (reserved, not in v0.4).
80
+ * - `logPath` — path to `~/.cache/bizar/logs/<sessionId>.log`.
81
+ * - `toolCallCount` — updated via `EventMessagePartUpdated` events.
82
+ * - `loopGuardTool` — set when threshold-12 throw is captured. Used at
83
+ * `bizar_collect` to prepend the marker.
84
+ * - `timeoutMs` — collect-time timeout requested by caller.
85
+ * - `lastEventAt` — epoch ms when the most recent SSE event arrived for
86
+ * this instance. Set on every event the manager observes (tool/text
87
+ * part updates, session.idle, session.error, session.created/updated/
88
+ * deleted). Used by the stall checker (§v0.3.0). Optional in the
89
+ * type but always populated by `InstanceManager.add()` (the manager
90
+ * seeds it from `startedAt`); older on-disk files written before
91
+ * v0.3.0 are backfilled in `readState`.
92
+ * - `lastToolOrTextAt` — epoch ms when the most recent `tool` or `text`
93
+ * part arrived. `thinking` parts do NOT advance this timestamp; that
94
+ * is the whole point — repeated `thinking` parts with no `tool` or
95
+ * `text` is what we detect as a thinking loop (§v0.3.0).
96
+ * - `interventionCount` — number of research interventions sent for
97
+ * this instance. Reset to 0 when the agent makes progress (tool or
98
+ * text part after an intervention). Default 0.
99
+ * - `interventionAt` — epoch ms of the most recent intervention.
100
+ * - `interventionReason` — short human-readable description of the
101
+ * intervention, e.g. `"thinking loop (5m 12s without tool/text)"`.
102
+ */
103
+ export interface BackgroundState {
104
+ instanceId: string;
105
+ sessionId: string;
106
+ agent: string;
107
+ status: BackgroundStatus;
108
+ startedAt: number;
109
+ completedAt?: number;
110
+ model: string;
111
+ promptPreview: string;
112
+ resultPreview?: string;
113
+ resultMessageIds?: string[];
114
+ error?: string;
115
+ parentAgent: string;
116
+ parentInstanceId?: string;
117
+ logPath: string;
118
+ timeoutMs: number;
119
+ toolCallCount: number;
120
+ loopGuardTool?: string;
121
+ // v0.3.0 — stall and thinking-loop protection. These are typed as
122
+ // optional in the schema because (a) the field can be absent in older
123
+ // files on disk, and (b) the `InstanceManager.add()` input (AddDraft)
124
+ // does not require them — the manager seeds them itself.
125
+ lastEventAt?: number;
126
+ lastToolOrTextAt?: number;
127
+ interventionCount?: number;
128
+ interventionAt?: number;
129
+ interventionReason?: string;
130
+ }
131
+
132
+ /**
133
+ * Partial state used during a `pending` insert, before the HTTP call has
134
+ * assigned a `sessionId`. After the call, the full state is updated.
135
+ *
136
+ * Note: the spec contract (see task description) types `EMPTY_BACKGROUND_STATE`
137
+ * as `Omit<BackgroundState, "instanceId" | "sessionId" | "agent" | "parentAgent"
138
+ * | "logPath" | "timeoutMs" | "model" | "promptPreview" | "startedAt">`.
139
+ */
140
+ export const EMPTY_BACKGROUND_STATE: Omit<
141
+ BackgroundState,
142
+ | "instanceId"
143
+ | "sessionId"
144
+ | "agent"
145
+ | "parentAgent"
146
+ | "logPath"
147
+ | "timeoutMs"
148
+ | "model"
149
+ | "promptPreview"
150
+ | "startedAt"
151
+ > = {
152
+ status: "pending",
153
+ toolCallCount: 0,
154
+ // v0.3.0 — stall and thinking-loop protection defaults. The manager
155
+ // seeds `lastEventAt` and `lastToolOrTextAt` from `Date.now()` at
156
+ // `add()` time; we set them to 0 here so a draft shape remains valid
157
+ // even if it is constructed without the manager (the manager always
158
+ // overwrites them).
159
+ lastEventAt: 0,
160
+ lastToolOrTextAt: 0,
161
+ interventionCount: 0,
162
+ };
163
+
164
+ /**
165
+ * Terminal states per spec §1.6 / §4.4. Used by collect/await logic.
166
+ */
167
+ export const TERMINAL_STATUSES: ReadonlySet<BackgroundStatus> = new Set<BackgroundStatus>([
168
+ "done",
169
+ "failed",
170
+ "killed",
171
+ "timed_out",
172
+ ]);
173
+
174
+ // --- Logger interface -----------------------------------------------------
175
+
176
+ /**
177
+ * Minimal Logger interface — matches the shape in `state.ts` / `logger.ts`.
178
+ * Synchronous, void-returning. We intentionally use a structural type so
179
+ * the plugin's `Logger` (which has additional convenience methods) is
180
+ * assignable.
181
+ */
182
+ export interface Logger {
183
+ log(opts: { level: "debug" | "info" | "warn" | "error"; message: string }): void;
184
+ debug(message: string): void;
185
+ info(message: string): void;
186
+ warn(message: string): void;
187
+ error(message: string): void;
188
+ }
189
+
190
+ // --- File-path helpers ----------------------------------------------------
191
+
192
+ function expandHome(p: string): string {
193
+ if (p === "~") return os.homedir();
194
+ if (p.startsWith("~/") || p.startsWith("~\\")) {
195
+ return path.join(os.homedir(), p.slice(2));
196
+ }
197
+ return p;
198
+ }
199
+
200
+ function backgroundStateDir(stateDir: string): string {
201
+ // Background state lives in a subdirectory to keep it isolated from the
202
+ // per-session `state.ts` files (which sit directly in `stateDir`).
203
+ return path.join(expandHome(stateDir), "bg");
204
+ }
205
+
206
+ function backgroundStateFilePath(stateDir: string, instanceId: string): string {
207
+ return path.join(backgroundStateDir(stateDir), `${instanceId}.json`);
208
+ }
209
+
210
+ // --- Mutex (spec §3.3) ----------------------------------------------------
211
+
212
+ /**
213
+ * Per-instance async mutex, identical pattern to the one in `state.ts`
214
+ * but keyed on `instanceId`. Each `BackgroundStateStore` has its own
215
+ * lock domain.
216
+ */
217
+ async function withLock<T>(
218
+ locks: Map<string, Promise<unknown>>,
219
+ instanceId: string,
220
+ fn: () => Promise<T>,
221
+ ): Promise<T> {
222
+ const prev = locks.get(instanceId) ?? Promise.resolve();
223
+ const next = prev.then(fn, fn);
224
+ locks.set(instanceId, next.catch(() => {}));
225
+ return next;
226
+ }
227
+
228
+ // --- Directory bootstrap --------------------------------------------------
229
+
230
+ /**
231
+ * Recursive mkdir with EACCES/EROFS handling per spec §8.2 (pattern borrowed
232
+ * from `state.ts` / `report.ts`).
233
+ */
234
+ function ensureDir(dir: string, logger: Logger): boolean {
235
+ try {
236
+ mkdirSync(dir, { recursive: true });
237
+ return true;
238
+ } catch (err: unknown) {
239
+ const code = (err as NodeJS.ErrnoException).code;
240
+ if (code === "EACCES" || code === "EROFS") {
241
+ logger.log({
242
+ level: "error",
243
+ message: `bizar: cannot create background state directory ${dir}: ${String(err)}`,
244
+ });
245
+ return false;
246
+ }
247
+ throw err;
248
+ }
249
+ }
250
+
251
+ // --- Read / write ---------------------------------------------------------
252
+
253
+ /**
254
+ * Atomic write: write to `.tmp` then rename. Best-effort cleanup of `.tmp`
255
+ * on failure. Mirrors `state.ts` / `report.ts`.
256
+ */
257
+ function writeStateAtomic(
258
+ filePath: string,
259
+ state: BackgroundState,
260
+ logger: Logger,
261
+ ): void {
262
+ const tmp = `${filePath}.tmp`;
263
+ try {
264
+ writeFileSync(tmp, JSON.stringify(state), "utf8");
265
+ renameSync(tmp, filePath);
266
+ } catch (err: unknown) {
267
+ logger.log({
268
+ level: "warn",
269
+ message: `bizar: failed to write background state file ${filePath}: ${String(err)}`,
270
+ });
271
+ try {
272
+ if (existsSync(tmp)) unlinkSync(tmp);
273
+ } catch {
274
+ // non-fatal
275
+ }
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Read & validate a single background state file. Returns null on missing
281
+ * or corrupt input. Logs a warning on corrupt files.
282
+ *
283
+ * Backward compatibility (v0.3.0): state files written before the stall
284
+ * and thinking-loop fields existed may not carry `lastEventAt` /
285
+ * `lastToolOrTextAt` / `interventionCount`. We backfill them here from
286
+ * `startedAt` so the stall and thinking-loop checkers have a sensible
287
+ * baseline (and so a plugin restart does not immediately flag a
288
+ * pre-existing live instance as stalled).
289
+ */
290
+ function readState(
291
+ filePath: string,
292
+ instanceId: string,
293
+ logger: Logger,
294
+ ): BackgroundState | null {
295
+ if (!existsSync(filePath)) return null;
296
+ try {
297
+ const raw = readFileSync(filePath, "utf8");
298
+ const parsed = JSON.parse(raw) as BackgroundState;
299
+ if (
300
+ typeof parsed.instanceId !== "string" ||
301
+ typeof parsed.sessionId !== "string" ||
302
+ typeof parsed.agent !== "string" ||
303
+ typeof parsed.status !== "string"
304
+ ) {
305
+ throw new Error("schema mismatch");
306
+ }
307
+ // Backfill v0.3.0 fields for files written by older versions.
308
+ if (typeof parsed.lastEventAt !== "number") {
309
+ parsed.lastEventAt = parsed.startedAt;
310
+ }
311
+ if (typeof parsed.lastToolOrTextAt !== "number") {
312
+ parsed.lastToolOrTextAt = parsed.startedAt;
313
+ }
314
+ if (typeof parsed.interventionCount !== "number") {
315
+ parsed.interventionCount = 0;
316
+ }
317
+ return parsed;
318
+ } catch (err: unknown) {
319
+ logger.log({
320
+ level: "warn",
321
+ message: `bizar: corrupt background state file for instance ${instanceId}: ${String(err)}`,
322
+ });
323
+ return null;
324
+ }
325
+ }
326
+
327
+ // --- Public store ---------------------------------------------------------
328
+
329
+ /**
330
+ * Persistent store for `BackgroundState`. File-backed, with a per-instance
331
+ * mutex and best-effort corrupt-file handling.
332
+ *
333
+ * Public surface (the interface contract for Thor's tests):
334
+ * - `load(instanceId)` — read one instance (or null if missing/corrupt).
335
+ * - `save(state)` — atomic write.
336
+ * - `list()` — read all instances in the bg directory.
337
+ * - `cleanup(maxAgeDays)` — delete terminal instances older than the
338
+ * threshold. Returns the count deleted.
339
+ * - `withLock(instanceId, fn)` — per-instance async mutex.
340
+ *
341
+ * The directory is created lazily on first use. If creation fails, all
342
+ * read/write operations are silent no-ops and the caller should disable
343
+ * background agents for this session (spec §8.2).
344
+ */
345
+ export class BackgroundStateStore {
346
+ private stateDir: string;
347
+ private logger: Logger;
348
+ private initialized = false;
349
+ private locks = new Map<string, Promise<unknown>>();
350
+ /** True if the bg directory became unusable (EACCES/EROFS on init). */
351
+ private dirUsable: boolean | null = null;
352
+
353
+ constructor(stateDir: string, logger: Logger) {
354
+ this.stateDir = stateDir;
355
+ this.logger = logger;
356
+ }
357
+
358
+ /**
359
+ * Lazily ensure the bg directory exists. Caches the result so the
360
+ * EACCES/EROFS branch is taken at most once per process.
361
+ */
362
+ private ensureDir(): boolean {
363
+ if (this.dirUsable !== null) return this.dirUsable;
364
+ const ok = ensureDir(backgroundStateDir(this.stateDir), this.logger);
365
+ this.dirUsable = ok;
366
+ this.initialized = true;
367
+ return ok;
368
+ }
369
+
370
+ /** Resolve the bg directory for callers that need to enumerate it. */
371
+ get bgDir(): string {
372
+ return backgroundStateDir(this.stateDir);
373
+ }
374
+
375
+ /**
376
+ * Load a single instance. Returns null if the file is missing or corrupt.
377
+ * Does not throw. The corrupt-file warning is logged inside `readState`.
378
+ */
379
+ async load(instanceId: string): Promise<BackgroundState | null> {
380
+ if (!this.ensureDir()) return null;
381
+ const filePath = backgroundStateFilePath(this.stateDir, instanceId);
382
+ return withLock(this.locks, instanceId, () =>
383
+ Promise.resolve(readState(filePath, instanceId, this.logger)),
384
+ );
385
+ }
386
+
387
+ /**
388
+ * Persist a `BackgroundState` atomically.
389
+ */
390
+ async save(state: BackgroundState): Promise<void> {
391
+ if (!this.ensureDir()) return;
392
+ 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
+ });
397
+ }
398
+
399
+ /**
400
+ * Read every instance in the bg directory. Corrupt files are skipped
401
+ * (warning already logged by `readState`). Best-effort; returns [] on
402
+ * directory read error.
403
+ */
404
+ async list(): Promise<BackgroundState[]> {
405
+ if (!this.ensureDir()) return [];
406
+ const dir = backgroundStateDir(this.stateDir);
407
+ let files: string[];
408
+ try {
409
+ files = readdirSync(dir).filter((f) => f.endsWith(".json"));
410
+ } catch {
411
+ return [];
412
+ }
413
+ const out: BackgroundState[] = [];
414
+ for (const f of files) {
415
+ const id = f.replace(/\.json$/, "");
416
+ const filePath = path.join(dir, f);
417
+ const state = readState(filePath, id, this.logger);
418
+ if (state !== null) out.push(state);
419
+ }
420
+ return out;
421
+ }
422
+
423
+ /**
424
+ * Delete terminal instances whose `completedAt` (or `startedAt` as a
425
+ * fallback) is older than `maxAgeDays`. Returns the number of files
426
+ * removed. Non-terminal instances are NEVER removed.
427
+ */
428
+ async cleanup(maxAgeDays: number): Promise<number> {
429
+ if (!this.ensureDir()) return 0;
430
+ const all = await this.list();
431
+ const maxAgeMs = maxAgeDays * 24 * 60 * 60 * 1000;
432
+ const now = Date.now();
433
+ let deleted = 0;
434
+ for (const s of all) {
435
+ if (!TERMINAL_STATUSES.has(s.status)) continue;
436
+ const ts = s.completedAt ?? s.startedAt;
437
+ if (now - ts <= maxAgeMs) continue;
438
+ const filePath = backgroundStateFilePath(this.stateDir, s.instanceId);
439
+ try {
440
+ if (existsSync(filePath)) {
441
+ unlinkSync(filePath);
442
+ deleted += 1;
443
+ }
444
+ } catch (err: unknown) {
445
+ this.logger.log({
446
+ level: "warn",
447
+ message: `bizar: failed to delete background state file ${filePath}: ${String(err)}`,
448
+ });
449
+ }
450
+ }
451
+ // touch statSync to silence the unused import if no other consumer
452
+ void statSync;
453
+ return deleted;
454
+ }
455
+
456
+ /**
457
+ * Acquire the per-instance mutex. The callback is serialized with all
458
+ * other state operations for the same `instanceId`.
459
+ */
460
+ withLock<T>(instanceId: string, fn: () => Promise<T>): Promise<T> {
461
+ return withLock(this.locks, instanceId, fn);
462
+ }
463
+ }