@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
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";
|
|
@@ -124,9 +125,12 @@ import { SettingsStore } from "./src/settings.js";
|
|
|
124
125
|
import { parseSlashCommand } from "./src/commands.js";
|
|
125
126
|
import { createPlanActionTool } from "./src/tools/plan-action.js";
|
|
126
127
|
import { createWaitForFeedbackTool } from "./src/tools/wait-for-feedback.js";
|
|
128
|
+
import { wrapFetchForReasoningCleanup } from "./src/reasoning-clean.js";
|
|
127
129
|
|
|
128
130
|
// v0.5.0 — visual plan wiring: side-effect executor + plan-fs
|
|
129
131
|
import { executeSideEffect, type ExecuteOptions } from "./src/commands-impl.js";
|
|
132
|
+
import { join as pathJoin } from "node:path";
|
|
133
|
+
import { homedir } from "node:os";
|
|
130
134
|
|
|
131
135
|
// --- Env-var constants (per spec §8) -------------------------------------
|
|
132
136
|
|
|
@@ -216,6 +220,7 @@ let instanceManagerHandle: InstanceManager | null = null;
|
|
|
216
220
|
let serveHandle: ServeLifecycle | null = null;
|
|
217
221
|
let streamHandle: EventStream | null = null;
|
|
218
222
|
let loggerHandle: Logger | null = null;
|
|
223
|
+
const signalHandlerRefs = new Map<"SIGTERM" | "SIGINT", () => void>();
|
|
219
224
|
|
|
220
225
|
// --- Plugin entry point ---------------------------------------------------
|
|
221
226
|
|
|
@@ -359,6 +364,20 @@ async function init(
|
|
|
359
364
|
});
|
|
360
365
|
serveHandle = serve;
|
|
361
366
|
const serveInfo = await serve.start();
|
|
367
|
+
// v3.5.7 — Persist serve-info so the dashboard can talk to us
|
|
368
|
+
try {
|
|
369
|
+
writeServeInfo(options.stateDir, {
|
|
370
|
+
baseUrl: serveInfo.baseUrl,
|
|
371
|
+
port: serveInfo.port,
|
|
372
|
+
password: serveInfo.password,
|
|
373
|
+
worktree: serveInfo.worktree,
|
|
374
|
+
pid: serveInfo.pid,
|
|
375
|
+
startedAt: serveInfo.startedAt,
|
|
376
|
+
}, logger);
|
|
377
|
+
logger.info(`[bizar] wrote serve-info to ${options.stateDir}/serve.json`);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
logger.warn(`[bizar] failed to write serve-info: ${err instanceof Error ? err.message : String(err)}`);
|
|
380
|
+
}
|
|
362
381
|
const http = new HttpClient({
|
|
363
382
|
baseUrl: `http://127.0.0.1:${serveInfo.port}`,
|
|
364
383
|
password: serveInfo.password,
|
|
@@ -437,7 +456,7 @@ async function init(
|
|
|
437
456
|
|
|
438
457
|
// --- Signal traps (spec §5.3) ------------------------------------------
|
|
439
458
|
|
|
440
|
-
installSignalHandlers(logger, instanceManager, serve, stream);
|
|
459
|
+
installSignalHandlers(logger, instanceManager, serve, stream, options.stateDir);
|
|
441
460
|
|
|
442
461
|
const ctx: RuntimeContext = {
|
|
443
462
|
logger,
|
|
@@ -462,6 +481,7 @@ function installSignalHandlers(
|
|
|
462
481
|
instanceManager: InstanceManager | null,
|
|
463
482
|
serve: ServeLifecycle | null,
|
|
464
483
|
stream: EventStream | null,
|
|
484
|
+
stateDir: string,
|
|
465
485
|
): void {
|
|
466
486
|
const onSignal = async (sig: "SIGTERM" | "SIGINT") => {
|
|
467
487
|
if (shuttingDown) return;
|
|
@@ -503,7 +523,10 @@ function installSignalHandlers(
|
|
|
503
523
|
}
|
|
504
524
|
}
|
|
505
525
|
|
|
506
|
-
// 4.
|
|
526
|
+
// 4. Clear serve-info so the dashboard doesn't try to talk to a dead serve.
|
|
527
|
+
clearServeInfo(stateDir, logger);
|
|
528
|
+
|
|
529
|
+
// 5. Exit. (Note: the host may keep the process alive if other work
|
|
507
530
|
// is pending, but for the plugin process this is the end.)
|
|
508
531
|
try {
|
|
509
532
|
process.exit(0);
|
|
@@ -516,14 +539,19 @@ function installSignalHandlers(
|
|
|
516
539
|
// duplicate handlers. Use `process.once` so each handler runs at most
|
|
517
540
|
// once per signal; the `shuttingDown` guard catches reentry.
|
|
518
541
|
for (const sig of ["SIGTERM", "SIGINT"] as const) {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
542
|
+
const previous = signalHandlerRefs.get(sig);
|
|
543
|
+
if (previous) {
|
|
544
|
+
try {
|
|
545
|
+
process.removeListener(sig, previous);
|
|
546
|
+
} catch {
|
|
547
|
+
// ignore
|
|
548
|
+
}
|
|
523
549
|
}
|
|
524
|
-
|
|
550
|
+
const handler = () => {
|
|
525
551
|
void onSignal(sig);
|
|
526
|
-
}
|
|
552
|
+
};
|
|
553
|
+
signalHandlerRefs.set(sig, handler);
|
|
554
|
+
process.once(sig, handler);
|
|
527
555
|
}
|
|
528
556
|
}
|
|
529
557
|
|
|
@@ -728,9 +756,40 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
|
|
|
728
756
|
};
|
|
729
757
|
|
|
730
758
|
return {
|
|
731
|
-
// §3.1 — config:
|
|
732
|
-
|
|
733
|
-
|
|
759
|
+
// §3.1 — config: wrap provider fetches to strip duplicated inline
|
|
760
|
+
// think blocks from responses of reasoning models that emit BOTH a
|
|
761
|
+
// structured reasoning field (rendered as a thought) AND an inline
|
|
762
|
+
// `` block (which would otherwise leak into the visible message).
|
|
763
|
+
// See plugins/bizar/src/reasoning-clean.ts for the full rationale.
|
|
764
|
+
config: async (cfg) => {
|
|
765
|
+
try {
|
|
766
|
+
const providers = (cfg as { provider?: Record<string, unknown> } | undefined)?.provider;
|
|
767
|
+
if (!providers || typeof providers !== "object") return;
|
|
768
|
+
const debug = (msg: string) => ctx.logger.debug(`bizar: ${msg}`);
|
|
769
|
+
for (const [name, provider] of Object.entries(providers)) {
|
|
770
|
+
if (!provider || typeof provider !== "object") continue;
|
|
771
|
+
const prov = provider as { options?: Record<string, unknown> };
|
|
772
|
+
if (!prov.options || typeof prov.options !== "object") continue;
|
|
773
|
+
const original = prov.options.fetch;
|
|
774
|
+
if (typeof original !== "function") continue;
|
|
775
|
+
// Only wrap once — detect by stamping a sentinel.
|
|
776
|
+
const wrapped = (original as { __bizarReasoningClean?: boolean })
|
|
777
|
+
.__bizarReasoningClean;
|
|
778
|
+
if (wrapped) continue;
|
|
779
|
+
prov.options.fetch = wrapFetchForReasoningCleanup(
|
|
780
|
+
original as Parameters<typeof wrapFetchForReasoningCleanup>[0],
|
|
781
|
+
{ debug, providers: [name] },
|
|
782
|
+
);
|
|
783
|
+
(prov.options.fetch as { __bizarReasoningClean?: boolean }).__bizarReasoningClean = true;
|
|
784
|
+
debug(`wrapped provider.fetch for ${name}`);
|
|
785
|
+
}
|
|
786
|
+
} catch (err) {
|
|
787
|
+
ctx.logger.warn(
|
|
788
|
+
`bizar: config hook failed (passing through): ${
|
|
789
|
+
err instanceof Error ? err.message : String(err)
|
|
790
|
+
}`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
734
793
|
},
|
|
735
794
|
|
|
736
795
|
// §3.1, §4.5.1 — event: track session boundaries. We do NOT create
|
|
@@ -837,6 +896,29 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
|
|
|
837
896
|
finalResponse = `Command failed: ${msg}`;
|
|
838
897
|
}
|
|
839
898
|
}
|
|
899
|
+
// --- v0.5.1: dialog support.
|
|
900
|
+
// If the command emitted a dialog descriptor, persist it to disk
|
|
901
|
+
// so Tyr's dialog-poller can broadcast it to the dashboard.
|
|
902
|
+
// We return without throwing so no chat bubble is shown.
|
|
903
|
+
if (result.dialog) {
|
|
904
|
+
// S4 — defense-in-depth: validate ID shape before touching disk.
|
|
905
|
+
if (!/^dlg_[a-zA-Z0-9_-]{1,64}$/.test(result.dialog.id)) {
|
|
906
|
+
return; // silently drop malformed dialog IDs
|
|
907
|
+
}
|
|
908
|
+
try {
|
|
909
|
+
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
910
|
+
const dialogDir = pathJoin(homedir(), ".cache", "bizar", "dialogs");
|
|
911
|
+
await mkdir(dialogDir, { recursive: true });
|
|
912
|
+
await writeFile(
|
|
913
|
+
pathJoin(dialogDir, `${result.dialog.id}.json`),
|
|
914
|
+
JSON.stringify({ ...result.dialog, createdAt: new Date().toISOString() }, null, 2),
|
|
915
|
+
);
|
|
916
|
+
} catch (dialogErr: unknown) {
|
|
917
|
+
const msg = dialogErr instanceof Error ? dialogErr.message : String(dialogErr);
|
|
918
|
+
ctx.logger.warn(`bizar: failed to write dialog file: ${msg}`);
|
|
919
|
+
}
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
840
922
|
// Surface the response to the user/host. We throw so the
|
|
841
923
|
// message is treated as handled; the LLM does not process
|
|
842
924
|
// it further. The host renders the throw message.
|
|
@@ -1070,6 +1152,7 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
|
|
|
1070
1152
|
);
|
|
1071
1153
|
}
|
|
1072
1154
|
}
|
|
1155
|
+
clearServeInfo(ctx.options.stateDir, ctx.logger);
|
|
1073
1156
|
},
|
|
1074
1157
|
};
|
|
1075
1158
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polderlabs/bizar-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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
|
@@ -99,6 +99,15 @@ export type BackgroundStatus =
|
|
|
99
99
|
* - `interventionAt` — epoch ms of the most recent intervention.
|
|
100
100
|
* - `interventionReason` — short human-readable description of the
|
|
101
101
|
* intervention, e.g. `"thinking loop (5m 12s without tool/text)"`.
|
|
102
|
+
*
|
|
103
|
+
* v0.5.5 — persistent auto-restart. These are typed as optional so
|
|
104
|
+
* existing state files on disk remain valid after upgrade.
|
|
105
|
+
* - `persistent` — when true, the manager auto-restarts on terminal
|
|
106
|
+
* failure (up to maxRestarts). Default false.
|
|
107
|
+
* - `restartCount` — number of times this instance has been
|
|
108
|
+
* auto-restarted (not including the original spawn). Default 0.
|
|
109
|
+
* - `maxRestarts` — cap; default 3.
|
|
110
|
+
* - `lastRestartAt` — epoch ms of the most recent auto-restart.
|
|
102
111
|
*/
|
|
103
112
|
export interface BackgroundState {
|
|
104
113
|
instanceId: string;
|
|
@@ -127,6 +136,23 @@ export interface BackgroundState {
|
|
|
127
136
|
interventionCount?: number;
|
|
128
137
|
interventionAt?: number;
|
|
129
138
|
interventionReason?: string;
|
|
139
|
+
// v0.5.5 — persistent auto-restart
|
|
140
|
+
persistent?: boolean;
|
|
141
|
+
restartCount?: number;
|
|
142
|
+
maxRestarts?: number;
|
|
143
|
+
lastRestartAt?: string;
|
|
144
|
+
/**
|
|
145
|
+
* Full original prompt text, stored so the instance can be restarted
|
|
146
|
+
* with the same input. Optional for backward compat; always set on
|
|
147
|
+
* new spawns from v0.5.5+.
|
|
148
|
+
*/
|
|
149
|
+
prompt?: string;
|
|
150
|
+
/**
|
|
151
|
+
* Human-readable error from the last failed restart attempt.
|
|
152
|
+
* Set only when `_maybeAutoRestart` calls `restart()` and it returns
|
|
153
|
+
* `{ ok: false }`. Cleared on a successful restart.
|
|
154
|
+
*/
|
|
155
|
+
restartError?: string;
|
|
130
156
|
}
|
|
131
157
|
|
|
132
158
|
/**
|
|
@@ -159,6 +185,10 @@ export const EMPTY_BACKGROUND_STATE: Omit<
|
|
|
159
185
|
lastEventAt: 0,
|
|
160
186
|
lastToolOrTextAt: 0,
|
|
161
187
|
interventionCount: 0,
|
|
188
|
+
// v0.5.5 — persistent auto-restart defaults
|
|
189
|
+
persistent: false,
|
|
190
|
+
restartCount: 0,
|
|
191
|
+
maxRestarts: 3,
|
|
162
192
|
};
|
|
163
193
|
|
|
164
194
|
/**
|
|
@@ -314,6 +344,17 @@ function readState(
|
|
|
314
344
|
if (typeof parsed.interventionCount !== "number") {
|
|
315
345
|
parsed.interventionCount = 0;
|
|
316
346
|
}
|
|
347
|
+
// v0.5.5 — backfill persistent/restart fields for files written by
|
|
348
|
+
// older versions. These are optional but the restarter needs them.
|
|
349
|
+
if (typeof parsed.persistent !== "boolean") {
|
|
350
|
+
parsed.persistent = false;
|
|
351
|
+
}
|
|
352
|
+
if (typeof parsed.restartCount !== "number") {
|
|
353
|
+
parsed.restartCount = 0;
|
|
354
|
+
}
|
|
355
|
+
if (typeof parsed.maxRestarts !== "number") {
|
|
356
|
+
parsed.maxRestarts = 3;
|
|
357
|
+
}
|
|
317
358
|
return parsed;
|
|
318
359
|
} catch (err: unknown) {
|
|
319
360
|
logger.log({
|
|
@@ -389,11 +430,22 @@ export class BackgroundStateStore {
|
|
|
389
430
|
*/
|
|
390
431
|
async save(state: BackgroundState): Promise<void> {
|
|
391
432
|
if (!this.ensureDir()) return;
|
|
433
|
+
return withLock(this.locks, state.instanceId, () => this.saveUnlocked(state));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Persist a `BackgroundState` without acquiring the per-instance mutex.
|
|
438
|
+
*
|
|
439
|
+
* Callers must already hold the lock for `state.instanceId`. This exists
|
|
440
|
+
* for internal code paths such as `InstanceManager.update()` that need to
|
|
441
|
+
* mutate in-memory state while holding the same lock; calling `save()`
|
|
442
|
+
* there would re-enter the mutex and deadlock the promise chain.
|
|
443
|
+
*/
|
|
444
|
+
saveUnlocked(state: BackgroundState): Promise<void> {
|
|
445
|
+
if (!this.ensureDir()) return Promise.resolve();
|
|
392
446
|
const filePath = backgroundStateFilePath(this.stateDir, state.instanceId);
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
return Promise.resolve();
|
|
396
|
-
});
|
|
447
|
+
writeStateAtomic(filePath, state, this.logger);
|
|
448
|
+
return Promise.resolve();
|
|
397
449
|
}
|
|
398
450
|
|
|
399
451
|
/**
|
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 ------------------------------------------------------------
|
|
@@ -276,6 +278,30 @@ export class InstanceManager {
|
|
|
276
278
|
}
|
|
277
279
|
}
|
|
278
280
|
|
|
281
|
+
// --- Internal dispatch (shared by add() and restart()) ------------------
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Insert a fully-constructed BackgroundState into the in-memory map
|
|
285
|
+
* and persist to disk asynchronously. Does NOT check the concurrency
|
|
286
|
+
* cap — the caller handles that.
|
|
287
|
+
*
|
|
288
|
+
* This is extracted from `add()` so `restart()` can reuse the same
|
|
289
|
+
* insert-and-persist path without duplicating the logic.
|
|
290
|
+
*/
|
|
291
|
+
private dispatchInternal(full: BackgroundState): BackgroundState {
|
|
292
|
+
this.instances.set(full.instanceId, full);
|
|
293
|
+
// Persist asynchronously; failure is logged but does not roll back
|
|
294
|
+
// the in-memory insert (the instance is "tracked" either way).
|
|
295
|
+
this.stateStore.save(full).catch((err: unknown) => {
|
|
296
|
+
this.logger.warn(
|
|
297
|
+
`bizar: failed to persist new instance ${full.instanceId}: ${
|
|
298
|
+
err instanceof Error ? err.message : String(err)
|
|
299
|
+
}`,
|
|
300
|
+
);
|
|
301
|
+
});
|
|
302
|
+
return full;
|
|
303
|
+
}
|
|
304
|
+
|
|
279
305
|
// --- Atomic add (spec §2.2) ---------------------------------------------
|
|
280
306
|
|
|
281
307
|
/**
|
|
@@ -313,16 +339,7 @@ export class InstanceManager {
|
|
|
313
339
|
lastToolOrTextAt: now,
|
|
314
340
|
interventionCount: 0,
|
|
315
341
|
};
|
|
316
|
-
this.
|
|
317
|
-
// Persist asynchronously; failure is logged but does not roll back
|
|
318
|
-
// the in-memory insert (the instance is "tracked" either way).
|
|
319
|
-
this.stateStore.save(full).catch((err: unknown) => {
|
|
320
|
-
this.logger.warn(
|
|
321
|
-
`bizar: failed to persist new instance ${draft.instanceId}: ${
|
|
322
|
-
err instanceof Error ? err.message : String(err)
|
|
323
|
-
}`,
|
|
324
|
-
);
|
|
325
|
-
});
|
|
342
|
+
this.dispatchInternal(full);
|
|
326
343
|
// BUGFIX (v0.5.1): Do NOT call attachEventHandler() here. The
|
|
327
344
|
// instance was just added with sessionId="" (filled in later by
|
|
328
345
|
// POST /session). EventStream.onSessionEvent rejects empty strings,
|
|
@@ -376,8 +393,11 @@ export class InstanceManager {
|
|
|
376
393
|
if (TERMINAL_STATUSES.has(patch.status ?? current.status) && !current.completedAt) {
|
|
377
394
|
current.completedAt = Date.now();
|
|
378
395
|
}
|
|
396
|
+
if (TERMINAL_STATUSES.has(current.status)) {
|
|
397
|
+
this.detachEventHandler(instanceId);
|
|
398
|
+
}
|
|
379
399
|
try {
|
|
380
|
-
await this.stateStore.
|
|
400
|
+
await this.stateStore.saveUnlocked(current);
|
|
381
401
|
} catch (err: unknown) {
|
|
382
402
|
this.logger.warn(
|
|
383
403
|
`bizar: failed to persist update for ${instanceId}: ${
|
|
@@ -421,6 +441,86 @@ export class InstanceManager {
|
|
|
421
441
|
});
|
|
422
442
|
this.logger.info(`bizar: killed background instance ${instanceId}`);
|
|
423
443
|
}
|
|
444
|
+
|
|
445
|
+
// --- Restart (v0.5.5 — persistent auto-restart) ------------------------
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Re-spawn a failed persistent instance with the same prompt, agent,
|
|
449
|
+
* and model config. The new instance gets a fresh `instanceId` and is
|
|
450
|
+
* linked to the original via `parentInstanceId`.
|
|
451
|
+
*
|
|
452
|
+
* Returns `{ ok: true, newInstanceId }` on success, or
|
|
453
|
+
* `{ ok: false, error: "..." }` on failure. Does NOT check the
|
|
454
|
+
* concurrency cap — the original instance is terminal, so its slot
|
|
455
|
+
* is already freed.
|
|
456
|
+
*/
|
|
457
|
+
async restart(instanceId: string): Promise<{
|
|
458
|
+
ok: boolean;
|
|
459
|
+
newInstanceId?: string;
|
|
460
|
+
error?: string;
|
|
461
|
+
}> {
|
|
462
|
+
const existing = this.instances.get(instanceId);
|
|
463
|
+
if (!existing) return { ok: false, error: "instance_not_found" };
|
|
464
|
+
const totalRestarts = this.getTotalRestartCount(instanceId);
|
|
465
|
+
if (totalRestarts >= (existing.maxRestarts ?? 3)) {
|
|
466
|
+
return { ok: false, error: "max_restarts_reached" };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const now = Date.now();
|
|
470
|
+
const newInstanceId = generateInstanceId();
|
|
471
|
+
const full: BackgroundState = {
|
|
472
|
+
instanceId: newInstanceId,
|
|
473
|
+
sessionId: "",
|
|
474
|
+
agent: existing.agent,
|
|
475
|
+
model: existing.model,
|
|
476
|
+
promptPreview: (existing.prompt ?? existing.promptPreview ?? "").slice(0, PROMPT_PREVIEW_MAX),
|
|
477
|
+
prompt: existing.prompt,
|
|
478
|
+
parentAgent: existing.parentAgent,
|
|
479
|
+
parentInstanceId: instanceId,
|
|
480
|
+
logPath: `${this.worktree}/.opencode/log/${newInstanceId}.log`,
|
|
481
|
+
timeoutMs: existing.timeoutMs,
|
|
482
|
+
toolCallCount: 0,
|
|
483
|
+
// v0.5.5 — persist the auto-restart fields
|
|
484
|
+
persistent: existing.persistent,
|
|
485
|
+
maxRestarts: existing.maxRestarts,
|
|
486
|
+
restartCount: (existing.restartCount ?? 0) + 1,
|
|
487
|
+
status: "pending",
|
|
488
|
+
startedAt: now,
|
|
489
|
+
lastEventAt: now,
|
|
490
|
+
lastToolOrTextAt: now,
|
|
491
|
+
interventionCount: 0,
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
this.dispatchInternal(full);
|
|
495
|
+
this.logger.info(
|
|
496
|
+
`bizar: restarted instance ${instanceId} as ${newInstanceId} (restart #${full.restartCount})`,
|
|
497
|
+
);
|
|
498
|
+
return { ok: true, newInstanceId };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* Walk the parent chain to compute the true restart count for this
|
|
503
|
+
* instance (Forseti C4). `restartCount` alone only reflects the child's
|
|
504
|
+
* own counter, so a chain of restarts can exceed `maxRestarts`. This
|
|
505
|
+
* helper sums the counters across the entire chain (parent + all
|
|
506
|
+
* descendants) and returns the total.
|
|
507
|
+
*/
|
|
508
|
+
private getTotalRestartCount(instanceId: string): number {
|
|
509
|
+
let current = this.instances.get(instanceId);
|
|
510
|
+
if (!current) return 0;
|
|
511
|
+
let count = current.restartCount ?? 0;
|
|
512
|
+
let parentId = current.parentInstanceId;
|
|
513
|
+
const visited = new Set<string>([instanceId]);
|
|
514
|
+
while (parentId && !visited.has(parentId)) {
|
|
515
|
+
visited.add(parentId);
|
|
516
|
+
const parent = this.instances.get(parentId);
|
|
517
|
+
if (!parent) break;
|
|
518
|
+
count += parent.restartCount ?? 0;
|
|
519
|
+
parentId = parent.parentInstanceId;
|
|
520
|
+
}
|
|
521
|
+
return count;
|
|
522
|
+
}
|
|
523
|
+
|
|
424
524
|
// --- Collect ------------------------------------------------------------
|
|
425
525
|
|
|
426
526
|
/**
|
|
@@ -629,6 +729,8 @@ export class InstanceManager {
|
|
|
629
729
|
error: `No activity for ${this.stallTimeoutMs}ms — LLM appears stalled`,
|
|
630
730
|
completedAt: Date.now(),
|
|
631
731
|
});
|
|
732
|
+
// v0.5.5 — persistent auto-restart on stall
|
|
733
|
+
await this._maybeAutoRestart(inst.instanceId);
|
|
632
734
|
}
|
|
633
735
|
|
|
634
736
|
/**
|
|
@@ -699,18 +801,33 @@ export class InstanceManager {
|
|
|
699
801
|
error: `Thinking loop detected: ${formatDuration(sinceMs)} of thinking without tool calls or output. Spawn a Mimir agent for research.`,
|
|
700
802
|
completedAt: Date.now(),
|
|
701
803
|
});
|
|
804
|
+
// v0.5.5 — persistent auto-restart on thinking-loop exhaustion
|
|
805
|
+
await this._maybeAutoRestart(inst.instanceId);
|
|
702
806
|
}
|
|
703
807
|
|
|
704
808
|
// --- Internal: per-session event handler -------------------------------
|
|
705
809
|
|
|
706
810
|
public attachEventHandler(inst: BackgroundState): () => void {
|
|
811
|
+
this.detachEventHandler(inst.instanceId);
|
|
707
812
|
const handler: SessionEventHandler = (ev: StreamEvent) => {
|
|
708
813
|
void this.handleInstanceEvent(inst.instanceId, ev);
|
|
709
814
|
};
|
|
710
815
|
const unsubscribe = this.stream.onSessionEvent(inst.sessionId, handler);
|
|
816
|
+
this.eventUnsubscribers.set(inst.instanceId, unsubscribe);
|
|
711
817
|
return unsubscribe;
|
|
712
818
|
}
|
|
713
819
|
|
|
820
|
+
private detachEventHandler(instanceId: string): void {
|
|
821
|
+
const unsubscribe = this.eventUnsubscribers.get(instanceId);
|
|
822
|
+
if (!unsubscribe) return;
|
|
823
|
+
try {
|
|
824
|
+
unsubscribe();
|
|
825
|
+
} catch {
|
|
826
|
+
// ignore
|
|
827
|
+
}
|
|
828
|
+
this.eventUnsubscribers.delete(instanceId);
|
|
829
|
+
}
|
|
830
|
+
|
|
714
831
|
private async handleInstanceEvent(
|
|
715
832
|
instanceId: string,
|
|
716
833
|
ev: StreamEvent,
|
|
@@ -741,6 +858,37 @@ export class InstanceManager {
|
|
|
741
858
|
error: errMsg,
|
|
742
859
|
completedAt: Date.now(),
|
|
743
860
|
});
|
|
861
|
+
// v0.5.5 — persistent auto-restart. If the instance is persistent
|
|
862
|
+
// and was not explicitly killed, try to restart.
|
|
863
|
+
if (inst.persistent && inst.status !== "killed") {
|
|
864
|
+
await this._maybeAutoRestart(instanceId);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** v0.5.5 — Attempt an auto-restart for a persistent failed instance. */
|
|
870
|
+
private async _maybeAutoRestart(instanceId: string): Promise<void> {
|
|
871
|
+
const inst = this.instances.get(instanceId);
|
|
872
|
+
if (!inst || !inst.persistent) return;
|
|
873
|
+
if (inst.status === "killed") return; // user killed it — do not restart
|
|
874
|
+
this.logger.info(
|
|
875
|
+
`bizar: persistent instance ${instanceId} failed; auto-restarting`,
|
|
876
|
+
);
|
|
877
|
+
const result = await this.restart(instanceId);
|
|
878
|
+
if (result.ok) {
|
|
879
|
+
// Clear any previous restartError on the parent so the operator
|
|
880
|
+
// sees a clean state.
|
|
881
|
+
await this.update(instanceId, { restartError: undefined });
|
|
882
|
+
this.logger.info(
|
|
883
|
+
`bizar: auto-restart complete: ${instanceId} -> ${result.newInstanceId}`,
|
|
884
|
+
);
|
|
885
|
+
} else {
|
|
886
|
+
// Persist a human-readable error so the operator can see why the
|
|
887
|
+
// restart was rejected (e.g. max_restarts_reached).
|
|
888
|
+
await this.update(instanceId, { restartError: result.error || "unknown" });
|
|
889
|
+
this.logger.warn(
|
|
890
|
+
`bizar: auto-restart failed for ${instanceId}: ${result.error}`,
|
|
891
|
+
);
|
|
744
892
|
}
|
|
745
893
|
}
|
|
746
894
|
|
|
@@ -782,7 +930,11 @@ export class InstanceManager {
|
|
|
782
930
|
patch.completedAt = Date.now();
|
|
783
931
|
}
|
|
784
932
|
await this.update(instanceId, patch);
|
|
785
|
-
|
|
933
|
+
// v0.5.5 — persistent auto-restart on tool-call cap
|
|
934
|
+
if (patch.status === "failed") {
|
|
935
|
+
await this._maybeAutoRestart(instanceId);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
786
938
|
}
|
|
787
939
|
|
|
788
940
|
// --- Loop-guard threshold-12 detection (spec §4.1) ---
|
|
@@ -798,6 +950,8 @@ export class InstanceManager {
|
|
|
798
950
|
loopGuardTool: tool,
|
|
799
951
|
completedAt: Date.now(),
|
|
800
952
|
});
|
|
953
|
+
// v0.5.5 — persistent auto-restart on loop-guard
|
|
954
|
+
await this._maybeAutoRestart(instanceId);
|
|
801
955
|
return;
|
|
802
956
|
}
|
|
803
957
|
}
|
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 dash 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 dash 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", ["dash", "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 dash 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
|
/**
|