@polderlabs/bizar-plugin 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +448 -0
  3. package/bun.lock +88 -0
  4. package/index.ts +1113 -0
  5. package/package.json +42 -0
  6. package/scripts/check-forbidden-imports.sh +33 -0
  7. package/src/background-state.ts +463 -0
  8. package/src/background.ts +964 -0
  9. package/src/commands-impl.ts +369 -0
  10. package/src/commands.ts +880 -0
  11. package/src/event-stream.ts +574 -0
  12. package/src/fingerprint.ts +120 -0
  13. package/src/handoff.ts +79 -0
  14. package/src/http-client.ts +467 -0
  15. package/src/logger.ts +144 -0
  16. package/src/loop.ts +176 -0
  17. package/src/options.ts +421 -0
  18. package/src/plan-fs.ts +323 -0
  19. package/src/report.ts +178 -0
  20. package/src/research-prompt.ts +35 -0
  21. package/src/serve.ts +476 -0
  22. package/src/settings.ts +349 -0
  23. package/src/state.ts +298 -0
  24. package/src/tools/bg-collect.ts +104 -0
  25. package/src/tools/bg-get-comments.ts +239 -0
  26. package/src/tools/bg-kill.ts +87 -0
  27. package/src/tools/bg-spawn.ts +263 -0
  28. package/src/tools/bg-status.ts +99 -0
  29. package/src/tools/plan-action.ts +767 -0
  30. package/src/tools/wait-for-feedback.ts +402 -0
  31. package/tests/attach-handler-bug.test.ts +166 -0
  32. package/tests/background-state.test.ts +277 -0
  33. package/tests/background.test.ts +402 -0
  34. package/tests/block.test.ts +193 -0
  35. package/tests/canonical-key-order.test.ts +71 -0
  36. package/tests/commands-impl.test.ts +442 -0
  37. package/tests/commands.test.ts +548 -0
  38. package/tests/config.test.ts +122 -0
  39. package/tests/dispose.test.ts +336 -0
  40. package/tests/event-stream.test.ts +409 -0
  41. package/tests/event.test.ts +262 -0
  42. package/tests/fingerprint.test.ts +161 -0
  43. package/tests/http-client.test.ts +403 -0
  44. package/tests/init-helpers.test.ts +203 -0
  45. package/tests/integration/slash-command.test.ts +348 -0
  46. package/tests/integration/tool-routing.test.ts +314 -0
  47. package/tests/loop.test.ts +397 -0
  48. package/tests/options.test.ts +274 -0
  49. package/tests/serve.test.ts +335 -0
  50. package/tests/settings.test.ts +351 -0
  51. package/tests/stall-think.test.ts +749 -0
  52. package/tests/state.test.ts +275 -0
  53. package/tests/tools/bg-collect.test.ts +337 -0
  54. package/tests/tools/bg-get-comments.test.ts +485 -0
  55. package/tests/tools/bg-kill.test.ts +231 -0
  56. package/tests/tools/bg-spawn.test.ts +311 -0
  57. package/tests/tools/bg-status.test.ts +216 -0
  58. package/tests/tools/plan-action.test.ts +599 -0
  59. package/tests/tools/wait-for-feedback.test.ts +390 -0
  60. package/tsconfig.json +29 -0
package/src/loop.ts ADDED
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Loop — the threshold decision tree.
3
+ *
4
+ * Spec requirements satisfied here:
5
+ * - §5.1 — algorithm. Count how many of the last `loopWindowSize` tool
6
+ * calls share the given fingerprint. Re-scan the window each call.
7
+ * - §5.4 — threshold table. The action returned by `decide()` corresponds
8
+ * to the highest threshold that the count meets or exceeds.
9
+ * - §5.5 — window vs counter semantics. The count is a re-scan of the
10
+ * window, not a running counter. The intermediate non-matching calls
11
+ * in the window do NOT "reset" anything; they just sit in the window
12
+ * and shift older matching entries out as the window rolls forward.
13
+ * - §7.2 — the `reason` field returned by `decide()` for warn/escalate/
14
+ * block actions is a static message from `handoff.ts` with only the
15
+ * tool name interpolated. No tool args, no LLM output, no agent-
16
+ * controlled content is ever included.
17
+ *
18
+ * The `now` parameter is part of the public signature for API consistency
19
+ * with the spec (§5.1 timing context) but is not consumed by the decision
20
+ * logic — callers use it to timestamp the current tool call when adding it
21
+ * to the state.
22
+ */
23
+
24
+ import type { SessionState } from "./state.js";
25
+ import type { NormalizedOptions } from "./options.js";
26
+ import {
27
+ warnMessage,
28
+ escalateMessage,
29
+ blockMessage,
30
+ } from "./handoff.js";
31
+
32
+ /**
33
+ * The decision returned by `decide()`. The `allow` variant carries no
34
+ * payload; the three action variants carry the canonical handoff message
35
+ * (via `reason`), the fingerprint that triggered the decision, and the
36
+ * repetition count in the window.
37
+ */
38
+ export type Decision =
39
+ | { action: "allow" }
40
+ | {
41
+ action: "warn" | "escalate" | "block";
42
+ reason: string;
43
+ fingerprint: string;
44
+ count: number;
45
+ };
46
+
47
+ /**
48
+ * Implicit "log a warning" threshold from spec §5.4 row 1. This is always
49
+ * two below `loopThresholdWarn` so the gap between log-only and inject-warn
50
+ * stays constant when the user reconfigures. We `max(1, …)` so a user who
51
+ * sets `loopThresholdWarn = 1` does not push the log-only threshold below 1.
52
+ */
53
+ function logOnlyThreshold(options: NormalizedOptions): number {
54
+ return Math.max(1, options.loopThresholdWarn - 2);
55
+ }
56
+
57
+ /**
58
+ * Look at the last `loopWindowSize` tool calls and return those that share
59
+ * the given fingerprint. The window is a slice of the most recent entries;
60
+ * older entries are ignored (spec §5.1 step 3).
61
+ */
62
+ function windowMatches(
63
+ state: SessionState,
64
+ fingerprint: string,
65
+ windowSize: number,
66
+ ): { calls: SessionState["toolCalls"]; count: number } {
67
+ if (state.toolCalls.length === 0 || windowSize <= 0) {
68
+ return { calls: [], count: 0 };
69
+ }
70
+ const start = Math.max(0, state.toolCalls.length - windowSize);
71
+ const window = state.toolCalls.slice(start);
72
+ const matches = window.filter((c) => c.fingerprint === fingerprint);
73
+ return { calls: matches, count: matches.length };
74
+ }
75
+
76
+ /**
77
+ * Recover the tool name from any call in `matches` that shares the
78
+ * fingerprint. The fingerprint is computed from `(tool, normalized args)`,
79
+ * so every call with this fingerprint has the same tool name. If the
80
+ * matches list is empty (should not happen when this is called — the
81
+ * caller only invokes us when `count >= 1`), fall back to `"unknown"`.
82
+ */
83
+ function toolNameOf(matches: SessionState["toolCalls"]): string {
84
+ const first = matches[0];
85
+ return first ? first.tool : "unknown";
86
+ }
87
+
88
+ /**
89
+ * Build a Decision payload for a non-allow action. Pulls the canonical
90
+ * handoff message for the given action and the tool name recovered from
91
+ * the matching window entries.
92
+ */
93
+ function payload(
94
+ action: "warn" | "escalate" | "block",
95
+ fingerprint: string,
96
+ count: number,
97
+ matches: SessionState["toolCalls"],
98
+ ): Decision {
99
+ const tool = toolNameOf(matches);
100
+ const reason =
101
+ action === "block"
102
+ ? blockMessage(tool)
103
+ : action === "escalate"
104
+ ? escalateMessage(tool)
105
+ : warnMessage(tool);
106
+ return { action, reason, fingerprint, count };
107
+ }
108
+
109
+ /**
110
+ * Decide what action to take for the current tool call.
111
+ *
112
+ * The function is pure: it does not mutate `state` and does not perform any
113
+ * I/O. The caller (the `tool.execute.before` hook in `index.ts`) is
114
+ * responsible for adding the current call to the state before invoking
115
+ * `decide()`, and for acting on the returned decision (log / inject / throw).
116
+ *
117
+ * @param state The current session state. Must already include the
118
+ * current tool call in `state.toolCalls` (the caller
119
+ * appends it before calling `decide()`).
120
+ * @param fingerprint The fingerprint of the current tool call.
121
+ * @param now Current epoch milliseconds. Part of the public signature
122
+ * for API consistency; not consumed by the decision logic.
123
+ * @param options Normalized plugin options.
124
+ */
125
+ export function decide(
126
+ state: SessionState,
127
+ fingerprint: string,
128
+ now: number,
129
+ options: NormalizedOptions,
130
+ ): Decision {
131
+ // Step 1: scan the window.
132
+ const { calls: matches, count } = windowMatches(
133
+ state,
134
+ fingerprint,
135
+ options.loopWindowSize,
136
+ );
137
+
138
+ // Step 2: apply thresholds from highest to lowest.
139
+ if (count >= options.loopThresholdBlock) {
140
+ return payload("block", fingerprint, count, matches);
141
+ }
142
+ if (count >= options.loopThresholdEscalate) {
143
+ return payload("escalate", fingerprint, count, matches);
144
+ }
145
+ if (count >= options.loopThresholdWarn) {
146
+ return payload("warn", fingerprint, count, matches);
147
+ }
148
+
149
+ // Step 3: log-only band (spec §5.4 row 1). Sits between `allow` and the
150
+ // inject-warn threshold. The caller distinguishes this case from
151
+ // inject-warn by inspecting `count` against `options.loopThresholdWarn`.
152
+ const logOnly = logOnlyThreshold(options);
153
+ if (count >= logOnly) {
154
+ return payload("warn", fingerprint, count, matches);
155
+ }
156
+
157
+ // `now` is accepted for API consistency; reference it so strict no-unused
158
+ // linters do not flag the parameter.
159
+ void now;
160
+
161
+ return { action: "allow" };
162
+ }
163
+
164
+ /**
165
+ * Convenience helper for the caller: given a `warn` decision, was this the
166
+ * log-only band (no injection) or the inject-warn band? The caller uses
167
+ * this to decide whether to write a `client.app.log` line or to set a
168
+ * pending system-transform injection.
169
+ */
170
+ export function isLogOnlyWarn(
171
+ decision: Decision,
172
+ options: NormalizedOptions,
173
+ ): boolean {
174
+ if (decision.action !== "warn") return false;
175
+ return decision.count < options.loopThresholdWarn;
176
+ }
package/src/options.ts ADDED
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Options — plugin option validation, clamping, and secret-dir refusal.
3
+ *
4
+ * Spec requirements satisfied here:
5
+ * - §6.1 — plugin option shape (defaults listed below).
6
+ * - §6.2 — clamping rules. The plugin NEVER throws on bad config.
7
+ * - §6.3 — threshold/window constraint (`loopThresholdBlock <= loopWindowSize + 2`).
8
+ * - §6.4 — secret-directory refusal. The plugin refuses to start if `logDir`
9
+ * or `stateDir` resolves inside `~/.ssh/`, `~/.gnupg/`, `~/.aws/`, or
10
+ * `~/.kube/`. Matching uses the spec's exact algorithm: `path.resolve`
11
+ * on both sides, then test equality OR `startsWith(resolved + path.sep)`.
12
+ * The `+ path.sep` is critical — without it, `~/.ssh-foo` would falsely
13
+ * match the `~/.ssh` prefix.
14
+ * - §6.5 — environment-variable overrides (`BIZAR_DISABLE`,
15
+ * `BIZAR_DISABLE_LOOP`, `BIZAR_DISABLE_LOG`, `BIZAR_LOG_LEVEL`,
16
+ * `BIZAR_SERVE_PORT`, `BIZAR_MAX_CONCURRENT_INSTANCES`,
17
+ * `BIZAR_SERVE_DISABLE`, `BIZAR_BACKGROUND_TOOL_CALL_CAP`,
18
+ * `BIZAR_BACKGROUND_SKIP_PERMISSIONS`,
19
+ * `BIZAR_STALL_TIMEOUT_MS`, `BIZAR_THINKING_LOOP_TIMEOUT_MS`,
20
+ * `BIZAR_MAX_INTERVENTIONS`). Env vars are read once at init;
21
+ * mid-session changes are ignored.
22
+ */
23
+
24
+ import path from "node:path";
25
+ import os from "node:os";
26
+
27
+ /** Raw plugin options as they appear in `opencode.json` (before clamping). */
28
+ export interface RawOptions {
29
+ loopThresholdWarn?: unknown;
30
+ loopThresholdEscalate?: unknown;
31
+ loopThresholdBlock?: unknown;
32
+ loopWindowSize?: unknown;
33
+ logDir?: unknown;
34
+ stateDir?: unknown;
35
+ logRotationBytes?: unknown;
36
+ servePort?: unknown;
37
+ maxConcurrentInstances?: unknown;
38
+ serveDisabled?: unknown;
39
+ backgroundToolCallCap?: unknown;
40
+ backgroundSkipPermissions?: unknown;
41
+ httpTimeoutMs?: unknown;
42
+ // v0.3.0 — stall and thinking-loop protection
43
+ backgroundStallTimeoutMs?: unknown;
44
+ backgroundThinkingLoopTimeoutMs?: unknown;
45
+ backgroundMaxInterventions?: unknown;
46
+ }
47
+
48
+ /**
49
+ * Normalized options — the output of `normalizeOptions`. Every field has a
50
+ * safe, valid value. The loop guard and logger consume this shape.
51
+ */
52
+ export interface NormalizedOptions {
53
+ loopThresholdWarn: number;
54
+ loopThresholdEscalate: number;
55
+ loopThresholdBlock: number;
56
+ loopWindowSize: number;
57
+ logDir: string;
58
+ stateDir: string;
59
+ logRotationBytes: number;
60
+ servePort: number;
61
+ maxConcurrentInstances: number;
62
+ serveDisabled: boolean;
63
+ backgroundToolCallCap: number;
64
+ backgroundSkipPermissions: boolean;
65
+ httpTimeoutMs: number;
66
+ // v0.3.0 — stall and thinking-loop protection
67
+ /** ms without any SSE event before a non-terminal instance is aborted as stalled (default 180000 = 3 min, range [10000, 600000]). */
68
+ backgroundStallTimeoutMs: number;
69
+ /** ms without any tool/text part before a `running` instance is flagged as a thinking loop (default 300000 = 5 min, range [30000, 900000]). */
70
+ backgroundThinkingLoopTimeoutMs: number;
71
+ /** max number of research interventions sent before forcing an abort (default 1, range [1, 3]). */
72
+ backgroundMaxInterventions: number;
73
+ }
74
+
75
+ /** Environment-variable override flags. */
76
+ export interface EnvFlags {
77
+ disable: boolean;
78
+ disableLoop: boolean;
79
+ disableLog: boolean;
80
+ }
81
+
82
+ /** Default values per spec §6.1 / §8. */
83
+ export const DEFAULT_OPTIONS: NormalizedOptions = {
84
+ loopThresholdWarn: 5,
85
+ loopThresholdEscalate: 8,
86
+ loopThresholdBlock: 12,
87
+ loopWindowSize: 10,
88
+ logDir: "~/.cache/bizar/logs",
89
+ stateDir: "~/.cache/bizar",
90
+ logRotationBytes: 10_485_760, // 10 MB
91
+ servePort: 0, // 0 = random OS-assigned port (§1.1)
92
+ maxConcurrentInstances: 8, // §8
93
+ serveDisabled: false, // §8
94
+ backgroundToolCallCap: 500, // §8 / §6.2
95
+ backgroundSkipPermissions: false, // §8 / §6.4
96
+ httpTimeoutMs: 30_000, // §2.3 / §8
97
+ // v0.3.0 — stall and thinking-loop protection
98
+ backgroundStallTimeoutMs: 180_000, // 3 min
99
+ backgroundThinkingLoopTimeoutMs: 300_000, // 5 min
100
+ backgroundMaxInterventions: 1,
101
+ };
102
+
103
+ const SECRET_DIRS: readonly string[] = [
104
+ "~/.ssh",
105
+ "~/.gnupg",
106
+ "~/.aws",
107
+ "~/.kube",
108
+ ] as const;
109
+
110
+ /**
111
+ * Expand a leading `~` to the user's home directory. The spec's default
112
+ * paths use `~/.cache/bizar`; we honor that.
113
+ */
114
+ export function expandHome(p: string): string {
115
+ if (p === "~") return os.homedir();
116
+ if (p.startsWith("~/") || p.startsWith("~\\")) {
117
+ return path.join(os.homedir(), p.slice(2));
118
+ }
119
+ return p;
120
+ }
121
+
122
+ /**
123
+ * Spec §6.4 matching algorithm. Returns the secret-dir kind that `target`
124
+ * matches (e.g. `~/.ssh`), or `null` if `target` is safe.
125
+ *
126
+ * resolvedTarget === resolvedSecret → equal
127
+ * resolvedTarget.startsWith(resolvedSecret + sep) → strict descendant
128
+ */
129
+ export function findSecretDirMatch(target: string): string | null {
130
+ const resolvedTarget = path.resolve(expandHome(target));
131
+ for (const secret of SECRET_DIRS) {
132
+ const resolvedSecret = path.resolve(expandHome(secret));
133
+ if (resolvedTarget === resolvedSecret) return secret;
134
+ if (resolvedTarget.startsWith(resolvedSecret + path.sep)) return secret;
135
+ }
136
+ return null;
137
+ }
138
+
139
+ /** Coerce an unknown value to a finite integer, or return `undefined`. */
140
+ function toFiniteInt(value: unknown): number | undefined {
141
+ if (typeof value === "number" && Number.isFinite(value)) {
142
+ return Math.floor(value);
143
+ }
144
+ if (typeof value === "string" && value.trim() !== "") {
145
+ const n = Number(value);
146
+ if (Number.isFinite(n)) return Math.floor(n);
147
+ }
148
+ return undefined;
149
+ }
150
+
151
+ /** Coerce to a string path; return `undefined` if the value is not a string. */
152
+ function toStringPath(value: unknown): string | undefined {
153
+ if (typeof value === "string" && value !== "") return value;
154
+ return undefined;
155
+ }
156
+
157
+ /**
158
+ * Apply the clamping rules from spec §6.2. Returns the normalized options
159
+ * and a list of "clamp notes" — human-readable strings describing each
160
+ * non-default normalization applied. The caller is expected to log these
161
+ * via `client.app.log` at warn level (spec §6.2: "After clamping, log a
162
+ * `client.app.log` warning if any non-default normalization was applied").
163
+ */
164
+ export function normalizeOptions(raw: RawOptions | undefined): {
165
+ options: NormalizedOptions;
166
+ notes: string[];
167
+ } {
168
+ const notes: string[] = [];
169
+ const r = raw ?? {};
170
+
171
+ // --- Numeric thresholds (§6.2) ---
172
+ const rawWarn = toFiniteInt(r.loopThresholdWarn);
173
+ const rawEscalate = toFiniteInt(r.loopThresholdEscalate);
174
+ const rawBlock = toFiniteInt(r.loopThresholdBlock);
175
+
176
+ let warn = rawWarn !== undefined ? Math.max(1, rawWarn) : DEFAULT_OPTIONS.loopThresholdWarn;
177
+ let escalate = rawEscalate !== undefined ? Math.max(1, rawEscalate) : DEFAULT_OPTIONS.loopThresholdEscalate;
178
+ let block = rawBlock !== undefined ? Math.max(1, rawBlock) : DEFAULT_OPTIONS.loopThresholdBlock;
179
+
180
+ if (rawWarn !== undefined && rawWarn < 1) {
181
+ notes.push(`loopThresholdWarn ${rawWarn} clamped to 1`);
182
+ } else if (rawWarn === undefined) {
183
+ notes.push(`loopThresholdWarn defaulted to ${warn}`);
184
+ }
185
+
186
+ // --- Ordering: warn < escalate < block (§6.2) ---
187
+ if (warn >= escalate) {
188
+ const newEscalate = warn + 1;
189
+ notes.push(`loopThresholdEscalate adjusted from ${escalate} to ${newEscalate} (must be > loopThresholdWarn)`);
190
+ escalate = newEscalate;
191
+ }
192
+ if (escalate >= block) {
193
+ const newBlock = escalate + 1;
194
+ notes.push(`loopThresholdBlock adjusted from ${block} to ${newBlock} (must be > loopThresholdEscalate)`);
195
+ block = newBlock;
196
+ }
197
+
198
+ // --- Window size (§6.2: clamped to [3, 50]) ---
199
+ const rawWindow = toFiniteInt(r.loopWindowSize);
200
+ let window: number;
201
+ if (rawWindow === undefined) {
202
+ window = DEFAULT_OPTIONS.loopWindowSize;
203
+ } else if (rawWindow < 3) {
204
+ window = 3;
205
+ notes.push(`loopWindowSize ${rawWindow} clamped to 3 (minimum)`);
206
+ } else if (rawWindow > 50) {
207
+ window = 50;
208
+ notes.push(`loopWindowSize ${rawWindow} clamped to 50 (maximum)`);
209
+ } else {
210
+ window = rawWindow;
211
+ }
212
+
213
+ // --- Threshold/window constraint (§6.3) ---
214
+ if (block > window + 2) {
215
+ const newBlock = window + 2;
216
+ notes.push(
217
+ `loopThresholdBlock ${block} adjusted to ${newBlock} (must be <= loopWindowSize + 2)`,
218
+ );
219
+ block = newBlock;
220
+ }
221
+
222
+ // --- logRotationBytes (§6.2: minimum 1024) ---
223
+ const rawBytes = toFiniteInt(r.logRotationBytes);
224
+ let bytes: number;
225
+ if (rawBytes === undefined) {
226
+ bytes = DEFAULT_OPTIONS.logRotationBytes;
227
+ } else if (rawBytes < 1024) {
228
+ bytes = 1024;
229
+ notes.push(`logRotationBytes ${rawBytes} clamped to 1024 (minimum)`);
230
+ } else {
231
+ bytes = rawBytes;
232
+ }
233
+
234
+ // --- Background agent options (§6.5 / §8) ---
235
+ // servePort: default 0 (random); env BIZAR_SERVE_PORT overrides
236
+ const rawServePort = toFiniteInt(r.servePort);
237
+ const envServePort = toFiniteInt(process.env.BIZAR_SERVE_PORT);
238
+ const servePort = rawServePort !== undefined
239
+ ? (rawServePort < 0 ? (notes.push(`servePort ${rawServePort} clamped to 0 (minimum)`), 0) : rawServePort)
240
+ : envServePort !== undefined
241
+ ? envServePort
242
+ : DEFAULT_OPTIONS.servePort;
243
+
244
+ // maxConcurrentInstances: default 8, max 32 (§6.5)
245
+ const rawMaxInstances = toFiniteInt(r.maxConcurrentInstances);
246
+ const envMaxInstances = toFiniteInt(process.env.BIZAR_MAX_CONCURRENT_INSTANCES);
247
+ let maxConcurrentInstances: number;
248
+ if (rawMaxInstances !== undefined) {
249
+ maxConcurrentInstances = Math.min(Math.max(1, rawMaxInstances), 32);
250
+ if (rawMaxInstances !== maxConcurrentInstances) {
251
+ notes.push(`maxConcurrentInstances ${rawMaxInstances} clamped to ${maxConcurrentInstances} (range [1, 32])`);
252
+ }
253
+ } else if (envMaxInstances !== undefined) {
254
+ maxConcurrentInstances = Math.min(Math.max(1, envMaxInstances), 32);
255
+ if (envMaxInstances !== maxConcurrentInstances) {
256
+ notes.push(`maxConcurrentInstances ${envMaxInstances} (env) clamped to ${maxConcurrentInstances} (range [1, 32])`);
257
+ }
258
+ } else {
259
+ maxConcurrentInstances = DEFAULT_OPTIONS.maxConcurrentInstances;
260
+ }
261
+
262
+ // serveDisabled: default false; env BIZAR_SERVE_DISABLE=1 sets true (§8)
263
+ const serveDisabled = process.env.BIZAR_SERVE_DISABLE === "1"
264
+ ? true
265
+ : (r.serveDisabled === true || r.serveDisabled === "true");
266
+
267
+ // backgroundToolCallCap: default 500, max 5000 (§6.5)
268
+ const rawToolCap = toFiniteInt(r.backgroundToolCallCap);
269
+ const envToolCap = toFiniteInt(process.env.BIZAR_BACKGROUND_TOOL_CALL_CAP);
270
+ let backgroundToolCallCap: number;
271
+ if (rawToolCap !== undefined) {
272
+ backgroundToolCallCap = Math.min(Math.max(1, rawToolCap), 5000);
273
+ if (rawToolCap !== backgroundToolCallCap) {
274
+ notes.push(`backgroundToolCallCap ${rawToolCap} clamped to ${backgroundToolCallCap} (range [1, 5000])`);
275
+ }
276
+ } else if (envToolCap !== undefined) {
277
+ backgroundToolCallCap = Math.min(Math.max(1, envToolCap), 5000);
278
+ if (envToolCap !== backgroundToolCallCap) {
279
+ notes.push(`backgroundToolCallCap ${envToolCap} (env) clamped to ${backgroundToolCallCap} (range [1, 5000])`);
280
+ }
281
+ } else {
282
+ backgroundToolCallCap = DEFAULT_OPTIONS.backgroundToolCallCap;
283
+ }
284
+
285
+ // backgroundSkipPermissions: default false; env BIZAR_BACKGROUND_SKIP_PERMISSIONS=1 sets true (§6.4)
286
+ const backgroundSkipPermissions = process.env.BIZAR_BACKGROUND_SKIP_PERMISSIONS === "1"
287
+ ? true
288
+ : (r.backgroundSkipPermissions === true || r.backgroundSkipPermissions === "true");
289
+
290
+ // httpTimeoutMs: default 30000, range [5000, 120000] (§2.3)
291
+ const rawHttpTimeout = toFiniteInt(r.httpTimeoutMs);
292
+ const envHttpTimeout = toFiniteInt(process.env.BIZAR_HTTP_TIMEOUT_MS);
293
+ let httpTimeoutMs: number;
294
+ if (rawHttpTimeout !== undefined) {
295
+ httpTimeoutMs = Math.min(Math.max(5000, rawHttpTimeout), 120_000);
296
+ if (rawHttpTimeout !== httpTimeoutMs) {
297
+ notes.push(`httpTimeoutMs ${rawHttpTimeout} clamped to ${httpTimeoutMs} (range [5000, 120000])`);
298
+ }
299
+ } else if (envHttpTimeout !== undefined) {
300
+ httpTimeoutMs = Math.min(Math.max(5000, envHttpTimeout), 120_000);
301
+ if (envHttpTimeout !== httpTimeoutMs) {
302
+ notes.push(`httpTimeoutMs ${envHttpTimeout} (env) clamped to ${httpTimeoutMs} (range [5000, 120000])`);
303
+ }
304
+ } else {
305
+ httpTimeoutMs = DEFAULT_OPTIONS.httpTimeoutMs;
306
+ }
307
+
308
+ // --- v0.3.0 stall and thinking-loop protection -------------------------
309
+
310
+ // backgroundStallTimeoutMs: default 180000, range [10000, 600000] (10s..10min)
311
+ const rawStallTimeout = toFiniteInt(r.backgroundStallTimeoutMs);
312
+ const envStallTimeout = toFiniteInt(process.env.BIZAR_STALL_TIMEOUT_MS);
313
+ let backgroundStallTimeoutMs: number;
314
+ if (rawStallTimeout !== undefined) {
315
+ backgroundStallTimeoutMs = Math.min(Math.max(10_000, rawStallTimeout), 600_000);
316
+ if (rawStallTimeout !== backgroundStallTimeoutMs) {
317
+ notes.push(`backgroundStallTimeoutMs ${rawStallTimeout} clamped to ${backgroundStallTimeoutMs} (range [10000, 600000])`);
318
+ }
319
+ } else if (envStallTimeout !== undefined) {
320
+ backgroundStallTimeoutMs = Math.min(Math.max(10_000, envStallTimeout), 600_000);
321
+ if (envStallTimeout !== backgroundStallTimeoutMs) {
322
+ notes.push(`backgroundStallTimeoutMs ${envStallTimeout} (env) clamped to ${backgroundStallTimeoutMs} (range [10000, 600000])`);
323
+ }
324
+ } else {
325
+ backgroundStallTimeoutMs = DEFAULT_OPTIONS.backgroundStallTimeoutMs;
326
+ }
327
+
328
+ // backgroundThinkingLoopTimeoutMs: default 300000, range [30000, 900000] (30s..15min)
329
+ const rawThinkingTimeout = toFiniteInt(r.backgroundThinkingLoopTimeoutMs);
330
+ const envThinkingTimeout = toFiniteInt(process.env.BIZAR_THINKING_LOOP_TIMEOUT_MS);
331
+ let backgroundThinkingLoopTimeoutMs: number;
332
+ if (rawThinkingTimeout !== undefined) {
333
+ backgroundThinkingLoopTimeoutMs = Math.min(Math.max(30_000, rawThinkingTimeout), 900_000);
334
+ if (rawThinkingTimeout !== backgroundThinkingLoopTimeoutMs) {
335
+ notes.push(`backgroundThinkingLoopTimeoutMs ${rawThinkingTimeout} clamped to ${backgroundThinkingLoopTimeoutMs} (range [30000, 900000])`);
336
+ }
337
+ } else if (envThinkingTimeout !== undefined) {
338
+ backgroundThinkingLoopTimeoutMs = Math.min(Math.max(30_000, envThinkingTimeout), 900_000);
339
+ if (envThinkingTimeout !== backgroundThinkingLoopTimeoutMs) {
340
+ notes.push(`backgroundThinkingLoopTimeoutMs ${envThinkingTimeout} (env) clamped to ${backgroundThinkingLoopTimeoutMs} (range [30000, 900000])`);
341
+ }
342
+ } else {
343
+ backgroundThinkingLoopTimeoutMs = DEFAULT_OPTIONS.backgroundThinkingLoopTimeoutMs;
344
+ }
345
+
346
+ // backgroundMaxInterventions: default 1, range [1, 3]
347
+ const rawMaxInterventions = toFiniteInt(r.backgroundMaxInterventions);
348
+ const envMaxInterventions = toFiniteInt(process.env.BIZAR_MAX_INTERVENTIONS);
349
+ let backgroundMaxInterventions: number;
350
+ if (rawMaxInterventions !== undefined) {
351
+ backgroundMaxInterventions = Math.min(Math.max(1, rawMaxInterventions), 3);
352
+ if (rawMaxInterventions !== backgroundMaxInterventions) {
353
+ notes.push(`backgroundMaxInterventions ${rawMaxInterventions} clamped to ${backgroundMaxInterventions} (range [1, 3])`);
354
+ }
355
+ } else if (envMaxInterventions !== undefined) {
356
+ backgroundMaxInterventions = Math.min(Math.max(1, envMaxInterventions), 3);
357
+ if (envMaxInterventions !== backgroundMaxInterventions) {
358
+ notes.push(`backgroundMaxInterventions ${envMaxInterventions} (env) clamped to ${backgroundMaxInterventions} (range [1, 3])`);
359
+ }
360
+ } else {
361
+ backgroundMaxInterventions = DEFAULT_OPTIONS.backgroundMaxInterventions;
362
+ }
363
+
364
+ // --- Paths (§6.1 defaults) ---
365
+ const logDir = toStringPath(r.logDir) ?? DEFAULT_OPTIONS.logDir;
366
+ const stateDir = toStringPath(r.stateDir) ?? DEFAULT_OPTIONS.stateDir;
367
+
368
+ return {
369
+ options: {
370
+ loopThresholdWarn: warn,
371
+ loopThresholdEscalate: escalate,
372
+ loopThresholdBlock: block,
373
+ loopWindowSize: window,
374
+ logDir,
375
+ stateDir,
376
+ logRotationBytes: bytes,
377
+ servePort,
378
+ maxConcurrentInstances,
379
+ serveDisabled,
380
+ backgroundToolCallCap,
381
+ backgroundSkipPermissions,
382
+ httpTimeoutMs,
383
+ backgroundStallTimeoutMs,
384
+ backgroundThinkingLoopTimeoutMs,
385
+ backgroundMaxInterventions,
386
+ },
387
+ notes,
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Read environment-variable override flags per spec §6.5. Read once at
393
+ * plugin init. Mid-session changes are ignored.
394
+ */
395
+ export function readEnvFlags(): EnvFlags {
396
+ return {
397
+ disable: process.env.BIZAR_DISABLE === "1",
398
+ disableLoop: process.env.BIZAR_DISABLE_LOOP === "1",
399
+ disableLog: process.env.BIZAR_DISABLE_LOG === "1",
400
+ };
401
+ }
402
+
403
+ /**
404
+ * Spec §6.4: refuse to start if `logDir` or `stateDir` is inside a secret
405
+ * directory. Returns the offending path and the matched secret kind, or
406
+ * `null` if both paths are safe.
407
+ */
408
+ export function findOffendingPath(options: NormalizedOptions): {
409
+ path: string;
410
+ kind: string;
411
+ } | null {
412
+ const logMatch = findSecretDirMatch(options.logDir);
413
+ if (logMatch !== null) {
414
+ return { path: path.resolve(expandHome(options.logDir)), kind: logMatch };
415
+ }
416
+ const stateMatch = findSecretDirMatch(options.stateDir);
417
+ if (stateMatch !== null) {
418
+ return { path: path.resolve(expandHome(options.stateDir)), kind: stateMatch };
419
+ }
420
+ return null;
421
+ }