@polderlabs/bizar-plugin 0.6.2 → 0.8.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/index.ts +85 -8
- package/package.json +3 -2
- package/src/background-state.ts +38 -2
- package/src/background.ts +208 -76
- package/src/commands.ts +28 -11
- package/src/dashboard-client.ts +235 -0
- package/src/event-stream.ts +32 -0
- package/src/opencode-runner.ts +390 -0
- package/src/tools/bg-spawn.ts +161 -124
- package/tests/attach-handler-bug.test.ts +2 -1
- package/tests/background-state.test.ts +1 -1
- package/tests/background.test.ts +1 -1
- package/tests/config.test.ts +2 -2
- package/tests/dashboard-client.test.ts +159 -0
- package/tests/stall-think.test.ts +6 -6
- package/tests/tools/bg-spawn.test.ts +6 -6
- package/tests/tools/opencode-runner.test.ts +115 -0
- package/tests/update-deadlock.test.ts +1 -0
package/src/commands.ts
CHANGED
|
@@ -314,7 +314,7 @@ function handleVisualPlan(arg: string, ctx: ParseContext): SlashCommandResult {
|
|
|
314
314
|
// No argument — return current state as a dialog
|
|
315
315
|
return {
|
|
316
316
|
handled: true,
|
|
317
|
-
response: ""
|
|
317
|
+
response: `Visual plan mode is currently ${currentEnabled ? "on" : "off"}.`,
|
|
318
318
|
dialog: {
|
|
319
319
|
id: generateId(),
|
|
320
320
|
title: "Visual Plan",
|
|
@@ -334,7 +334,7 @@ function handleVisualPlan(arg: string, ctx: ParseContext): SlashCommandResult {
|
|
|
334
334
|
if (lc === "on" || lc === "true" || lc === "1" || lc === "enable") {
|
|
335
335
|
return {
|
|
336
336
|
handled: true,
|
|
337
|
-
response:
|
|
337
|
+
response: `Visual plan mode is now on.`,
|
|
338
338
|
settingsPatch: { visualPlanEnabled: true },
|
|
339
339
|
dialog: {
|
|
340
340
|
id: generateId(),
|
|
@@ -353,7 +353,7 @@ function handleVisualPlan(arg: string, ctx: ParseContext): SlashCommandResult {
|
|
|
353
353
|
if (lc === "off" || lc === "false" || lc === "0" || lc === "disable") {
|
|
354
354
|
return {
|
|
355
355
|
handled: true,
|
|
356
|
-
response:
|
|
356
|
+
response: `Visual plan mode is now off.`,
|
|
357
357
|
settingsPatch: { visualPlanEnabled: false },
|
|
358
358
|
dialog: {
|
|
359
359
|
id: generateId(),
|
|
@@ -372,7 +372,7 @@ function handleVisualPlan(arg: string, ctx: ParseContext): SlashCommandResult {
|
|
|
372
372
|
if (lc === "status" || lc === "state" || lc === "?") {
|
|
373
373
|
return {
|
|
374
374
|
handled: true,
|
|
375
|
-
response: ""
|
|
375
|
+
response: `Visual plan mode is currently ${currentEnabled ? "on" : "off"}.`,
|
|
376
376
|
dialog: {
|
|
377
377
|
id: generateId(),
|
|
378
378
|
title: "Visual Plan",
|
|
@@ -444,7 +444,7 @@ function handlePlan(arg: string, ctx: ParseContext): SlashCommandResult {
|
|
|
444
444
|
function helpPlan(): SlashCommandResult {
|
|
445
445
|
return {
|
|
446
446
|
handled: true,
|
|
447
|
-
response: "",
|
|
447
|
+
response: "Plan commands: /plan new <slug> [template] | /plan list | /plan open <slug> | /plan get <slug> | /plan add <slug> | /plan update <slug> <id> | /plan delete <slug> <id> | /plan comment <slug> [id] \"text\" | /plan comments <slug> [id] | /plan status <slug> <status> | /plan wait <slug> [--timeout N]. Run /plan <subcommand> for details.",
|
|
448
448
|
dialog: {
|
|
449
449
|
id: generateId(),
|
|
450
450
|
title: "Plan Commands",
|
|
@@ -477,7 +477,7 @@ function handlePlanNew(args: string[], ctx: ParseContext): SlashCommandResult {
|
|
|
477
477
|
if (args.length === 0 || args[0] === "") {
|
|
478
478
|
return {
|
|
479
479
|
handled: true,
|
|
480
|
-
response: "",
|
|
480
|
+
response: "Usage: /plan new <slug> [template]. Available templates: " + KNOWN_TEMPLATES.join(", ") + ".",
|
|
481
481
|
dialog: {
|
|
482
482
|
id: generateId(),
|
|
483
483
|
title: "Create New Plan",
|
|
@@ -520,7 +520,7 @@ function handlePlanNew(args: string[], ctx: ParseContext): SlashCommandResult {
|
|
|
520
520
|
|
|
521
521
|
return {
|
|
522
522
|
handled: true,
|
|
523
|
-
response: ""
|
|
523
|
+
response: `Created plan "${titleCase(slug)}" with the "${resolvedTemplate}" template. Use /plan open ${slug} to open it.`,
|
|
524
524
|
sideEffect: {
|
|
525
525
|
kind: "create_plan",
|
|
526
526
|
slug,
|
|
@@ -544,9 +544,26 @@ function handlePlanNew(args: string[], ctx: ParseContext): SlashCommandResult {
|
|
|
544
544
|
|
|
545
545
|
function handlePlanList(ctx: ParseContext): SlashCommandResult {
|
|
546
546
|
const slugs = ctx.availablePlanSlugs ?? [];
|
|
547
|
+
if (slugs.length === 0) {
|
|
548
|
+
return {
|
|
549
|
+
handled: true,
|
|
550
|
+
response: "No plans found in the current worktree. Use /plan new <slug> to create one.",
|
|
551
|
+
sideEffect: { kind: "list_plans" },
|
|
552
|
+
dialog: {
|
|
553
|
+
id: generateId(),
|
|
554
|
+
title: "Plans",
|
|
555
|
+
command: "/plan list",
|
|
556
|
+
component: "plan-list",
|
|
557
|
+
data: {
|
|
558
|
+
plans: slugs,
|
|
559
|
+
count: slugs.length,
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
}
|
|
547
564
|
return {
|
|
548
565
|
handled: true,
|
|
549
|
-
response: ""
|
|
566
|
+
response: `Found ${slugs.length} plan(s) (${slugs.length}): ${slugs.join(", ")}.`,
|
|
550
567
|
sideEffect: { kind: "list_plans" },
|
|
551
568
|
dialog: {
|
|
552
569
|
id: generateId(),
|
|
@@ -584,7 +601,7 @@ function handlePlanOpen(args: string[], ctx: ParseContext): SlashCommandResult {
|
|
|
584
601
|
|
|
585
602
|
return {
|
|
586
603
|
handled: true,
|
|
587
|
-
response: ""
|
|
604
|
+
response: `Opening plan "${slug}" at ${url}.`,
|
|
588
605
|
settingsPatch: { lastUsedSlug: slug },
|
|
589
606
|
sideEffect: {
|
|
590
607
|
kind: "open_plan_url",
|
|
@@ -618,7 +635,7 @@ function handlePlanGet(args: string[]): SlashCommandResult {
|
|
|
618
635
|
}
|
|
619
636
|
return {
|
|
620
637
|
handled: true,
|
|
621
|
-
response: ""
|
|
638
|
+
response: `Fetching canvas for plan "${slug}"…`,
|
|
622
639
|
sideEffect: {
|
|
623
640
|
kind: "tool_invocation",
|
|
624
641
|
toolName: "bizar_plan_action",
|
|
@@ -1078,7 +1095,7 @@ function handleBizar(arg: string, ctx: ParseContext): SlashCommandResult {
|
|
|
1078
1095
|
function helpResult(): SlashCommandResult {
|
|
1079
1096
|
return {
|
|
1080
1097
|
handled: true,
|
|
1081
|
-
response: "",
|
|
1098
|
+
response: "Available commands: /visual-plan [on|off|status], /plan new <slug> [template], /plan list, /plan open <slug>, /plan get <slug>, /plan add <slug>, /plan update <slug> <id>, /plan delete <slug> <id>, /plan comment <slug> [id] \"text\", /plan comments <slug> [id], /plan status <slug> <status>, /plan wait <slug> [--timeout N], /bizar, /bizar <args>, /help. See the dialog for full descriptions.",
|
|
1082
1099
|
dialog: {
|
|
1083
1100
|
id: generateId(),
|
|
1084
1101
|
title: "Bizar Commands",
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard-client.ts
|
|
3
|
+
*
|
|
4
|
+
* v0.7.0-alpha.1 — Plugin-side bridge to the Bizar dashboard via the
|
|
5
|
+
* @polderlabs/bizar-sdk. Replaces (does not remove) the file-based
|
|
6
|
+
* `serve-info.ts` bridge.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* import { createDashboardPublisher } from "./dashboard-client.js";
|
|
10
|
+
*
|
|
11
|
+
* const publisher = createDashboardPublisher({
|
|
12
|
+
* logger: myLogger,
|
|
13
|
+
* });
|
|
14
|
+
* await publisher.start();
|
|
15
|
+
* publisher.publish({
|
|
16
|
+
* type: "session.created",
|
|
17
|
+
* properties: { sessionId: "ses_1", agent: "mimir" },
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* Behavior:
|
|
21
|
+
* - Reads `BIZAR_DASHBOARD_URL` (default http://127.0.0.1:4098).
|
|
22
|
+
* - Reads `BIZAR_DASHBOARD_PASSWORD` first, then falls back to
|
|
23
|
+
* `~/.cache/bizarharness/dash-auth.json` (the file written by the
|
|
24
|
+
* dashboard on first start).
|
|
25
|
+
* - `publish()` is fire-and-forget: returns a Promise that resolves
|
|
26
|
+
* after one HTTP round-trip or rejects on failure. NEVER throws
|
|
27
|
+
* into the caller — failures are logged and swallowed (the plugin
|
|
28
|
+
* must keep running even when the dashboard is down).
|
|
29
|
+
* - `publish()` queues events if the dashboard is unreachable and
|
|
30
|
+
* drains the queue on reconnect (best-effort).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
createBizarClient,
|
|
35
|
+
isBizarError,
|
|
36
|
+
type BizarClient,
|
|
37
|
+
type DashboardEvent,
|
|
38
|
+
} from "@polderlabs/bizar-sdk";
|
|
39
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import { homedir } from "node:os";
|
|
42
|
+
|
|
43
|
+
const DEFAULT_DASHBOARD_URL = "http://127.0.0.1:4098";
|
|
44
|
+
const DEFAULT_AUTH_FILE_PATHS = [
|
|
45
|
+
join(homedir(), ".cache", "bizarharness", "dash-auth.json"),
|
|
46
|
+
join(homedir(), ".cache", "bizar", "dash-auth.json"),
|
|
47
|
+
];
|
|
48
|
+
/**
|
|
49
|
+
* The auth file paths to search. Override at test-time via
|
|
50
|
+
* `process.env.BIZAR_DASHBOARD_AUTH_FILE` (single file) or
|
|
51
|
+
* `process.env.BIZAR_DASHBOARD_AUTH_FILES` (colon-separated list).
|
|
52
|
+
*/
|
|
53
|
+
function getAuthFilePaths(): string[] {
|
|
54
|
+
const single = process.env.BIZAR_DASHBOARD_AUTH_FILE;
|
|
55
|
+
if (single) return [single];
|
|
56
|
+
const multi = process.env.BIZAR_DASHBOARD_AUTH_FILES;
|
|
57
|
+
if (multi) return multi.split(":").filter(Boolean);
|
|
58
|
+
return DEFAULT_AUTH_FILE_PATHS;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Logger {
|
|
62
|
+
debug(message: string): void;
|
|
63
|
+
info(message: string): void;
|
|
64
|
+
warn(message: string): void;
|
|
65
|
+
error(message: string): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface DashboardPublisherOptions {
|
|
69
|
+
logger: Logger;
|
|
70
|
+
baseUrl?: string;
|
|
71
|
+
password?: string;
|
|
72
|
+
/** Disable the publisher entirely (e.g. BIZAR_DASHBOARD_DISABLE=1). */
|
|
73
|
+
disabled?: boolean;
|
|
74
|
+
/** Max queued events while the dashboard is unreachable. */
|
|
75
|
+
queueLimit?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read the dashboard auth record from one of the candidate paths.
|
|
80
|
+
* Returns null if no usable file exists. Never throws.
|
|
81
|
+
*/
|
|
82
|
+
function readDashboardAuth(): { password: string; baseUrl?: string; port?: number } | null {
|
|
83
|
+
for (const candidate of getAuthFilePaths()) {
|
|
84
|
+
if (!existsSync(candidate)) continue;
|
|
85
|
+
try {
|
|
86
|
+
const raw = readFileSync(candidate, "utf8");
|
|
87
|
+
const parsed = JSON.parse(raw) as {
|
|
88
|
+
password?: unknown;
|
|
89
|
+
baseUrl?: unknown;
|
|
90
|
+
port?: unknown;
|
|
91
|
+
};
|
|
92
|
+
if (typeof parsed.password !== "string" || parsed.password.length < 16) continue;
|
|
93
|
+
return {
|
|
94
|
+
password: parsed.password,
|
|
95
|
+
baseUrl: typeof parsed.baseUrl === "string" ? parsed.baseUrl : undefined,
|
|
96
|
+
port: typeof parsed.port === "number" ? parsed.port : undefined,
|
|
97
|
+
};
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore malformed file
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface DashboardPublisher {
|
|
106
|
+
start(): Promise<void>;
|
|
107
|
+
publish(event: DashboardEvent): Promise<void>;
|
|
108
|
+
stop(): void;
|
|
109
|
+
/** Whether the publisher is currently configured and ready. */
|
|
110
|
+
isReady(): boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createDashboardPublisher(
|
|
114
|
+
options: DashboardPublisherOptions,
|
|
115
|
+
): DashboardPublisher {
|
|
116
|
+
const { logger, disabled = false, queueLimit = 100 } = options;
|
|
117
|
+
|
|
118
|
+
let client: BizarClient | null = null;
|
|
119
|
+
const queue: DashboardEvent[] = [];
|
|
120
|
+
let flushing = false;
|
|
121
|
+
|
|
122
|
+
function resolveConfig(): { baseUrl: string; password: string } | null {
|
|
123
|
+
let baseUrl = options.baseUrl ?? process.env.BIZAR_DASHBOARD_URL;
|
|
124
|
+
if (!baseUrl) {
|
|
125
|
+
const port = process.env.BIZAR_DASHBOARD_PORT;
|
|
126
|
+
baseUrl = port ? `http://127.0.0.1:${port}` : DEFAULT_DASHBOARD_URL;
|
|
127
|
+
}
|
|
128
|
+
const password =
|
|
129
|
+
options.password ??
|
|
130
|
+
process.env.BIZAR_DASHBOARD_PASSWORD ??
|
|
131
|
+
readDashboardAuth()?.password;
|
|
132
|
+
|
|
133
|
+
if (!password) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
return { baseUrl, password };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isReady(): boolean {
|
|
140
|
+
return client !== null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function start(): Promise<void> {
|
|
144
|
+
if (disabled) {
|
|
145
|
+
logger.debug("bizar: dashboard publisher disabled by config");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const cfg = resolveConfig();
|
|
149
|
+
if (!cfg) {
|
|
150
|
+
logger.debug(
|
|
151
|
+
"bizar: dashboard publisher not started (no password in env or auth file)",
|
|
152
|
+
);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
client = createBizarClient({
|
|
156
|
+
baseUrl: cfg.baseUrl,
|
|
157
|
+
password: cfg.password,
|
|
158
|
+
});
|
|
159
|
+
logger.info(
|
|
160
|
+
`bizar: dashboard publisher started (url=${cfg.baseUrl}, password len=${cfg.password.length})`,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Verify the dashboard is reachable. Non-fatal — publish() will retry
|
|
164
|
+
// and surface connection errors as warnings.
|
|
165
|
+
try {
|
|
166
|
+
const health = await client.health.check();
|
|
167
|
+
if (isBizarError(health)) {
|
|
168
|
+
logger.warn(
|
|
169
|
+
`bizar: dashboard unreachable at ${cfg.baseUrl} (${health.name}); publish events will be queued`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
} catch (err) {
|
|
173
|
+
logger.warn(
|
|
174
|
+
`bizar: dashboard health check failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
void flushQueue();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function publish(event: DashboardEvent): Promise<void> {
|
|
182
|
+
if (!client) {
|
|
183
|
+
logger.debug(
|
|
184
|
+
`bizar: dashboard publisher not ready — dropping event ${event.type}`,
|
|
185
|
+
);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (queue.length >= queueLimit) {
|
|
189
|
+
queue.shift();
|
|
190
|
+
logger.warn(
|
|
191
|
+
`bizar: dashboard publisher queue full (${queueLimit}); dropping oldest event`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
queue.push(event);
|
|
195
|
+
if (!flushing) {
|
|
196
|
+
void flushQueue();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function flushQueue(): Promise<void> {
|
|
201
|
+
if (flushing || !client) return;
|
|
202
|
+
flushing = true;
|
|
203
|
+
try {
|
|
204
|
+
while (queue.length > 0 && client) {
|
|
205
|
+
const next = queue.shift()!;
|
|
206
|
+
try {
|
|
207
|
+
const result = await client.events.publish(next);
|
|
208
|
+
if (isBizarError(result)) {
|
|
209
|
+
logger.warn(
|
|
210
|
+
`bizar: dashboard publish failed (${result.name}); requeueing ${next.type}`,
|
|
211
|
+
);
|
|
212
|
+
queue.unshift(next);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
} catch (err) {
|
|
216
|
+
logger.warn(
|
|
217
|
+
`bizar: dashboard publish threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
218
|
+
);
|
|
219
|
+
queue.unshift(next);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
flushing = false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function stop(): void {
|
|
229
|
+
client = null;
|
|
230
|
+
queue.length = 0;
|
|
231
|
+
logger.debug("bizar: dashboard publisher stopped");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return { start, publish, stop, isReady };
|
|
235
|
+
}
|
package/src/event-stream.ts
CHANGED
|
@@ -120,6 +120,8 @@ export class EventStream {
|
|
|
120
120
|
private _logger: Logger;
|
|
121
121
|
private _http: HttpClient;
|
|
122
122
|
private _handlers = new Map<string, Set<SessionEventHandler>>();
|
|
123
|
+
/** Global handler invoked for every event regardless of session (v0.7.0). */
|
|
124
|
+
private _globalHandler: SessionEventHandler | null = null;
|
|
123
125
|
private _connected = false;
|
|
124
126
|
private _aborted = false;
|
|
125
127
|
private _abortController: AbortController | null = null;
|
|
@@ -128,6 +130,21 @@ export class EventStream {
|
|
|
128
130
|
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
129
131
|
private _connectPromise: Promise<void> | null = null;
|
|
130
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Register a global handler invoked for every event (regardless of
|
|
135
|
+
* session). Used to forward events to the dashboard publisher. Returns
|
|
136
|
+
* an unsubscribe function. (v0.7.0-alpha.1 — wired into the v2
|
|
137
|
+
* SDK dashboard-client.ts bridge.)
|
|
138
|
+
*/
|
|
139
|
+
onEvent(handler: SessionEventHandler): () => void {
|
|
140
|
+
this._globalHandler = handler;
|
|
141
|
+
return () => {
|
|
142
|
+
if (this._globalHandler === handler) {
|
|
143
|
+
this._globalHandler = null;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
constructor(opts: {
|
|
132
149
|
baseUrl: string;
|
|
133
150
|
directory: string;
|
|
@@ -439,6 +456,21 @@ export class EventStream {
|
|
|
439
456
|
}
|
|
440
457
|
|
|
441
458
|
private dispatchToHandlers(sessionID: string, event: StreamEvent): void {
|
|
459
|
+
// v0.7.0-alpha.1 — Forward every event to the global handler
|
|
460
|
+
// (typically the dashboard publisher) BEFORE the per-session dispatch.
|
|
461
|
+
// Failures here are isolated so one bad handler doesn't break the
|
|
462
|
+
// dispatch chain for other subscribers.
|
|
463
|
+
if (this._globalHandler) {
|
|
464
|
+
try {
|
|
465
|
+
this._globalHandler(event);
|
|
466
|
+
} catch (err: unknown) {
|
|
467
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
468
|
+
this._logger.warn(
|
|
469
|
+
`bizar: SSE global handler threw for session ${sessionID} (type=${event.type}): ${msg}`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
442
474
|
const set = this._handlers.get(sessionID);
|
|
443
475
|
if (!set || set.size === 0) {
|
|
444
476
|
this._logger.debug(
|