@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.
- package/LICENSE +21 -0
- package/README.md +448 -0
- package/bun.lock +88 -0
- package/index.ts +1113 -0
- package/package.json +42 -0
- package/scripts/check-forbidden-imports.sh +33 -0
- package/src/background-state.ts +463 -0
- package/src/background.ts +964 -0
- package/src/commands-impl.ts +369 -0
- package/src/commands.ts +880 -0
- package/src/event-stream.ts +574 -0
- package/src/fingerprint.ts +120 -0
- package/src/handoff.ts +79 -0
- package/src/http-client.ts +467 -0
- package/src/logger.ts +144 -0
- package/src/loop.ts +176 -0
- package/src/options.ts +421 -0
- package/src/plan-fs.ts +323 -0
- package/src/report.ts +178 -0
- package/src/research-prompt.ts +35 -0
- package/src/serve.ts +476 -0
- package/src/settings.ts +349 -0
- package/src/state.ts +298 -0
- package/src/tools/bg-collect.ts +104 -0
- package/src/tools/bg-get-comments.ts +239 -0
- package/src/tools/bg-kill.ts +87 -0
- package/src/tools/bg-spawn.ts +263 -0
- package/src/tools/bg-status.ts +99 -0
- package/src/tools/plan-action.ts +767 -0
- package/src/tools/wait-for-feedback.ts +402 -0
- package/tests/attach-handler-bug.test.ts +166 -0
- package/tests/background-state.test.ts +277 -0
- package/tests/background.test.ts +402 -0
- package/tests/block.test.ts +193 -0
- package/tests/canonical-key-order.test.ts +71 -0
- package/tests/commands-impl.test.ts +442 -0
- package/tests/commands.test.ts +548 -0
- package/tests/config.test.ts +122 -0
- package/tests/dispose.test.ts +336 -0
- package/tests/event-stream.test.ts +409 -0
- package/tests/event.test.ts +262 -0
- package/tests/fingerprint.test.ts +161 -0
- package/tests/http-client.test.ts +403 -0
- package/tests/init-helpers.test.ts +203 -0
- package/tests/integration/slash-command.test.ts +348 -0
- package/tests/integration/tool-routing.test.ts +314 -0
- package/tests/loop.test.ts +397 -0
- package/tests/options.test.ts +274 -0
- package/tests/serve.test.ts +335 -0
- package/tests/settings.test.ts +351 -0
- package/tests/stall-think.test.ts +749 -0
- package/tests/state.test.ts +275 -0
- package/tests/tools/bg-collect.test.ts +337 -0
- package/tests/tools/bg-get-comments.test.ts +485 -0
- package/tests/tools/bg-kill.test.ts +231 -0
- package/tests/tools/bg-spawn.test.ts +311 -0
- package/tests/tools/bg-status.test.ts +216 -0
- package/tests/tools/plan-action.test.ts +599 -0
- package/tests/tools/wait-for-feedback.test.ts +390 -0
- package/tsconfig.json +29 -0
package/index.ts
ADDED
|
@@ -0,0 +1,1113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bizar plugin — opencode plugin entry point.
|
|
3
|
+
*
|
|
4
|
+
* Spec contract (cumulative):
|
|
5
|
+
* v0.3.1:
|
|
6
|
+
* - §3.1 — hook surface used. The plugin wires up `config`, `event`,
|
|
7
|
+
* `chat.message`, `tool.execute.before`, `tool.execute.after`, and
|
|
8
|
+
* `experimental.chat.system.transform`.
|
|
9
|
+
* - §4.3 — per-session async mutex. All state reads/writes go through
|
|
10
|
+
* `stateStore.withLock(sessionID, …)`.
|
|
11
|
+
* - §4.5 — `chat.message` dedupes by message ID and seeds `parentAgent`
|
|
12
|
+
* on the first message per session.
|
|
13
|
+
* - §4.5.1 — state file is created on the `chat.message` seed (not on
|
|
14
|
+
* `session.created`). A lazy fallback creates the file on the first
|
|
15
|
+
* `tool.execute.before` for subagent-only sessions.
|
|
16
|
+
* - §4.6 — stale session cleanup runs on init.
|
|
17
|
+
* - §4.7 — corrupt-state fallback. StateStore handles this; we never
|
|
18
|
+
* see a corrupt file from the hook side.
|
|
19
|
+
* - §5.4 — thresholds 5 and 8 inject via `experimental.chat.system.transform`;
|
|
20
|
+
* threshold 12 throws from `tool.execute.before`.
|
|
21
|
+
* - §6.4 — refuse to start if `logDir` or `stateDir` is inside a secret
|
|
22
|
+
* directory. Return empty hooks in that case.
|
|
23
|
+
* - §6.5 — honor `BIZAR_DISABLE`, `BIZAR_DISABLE_LOOP`, `BIZAR_DISABLE_LOG`,
|
|
24
|
+
* `BIZAR_LOG_LEVEL`. Read once at init.
|
|
25
|
+
* - §7.6 — never log raw tool args. The logger only accepts message strings.
|
|
26
|
+
* - §8.1 — wrap init in try/catch. Return empty hooks on any init error.
|
|
27
|
+
* - §8.2 — create directories on init. If creation fails, return empty hooks.
|
|
28
|
+
* - §10.1 — per-call log line via `LogWriter.write`.
|
|
29
|
+
*
|
|
30
|
+
* v0.4.0 (visual plan flow — slash commands + plan tools):
|
|
31
|
+
* - §v4.1 — `chat.message` hook detects slash commands BEFORE state
|
|
32
|
+
* seeding. Settings changes are applied silently; commands with
|
|
33
|
+
* a response text are surfaced by throwing from the hook (the
|
|
34
|
+
* same pattern `tool.execute.before` uses for block decisions).
|
|
35
|
+
* - §v4.2 — `SettingsStore` persists user-controlled plan settings
|
|
36
|
+
* (visualPlanEnabled, defaultTemplate, lastUsedSlug) at
|
|
37
|
+
* `~/.cache/bizar/plan-settings.json`. Atomic writes,
|
|
38
|
+
* corrupt-file fallback to defaults, no throw on bad input.
|
|
39
|
+
* - §v4.3 — `parseSlashCommand` is a pure function (no I/O). The
|
|
40
|
+
* hook gathers context (current settings, available plan slugs)
|
|
41
|
+
* and feeds it to the parser.
|
|
42
|
+
* - §v4.4 — `bizar_plan_action` tool exposes CRUD on the v2
|
|
43
|
+
* canvas (`plan.json`) and the plan metadata (`meta.json`).
|
|
44
|
+
* Pure file I/O — no serve child required.
|
|
45
|
+
* - §v4.5 — `bizar_wait_for_feedback` tool polls every 2 s until
|
|
46
|
+
* a new comment appears, status becomes approved/rejected, or
|
|
47
|
+
* the timeout fires. Never throws.
|
|
48
|
+
*
|
|
49
|
+
* v0.5.0 (visual plan wiring — chat hook executes side effects):
|
|
50
|
+
* - §v5.1 — `chat.message` hook now invokes the parser, then
|
|
51
|
+
* calls `executeSideEffect(result.sideEffect, ctx, opts)` from
|
|
52
|
+
* `src/commands-impl.ts` BEFORE throwing the response. Side
|
|
53
|
+
* effects include `create_plan` (mkdir + write meta/canvas
|
|
54
|
+
* via `src/plan-fs.ts`), `list_plans` (re-read directory and
|
|
55
|
+
* return rich list), `open_plan_url` (no I/O), and
|
|
56
|
+
* `tool_invocation` (build synthetic `ToolContext`, validate
|
|
57
|
+
* args via the tool's Zod schema, then call `tool.execute`).
|
|
58
|
+
* - §v5.2 — synthetic `ToolContext` is built from the runtime
|
|
59
|
+
* context's `worktree` and `directory`, a fresh
|
|
60
|
+
* `AbortController().signal`, and no-op `metadata`/`ask`
|
|
61
|
+
* stubs. NOT from the chat-message input. Session/message/agent
|
|
62
|
+
* IDs use a `"slash-command"` sentinel so downstream code can
|
|
63
|
+
* recognize out-of-band calls.
|
|
64
|
+
* - §v5.3 — tool key names use the `bizar_*` form (single `r`)
|
|
65
|
+
* throughout the plugin, matching the docs and
|
|
66
|
+
* `config/opencode.json`. The earlier `bizarre_*` typo silently
|
|
67
|
+
* disabled the plan tools at runtime; the rename brings the
|
|
68
|
+
* runtime registry back in sync.
|
|
69
|
+
* - §v5.4 — subcommand form: `/plan get|add|update|delete|comment|
|
|
70
|
+
* comments|status|wait` route through `bizar_plan_action` (or
|
|
71
|
+
* `bizar_get_plan_comments`) via the new `tool_invocation`
|
|
72
|
+
* side-effect. `/plan wait` is deferred from MVP and returns
|
|
73
|
+
* a clear "use bizar_wait_for_feedback directly" response.
|
|
74
|
+
*
|
|
75
|
+
* v0.4.2 (background agents):
|
|
76
|
+
* - §1 — start `opencode serve` on init; spawn background sessions
|
|
77
|
+
* via `POST /session` + `POST /session/{id}/prompt_async`.
|
|
78
|
+
* - §2.1 — open ONE global SSE subscription to `GET /event`.
|
|
79
|
+
* - §2.2 — `InstanceManager.add()` is atomic.
|
|
80
|
+
* - §5.1 — serve child on 127.0.0.1 with `--hostname` hardcoded.
|
|
81
|
+
* - §5.3 — SIGTERM/SIGINT trap walks the in-memory map, aborts
|
|
82
|
+
* running sessions, kills the serve child, exits.
|
|
83
|
+
* - §5.4 — on init, scan `bg/*.json`; rebuild in-memory map; mark
|
|
84
|
+
* orphaned `running`/`pending` as `failed`.
|
|
85
|
+
* - §6.1 — 32-byte secret for the serve child; `node:crypto` only in `serve.ts`.
|
|
86
|
+
* - §6.3 — only Odin may call `bizar_spawn_background`.
|
|
87
|
+
* - §7.1 — register 4 background tools: `bizar_spawn_background`,
|
|
88
|
+
* `bizar_status`, `bizar_collect`, `bizar_kill`.
|
|
89
|
+
* - §v2.1 — register 1 read-only tool: `bizar_get_plan_comments`.
|
|
90
|
+
* Reads `plans/<slug>/plan.json` so background agents can pick up
|
|
91
|
+
* user feedback pinned to the elements they're working on.
|
|
92
|
+
* Available to all agents (read-only — no serve child required).
|
|
93
|
+
* - §5.5 — `--hostname 127.0.0.1` hardcoded.
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
import type { Plugin, Hooks, PluginInput, PluginOptions } from "@opencode-ai/plugin";
|
|
97
|
+
|
|
98
|
+
import { createLogger, type Logger } from "./src/logger.js";
|
|
99
|
+
import { decide, isLogOnlyWarn } from "./src/loop.js";
|
|
100
|
+
import { fingerprint } from "./src/fingerprint.js";
|
|
101
|
+
import { StateStore, type SessionState } from "./src/state.js";
|
|
102
|
+
import { LogWriter } from "./src/report.js";
|
|
103
|
+
import {
|
|
104
|
+
normalizeOptions,
|
|
105
|
+
readEnvFlags,
|
|
106
|
+
findOffendingPath,
|
|
107
|
+
type NormalizedOptions,
|
|
108
|
+
type EnvFlags,
|
|
109
|
+
} from "./src/options.js";
|
|
110
|
+
|
|
111
|
+
import { ServeLifecycle } from "./src/serve.js";
|
|
112
|
+
import { HttpClient } from "./src/http-client.js";
|
|
113
|
+
import { EventStream } from "./src/event-stream.js";
|
|
114
|
+
import { BackgroundStateStore, type BackgroundState } from "./src/background-state.js";
|
|
115
|
+
import { InstanceManager } from "./src/background.js";
|
|
116
|
+
import { createBgSpawnTool } from "./src/tools/bg-spawn.js";
|
|
117
|
+
import { createBgStatusTool } from "./src/tools/bg-status.js";
|
|
118
|
+
import { createBgCollectTool } from "./src/tools/bg-collect.js";
|
|
119
|
+
import { createBgKillTool } from "./src/tools/bg-kill.js";
|
|
120
|
+
import { createBgGetCommentsTool } from "./src/tools/bg-get-comments.js";
|
|
121
|
+
|
|
122
|
+
// v0.4.0 — visual plan flow: settings, slash commands, plan tools
|
|
123
|
+
import { SettingsStore } from "./src/settings.js";
|
|
124
|
+
import { parseSlashCommand } from "./src/commands.js";
|
|
125
|
+
import { createPlanActionTool } from "./src/tools/plan-action.js";
|
|
126
|
+
import { createWaitForFeedbackTool } from "./src/tools/wait-for-feedback.js";
|
|
127
|
+
|
|
128
|
+
// v0.5.0 — visual plan wiring: side-effect executor + plan-fs
|
|
129
|
+
import { executeSideEffect, type ExecuteOptions } from "./src/commands-impl.js";
|
|
130
|
+
|
|
131
|
+
// --- Env-var constants (per spec §8) -------------------------------------
|
|
132
|
+
|
|
133
|
+
/** `BIZAR_SERVE_PORT` — default 0 (random). */
|
|
134
|
+
function readServePort(): number {
|
|
135
|
+
const raw = process.env.BIZAR_SERVE_PORT;
|
|
136
|
+
if (raw === undefined || raw === "") return 0;
|
|
137
|
+
const n = Number(raw);
|
|
138
|
+
if (!Number.isFinite(n) || n < 0 || n > 65535) return 0;
|
|
139
|
+
return Math.floor(n);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** `BIZAR_SERVE_DISABLE=1` disables the serve child entirely. */
|
|
143
|
+
function readServeDisabled(): boolean {
|
|
144
|
+
return process.env.BIZAR_SERVE_DISABLE === "1";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** `BIZAR_MAX_CONCURRENT_INSTANCES` — default 8. */
|
|
148
|
+
function readMaxConcurrent(): number {
|
|
149
|
+
const raw = process.env.BIZAR_MAX_CONCURRENT_INSTANCES;
|
|
150
|
+
if (raw === undefined || raw === "") return 8;
|
|
151
|
+
const n = Number(raw);
|
|
152
|
+
if (!Number.isFinite(n) || n < 1) return 8;
|
|
153
|
+
return Math.floor(n);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `BIZAR_BACKGROUND_TOOL_CALL_CAP` — default 500. */
|
|
157
|
+
function readToolCallCap(): number {
|
|
158
|
+
const raw = process.env.BIZAR_BACKGROUND_TOOL_CALL_CAP;
|
|
159
|
+
if (raw === undefined || raw === "") return 500;
|
|
160
|
+
const n = Number(raw);
|
|
161
|
+
if (!Number.isFinite(n) || n < 1) return 500;
|
|
162
|
+
return Math.floor(n);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* v0.3.0 — `BIZAR_STALL_TIMEOUT_MS` — default 180000 (3 min).
|
|
167
|
+
* Range [10000, 600000]; out-of-range falls back to default.
|
|
168
|
+
*/
|
|
169
|
+
function readStallTimeoutMs(): number {
|
|
170
|
+
const raw = process.env.BIZAR_STALL_TIMEOUT_MS;
|
|
171
|
+
if (raw === undefined || raw === "") return 180_000;
|
|
172
|
+
const n = Number(raw);
|
|
173
|
+
if (!Number.isFinite(n) || n < 10_000) return 180_000;
|
|
174
|
+
return Math.min(Math.floor(n), 600_000);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* v0.3.0 — `BIZAR_THINKING_LOOP_TIMEOUT_MS` — default 300000 (5 min).
|
|
179
|
+
* Range [30000, 900000]; out-of-range falls back to default.
|
|
180
|
+
*/
|
|
181
|
+
function readThinkingLoopTimeoutMs(): number {
|
|
182
|
+
const raw = process.env.BIZAR_THINKING_LOOP_TIMEOUT_MS;
|
|
183
|
+
if (raw === undefined || raw === "") return 300_000;
|
|
184
|
+
const n = Number(raw);
|
|
185
|
+
if (!Number.isFinite(n) || n < 30_000) return 300_000;
|
|
186
|
+
return Math.min(Math.floor(n), 900_000);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* v0.3.0 — `BIZAR_MAX_INTERVENTIONS` — default 1.
|
|
191
|
+
* Range [1, 3]; out-of-range falls back to default.
|
|
192
|
+
*/
|
|
193
|
+
function readMaxInterventions(): number {
|
|
194
|
+
const raw = process.env.BIZAR_MAX_INTERVENTIONS;
|
|
195
|
+
if (raw === undefined || raw === "") return 1;
|
|
196
|
+
const n = Number(raw);
|
|
197
|
+
if (!Number.isFinite(n) || n < 1) return 1;
|
|
198
|
+
return Math.min(Math.floor(n), 3);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** `BIZAR_HTTP_TIMEOUT_MS` — default 30000. */
|
|
202
|
+
function readHttpTimeoutMs(): number {
|
|
203
|
+
const raw = process.env.BIZAR_HTTP_TIMEOUT_MS;
|
|
204
|
+
if (raw === undefined || raw === "") return 30_000;
|
|
205
|
+
const n = Number(raw);
|
|
206
|
+
if (!Number.isFinite(n) || n < 1000) return 30_000;
|
|
207
|
+
return Math.floor(n);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- Shutdown coordination -----------------------------------------------
|
|
211
|
+
|
|
212
|
+
/** Module-level guard for SIGTERM/SIGINT reentry (spec §5.3). */
|
|
213
|
+
let shuttingDown = false;
|
|
214
|
+
/** Module-level handle to the InstanceManager for the signal handlers. */
|
|
215
|
+
let instanceManagerHandle: InstanceManager | null = null;
|
|
216
|
+
let serveHandle: ServeLifecycle | null = null;
|
|
217
|
+
let streamHandle: EventStream | null = null;
|
|
218
|
+
let loggerHandle: Logger | null = null;
|
|
219
|
+
|
|
220
|
+
// --- Plugin entry point ---------------------------------------------------
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Runtime context shared across all hooks for one plugin instance. Created
|
|
224
|
+
* during init and closed over by every hook closure.
|
|
225
|
+
*/
|
|
226
|
+
interface RuntimeContext {
|
|
227
|
+
logger: Logger;
|
|
228
|
+
options: NormalizedOptions;
|
|
229
|
+
envFlags: EnvFlags;
|
|
230
|
+
stateStore: StateStore;
|
|
231
|
+
settingsStore: SettingsStore;
|
|
232
|
+
logWriter: LogWriter;
|
|
233
|
+
worktree: string;
|
|
234
|
+
/** Project directory (often equal to `worktree`; the viewer / TUI
|
|
235
|
+
* may use this to display paths differently). v0.5.0 — the synthetic
|
|
236
|
+
* `ToolContext` built for slash-command tool invocations reads this. */
|
|
237
|
+
directory: string;
|
|
238
|
+
/** sessionID → set of message IDs already processed (spec §4.5). */
|
|
239
|
+
seenMessageIds: Map<string, Set<string>>;
|
|
240
|
+
/** sessionID → pending system-transform message, set at warn/escalate. */
|
|
241
|
+
pendingInjections: Map<string, string>;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Default-exported Plugin function. The whole body is wrapped in try/catch
|
|
246
|
+
* so that any initialization error logs via the SDK and returns empty
|
|
247
|
+
* hooks — opencode never crashes on a broken plugin (spec §8.1).
|
|
248
|
+
*/
|
|
249
|
+
const plugin: Plugin = async (
|
|
250
|
+
input: PluginInput,
|
|
251
|
+
rawOptions?: PluginOptions,
|
|
252
|
+
) => {
|
|
253
|
+
try {
|
|
254
|
+
return await init(input, rawOptions);
|
|
255
|
+
} catch (err) {
|
|
256
|
+
try {
|
|
257
|
+
const client = input.client as unknown as {
|
|
258
|
+
app?: { log?: (input: unknown) => unknown };
|
|
259
|
+
};
|
|
260
|
+
client.app?.log?.({
|
|
261
|
+
body: {
|
|
262
|
+
service: "bizar",
|
|
263
|
+
level: "error",
|
|
264
|
+
message: `bizar: init failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
} catch {
|
|
268
|
+
// ignore — logging must never throw
|
|
269
|
+
}
|
|
270
|
+
return {};
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
export default plugin;
|
|
275
|
+
|
|
276
|
+
// --- Init ------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Initialize the plugin and return the hooks object. Separated from the
|
|
280
|
+
* top-level `plugin` function so the try/catch wrapper is unambiguous.
|
|
281
|
+
*/
|
|
282
|
+
async function init(
|
|
283
|
+
input: PluginInput,
|
|
284
|
+
rawOptions?: PluginOptions,
|
|
285
|
+
): Promise<Hooks> {
|
|
286
|
+
const envFlags = readEnvFlags();
|
|
287
|
+
const { options, notes } = normalizeOptions(rawOptions as never);
|
|
288
|
+
|
|
289
|
+
const logger = createLogger(input.client as unknown as Parameters<typeof createLogger>[0]);
|
|
290
|
+
loggerHandle = logger;
|
|
291
|
+
|
|
292
|
+
// §6.4 — refuse to start if logDir or stateDir is inside a secret dir.
|
|
293
|
+
const offending = findOffendingPath(options);
|
|
294
|
+
if (offending !== null) {
|
|
295
|
+
logger.error(
|
|
296
|
+
`bizar: refusing to start — logDir/stateDir ${offending.path} is inside a secret directory (${offending.kind}). Set BIZAR_DISABLE=1 or specify a different path.`,
|
|
297
|
+
);
|
|
298
|
+
return {};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// §6.5 — BIZAR_DISABLE=1 disables the plugin entirely.
|
|
302
|
+
if (envFlags.disable) {
|
|
303
|
+
logger.debug("bizar: disabled via BIZAR_DISABLE=1");
|
|
304
|
+
return {};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Log any non-default normalization notes (spec §6.2).
|
|
308
|
+
for (const note of notes) {
|
|
309
|
+
logger.warn(`bizar: ${note}`);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const stateStore = new StateStore(options.stateDir, logger);
|
|
313
|
+
const settingsStore = new SettingsStore(options.stateDir, logger);
|
|
314
|
+
const logWriter = new LogWriter(options.logDir, options.logRotationBytes, logger);
|
|
315
|
+
|
|
316
|
+
// §4.6 — stale session cleanup (best-effort, on init).
|
|
317
|
+
try {
|
|
318
|
+
const validIds = await readValidSessionIds(input);
|
|
319
|
+
const deleted = await stateStore.cleanup(7, validIds);
|
|
320
|
+
if (deleted > 0) {
|
|
321
|
+
logger.info(`bizar: cleaned up ${deleted} stale session file(s)`);
|
|
322
|
+
}
|
|
323
|
+
} catch (err) {
|
|
324
|
+
logger.warn(
|
|
325
|
+
`bizar: stale session cleanup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// --- Background agents (v0.4.2) -----------------------------------------
|
|
330
|
+
|
|
331
|
+
let instanceManager: InstanceManager | null = null;
|
|
332
|
+
let serve: ServeLifecycle | null = null;
|
|
333
|
+
let stream: EventStream | null = null;
|
|
334
|
+
let bgAvailable = false;
|
|
335
|
+
|
|
336
|
+
if (readServeDisabled()) {
|
|
337
|
+
logger.info("bizar: background agents disabled via BIZAR_SERVE_DISABLE=1");
|
|
338
|
+
} else {
|
|
339
|
+
try {
|
|
340
|
+
const servePort = readServePort();
|
|
341
|
+
const bgStateStore = new BackgroundStateStore(options.stateDir, logger);
|
|
342
|
+
const backgroundStateCleanup = bgStateStore.cleanup(7).catch((err: unknown) => {
|
|
343
|
+
logger.warn(
|
|
344
|
+
`bizar: background state cleanup failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
345
|
+
);
|
|
346
|
+
return 0;
|
|
347
|
+
});
|
|
348
|
+
const maxConcurrent = readMaxConcurrent();
|
|
349
|
+
const toolCallCap = readToolCallCap();
|
|
350
|
+
const httpTimeoutMs = readHttpTimeoutMs();
|
|
351
|
+
const stallTimeoutMs = readStallTimeoutMs();
|
|
352
|
+
const thinkingLoopTimeoutMs = readThinkingLoopTimeoutMs();
|
|
353
|
+
const maxInterventions = readMaxInterventions();
|
|
354
|
+
|
|
355
|
+
serve = new ServeLifecycle({
|
|
356
|
+
port: servePort,
|
|
357
|
+
worktree: input.worktree,
|
|
358
|
+
logger,
|
|
359
|
+
});
|
|
360
|
+
serveHandle = serve;
|
|
361
|
+
const serveInfo = await serve.start();
|
|
362
|
+
const http = new HttpClient({
|
|
363
|
+
baseUrl: `http://127.0.0.1:${serveInfo.port}`,
|
|
364
|
+
password: serveInfo.password,
|
|
365
|
+
logger,
|
|
366
|
+
timeoutMs: httpTimeoutMs,
|
|
367
|
+
});
|
|
368
|
+
const authHeader = `Basic ${btoa(`opencode:${serveInfo.password}`)}`;
|
|
369
|
+
stream = new EventStream({
|
|
370
|
+
baseUrl: `http://127.0.0.1:${serveInfo.port}`,
|
|
371
|
+
directory: input.worktree,
|
|
372
|
+
authHeader,
|
|
373
|
+
logger,
|
|
374
|
+
http,
|
|
375
|
+
});
|
|
376
|
+
streamHandle = stream;
|
|
377
|
+
|
|
378
|
+
instanceManager = new InstanceManager({
|
|
379
|
+
stateStore: bgStateStore,
|
|
380
|
+
maxConcurrent,
|
|
381
|
+
toolCallCap,
|
|
382
|
+
logger,
|
|
383
|
+
serve,
|
|
384
|
+
http,
|
|
385
|
+
stream,
|
|
386
|
+
stallTimeoutMs,
|
|
387
|
+
thinkingLoopTimeoutMs,
|
|
388
|
+
maxInterventions,
|
|
389
|
+
});
|
|
390
|
+
instanceManagerHandle = instanceManager;
|
|
391
|
+
|
|
392
|
+
// §5.4 — rebuild in-memory map from disk.
|
|
393
|
+
await instanceManager.rebuildInMemoryMap();
|
|
394
|
+
|
|
395
|
+
// §5.2 — crash-recovery handler.
|
|
396
|
+
serve.onUnexpectedExit(() => {
|
|
397
|
+
if (!instanceManager) return;
|
|
398
|
+
void (async () => {
|
|
399
|
+
try {
|
|
400
|
+
await instanceManager.shutdownAll();
|
|
401
|
+
} catch (err: unknown) {
|
|
402
|
+
logger.warn(
|
|
403
|
+
`bizar: shutdownAll on serve crash failed: ${
|
|
404
|
+
err instanceof Error ? err.message : String(err)
|
|
405
|
+
}`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
})();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Open the SSE connection (best-effort — failure does not abort init).
|
|
412
|
+
try {
|
|
413
|
+
await stream.connect();
|
|
414
|
+
bgAvailable = true;
|
|
415
|
+
} catch (err: unknown) {
|
|
416
|
+
logger.warn(
|
|
417
|
+
`bizar: SSE connection failed: ${err instanceof Error ? err.message : String(err)}; background agents will still accept new spawns (will reconnect on demand)`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Surface the cleanup result.
|
|
422
|
+
const deletedBg = await backgroundStateCleanup;
|
|
423
|
+
if (deletedBg > 0) {
|
|
424
|
+
logger.info(`bizar: cleaned up ${deletedBg} stale background state file(s)`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
logger.info(
|
|
428
|
+
`bizar: background agents ready (port=${serveInfo.port}, cap=${maxConcurrent}, toolCallCap=${toolCallCap}, stallTimeoutMs=${stallTimeoutMs}, thinkingLoopTimeoutMs=${thinkingLoopTimeoutMs}, maxInterventions=${maxInterventions})`,
|
|
429
|
+
);
|
|
430
|
+
} catch (err: unknown) {
|
|
431
|
+
logger.warn(
|
|
432
|
+
`bizar: background agents unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
|
433
|
+
);
|
|
434
|
+
// Leave `bgAvailable = false`. The tools will return a clear error.
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// --- Signal traps (spec §5.3) ------------------------------------------
|
|
439
|
+
|
|
440
|
+
installSignalHandlers(logger, instanceManager, serve, stream);
|
|
441
|
+
|
|
442
|
+
const ctx: RuntimeContext = {
|
|
443
|
+
logger,
|
|
444
|
+
options,
|
|
445
|
+
envFlags,
|
|
446
|
+
stateStore,
|
|
447
|
+
settingsStore,
|
|
448
|
+
logWriter,
|
|
449
|
+
worktree: input.worktree,
|
|
450
|
+
directory: input.directory,
|
|
451
|
+
seenMessageIds: new Map(),
|
|
452
|
+
pendingInjections: new Map(),
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
return buildHooks(ctx, { instanceManager, bgAvailable });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// --- Signal handling (spec §5.3) -----------------------------------------
|
|
459
|
+
|
|
460
|
+
function installSignalHandlers(
|
|
461
|
+
logger: Logger,
|
|
462
|
+
instanceManager: InstanceManager | null,
|
|
463
|
+
serve: ServeLifecycle | null,
|
|
464
|
+
stream: EventStream | null,
|
|
465
|
+
): void {
|
|
466
|
+
const onSignal = async (sig: "SIGTERM" | "SIGINT") => {
|
|
467
|
+
if (shuttingDown) return;
|
|
468
|
+
shuttingDown = true;
|
|
469
|
+
logger.warn(`bizar: received ${sig}; shutting down`);
|
|
470
|
+
|
|
471
|
+
// 1. Mark all in-memory instances as failed (spec §5.3 step 1).
|
|
472
|
+
if (instanceManager !== null) {
|
|
473
|
+
try {
|
|
474
|
+
await instanceManager.shutdownAll();
|
|
475
|
+
} catch (err: unknown) {
|
|
476
|
+
logger.warn(
|
|
477
|
+
`bizar: shutdownAll on ${sig} failed: ${
|
|
478
|
+
err instanceof Error ? err.message : String(err)
|
|
479
|
+
}`,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 2. Close SSE.
|
|
485
|
+
if (stream !== null) {
|
|
486
|
+
try {
|
|
487
|
+
await stream.disconnect();
|
|
488
|
+
} catch {
|
|
489
|
+
// ignore
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// 3. Kill serve child.
|
|
494
|
+
if (serve !== null) {
|
|
495
|
+
try {
|
|
496
|
+
await serve.stop();
|
|
497
|
+
} catch (err: unknown) {
|
|
498
|
+
logger.warn(
|
|
499
|
+
`bizar: serve.stop on ${sig} failed: ${
|
|
500
|
+
err instanceof Error ? err.message : String(err)
|
|
501
|
+
}`,
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// 4. Exit. (Note: the host may keep the process alive if other work
|
|
507
|
+
// is pending, but for the plugin process this is the end.)
|
|
508
|
+
try {
|
|
509
|
+
process.exit(0);
|
|
510
|
+
} catch {
|
|
511
|
+
// process.exit may not be available in all environments; ignore.
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
// Idempotent registration — if the plugin is reloaded, we don't want
|
|
516
|
+
// duplicate handlers. Use `process.once` so each handler runs at most
|
|
517
|
+
// once per signal; the `shuttingDown` guard catches reentry.
|
|
518
|
+
for (const sig of ["SIGTERM", "SIGINT"] as const) {
|
|
519
|
+
try {
|
|
520
|
+
process.removeAllListeners(sig);
|
|
521
|
+
} catch {
|
|
522
|
+
// ignore
|
|
523
|
+
}
|
|
524
|
+
process.on(sig, () => {
|
|
525
|
+
void onSignal(sig);
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// --- Init helpers ---------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Race a promise against a timeout. Returns the promise's value if it
|
|
534
|
+
* resolves in time; throws a labeled `Error` otherwise.
|
|
535
|
+
*
|
|
536
|
+
* The original promise is intentionally NOT cancelled (we don't have
|
|
537
|
+
* an `AbortSignal` to pass to the opencode client). If the underlying
|
|
538
|
+
* call eventually rejects after we've already returned, the caller
|
|
539
|
+
* should attach a no-op `.catch(() => undefined)` to suppress the
|
|
540
|
+
* unhandled-rejection warning.
|
|
541
|
+
*
|
|
542
|
+
* v0.5.2: extracted from `readValidSessionIds` so it can be unit
|
|
543
|
+
* tested in isolation. See `tests/init-helpers.test.ts`.
|
|
544
|
+
*/
|
|
545
|
+
export async function withTimeout<T>(
|
|
546
|
+
promise: Promise<T>,
|
|
547
|
+
timeoutMs: number,
|
|
548
|
+
label: string,
|
|
549
|
+
): Promise<T> {
|
|
550
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
551
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
552
|
+
timer = setTimeout(
|
|
553
|
+
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
|
|
554
|
+
timeoutMs,
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
try {
|
|
558
|
+
return await Promise.race([promise, timeoutPromise]);
|
|
559
|
+
} finally {
|
|
560
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// --- Hooks ----------------------------------------------------------------
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* Best-effort read of valid session IDs from opencode. If `client.session`
|
|
568
|
+
* is unavailable or the call fails or times out, return an empty set —
|
|
569
|
+
* the age-based branch of the cleanup still runs (spec §4.6).
|
|
570
|
+
*
|
|
571
|
+
* v0.5.2 FIX (postmortem 2026-06-18, Layer 1): the previous version
|
|
572
|
+
* called `client.session.list()` with no timeout. If the session
|
|
573
|
+
* store was slow, busy, or in a broken state, the call would hang
|
|
574
|
+
* forever, blocking the plugin's `init()` and stalling the UI on a
|
|
575
|
+
* blank screen. We now race the call against a 1-second timeout and
|
|
576
|
+
* fall back to an empty set on timeout.
|
|
577
|
+
*/
|
|
578
|
+
export async function readValidSessionIds(input: PluginInput): Promise<Set<string>> {
|
|
579
|
+
try {
|
|
580
|
+
const client = input.client as unknown as {
|
|
581
|
+
session?: { list?: () => Promise<{ data?: Array<{ id: string }> } | Array<{ id: string }>> };
|
|
582
|
+
};
|
|
583
|
+
if (!client.session || typeof client.session.list !== "function") {
|
|
584
|
+
return new Set();
|
|
585
|
+
}
|
|
586
|
+
// Suppress unhandled rejection if the call eventually rejects after
|
|
587
|
+
// the timeout has already fired (see `withTimeout` note above).
|
|
588
|
+
const listPromise = client.session.list();
|
|
589
|
+
listPromise.catch(() => undefined);
|
|
590
|
+
const result = await withTimeout(
|
|
591
|
+
listPromise,
|
|
592
|
+
1000,
|
|
593
|
+
"client.session.list",
|
|
594
|
+
);
|
|
595
|
+
const list = Array.isArray(result) ? result : (result.data ?? []);
|
|
596
|
+
return new Set(list.map((s) => s.id));
|
|
597
|
+
} catch {
|
|
598
|
+
return new Set();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
interface BgDeps {
|
|
603
|
+
instanceManager: InstanceManager | null;
|
|
604
|
+
bgAvailable: boolean;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// --- Slash-command helpers (v0.4.0) ------------------------------------
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Read the user-typed text from a `chat.message` hook output.
|
|
611
|
+
*
|
|
612
|
+
* `output.parts` is a discriminated union (`Part[]`). We concatenate any
|
|
613
|
+
* TextPart entries. Other part types (file, tool, etc.) are skipped.
|
|
614
|
+
*
|
|
615
|
+
* Returns `null` if no text could be extracted (e.g. the message is a
|
|
616
|
+
* file-only attachment, or the parts array is missing/malformed).
|
|
617
|
+
*/
|
|
618
|
+
function readMessageText(
|
|
619
|
+
output: { message?: unknown; parts?: unknown } | undefined,
|
|
620
|
+
): string | null {
|
|
621
|
+
if (!output || !Array.isArray(output.parts)) return null;
|
|
622
|
+
const parts = output.parts as Array<{ type?: string; text?: string }>;
|
|
623
|
+
const fragments: string[] = [];
|
|
624
|
+
for (const part of parts) {
|
|
625
|
+
if (part && part.type === "text" && typeof part.text === "string") {
|
|
626
|
+
fragments.push(part.text);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
const joined = fragments.join("\n").trim();
|
|
630
|
+
return joined === "" ? null : joined;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* List the slugs of plans in the worktree's `plans/` directory.
|
|
635
|
+
*
|
|
636
|
+
* Pure best-effort: returns `[]` on missing dir, read errors, or any
|
|
637
|
+
* I/O exception. The slash-command parser uses this only for the
|
|
638
|
+
* `/plan list` response, so a missing list should not throw.
|
|
639
|
+
*/
|
|
640
|
+
async function listPlanSlugs(worktree: string, logger: Logger): Promise<string[]> {
|
|
641
|
+
try {
|
|
642
|
+
const { readdirSync, statSync } = await import("node:fs");
|
|
643
|
+
const { join } = await import("node:path");
|
|
644
|
+
const plansDir = join(worktree, "plans");
|
|
645
|
+
let entries: string[];
|
|
646
|
+
try {
|
|
647
|
+
entries = readdirSync(plansDir);
|
|
648
|
+
} catch {
|
|
649
|
+
return [];
|
|
650
|
+
}
|
|
651
|
+
const slugs: string[] = [];
|
|
652
|
+
for (const name of entries) {
|
|
653
|
+
try {
|
|
654
|
+
const stat = statSync(join(plansDir, name));
|
|
655
|
+
if (stat.isDirectory()) slugs.push(name);
|
|
656
|
+
} catch {
|
|
657
|
+
// skip unreadable entries
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
slugs.sort();
|
|
661
|
+
return slugs;
|
|
662
|
+
} catch (err: unknown) {
|
|
663
|
+
logger.debug(
|
|
664
|
+
`bizar: listPlanSlugs failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
665
|
+
);
|
|
666
|
+
return [];
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Build the hooks object. Each hook is a small async function that
|
|
672
|
+
* delegates to the runtime context and the supporting modules.
|
|
673
|
+
*/
|
|
674
|
+
function buildHooks(ctx: RuntimeContext, bg: BgDeps): Hooks {
|
|
675
|
+
// Build the 7 tools. We always register them; if the serve child is
|
|
676
|
+
// not available, the background tools return a clear error. The
|
|
677
|
+
// bizar_get_plan_comments, bizar_plan_action, and
|
|
678
|
+
// bizar_wait_for_feedback tools only need the worktree, so they
|
|
679
|
+
// work regardless of the serve child's state.
|
|
680
|
+
//
|
|
681
|
+
// v0.4.0 — added `bizar_plan_action` (CRUD on the v2 canvas) and
|
|
682
|
+
// `bizar_wait_for_feedback` (poll until feedback). Both are pure
|
|
683
|
+
// file I/O — no serve child required.
|
|
684
|
+
//
|
|
685
|
+
// v0.5.0 — renamed `bizarre_*` → `bizar_*` (single `r`) to match
|
|
686
|
+
// the docs and `config/opencode.json`. The earlier typo silently
|
|
687
|
+
// disabled the plan tools at runtime; this fix brings the registry
|
|
688
|
+
// in sync.
|
|
689
|
+
const basePlanTools = {
|
|
690
|
+
bizar_get_plan_comments: createBgGetCommentsTool({
|
|
691
|
+
worktree: ctx.worktree,
|
|
692
|
+
logger: ctx.logger,
|
|
693
|
+
}),
|
|
694
|
+
bizar_plan_action: createPlanActionTool({
|
|
695
|
+
worktree: ctx.worktree,
|
|
696
|
+
logger: ctx.logger,
|
|
697
|
+
}),
|
|
698
|
+
bizar_wait_for_feedback: createWaitForFeedbackTool({
|
|
699
|
+
worktree: ctx.worktree,
|
|
700
|
+
logger: ctx.logger,
|
|
701
|
+
}),
|
|
702
|
+
};
|
|
703
|
+
const tools = bg.instanceManager
|
|
704
|
+
? {
|
|
705
|
+
...basePlanTools,
|
|
706
|
+
bizar_spawn_background: createBgSpawnTool({
|
|
707
|
+
instanceManager: bg.instanceManager,
|
|
708
|
+
http: (bg.instanceManager as unknown as { http: HttpClient }).http,
|
|
709
|
+
worktree: ctx.worktree,
|
|
710
|
+
logger: ctx.logger,
|
|
711
|
+
}),
|
|
712
|
+
bizar_status: createBgStatusTool({
|
|
713
|
+
instanceManager: bg.instanceManager,
|
|
714
|
+
logger: ctx.logger,
|
|
715
|
+
}),
|
|
716
|
+
bizar_collect: createBgCollectTool({
|
|
717
|
+
instanceManager: bg.instanceManager,
|
|
718
|
+
logger: ctx.logger,
|
|
719
|
+
}),
|
|
720
|
+
bizar_kill: createBgKillTool({
|
|
721
|
+
instanceManager: bg.instanceManager,
|
|
722
|
+
logger: ctx.logger,
|
|
723
|
+
}),
|
|
724
|
+
}
|
|
725
|
+
: {
|
|
726
|
+
...basePlanTools,
|
|
727
|
+
...bgDisabledTools(ctx.logger),
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
return {
|
|
731
|
+
// §3.1 — config: no mutation. We already resolved options in init().
|
|
732
|
+
config: async () => {
|
|
733
|
+
// intentionally empty — options are resolved at init time
|
|
734
|
+
},
|
|
735
|
+
|
|
736
|
+
// §3.1, §4.5.1 — event: track session boundaries. We do NOT create
|
|
737
|
+
// the state file here (canonical lifecycle: file is created at the
|
|
738
|
+
// `chat.message` seed, per spec §4.5.1).
|
|
739
|
+
event: async ({ event }) => {
|
|
740
|
+
try {
|
|
741
|
+
const ev = event as { type?: string; sessionID?: string };
|
|
742
|
+
const type = ev.type;
|
|
743
|
+
const sessionID = ev.sessionID;
|
|
744
|
+
if (!type || !sessionID) return;
|
|
745
|
+
|
|
746
|
+
if (type === "session.deleted") {
|
|
747
|
+
await ctx.stateStore.withLock(sessionID, async () => {
|
|
748
|
+
await ctx.stateStore.delete(sessionID);
|
|
749
|
+
});
|
|
750
|
+
ctx.pendingInjections.delete(sessionID);
|
|
751
|
+
ctx.seenMessageIds.delete(sessionID);
|
|
752
|
+
}
|
|
753
|
+
// Other event types are no-ops on the hook side. The state file
|
|
754
|
+
// is updated by `chat.message` and `tool.execute.before/after`.
|
|
755
|
+
} catch (err) {
|
|
756
|
+
ctx.logger.warn(
|
|
757
|
+
`bizar: event hook error: ${err instanceof Error ? err.message : String(err)}`,
|
|
758
|
+
);
|
|
759
|
+
}
|
|
760
|
+
},
|
|
761
|
+
|
|
762
|
+
// §4.5 — seed session state on first user message per session.
|
|
763
|
+
// The hook key is the literal string "chat.message" (with a dot) per
|
|
764
|
+
// the opencode plugin API.
|
|
765
|
+
//
|
|
766
|
+
// §v4.1 (v0.4.0) — Slash command detection happens FIRST, before the
|
|
767
|
+
// existing state-seeding logic. If the user typed a slash command we:
|
|
768
|
+
// 1. Apply any settings patch via `SettingsStore`.
|
|
769
|
+
// 2. Execute the side-effect (v0.5.0 — previously silently dropped).
|
|
770
|
+
// 3. Throw the response text. The host (TUI/CLI) surfaces it to
|
|
771
|
+
// the user; the LLM sees it on the next turn as a tool error.
|
|
772
|
+
//
|
|
773
|
+
// We chose throw-over-mutate because:
|
|
774
|
+
// - Throwing is the same pattern `tool.execute.before` uses for
|
|
775
|
+
// loop-detection blocks (see §5.4). It's well-tested in production.
|
|
776
|
+
// - Mutating `output.parts` / `output.message` is brittle — the
|
|
777
|
+
// shapes differ between opencode versions, and the host may not
|
|
778
|
+
// honor a synthetic `text` part from a hook.
|
|
779
|
+
"chat.message": async (input, output) => {
|
|
780
|
+
const sessionID = input.sessionID;
|
|
781
|
+
const messageID = input.messageID;
|
|
782
|
+
const agent = input.agent;
|
|
783
|
+
if (!sessionID) return;
|
|
784
|
+
|
|
785
|
+
// --- v0.4.0: slash command detection -----------------------------
|
|
786
|
+
// Runs before the disableLoop/disableLog check — slash commands
|
|
787
|
+
// should work even when loop detection / logging is off.
|
|
788
|
+
try {
|
|
789
|
+
const messageText = readMessageText(output);
|
|
790
|
+
if (messageText !== null) {
|
|
791
|
+
const currentSettings = await ctx.settingsStore.get();
|
|
792
|
+
const availableSlugs = await listPlanSlugs(ctx.worktree, ctx.logger);
|
|
793
|
+
const result = parseSlashCommand(messageText, {
|
|
794
|
+
currentSettings,
|
|
795
|
+
availablePlanSlugs: availableSlugs,
|
|
796
|
+
defaultPort: 4321,
|
|
797
|
+
});
|
|
798
|
+
if (result !== null) {
|
|
799
|
+
if (result.settingsPatch) {
|
|
800
|
+
await ctx.settingsStore.update(result.settingsPatch);
|
|
801
|
+
}
|
|
802
|
+
// --- v0.5.0: execute the side-effect (was silently dropped
|
|
803
|
+
// in v0.4.0). The executor returns an optional
|
|
804
|
+
// override/suffix that replaces/appends the
|
|
805
|
+
// parser's response. Tool invocations build a
|
|
806
|
+
// synthetic ToolContext and pre-validate args.
|
|
807
|
+
let finalResponse = result.response;
|
|
808
|
+
if (result.sideEffect !== undefined) {
|
|
809
|
+
const execOpts: ExecuteOptions = {
|
|
810
|
+
tools,
|
|
811
|
+
defaultTemplate: currentSettings.defaultTemplate,
|
|
812
|
+
defaultPort: 4321,
|
|
813
|
+
};
|
|
814
|
+
try {
|
|
815
|
+
const exec = await executeSideEffect(
|
|
816
|
+
result.sideEffect,
|
|
817
|
+
{
|
|
818
|
+
worktree: ctx.worktree,
|
|
819
|
+
directory: ctx.directory,
|
|
820
|
+
logger: ctx.logger,
|
|
821
|
+
},
|
|
822
|
+
execOpts,
|
|
823
|
+
);
|
|
824
|
+
if (exec.responseOverride !== undefined) {
|
|
825
|
+
finalResponse = exec.responseOverride;
|
|
826
|
+
} else if (exec.responseSuffix !== undefined) {
|
|
827
|
+
finalResponse = `${result.response}${exec.responseSuffix}`;
|
|
828
|
+
}
|
|
829
|
+
} catch (execErr: unknown) {
|
|
830
|
+
// Defense-in-depth — `executeSideEffect` already catches
|
|
831
|
+
// its own errors, but if it ever throws (e.g. a bug in
|
|
832
|
+
// a future handler) we stringify into the response
|
|
833
|
+
// rather than crashing the chat hook.
|
|
834
|
+
const msg =
|
|
835
|
+
execErr instanceof Error ? execErr.message : String(execErr);
|
|
836
|
+
ctx.logger.warn(`bizar: side-effect crashed: ${msg}`);
|
|
837
|
+
finalResponse = `Command failed: ${msg}`;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
// Surface the response to the user/host. We throw so the
|
|
841
|
+
// message is treated as handled; the LLM does not process
|
|
842
|
+
// it further. The host renders the throw message.
|
|
843
|
+
throw new Error(finalResponse);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
} catch (err) {
|
|
847
|
+
// Re-throw — if it's our slash-command response, propagate it.
|
|
848
|
+
// If it's an unexpected I/O error, log and fall through.
|
|
849
|
+
if (err instanceof Error && err.message !== "" && err.message !== undefined) {
|
|
850
|
+
// Heuristic: errors we throw ourselves contain a non-technical
|
|
851
|
+
// response (starts with one of the canonical prefixes OR is
|
|
852
|
+
// simply a human-readable sentence). Errors from I/O contain
|
|
853
|
+
// "ENOENT", "EACCES", etc. We always re-throw errors that the
|
|
854
|
+
// parser produced (response starts with known prefixes or
|
|
855
|
+
// doesn't contain a colon+code pattern).
|
|
856
|
+
const msg = err.message;
|
|
857
|
+
const looksLikeIoError = /(ENOENT|EACCES|EROFS|EISDIR|EPERM|Error:)/.test(msg);
|
|
858
|
+
if (!looksLikeIoError) {
|
|
859
|
+
throw err;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
ctx.logger.warn(
|
|
863
|
+
`bizar: slash-command handling failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
864
|
+
);
|
|
865
|
+
// Fall through to normal state seeding.
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// --- v0.3.0: state seeding ----------------------------------------
|
|
869
|
+
if (ctx.envFlags.disableLoop && ctx.envFlags.disableLog) return;
|
|
870
|
+
|
|
871
|
+
// Dedupe by message ID (spec §4.5).
|
|
872
|
+
if (messageID) {
|
|
873
|
+
let seen = ctx.seenMessageIds.get(sessionID);
|
|
874
|
+
if (!seen) {
|
|
875
|
+
seen = new Set();
|
|
876
|
+
ctx.seenMessageIds.set(sessionID, seen);
|
|
877
|
+
}
|
|
878
|
+
if (seen.has(messageID)) return; // duplicate event — no-op
|
|
879
|
+
seen.add(messageID);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// Seed parentAgent and create the state file on the first
|
|
883
|
+
// message per session (spec §4.5.1 — canonical lifecycle).
|
|
884
|
+
await ctx.stateStore.withLock(sessionID, async () => {
|
|
885
|
+
const existing = await ctx.stateStore.load(sessionID);
|
|
886
|
+
if (existing.parentAgent !== null) return; // already seeded
|
|
887
|
+
const now = Date.now();
|
|
888
|
+
const seeded: SessionState = {
|
|
889
|
+
sessionId: sessionID,
|
|
890
|
+
parentAgent: agent ?? null,
|
|
891
|
+
startedAt: now,
|
|
892
|
+
lastActivityAt: now,
|
|
893
|
+
turnCount: 1,
|
|
894
|
+
toolCalls: [],
|
|
895
|
+
warningsIssued: 0,
|
|
896
|
+
blocksTriggered: 0,
|
|
897
|
+
};
|
|
898
|
+
await ctx.stateStore.save(seeded);
|
|
899
|
+
ctx.logger.debug(`bizar: seeded session ${sessionID} with parentAgent=${seeded.parentAgent}`);
|
|
900
|
+
});
|
|
901
|
+
},
|
|
902
|
+
|
|
903
|
+
// §3.1, §5.1, §5.4 — primary loop-detection point.
|
|
904
|
+
"tool.execute.before": async (input, output) => {
|
|
905
|
+
if (ctx.envFlags.disableLoop) return;
|
|
906
|
+
const sessionID = input.sessionID;
|
|
907
|
+
const tool = input.tool;
|
|
908
|
+
if (!sessionID || !tool) return;
|
|
909
|
+
|
|
910
|
+
// Compute fingerprint of (tool, args). args is mutable in the
|
|
911
|
+
// hook output; we read it as-is for fingerprinting.
|
|
912
|
+
const args = output.args;
|
|
913
|
+
const fp = fingerprint(tool, args, ctx.worktree);
|
|
914
|
+
|
|
915
|
+
// All state mutations go through the per-session mutex (§4.3).
|
|
916
|
+
await ctx.stateStore.withLock(sessionID, async () => {
|
|
917
|
+
const state = await ctx.stateStore.load(sessionID);
|
|
918
|
+
// Lazy fallback for subagent-only sessions: if the state file
|
|
919
|
+
// doesn't exist yet (no chat.message has fired), create the
|
|
920
|
+
// empty state now (spec §4.5.1).
|
|
921
|
+
if (state.startedAt === 0) {
|
|
922
|
+
const now = Date.now();
|
|
923
|
+
state.parentAgent = null;
|
|
924
|
+
state.startedAt = now;
|
|
925
|
+
state.lastActivityAt = now;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
// Append the current call to the state before deciding (§5.1:
|
|
929
|
+
// the count includes the current call).
|
|
930
|
+
const now = Date.now();
|
|
931
|
+
state.toolCalls.push({ tool, fingerprint: fp, at: now });
|
|
932
|
+
// Cap toolCalls at last 50 (§4.1).
|
|
933
|
+
if (state.toolCalls.length > 50) {
|
|
934
|
+
state.toolCalls.splice(0, state.toolCalls.length - 50);
|
|
935
|
+
}
|
|
936
|
+
state.lastActivityAt = now;
|
|
937
|
+
state.turnCount += 1;
|
|
938
|
+
|
|
939
|
+
const decision = decide(state, fp, now, ctx.options);
|
|
940
|
+
|
|
941
|
+
if (decision.action === "allow") {
|
|
942
|
+
await ctx.stateStore.save(state);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (decision.action === "block") {
|
|
947
|
+
state.blocksTriggered += 1;
|
|
948
|
+
await ctx.stateStore.save(state);
|
|
949
|
+
// Throw from the hook — surfaces as a tool error in the TUI
|
|
950
|
+
// and runs BEFORE opencode's doom_loop recovery (§3.3).
|
|
951
|
+
throw new Error(decision.reason);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// warn or escalate — log first, then queue the injection.
|
|
955
|
+
if (decision.action === "warn") {
|
|
956
|
+
state.warningsIssued += 1;
|
|
957
|
+
if (isLogOnlyWarn(decision, ctx.options)) {
|
|
958
|
+
// Threshold-3 band: log only, no injection (§5.4 row 1).
|
|
959
|
+
ctx.logger.warn(`bizar: ${decision.reason}`);
|
|
960
|
+
} else {
|
|
961
|
+
// Threshold-5/8 band: queue system-transform injection.
|
|
962
|
+
ctx.pendingInjections.set(sessionID, decision.reason);
|
|
963
|
+
ctx.logger.warn(`bizar: ${decision.reason}`);
|
|
964
|
+
}
|
|
965
|
+
} else {
|
|
966
|
+
// escalate — always inject.
|
|
967
|
+
ctx.pendingInjections.set(sessionID, decision.reason);
|
|
968
|
+
ctx.logger.warn(`bizar: ${decision.reason}`);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
await ctx.stateStore.save(state);
|
|
972
|
+
});
|
|
973
|
+
},
|
|
974
|
+
|
|
975
|
+
// §3.1 — record the call result and update the outcome.
|
|
976
|
+
"tool.execute.after": async (input, output) => {
|
|
977
|
+
if (ctx.envFlags.disableLoop && ctx.envFlags.disableLog) return;
|
|
978
|
+
const sessionID = input.sessionID;
|
|
979
|
+
const tool = input.tool;
|
|
980
|
+
if (!sessionID || !tool) return;
|
|
981
|
+
|
|
982
|
+
const startMs = Date.now();
|
|
983
|
+
|
|
984
|
+
await ctx.stateStore.withLock(sessionID, async () => {
|
|
985
|
+
const state = await ctx.stateStore.load(sessionID);
|
|
986
|
+
if (state.startedAt === 0) return; // nothing to update
|
|
987
|
+
// Find the matching call by fingerprint. The hook appends the
|
|
988
|
+
// call in `before`; we update its outcome here.
|
|
989
|
+
const fp = fingerprint(tool, input.args, ctx.worktree);
|
|
990
|
+
const idx = findLastIndex(state.toolCalls, (c) => c.fingerprint === fp);
|
|
991
|
+
if (idx >= 0) {
|
|
992
|
+
const call = state.toolCalls[idx];
|
|
993
|
+
if (call) {
|
|
994
|
+
call.outcome = output && typeof output.output === "string" ? "ok" : "error";
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
state.lastActivityAt = Date.now();
|
|
998
|
+
await ctx.stateStore.save(state);
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
// Per-call log line (§10.1). Metadata only — no args.
|
|
1002
|
+
if (!ctx.envFlags.disableLog) {
|
|
1003
|
+
const durationMs = Date.now() - startMs;
|
|
1004
|
+
const outcome: "ok" | "error" = output && typeof output.output === "string" ? "ok" : "error";
|
|
1005
|
+
const fp = fingerprint(tool, input.args, ctx.worktree);
|
|
1006
|
+
try {
|
|
1007
|
+
await ctx.logWriter.write({
|
|
1008
|
+
sessionId: sessionID,
|
|
1009
|
+
agent: null, // per-call agent attribution removed (§4.4)
|
|
1010
|
+
tool,
|
|
1011
|
+
fingerprint: fp,
|
|
1012
|
+
outcome,
|
|
1013
|
+
durationMs,
|
|
1014
|
+
});
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
ctx.logger.warn(
|
|
1017
|
+
`bizar: log write failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
},
|
|
1022
|
+
|
|
1023
|
+
// §3.1, §5.4 — handoff injection point. We push a single string onto
|
|
1024
|
+
// `output.system` if a pending injection is queued for this session.
|
|
1025
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
1026
|
+
const sessionID = input.sessionID;
|
|
1027
|
+
if (!sessionID) return;
|
|
1028
|
+
const pending = ctx.pendingInjections.get(sessionID);
|
|
1029
|
+
if (pending) {
|
|
1030
|
+
output.system.push(pending);
|
|
1031
|
+
ctx.pendingInjections.delete(sessionID);
|
|
1032
|
+
}
|
|
1033
|
+
},
|
|
1034
|
+
|
|
1035
|
+
// v0.4.2 — register the 4 background tools.
|
|
1036
|
+
tool: tools,
|
|
1037
|
+
|
|
1038
|
+
// v0.4.2 — dispose hook. Opencode calls this when the plugin is
|
|
1039
|
+
// being torn down. We do a best-effort cleanup similar to the
|
|
1040
|
+
// signal trap, but we do NOT call `process.exit` — that's the
|
|
1041
|
+
// signal handler's job.
|
|
1042
|
+
dispose: async () => {
|
|
1043
|
+
ctx.logger.debug("bizar: dispose hook fired");
|
|
1044
|
+
if (instanceManagerHandle !== null) {
|
|
1045
|
+
try {
|
|
1046
|
+
await instanceManagerHandle.shutdownAll();
|
|
1047
|
+
} catch (err: unknown) {
|
|
1048
|
+
ctx.logger.warn(
|
|
1049
|
+
`bizar: dispose: shutdownAll failed: ${
|
|
1050
|
+
err instanceof Error ? err.message : String(err)
|
|
1051
|
+
}`,
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
if (streamHandle !== null) {
|
|
1056
|
+
try {
|
|
1057
|
+
await streamHandle.disconnect();
|
|
1058
|
+
} catch {
|
|
1059
|
+
// ignore
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
if (serveHandle !== null) {
|
|
1063
|
+
try {
|
|
1064
|
+
await serveHandle.stop();
|
|
1065
|
+
} catch (err: unknown) {
|
|
1066
|
+
ctx.logger.warn(
|
|
1067
|
+
`bizar: dispose: serve.stop failed: ${
|
|
1068
|
+
err instanceof Error ? err.message : String(err)
|
|
1069
|
+
}`,
|
|
1070
|
+
);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
},
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function findLastIndex<T>(
|
|
1078
|
+
arr: readonly T[],
|
|
1079
|
+
predicate: (item: T) => boolean,
|
|
1080
|
+
): number {
|
|
1081
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
1082
|
+
const item = arr[i];
|
|
1083
|
+
if (item !== undefined && predicate(item)) return i;
|
|
1084
|
+
}
|
|
1085
|
+
return -1;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* When the serve child failed to start, register the 4 background tools
|
|
1090
|
+
* as stubs that return a clear error. This keeps the agent experience
|
|
1091
|
+
* consistent: calling `bizar_spawn_background` always returns JSON, not
|
|
1092
|
+
* a thrown exception from the tool framework. The plan tools
|
|
1093
|
+
* (`bizar_get_plan_comments`, `bizar_plan_action`, `bizar_wait_for_feedback`)
|
|
1094
|
+
* are NOT background tools — they read/write plan files directly — and
|
|
1095
|
+
* are always registered in `buildHooks`.
|
|
1096
|
+
*/
|
|
1097
|
+
function bgDisabledTools(logger: Logger): Hooks["tool"] {
|
|
1098
|
+
const disabled = (name: string) =>
|
|
1099
|
+
async () => {
|
|
1100
|
+
logger.debug(`bizar: ${name} called but background agents are disabled`);
|
|
1101
|
+
return {
|
|
1102
|
+
output: JSON.stringify({
|
|
1103
|
+
error: "background agents are disabled (opencode serve unavailable). See plugin logs.",
|
|
1104
|
+
}),
|
|
1105
|
+
};
|
|
1106
|
+
};
|
|
1107
|
+
return {
|
|
1108
|
+
bizar_spawn_background: { execute: disabled("bizar_spawn_background") } as never,
|
|
1109
|
+
bizar_status: { execute: disabled("bizar_status") } as never,
|
|
1110
|
+
bizar_collect: { execute: disabled("bizar_collect") } as never,
|
|
1111
|
+
bizar_kill: { execute: disabled("bizar_kill") } as never,
|
|
1112
|
+
};
|
|
1113
|
+
}
|