@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
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* event-stream.ts
|
|
3
|
+
*
|
|
4
|
+
* Global SSE subscription for opencode serve events (v0.4.2 spec §2.1, §4).
|
|
5
|
+
*
|
|
6
|
+
* Design contract:
|
|
7
|
+
* - ONE `GET /event?directory=<worktree>` connection per plugin process.
|
|
8
|
+
* - Events are filtered in-memory by `sessionID` and dispatched to
|
|
9
|
+
* per-session handlers registered via `onSessionEvent`.
|
|
10
|
+
* - The connection auto-reconnects with exponential backoff
|
|
11
|
+
* (1s, 2s, 4s, 8s, 16s, capped at 30s) on unexpected drops.
|
|
12
|
+
* - During the gap between drop and reconnect, in-flight instances are
|
|
13
|
+
* NOT marked failed (they may still complete once we reconnect).
|
|
14
|
+
* - `disconnect()` closes the stream and prevents further reconnects.
|
|
15
|
+
*
|
|
16
|
+
* v0.4.3 — CloudEvents-style schema support (see
|
|
17
|
+
* `.bizar/opencode-sse-investigation.md`):
|
|
18
|
+
* - The actual event schema on opencode serve 1.17.7 has two flavors:
|
|
19
|
+
*
|
|
20
|
+
* 1) **Direct events** (flat JSON, native `type` field):
|
|
21
|
+
* ```
|
|
22
|
+
* { id, type: "session.idle", properties: { sessionID } }
|
|
23
|
+
* { id, type: "message.part.delta", properties: { sessionID, ... } }
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* 2) **Sync events** (CloudEvents-style, wrapped):
|
|
27
|
+
* ```
|
|
28
|
+
* { type: "sync", syncEvent: {
|
|
29
|
+
* type: "session.created.1", // `.1` version suffix
|
|
30
|
+
* id, seq, aggregateID,
|
|
31
|
+
* data: { sessionID, info }
|
|
32
|
+
* }}
|
|
33
|
+
* ```
|
|
34
|
+
* Sync events are the primary delivery mechanism for session and
|
|
35
|
+
* message lifecycle events. Event names like `session.created.1`,
|
|
36
|
+
* `message.part.updated.1` carry a numeric version suffix.
|
|
37
|
+
*
|
|
38
|
+
* - This module unwraps sync events and strips the version suffix so
|
|
39
|
+
* downstream consumers (InstanceManager) see the same logical
|
|
40
|
+
* `StreamEvent` shape regardless of which wire format arrived.
|
|
41
|
+
*
|
|
42
|
+
* This module is a pure transport. The `InstanceManager` registers a
|
|
43
|
+
* handler per instance that does the actual state work (tool counting,
|
|
44
|
+
* loop-guard detection, status transitions, awaiting collect).
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
import type { HttpClient, HttpResult } from "./http-client.js";
|
|
48
|
+
|
|
49
|
+
// --- Logger interface -----------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Minimal Logger interface — matches the shape in `state.ts` / `logger.ts`.
|
|
53
|
+
*/
|
|
54
|
+
export interface Logger {
|
|
55
|
+
log(opts: { level: "debug" | "info" | "warn" | "error"; message: string }): void;
|
|
56
|
+
debug(message: string): void;
|
|
57
|
+
info(message: string): void;
|
|
58
|
+
warn(message: string): void;
|
|
59
|
+
error(message: string): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// --- Public event types ---------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The subset of session lifecycle events the plugin cares about. Each
|
|
66
|
+
* variant carries a `sessionID` plus the event-specific payload.
|
|
67
|
+
*
|
|
68
|
+
* The `type` field follows opencode's event namespacing
|
|
69
|
+
* (`session.created`, `session.idle`, `message.part.updated`, …).
|
|
70
|
+
*
|
|
71
|
+
* Note: there is no catch-all variant on purpose. The catch-all would
|
|
72
|
+
* have `type: string` which would defeat type narrowing on
|
|
73
|
+
* `ev.type === "session.idle"` checks. Untyped events are dropped
|
|
74
|
+
* silently in `dispatchEvent`.
|
|
75
|
+
*/
|
|
76
|
+
export type StreamEvent =
|
|
77
|
+
| { type: "session.created"; sessionID: string; raw: unknown }
|
|
78
|
+
| { type: "session.updated"; sessionID: string; raw: unknown }
|
|
79
|
+
| { type: "session.deleted"; sessionID: string; raw: unknown }
|
|
80
|
+
| { type: "session.idle"; sessionID: string; raw: unknown }
|
|
81
|
+
| { type: "session.error"; sessionID: string; error?: string; raw: unknown }
|
|
82
|
+
| { type: "message.updated"; sessionID: string; messageID: string; raw: unknown }
|
|
83
|
+
| {
|
|
84
|
+
type: "message.part.updated";
|
|
85
|
+
sessionID: string;
|
|
86
|
+
messageID: string;
|
|
87
|
+
partID: string;
|
|
88
|
+
part: { type: string; text?: string; error?: string; state?: { status?: string; error?: string } };
|
|
89
|
+
raw: unknown;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A per-session event handler. Called for every event whose `sessionID`
|
|
94
|
+
* matches the registration. Handlers are called synchronously; long
|
|
95
|
+
* work should be scheduled (await / setImmediate) inside the handler.
|
|
96
|
+
*/
|
|
97
|
+
export type SessionEventHandler = (event: StreamEvent) => void;
|
|
98
|
+
|
|
99
|
+
// --- Stream class ---------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Owns the single global SSE connection. The plugin constructs one
|
|
103
|
+
* instance at init time and disposes it on plugin shutdown.
|
|
104
|
+
*
|
|
105
|
+
* Public surface (interface contract for Thor's tests):
|
|
106
|
+
* - `connect()` — open the SSE stream. Idempotent (no-op if already connected).
|
|
107
|
+
* - `disconnect()` — close the stream; stop reconnecting.
|
|
108
|
+
* - `onSessionEvent(sessionId, handler)` — register a per-session handler.
|
|
109
|
+
* Returns an unsubscribe function.
|
|
110
|
+
*
|
|
111
|
+
* Internal lifecycle (not part of the contract):
|
|
112
|
+
* - `readLoop()` runs until disconnect; on error, schedules a reconnect.
|
|
113
|
+
* - `parseSseChunk()` splits a raw chunk into individual events.
|
|
114
|
+
* - `dispatch()` routes each event to handlers whose `sessionID` matches.
|
|
115
|
+
*/
|
|
116
|
+
export class EventStream {
|
|
117
|
+
private _baseUrl: string;
|
|
118
|
+
private _directory: string;
|
|
119
|
+
private _authHeader: string;
|
|
120
|
+
private _logger: Logger;
|
|
121
|
+
private _http: HttpClient;
|
|
122
|
+
private _handlers = new Map<string, Set<SessionEventHandler>>();
|
|
123
|
+
private _connected = false;
|
|
124
|
+
private _aborted = false;
|
|
125
|
+
private _abortController: AbortController | null = null;
|
|
126
|
+
private _reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
|
127
|
+
private _reconnectAttempt = 0;
|
|
128
|
+
private _reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
129
|
+
private _connectPromise: Promise<void> | null = null;
|
|
130
|
+
|
|
131
|
+
constructor(opts: {
|
|
132
|
+
baseUrl: string;
|
|
133
|
+
directory: string;
|
|
134
|
+
authHeader: string;
|
|
135
|
+
logger: Logger;
|
|
136
|
+
http: HttpClient;
|
|
137
|
+
}) {
|
|
138
|
+
this._baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
139
|
+
this._directory = opts.directory;
|
|
140
|
+
this._authHeader = opts.authHeader;
|
|
141
|
+
this._logger = opts.logger;
|
|
142
|
+
this._http = opts.http;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- Getters ------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
get connected(): boolean {
|
|
148
|
+
return this._connected;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
get directory(): string {
|
|
152
|
+
return this._directory;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- Lifecycle ----------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Open the SSE connection. Idempotent: a second call while a connection
|
|
159
|
+
* is in progress is a no-op that returns the in-flight promise.
|
|
160
|
+
*
|
|
161
|
+
* The first read block-waits up to 5s for the initial event so the
|
|
162
|
+
* caller can confirm the connection is alive (spec §2.1).
|
|
163
|
+
*/
|
|
164
|
+
async connect(): Promise<void> {
|
|
165
|
+
if (this._aborted) {
|
|
166
|
+
throw new Error("EventStream: cannot connect after disconnect()");
|
|
167
|
+
}
|
|
168
|
+
if (this._connectPromise !== null) {
|
|
169
|
+
return this._connectPromise;
|
|
170
|
+
}
|
|
171
|
+
this._connectPromise = this.openConnection();
|
|
172
|
+
try {
|
|
173
|
+
await this._connectPromise;
|
|
174
|
+
} finally {
|
|
175
|
+
this._connectPromise = null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Close the stream and stop further reconnects. Idempotent.
|
|
181
|
+
*/
|
|
182
|
+
async disconnect(): Promise<void> {
|
|
183
|
+
this._aborted = true;
|
|
184
|
+
if (this._reconnectTimer !== null) {
|
|
185
|
+
clearTimeout(this._reconnectTimer);
|
|
186
|
+
this._reconnectTimer = null;
|
|
187
|
+
}
|
|
188
|
+
if (this._abortController !== null) {
|
|
189
|
+
try {
|
|
190
|
+
this._abortController.abort();
|
|
191
|
+
} catch {
|
|
192
|
+
// ignore
|
|
193
|
+
}
|
|
194
|
+
this._abortController = null;
|
|
195
|
+
}
|
|
196
|
+
if (this._reader !== null) {
|
|
197
|
+
try {
|
|
198
|
+
await this._reader.cancel();
|
|
199
|
+
} catch {
|
|
200
|
+
// ignore
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
this._reader.releaseLock();
|
|
204
|
+
} catch {
|
|
205
|
+
// ignore
|
|
206
|
+
}
|
|
207
|
+
this._reader = null;
|
|
208
|
+
}
|
|
209
|
+
this._connected = false;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Register a handler for events of one session. Returns an unsubscribe
|
|
214
|
+
* function. Multiple handlers per session are allowed.
|
|
215
|
+
*/
|
|
216
|
+
onSessionEvent(sessionId: string, handler: SessionEventHandler): () => void {
|
|
217
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) {
|
|
218
|
+
throw new Error("EventStream.onSessionEvent: sessionId must be non-empty");
|
|
219
|
+
}
|
|
220
|
+
let set = this._handlers.get(sessionId);
|
|
221
|
+
if (!set) {
|
|
222
|
+
set = new Set();
|
|
223
|
+
this._handlers.set(sessionId, set);
|
|
224
|
+
}
|
|
225
|
+
set.add(handler);
|
|
226
|
+
return () => {
|
|
227
|
+
const s = this._handlers.get(sessionId);
|
|
228
|
+
if (!s) return;
|
|
229
|
+
s.delete(handler);
|
|
230
|
+
if (s.size === 0) this._handlers.delete(sessionId);
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// --- Internal: connection lifecycle ------------------------------------
|
|
235
|
+
|
|
236
|
+
private async openConnection(): Promise<void> {
|
|
237
|
+
this._abortController = new AbortController();
|
|
238
|
+
const result: HttpResult<ReadableStream> = await this._http.fetchEventStream(
|
|
239
|
+
this._directory,
|
|
240
|
+
this._abortController.signal,
|
|
241
|
+
);
|
|
242
|
+
if (!result.ok) {
|
|
243
|
+
this._connected = false;
|
|
244
|
+
throw new Error(`EventStream: failed to open SSE: ${result.error}`);
|
|
245
|
+
}
|
|
246
|
+
this._connected = true;
|
|
247
|
+
this._reconnectAttempt = 0;
|
|
248
|
+
this._logger.info(
|
|
249
|
+
`bizar: SSE event stream open (directory=${this._directory})`,
|
|
250
|
+
);
|
|
251
|
+
// Start the read loop in the background.
|
|
252
|
+
void this.readLoop(result.value);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Read the SSE stream chunk by chunk, parse events, and dispatch them.
|
|
257
|
+
* On error, schedule a reconnect (unless we're aborted).
|
|
258
|
+
*/
|
|
259
|
+
private async readLoop(stream: ReadableStream<Uint8Array>): Promise<void> {
|
|
260
|
+
const reader = stream.getReader() as ReadableStreamDefaultReader<Uint8Array>;
|
|
261
|
+
this._reader = reader;
|
|
262
|
+
const decoder = new TextDecoder("utf-8");
|
|
263
|
+
let buffer = "";
|
|
264
|
+
try {
|
|
265
|
+
while (!this._aborted) {
|
|
266
|
+
const { done, value } = await reader.read();
|
|
267
|
+
if (done) {
|
|
268
|
+
// Stream closed cleanly. This is unexpected unless we initiated it.
|
|
269
|
+
if (!this._aborted) {
|
|
270
|
+
this._logger.warn("bizar: SSE stream closed unexpectedly; will reconnect");
|
|
271
|
+
this.scheduleReconnect();
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
276
|
+
buffer += chunk;
|
|
277
|
+
// Process as many complete events as we have.
|
|
278
|
+
let sep: number;
|
|
279
|
+
// SSE events are separated by a blank line (\n\n or \r\n\r\n).
|
|
280
|
+
while ((sep = buffer.indexOf("\n\n")) >= 0 || (sep = buffer.indexOf("\r\n\r\n")) >= 0) {
|
|
281
|
+
const rawEvent = buffer.slice(0, sep);
|
|
282
|
+
buffer = buffer.slice(sep + (buffer[sep] === "\r" ? 4 : 2));
|
|
283
|
+
this.processSseBlock(rawEvent);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch (err: unknown) {
|
|
287
|
+
if (this._aborted) return;
|
|
288
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
289
|
+
this._logger.warn(`bizar: SSE read error: ${msg}; will reconnect`);
|
|
290
|
+
this.scheduleReconnect();
|
|
291
|
+
} finally {
|
|
292
|
+
this._connected = false;
|
|
293
|
+
try {
|
|
294
|
+
this._reader?.releaseLock();
|
|
295
|
+
} catch {
|
|
296
|
+
// ignore
|
|
297
|
+
}
|
|
298
|
+
this._reader = null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private processSseBlock(block: string): void {
|
|
303
|
+
if (block.trim() === "") return;
|
|
304
|
+
// An SSE block has lines like `event: <name>` and `data: <payload>`.
|
|
305
|
+
// Some servers only send `data:`. We treat unknown prefix lines as
|
|
306
|
+
// comments and skip them.
|
|
307
|
+
let eventName: string | null = null;
|
|
308
|
+
const dataLines: string[] = [];
|
|
309
|
+
for (const line of block.split(/\r?\n/)) {
|
|
310
|
+
if (line === "" || line.startsWith(":")) continue;
|
|
311
|
+
const colon = line.indexOf(":");
|
|
312
|
+
if (colon < 0) continue;
|
|
313
|
+
const field = line.slice(0, colon);
|
|
314
|
+
let value = line.slice(colon + 1);
|
|
315
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
316
|
+
if (field === "event") {
|
|
317
|
+
eventName = value;
|
|
318
|
+
} else if (field === "data") {
|
|
319
|
+
dataLines.push(value);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (dataLines.length === 0) return;
|
|
323
|
+
const data = dataLines.join("\n");
|
|
324
|
+
let parsed: unknown;
|
|
325
|
+
try {
|
|
326
|
+
parsed = JSON.parse(data);
|
|
327
|
+
} catch {
|
|
328
|
+
this._logger.debug(`bizar: SSE: dropping non-JSON data (${data.slice(0, 80)}…)`);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
this.dispatchEvent(eventName, parsed);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Map a raw opencode event to a `StreamEvent` and dispatch to handlers.
|
|
336
|
+
*
|
|
337
|
+
* v0.4.3: handles two wire formats.
|
|
338
|
+
* 1) Direct events — `{type, properties: {sessionID, ...}}`.
|
|
339
|
+
* 2) Sync events — `{type: "sync", syncEvent: {type: "x.y.1", data: {...}}}`.
|
|
340
|
+
* We unwrap the sync wrapper and set `obj` to `syncEvent.data` so
|
|
341
|
+
* downstream code can read `obj.sessionID`, `obj.part`, etc.
|
|
342
|
+
*
|
|
343
|
+
* After unwrapping, the event type from sync events has a version
|
|
344
|
+
* suffix (e.g. `session.created.1`). We strip the suffix so
|
|
345
|
+
* downstream code can match on `session.created` (the version is
|
|
346
|
+
* for the wire format, not the plugin's logical event name).
|
|
347
|
+
*/
|
|
348
|
+
private dispatchEvent(eventName: string | null, raw: unknown): void {
|
|
349
|
+
let obj = raw as Record<string, unknown> | null;
|
|
350
|
+
if (obj === null || typeof obj !== "object") return;
|
|
351
|
+
|
|
352
|
+
// Step 1: Detect sync event wrapper, unwrap to the inner data.
|
|
353
|
+
// Sync events have shape: {type: "sync", syncEvent: {type: "x.y.1", data: {...}}}
|
|
354
|
+
// After unwrap, obj points to the inner data so the rest of this
|
|
355
|
+
// method can read fields directly (obj.sessionID, obj.part, etc.).
|
|
356
|
+
let innerType: string | null = null;
|
|
357
|
+
if (obj.type === "sync" && obj.syncEvent) {
|
|
358
|
+
const syncEvent = obj.syncEvent as Record<string, unknown> | null;
|
|
359
|
+
if (syncEvent && typeof syncEvent === "object") {
|
|
360
|
+
if (typeof syncEvent.type === "string") innerType = syncEvent.type;
|
|
361
|
+
const syncData = syncEvent.data;
|
|
362
|
+
if (syncData && typeof syncData === "object") {
|
|
363
|
+
obj = syncData as Record<string, unknown>;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Step 2: Resolve the event type and strip the version suffix.
|
|
369
|
+
// Sync event types look like "session.created.1"; we strip to "session.created".
|
|
370
|
+
const typeFromObj = innerType ?? (typeof obj.type === "string" ? obj.type : null);
|
|
371
|
+
const rawType = typeFromObj ?? eventName ?? "unknown";
|
|
372
|
+
const type = stripVersionSuffix(rawType);
|
|
373
|
+
|
|
374
|
+
const sessionID = extractSessionId(obj);
|
|
375
|
+
const messageID = extractMessageId(obj);
|
|
376
|
+
// After unwrap, `obj.part` exists for `message.part.updated.*` events
|
|
377
|
+
// (sync) and may exist on the top-level payload. We also fall back to
|
|
378
|
+
// `obj.partID` on the part object itself for sync events whose
|
|
379
|
+
// `data` block does not carry `partID` separately.
|
|
380
|
+
const part = obj.part as { type?: string; text?: string; error?: string; state?: { status?: string; error?: string }; id?: string } | undefined;
|
|
381
|
+
const partID = extractPartId(obj, part);
|
|
382
|
+
|
|
383
|
+
// Build the typed event. We use a temporary permissive shape and only
|
|
384
|
+
// assign to a strongly-typed StreamEvent at the end of each branch.
|
|
385
|
+
let event: StreamEvent | null = null;
|
|
386
|
+
if (type === "message.part.updated" && sessionID && part) {
|
|
387
|
+
const partShape: { type: string; text?: string; error?: string; state?: { status?: string; error?: string } } = {
|
|
388
|
+
type: part.type ?? "unknown",
|
|
389
|
+
};
|
|
390
|
+
if (part.text !== undefined) partShape.text = part.text;
|
|
391
|
+
if (part.error !== undefined) partShape.error = part.error;
|
|
392
|
+
if (part.state !== undefined) partShape.state = part.state;
|
|
393
|
+
event = {
|
|
394
|
+
type: "message.part.updated",
|
|
395
|
+
sessionID,
|
|
396
|
+
messageID: messageID ?? "",
|
|
397
|
+
partID,
|
|
398
|
+
part: partShape,
|
|
399
|
+
raw,
|
|
400
|
+
};
|
|
401
|
+
} else if (type === "message.updated" && sessionID) {
|
|
402
|
+
event = { type: "message.updated", sessionID, messageID: messageID ?? "", raw };
|
|
403
|
+
} else if (type === "session.error" && sessionID) {
|
|
404
|
+
// session.error may carry the error string on `properties.error`
|
|
405
|
+
// (direct event) or on `data.error` (sync, but we already
|
|
406
|
+
// unwrapped, so both paths land at `obj.error`).
|
|
407
|
+
const props = obj.properties;
|
|
408
|
+
let errorField: string | undefined =
|
|
409
|
+
typeof obj.error === "string" ? obj.error : undefined;
|
|
410
|
+
if (errorField === undefined && props && typeof props === "object") {
|
|
411
|
+
const p = props as Record<string, unknown>;
|
|
412
|
+
if (typeof p.error === "string") errorField = p.error;
|
|
413
|
+
}
|
|
414
|
+
event = errorField !== undefined
|
|
415
|
+
? { type: "session.error", sessionID, error: errorField, raw }
|
|
416
|
+
: { type: "session.error", sessionID, raw };
|
|
417
|
+
} else if (type === "session.idle" && sessionID) {
|
|
418
|
+
event = { type: "session.idle", sessionID, raw };
|
|
419
|
+
} else if (type === "session.created" && sessionID) {
|
|
420
|
+
event = { type: "session.created", sessionID, raw };
|
|
421
|
+
} else if (type === "session.updated" && sessionID) {
|
|
422
|
+
event = { type: "session.updated", sessionID, raw };
|
|
423
|
+
} else if (type === "session.deleted" && sessionID) {
|
|
424
|
+
event = { type: "session.deleted", sessionID, raw };
|
|
425
|
+
} else {
|
|
426
|
+
// Unknown / untyped event — drop silently. Logged at debug level.
|
|
427
|
+
// We log the *raw* type (with version suffix) for diagnostics.
|
|
428
|
+
this._logger.debug(
|
|
429
|
+
`bizar: SSE: dropping untyped event (rawType=${rawType}${sessionID ? ` sessionID=${sessionID}` : ""})`,
|
|
430
|
+
);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (event === null) {
|
|
435
|
+
this._logger.debug(`bizar: SSE: event without sessionID (rawType=${rawType})`);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
this.dispatchToHandlers(sessionID, event);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private dispatchToHandlers(sessionID: string, event: StreamEvent): void {
|
|
442
|
+
const set = this._handlers.get(sessionID);
|
|
443
|
+
if (!set || set.size === 0) {
|
|
444
|
+
this._logger.debug(
|
|
445
|
+
`bizar: SSE event for untracked session ${sessionID} (type=${event.type})`,
|
|
446
|
+
);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
// Snapshot to avoid mutation during iteration.
|
|
450
|
+
for (const handler of [...set]) {
|
|
451
|
+
try {
|
|
452
|
+
handler(event);
|
|
453
|
+
} catch (err: unknown) {
|
|
454
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
455
|
+
this._logger.warn(
|
|
456
|
+
`bizar: SSE handler threw for session ${sessionID} (type=${event.type}): ${msg}`,
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// --- Reconnect ----------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
private scheduleReconnect(): void {
|
|
465
|
+
if (this._aborted) return;
|
|
466
|
+
if (this._reconnectTimer !== null) return;
|
|
467
|
+
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s.
|
|
468
|
+
const base = 1000 * Math.pow(2, this._reconnectAttempt);
|
|
469
|
+
const delay = Math.min(30_000, base);
|
|
470
|
+
this._reconnectAttempt += 1;
|
|
471
|
+
this._logger.warn(`bizar: SSE reconnecting in ${delay}ms (attempt ${this._reconnectAttempt})`);
|
|
472
|
+
this._reconnectTimer = setTimeout(() => {
|
|
473
|
+
this._reconnectTimer = null;
|
|
474
|
+
if (this._aborted) return;
|
|
475
|
+
void this.attemptReconnect();
|
|
476
|
+
}, delay);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
private async attemptReconnect(): Promise<void> {
|
|
480
|
+
try {
|
|
481
|
+
this._abortController = new AbortController();
|
|
482
|
+
const result = await this._http.fetchEventStream(
|
|
483
|
+
this._directory,
|
|
484
|
+
this._abortController.signal,
|
|
485
|
+
);
|
|
486
|
+
if (!result.ok) {
|
|
487
|
+
this._logger.warn(`bizar: SSE reconnect failed: ${result.error}`);
|
|
488
|
+
this.scheduleReconnect();
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
this._connected = true;
|
|
492
|
+
this._reconnectAttempt = 0;
|
|
493
|
+
this._logger.info("bizar: SSE reconnected");
|
|
494
|
+
void this.readLoop(result.value);
|
|
495
|
+
} catch (err: unknown) {
|
|
496
|
+
if (this._aborted) return;
|
|
497
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
498
|
+
this._logger.warn(`bizar: SSE reconnect threw: ${msg}`);
|
|
499
|
+
this.scheduleReconnect();
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// --- Helpers --------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Extract the `sessionID` from an opencode event payload.
|
|
508
|
+
*
|
|
509
|
+
* v0.4.3: this is called AFTER sync unwrapping, so the `obj` we receive
|
|
510
|
+
* is either:
|
|
511
|
+
* - A direct event: `{properties: {sessionID}}` or `{sessionID}`.
|
|
512
|
+
* - A sync event's `data` block: `{sessionID, ...}` (top-level).
|
|
513
|
+
*
|
|
514
|
+
* We check (in order):
|
|
515
|
+
* 1. `properties.sessionID` / `properties.sessionId` (direct events)
|
|
516
|
+
* 2. Top-level `sessionID` / `sessionId` (direct or post-unwrapped sync)
|
|
517
|
+
*
|
|
518
|
+
* Returns `undefined` if none of those is present.
|
|
519
|
+
*/
|
|
520
|
+
function extractSessionId(obj: Record<string, unknown>): string | undefined {
|
|
521
|
+
const props = obj.properties;
|
|
522
|
+
if (props && typeof props === "object") {
|
|
523
|
+
const p = props as Record<string, unknown>;
|
|
524
|
+
if (typeof p.sessionID === "string" && p.sessionID.length > 0) return p.sessionID;
|
|
525
|
+
if (typeof p.sessionId === "string" && p.sessionId.length > 0) return p.sessionId;
|
|
526
|
+
}
|
|
527
|
+
if (typeof obj.sessionID === "string" && obj.sessionID.length > 0) return obj.sessionID;
|
|
528
|
+
if (typeof obj.sessionId === "string" && obj.sessionId.length > 0) return obj.sessionId;
|
|
529
|
+
return undefined;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function extractMessageId(obj: Record<string, unknown>): string | undefined {
|
|
533
|
+
const props = obj.properties;
|
|
534
|
+
if (props && typeof props === "object") {
|
|
535
|
+
const p = props as Record<string, unknown>;
|
|
536
|
+
if (typeof p.messageID === "string") return p.messageID;
|
|
537
|
+
if (typeof p.messageId === "string") return p.messageId;
|
|
538
|
+
}
|
|
539
|
+
if (typeof obj.messageID === "string") return obj.messageID;
|
|
540
|
+
if (typeof obj.messageId === "string") return obj.messageId;
|
|
541
|
+
return undefined;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Extract the `partID` from an event payload. We check (in order):
|
|
546
|
+
* 1. `properties.partID` (direct events like `message.part.delta`)
|
|
547
|
+
* 2. Top-level `partID` (after sync unwrap, if the data block carries it)
|
|
548
|
+
* 3. `part.id` (sync `message.part.updated.1` — the part object itself
|
|
549
|
+
* carries its own id)
|
|
550
|
+
*/
|
|
551
|
+
function extractPartId(
|
|
552
|
+
obj: Record<string, unknown>,
|
|
553
|
+
part: { id?: string } | undefined,
|
|
554
|
+
): string {
|
|
555
|
+
const props = obj.properties;
|
|
556
|
+
if (props && typeof props === "object") {
|
|
557
|
+
const p = props as Record<string, unknown>;
|
|
558
|
+
if (typeof p.partID === "string" && p.partID.length > 0) return p.partID;
|
|
559
|
+
if (typeof p.partId === "string" && p.partId.length > 0) return p.partId;
|
|
560
|
+
}
|
|
561
|
+
if (typeof obj.partID === "string" && obj.partID.length > 0) return obj.partID;
|
|
562
|
+
if (typeof obj.partId === "string" && obj.partId.length > 0) return obj.partId;
|
|
563
|
+
if (part && typeof part.id === "string" && part.id.length > 0) return part.id;
|
|
564
|
+
return "";
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Strip a trailing version suffix from an event type.
|
|
569
|
+
* e.g. `"session.created.1"` → `"session.created"`, `"foo.bar.baz.12"` → `"foo.bar.baz"`.
|
|
570
|
+
* If the type has no `.N` suffix at the end, it is returned unchanged.
|
|
571
|
+
*/
|
|
572
|
+
function stripVersionSuffix(type: string): string {
|
|
573
|
+
return type.replace(/\.\d+$/, "");
|
|
574
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fingerprint.ts
|
|
3
|
+
*
|
|
4
|
+
* Stable hash of (tool, args) for loop detection.
|
|
5
|
+
* Uses canonical key ordering and path normalization per §5.3.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Recursively sort object keys into canonical (alphabetical) order
|
|
13
|
+
* and return a stable string representation.
|
|
14
|
+
*/
|
|
15
|
+
function canonicalStringify(value: unknown): string {
|
|
16
|
+
if (value === null) return "null";
|
|
17
|
+
if (value === undefined) return "undefined";
|
|
18
|
+
if (typeof value === "boolean") return String(value);
|
|
19
|
+
if (typeof value === "number") return String(value);
|
|
20
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
21
|
+
|
|
22
|
+
if (Array.isArray(value)) {
|
|
23
|
+
return `[${value.map(canonicalStringify).join(",")}]`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (typeof value === "object") {
|
|
27
|
+
const sorted = Object.keys(value as Record<string, unknown>)
|
|
28
|
+
.sort()
|
|
29
|
+
.reduce<Record<string, unknown>>((acc, key) => {
|
|
30
|
+
acc[key] = (value as Record<string, unknown>)[key];
|
|
31
|
+
return acc;
|
|
32
|
+
}, {});
|
|
33
|
+
|
|
34
|
+
const pairs = Object.entries(sorted)
|
|
35
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${canonicalStringify(v)}`)
|
|
36
|
+
.join(",");
|
|
37
|
+
return `{${pairs}}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// functions, symbols, etc. — stringify as a tag
|
|
41
|
+
return Object.prototype.toString.call(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Normalize a value for fingerprinting:
|
|
46
|
+
* - Strip noise fields (timestamps, IDs, nonces, cwd)
|
|
47
|
+
* - Resolve paths: in-worktree → relative, out-of-worktree → per-path hash
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* Normalize a string value that may be a path.
|
|
51
|
+
* Returns worktree-relative for in-worktree paths, per-path hash for outside paths.
|
|
52
|
+
*/
|
|
53
|
+
function normalizePath(v: string, worktree: string): string {
|
|
54
|
+
if (!path.isAbsolute(v)) return v;
|
|
55
|
+
const rel = path.relative(worktree, v);
|
|
56
|
+
if (!rel.startsWith("..") && !path.isAbsolute(rel)) {
|
|
57
|
+
return rel;
|
|
58
|
+
}
|
|
59
|
+
// outside worktree — per-path stable hash
|
|
60
|
+
const h = createHash("sha256").update(v).digest("hex");
|
|
61
|
+
return `path:${h.slice(0, 16)}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalize(value: unknown, worktree: string): unknown {
|
|
65
|
+
if (value === null || value === undefined) return value;
|
|
66
|
+
if (typeof value === "boolean") return value;
|
|
67
|
+
if (typeof value === "number") return value;
|
|
68
|
+
if (typeof value === "string") {
|
|
69
|
+
// Path normalization for strings that are absolute paths
|
|
70
|
+
return normalizePath(value, worktree);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
return value.map((item) => normalize(item, worktree));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (typeof value === "object") {
|
|
78
|
+
const obj = value as Record<string, unknown>;
|
|
79
|
+
const result: Record<string, unknown> = {};
|
|
80
|
+
|
|
81
|
+
// Per §5.3: strip fields whose name contains time/stamp/created/updated/timestamp
|
|
82
|
+
const SKIP_TIME_FIELDS =
|
|
83
|
+
/(^|_)time($|_)|stamp|created|updated|timestamp/i;
|
|
84
|
+
|
|
85
|
+
// Per §5.3: strip ID/nonce fields by exact name
|
|
86
|
+
const SKIP_FIELDS = /^(id|uuid|nonce|requestId|traceId)$/i;
|
|
87
|
+
|
|
88
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
89
|
+
// strip cwd field entirely per §5.3
|
|
90
|
+
if (k === "cwd") continue;
|
|
91
|
+
// strip ID/nonce fields per §5.3
|
|
92
|
+
if (SKIP_FIELDS.test(k)) continue;
|
|
93
|
+
// strip timestamp fields per §5.3
|
|
94
|
+
if (SKIP_TIME_FIELDS.test(k) && (typeof v === "number" || typeof v === "string")) continue;
|
|
95
|
+
|
|
96
|
+
// normalize (includes path normalization for strings via normalizePath)
|
|
97
|
+
result[k] = normalize(v, worktree);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Compute a stable fingerprint for a (tool, args) pair.
|
|
108
|
+
*
|
|
109
|
+
* Canonical key order is guaranteed by sorting keys before stringify.
|
|
110
|
+
* Paths are normalized per §5.3: in-worktree → relative, out-of-worktree → per-path hash.
|
|
111
|
+
*
|
|
112
|
+
* @param tool Tool name (e.g. "read", "bash")
|
|
113
|
+
* @param args Raw tool arguments
|
|
114
|
+
* @param worktree Absolute path to the worktree root (used for path normalization)
|
|
115
|
+
*/
|
|
116
|
+
export function fingerprint(tool: string, args: unknown, worktree: string): string {
|
|
117
|
+
const normalized = normalize(args, worktree);
|
|
118
|
+
const stable = canonicalStringify({ tool, args: normalized });
|
|
119
|
+
return createHash("sha256").update(stable, "utf8").digest("hex");
|
|
120
|
+
}
|