@polderlabs/bizar-plugin 0.6.0 → 0.6.2
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/index.ts +123 -16
- package/package.json +1 -1
- package/src/background-state.ts +41 -0
- package/src/background.ts +147 -11
- package/src/commands-impl.ts +4 -4
- package/src/commands.ts +278 -101
- package/src/reasoning-clean.ts +360 -0
- package/src/serve.ts +12 -3
- package/src/tools/bg-spawn.ts +21 -1
- package/tests/attach-handler-bug.test.ts +5 -3
- 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.test.ts +1 -1
- package/tests/fingerprint.test.ts +22 -21
- package/tests/http-client.test.ts +5 -3
- package/tests/options.test.ts +10 -8
- 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-spawn.test.ts +12 -12
- package/tests/update-deadlock.test.ts +1 -1
package/README.md
CHANGED
|
@@ -155,7 +155,7 @@ The plugin starts one `opencode serve` process on init (single-serve, multi-sess
|
|
|
155
155
|
const result = await bizarre_spawn_background({
|
|
156
156
|
agent: "mimir", // which agent to run
|
|
157
157
|
prompt: "Research X and return findings", // what to do
|
|
158
|
-
model: "minimax
|
|
158
|
+
model: "openrouter/minimax-m3", // optional: override model
|
|
159
159
|
timeoutMs: 300_000, // optional: default 5 min, max 30 min
|
|
160
160
|
}, ctx);
|
|
161
161
|
console.log(result.instanceId); // "bgr_01ARSH3J5V..."
|
package/index.ts
CHANGED
|
@@ -125,9 +125,12 @@ import { SettingsStore } from "./src/settings.js";
|
|
|
125
125
|
import { parseSlashCommand } from "./src/commands.js";
|
|
126
126
|
import { createPlanActionTool } from "./src/tools/plan-action.js";
|
|
127
127
|
import { createWaitForFeedbackTool } from "./src/tools/wait-for-feedback.js";
|
|
128
|
+
import { stripInlineThinkBlocks } from "./src/reasoning-clean.js";
|
|
128
129
|
|
|
129
130
|
// v0.5.0 — visual plan wiring: side-effect executor + plan-fs
|
|
130
131
|
import { executeSideEffect, type ExecuteOptions } from "./src/commands-impl.js";
|
|
132
|
+
import { join as pathJoin } from "node:path";
|
|
133
|
+
import { homedir } from "node:os";
|
|
131
134
|
|
|
132
135
|
// --- Env-var constants (per spec §8) -------------------------------------
|
|
133
136
|
|
|
@@ -697,6 +700,47 @@ async function listPlanSlugs(worktree: string, logger: Logger): Promise<string[]
|
|
|
697
700
|
* delegates to the runtime context and the supporting modules.
|
|
698
701
|
*/
|
|
699
702
|
function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
|
|
703
|
+
// ────────────────────────────────────────────────────────────────────
|
|
704
|
+
// v0.6.2 — Reasoning directive
|
|
705
|
+
// ────────────────────────────────────────────────────────────────────
|
|
706
|
+
// Some reasoning models (notably MiniMax M3 via OpenRouter) emit
|
|
707
|
+
// their chain-of-thought in BOTH the structured `reasoning` /
|
|
708
|
+
// `reasoning_details` field AND inline as `` blocks inside
|
|
709
|
+
// `message.content`. opencode's openrouter SDK extracts the structured
|
|
710
|
+
// reasoning correctly and renders it as a separate "Thought" panel,
|
|
711
|
+
// but it does NOT strip the inline blocks from `content`, so the user
|
|
712
|
+
// sees the same thinking text twice — once in the proper panel and
|
|
713
|
+
// again as visible message text below it.
|
|
714
|
+
//
|
|
715
|
+
// The opencode plugin API in this version does NOT trigger a
|
|
716
|
+
// `config` hook (the `wrap-fetch` workaround from v0.6.1 is dead
|
|
717
|
+
// code in current builds), so we cannot post-process the response
|
|
718
|
+
// stream. The only working hooks that can help are:
|
|
719
|
+
//
|
|
720
|
+
// 1. `experimental.chat.system.transform` — runs every turn; we
|
|
721
|
+
// push a directive telling the model to put thinking in the
|
|
722
|
+
// structured field only.
|
|
723
|
+
// 2. `experimental.chat.messages.transform` — runs before each
|
|
724
|
+
// request; we strip `` blocks from previous assistant
|
|
725
|
+
// messages so the model sees clean history and is less likely
|
|
726
|
+
// to keep emitting inline ``.
|
|
727
|
+
//
|
|
728
|
+
// Neither fixes the CURRENT response (the model has already
|
|
729
|
+
// returned), but together they strongly reduce — and in many cases
|
|
730
|
+
// eliminate — the duplication on subsequent turns.
|
|
731
|
+
const REASONING_DIRECTIVE_MARKER = "BIZAR_REASONING_DIRECTIVE_v0.6.2";
|
|
732
|
+
const REASONING_DIRECTIVE = [
|
|
733
|
+
REASONING_DIRECTIVE_MARKER,
|
|
734
|
+
"",
|
|
735
|
+
"When reasoning is enabled for this conversation, output your thinking",
|
|
736
|
+
"ONLY in the model's structured reasoning field. Do NOT emit `` blocks",
|
|
737
|
+
"inline inside your message content — the opencode host extracts the",
|
|
738
|
+
"reasoning field and renders it as a separate, collapsable \"Thought\"",
|
|
739
|
+
"panel. If you also emit the same text inline, the user will see your",
|
|
740
|
+
"thinking twice (once in the panel and once as visible message body).",
|
|
741
|
+
"Keep the actual response text in the normal content stream.",
|
|
742
|
+
].join(" ");
|
|
743
|
+
|
|
700
744
|
// Build the 7 tools. We always register them; if the serve child is
|
|
701
745
|
// not available, the background tools return a clear error. The
|
|
702
746
|
// bizar_get_plan_comments, bizar_plan_action, and
|
|
@@ -704,7 +748,7 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
|
|
|
704
748
|
// work regardless of the serve child's state.
|
|
705
749
|
//
|
|
706
750
|
// v0.4.0 — added `bizar_plan_action` (CRUD on the v2 canvas) and
|
|
707
|
-
//
|
|
751
|
+
// bizar_wait_for_feedback (poll until feedback). Both are pure
|
|
708
752
|
// file I/O — no serve child required.
|
|
709
753
|
//
|
|
710
754
|
// v0.5.0 — renamed `bizarre_*` → `bizar_*` (single `r`) to match
|
|
@@ -753,9 +797,61 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
|
|
|
753
797
|
};
|
|
754
798
|
|
|
755
799
|
return {
|
|
756
|
-
//
|
|
757
|
-
|
|
758
|
-
|
|
800
|
+
// Push a persistent system-prompt directive that tells reasoning
|
|
801
|
+
// models to put their thinking in the structured reasoning field
|
|
802
|
+
// (rendered as a separate "Thought" panel by opencode) rather than
|
|
803
|
+
// also emitting it inline as `` blocks in the content. The openrouter
|
|
804
|
+
// SDK does not strip the inline `` blocks, so without this
|
|
805
|
+
// directive the user sees the reasoning twice — once in the proper
|
|
806
|
+
// panel and once as visible message text.
|
|
807
|
+
//
|
|
808
|
+
// We push the directive only when the marker is absent so we don't
|
|
809
|
+
// append it on every turn.
|
|
810
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
811
|
+
const sessionID = input.sessionID;
|
|
812
|
+
if (!sessionID) return;
|
|
813
|
+
if (!output.system.some((s) => s.includes(REASONING_DIRECTIVE_MARKER))) {
|
|
814
|
+
output.system.push(REASONING_DIRECTIVE);
|
|
815
|
+
}
|
|
816
|
+
// §3.1, §5.4 — handoff injection point. We push a single string
|
|
817
|
+
// onto `output.system` if a pending injection is queued for this
|
|
818
|
+
// session.
|
|
819
|
+
const pending = ctx.pendingInjections.get(sessionID);
|
|
820
|
+
if (pending) {
|
|
821
|
+
output.system.push(pending);
|
|
822
|
+
ctx.pendingInjections.delete(sessionID);
|
|
823
|
+
}
|
|
824
|
+
},
|
|
825
|
+
|
|
826
|
+
// Before the model is called, strip `` blocks from
|
|
827
|
+
// any text content in previous assistant messages. This keeps the
|
|
828
|
+
// model's view of its own history clean of the duplicated thinking
|
|
829
|
+
// it emitted earlier, reducing the chance it will keep emitting
|
|
830
|
+
// inline `` on subsequent turns.
|
|
831
|
+
"experimental.chat.messages.transform": async (_input, output) => {
|
|
832
|
+
try {
|
|
833
|
+
const messages = (output as { messages?: unknown }).messages;
|
|
834
|
+
if (!Array.isArray(messages)) return;
|
|
835
|
+
for (const msg of messages) {
|
|
836
|
+
if (!msg || typeof msg !== "object") continue;
|
|
837
|
+
const m = msg as { role?: unknown; parts?: unknown };
|
|
838
|
+
if (m.role !== "assistant") continue;
|
|
839
|
+
if (!Array.isArray(m.parts)) continue;
|
|
840
|
+
for (const part of m.parts) {
|
|
841
|
+
if (!part || typeof part !== "object") continue;
|
|
842
|
+
const p = part as { type?: unknown; text?: unknown };
|
|
843
|
+
if (p.type === "text" && typeof p.text === "string" && p.text.includes("<think>")) {
|
|
844
|
+
p.text = stripInlineThinkBlocks(p.text);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
} catch (err) {
|
|
849
|
+
ctx.logger.warn(
|
|
850
|
+
`bizar: messages.transform failed (passing through): ${
|
|
851
|
+
err instanceof Error ? err.message : String(err)
|
|
852
|
+
}`,
|
|
853
|
+
);
|
|
854
|
+
}
|
|
759
855
|
},
|
|
760
856
|
|
|
761
857
|
// §3.1, §4.5.1 — event: track session boundaries. We do NOT create
|
|
@@ -862,6 +958,29 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
|
|
|
862
958
|
finalResponse = `Command failed: ${msg}`;
|
|
863
959
|
}
|
|
864
960
|
}
|
|
961
|
+
// --- v0.5.1: dialog support.
|
|
962
|
+
// If the command emitted a dialog descriptor, persist it to disk
|
|
963
|
+
// so Tyr's dialog-poller can broadcast it to the dashboard.
|
|
964
|
+
// We return without throwing so no chat bubble is shown.
|
|
965
|
+
if (result.dialog) {
|
|
966
|
+
// S4 — defense-in-depth: validate ID shape before touching disk.
|
|
967
|
+
if (!/^dlg_[a-zA-Z0-9_-]{1,64}$/.test(result.dialog.id)) {
|
|
968
|
+
return; // silently drop malformed dialog IDs
|
|
969
|
+
}
|
|
970
|
+
try {
|
|
971
|
+
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
972
|
+
const dialogDir = pathJoin(homedir(), ".cache", "bizar", "dialogs");
|
|
973
|
+
await mkdir(dialogDir, { recursive: true });
|
|
974
|
+
await writeFile(
|
|
975
|
+
pathJoin(dialogDir, `${result.dialog.id}.json`),
|
|
976
|
+
JSON.stringify({ ...result.dialog, createdAt: new Date().toISOString() }, null, 2),
|
|
977
|
+
);
|
|
978
|
+
} catch (dialogErr: unknown) {
|
|
979
|
+
const msg = dialogErr instanceof Error ? dialogErr.message : String(dialogErr);
|
|
980
|
+
ctx.logger.warn(`bizar: failed to write dialog file: ${msg}`);
|
|
981
|
+
}
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
865
984
|
// Surface the response to the user/host. We throw so the
|
|
866
985
|
// message is treated as handled; the LLM does not process
|
|
867
986
|
// it further. The host renders the throw message.
|
|
@@ -1045,18 +1164,6 @@ function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
|
|
|
1045
1164
|
}
|
|
1046
1165
|
},
|
|
1047
1166
|
|
|
1048
|
-
// §3.1, §5.4 — handoff injection point. We push a single string onto
|
|
1049
|
-
// `output.system` if a pending injection is queued for this session.
|
|
1050
|
-
"experimental.chat.system.transform": async (input, output) => {
|
|
1051
|
-
const sessionID = input.sessionID;
|
|
1052
|
-
if (!sessionID) return;
|
|
1053
|
-
const pending = ctx.pendingInjections.get(sessionID);
|
|
1054
|
-
if (pending) {
|
|
1055
|
-
output.system.push(pending);
|
|
1056
|
-
ctx.pendingInjections.delete(sessionID);
|
|
1057
|
-
}
|
|
1058
|
-
},
|
|
1059
|
-
|
|
1060
1167
|
// v0.4.2 — register the 4 background tools.
|
|
1061
1168
|
tool: tools,
|
|
1062
1169
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polderlabs/bizar-plugin",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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({
|
package/src/background.ts
CHANGED
|
@@ -278,6 +278,30 @@ export class InstanceManager {
|
|
|
278
278
|
}
|
|
279
279
|
}
|
|
280
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
|
+
|
|
281
305
|
// --- Atomic add (spec §2.2) ---------------------------------------------
|
|
282
306
|
|
|
283
307
|
/**
|
|
@@ -315,16 +339,7 @@ export class InstanceManager {
|
|
|
315
339
|
lastToolOrTextAt: now,
|
|
316
340
|
interventionCount: 0,
|
|
317
341
|
};
|
|
318
|
-
this.
|
|
319
|
-
// Persist asynchronously; failure is logged but does not roll back
|
|
320
|
-
// the in-memory insert (the instance is "tracked" either way).
|
|
321
|
-
this.stateStore.save(full).catch((err: unknown) => {
|
|
322
|
-
this.logger.warn(
|
|
323
|
-
`bizar: failed to persist new instance ${draft.instanceId}: ${
|
|
324
|
-
err instanceof Error ? err.message : String(err)
|
|
325
|
-
}`,
|
|
326
|
-
);
|
|
327
|
-
});
|
|
342
|
+
this.dispatchInternal(full);
|
|
328
343
|
// BUGFIX (v0.5.1): Do NOT call attachEventHandler() here. The
|
|
329
344
|
// instance was just added with sessionId="" (filled in later by
|
|
330
345
|
// POST /session). EventStream.onSessionEvent rejects empty strings,
|
|
@@ -426,6 +441,86 @@ export class InstanceManager {
|
|
|
426
441
|
});
|
|
427
442
|
this.logger.info(`bizar: killed background instance ${instanceId}`);
|
|
428
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
|
+
|
|
429
524
|
// --- Collect ------------------------------------------------------------
|
|
430
525
|
|
|
431
526
|
/**
|
|
@@ -634,6 +729,8 @@ export class InstanceManager {
|
|
|
634
729
|
error: `No activity for ${this.stallTimeoutMs}ms — LLM appears stalled`,
|
|
635
730
|
completedAt: Date.now(),
|
|
636
731
|
});
|
|
732
|
+
// v0.5.5 — persistent auto-restart on stall
|
|
733
|
+
await this._maybeAutoRestart(inst.instanceId);
|
|
637
734
|
}
|
|
638
735
|
|
|
639
736
|
/**
|
|
@@ -704,6 +801,8 @@ export class InstanceManager {
|
|
|
704
801
|
error: `Thinking loop detected: ${formatDuration(sinceMs)} of thinking without tool calls or output. Spawn a Mimir agent for research.`,
|
|
705
802
|
completedAt: Date.now(),
|
|
706
803
|
});
|
|
804
|
+
// v0.5.5 — persistent auto-restart on thinking-loop exhaustion
|
|
805
|
+
await this._maybeAutoRestart(inst.instanceId);
|
|
707
806
|
}
|
|
708
807
|
|
|
709
808
|
// --- Internal: per-session event handler -------------------------------
|
|
@@ -759,6 +858,37 @@ export class InstanceManager {
|
|
|
759
858
|
error: errMsg,
|
|
760
859
|
completedAt: Date.now(),
|
|
761
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
|
+
);
|
|
762
892
|
}
|
|
763
893
|
}
|
|
764
894
|
|
|
@@ -800,7 +930,11 @@ export class InstanceManager {
|
|
|
800
930
|
patch.completedAt = Date.now();
|
|
801
931
|
}
|
|
802
932
|
await this.update(instanceId, patch);
|
|
803
|
-
|
|
933
|
+
// v0.5.5 — persistent auto-restart on tool-call cap
|
|
934
|
+
if (patch.status === "failed") {
|
|
935
|
+
await this._maybeAutoRestart(instanceId);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
804
938
|
}
|
|
805
939
|
|
|
806
940
|
// --- Loop-guard threshold-12 detection (spec §4.1) ---
|
|
@@ -816,6 +950,8 @@ export class InstanceManager {
|
|
|
816
950
|
loopGuardTool: tool,
|
|
817
951
|
completedAt: Date.now(),
|
|
818
952
|
});
|
|
953
|
+
// v0.5.5 — persistent auto-restart on loop-guard
|
|
954
|
+
await this._maybeAutoRestart(instanceId);
|
|
819
955
|
return;
|
|
820
956
|
}
|
|
821
957
|
}
|
package/src/commands-impl.ts
CHANGED
|
@@ -112,7 +112,7 @@ 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
|
|
115
|
+
* `launch_dashboard` — spawns `bizar dash start` as a detached
|
|
116
116
|
* child process. Reads the port file back and
|
|
117
117
|
* appends the URL to the parser's response.
|
|
118
118
|
* `tool_invocation` — delegates to `executeToolInvocation`.
|
|
@@ -196,7 +196,7 @@ async function executeListPlans(
|
|
|
196
196
|
/**
|
|
197
197
|
* Launch the Bizar dashboard as a detached child process.
|
|
198
198
|
*
|
|
199
|
-
|
|
199
|
+
* We spawn `bizar dash start` with `detached: true` and `unref()`
|
|
200
200
|
* so the child's lifetime is independent of the plugin host. We then
|
|
201
201
|
* poll the port file (written by the child) for up to ~3s and append
|
|
202
202
|
* the URL to the parser's response. If anything goes wrong we surface
|
|
@@ -234,7 +234,7 @@ async function executeLaunchDashboard(
|
|
|
234
234
|
// `bizar` is on $PATH for global installs; for npx / local installs
|
|
235
235
|
// we'd want to resolve to the package's bin. Spawn `bizar` directly
|
|
236
236
|
// for now — the user's $PATH is the source of truth.
|
|
237
|
-
const child = spawn("bizar", ["
|
|
237
|
+
const child = spawn("bizar", ["dash", "start"], {
|
|
238
238
|
detached: true,
|
|
239
239
|
stdio: "ignore",
|
|
240
240
|
cwd: ctx.worktree,
|
|
@@ -248,7 +248,7 @@ async function executeLaunchDashboard(
|
|
|
248
248
|
return {
|
|
249
249
|
responseOverride:
|
|
250
250
|
`Could not launch the Bizar dashboard: ${msg}\n` +
|
|
251
|
-
`Try running \`bizar
|
|
251
|
+
`Try running \`bizar dash start\` in your terminal.`,
|
|
252
252
|
};
|
|
253
253
|
}
|
|
254
254
|
|