@shendeguize/dsh-agent-sidecar 0.1.0

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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +167 -0
  3. package/cordis.patch.yml +10 -0
  4. package/lib/client.js +8062 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.d.ts +396 -0
  7. package/lib/index.js +4166 -0
  8. package/package.json +101 -0
  9. package/src/analysis.ts +782 -0
  10. package/src/bridge.ts +841 -0
  11. package/src/client/analysis/AnalysisPanel.tsx +191 -0
  12. package/src/client/analysis/analysis.module.css +183 -0
  13. package/src/client/analysis-glue.ts +331 -0
  14. package/src/client/api.ts +380 -0
  15. package/src/client/board/Board.tsx +214 -0
  16. package/src/client/board/board.module.css +302 -0
  17. package/src/client/board/logic.ts +556 -0
  18. package/src/client/board/project-view-logic.ts +361 -0
  19. package/src/client/board/project-view.module.css +307 -0
  20. package/src/client/board/project-view.tsx +189 -0
  21. package/src/client/board/strings.ts +112 -0
  22. package/src/client/commands.ts +484 -0
  23. package/src/client/controller.ts +360 -0
  24. package/src/client/css-modules.d.ts +11 -0
  25. package/src/client/detail/SessionDetail.tsx +270 -0
  26. package/src/client/detail/detail.module.css +433 -0
  27. package/src/client/detail/logic.ts +779 -0
  28. package/src/client/detail/strings.ts +98 -0
  29. package/src/client/detail/transport.ts +175 -0
  30. package/src/client/detail-glue.ts +397 -0
  31. package/src/client/detail-view.module.css +79 -0
  32. package/src/client/detail-view.tsx +233 -0
  33. package/src/client/dsh-tools/LineageTree.tsx +210 -0
  34. package/src/client/dsh-tools/SearchPanel.tsx +169 -0
  35. package/src/client/dsh-tools/dsh-tools.module.css +374 -0
  36. package/src/client/dsh-tools/logic.ts +596 -0
  37. package/src/client/dsh-tools/strings.ts +90 -0
  38. package/src/client/index.ts +315 -0
  39. package/src/client/inject/InjectPanel.tsx +482 -0
  40. package/src/client/inject/inject.module.css +446 -0
  41. package/src/client/inject/logic.ts +516 -0
  42. package/src/client/inject/overlay.module.css +22 -0
  43. package/src/client/inject-glue.ts +171 -0
  44. package/src/client/locales/command.ts +48 -0
  45. package/src/client/locales/en.ts +385 -0
  46. package/src/client/locales/index.ts +123 -0
  47. package/src/client/locales/zh.ts +402 -0
  48. package/src/client/m3-transport.ts +151 -0
  49. package/src/client/mount.tsx +307 -0
  50. package/src/client/project-glue.ts +134 -0
  51. package/src/client/search-glue.ts +143 -0
  52. package/src/client/settings-card.module.css +359 -0
  53. package/src/client/settings-card.tsx +565 -0
  54. package/src/client/settings-glue.ts +130 -0
  55. package/src/client/sidebar-tab.tsx +494 -0
  56. package/src/client/sse.ts +366 -0
  57. package/src/client/widget.tsx +80 -0
  58. package/src/config.ts +193 -0
  59. package/src/dsh-inject.ts +240 -0
  60. package/src/fusion.ts +988 -0
  61. package/src/guard.ts +274 -0
  62. package/src/index.ts +950 -0
  63. package/src/inject-gateway.ts +574 -0
  64. package/src/routes.ts +1133 -0
  65. package/src/send-cli.ts +340 -0
  66. package/src/session-store.ts +184 -0
  67. package/src/skills-provider.ts +293 -0
  68. package/src/supervisor.ts +463 -0
package/src/bridge.ts ADDED
@@ -0,0 +1,841 @@
1
+ /**
2
+ * Sidecar Unix-socket bridge (host half, transport layer only).
3
+ *
4
+ * Pure `node:net`; deliberately free of any cordis/dsh import so the
5
+ * protocol client stays testable in isolation and reusable outside the
6
+ * plugin context.
7
+ *
8
+ * Protocol source of truth (verified against sidecar source, not docs):
9
+ * - Requests are single-line JSON
10
+ * `{"op":"ping"|"status"|"replay"|"subscribe"}` terminated by `\n`
11
+ * (`sidecar/daemon.py` `_handle_client`).
12
+ * - `ping`/`status`/`replay` answer with exactly one JSON line. The official
13
+ * client (`sidecar/client.py`) opens one fresh connection per op and
14
+ * closes it after the response; we mirror that semantic.
15
+ * - `replay {session_id, after_seq, limit}` (T5.2) answers one bounded page
16
+ * `{events, last_seq, truncated, count, agent, ...}` sourced from the
17
+ * session adapter's own transcript replay (daemon `_replay_response`;
18
+ * today only dsh sessions provide one). Unlike ping/status, {@link
19
+ * SidecarSocketClient.replay} REJECTS with a coded
20
+ * {@link SidecarDaemonError} instead of resolving null: the daemon error
21
+ * vocabulary (`unknown_session` / `replay_unsupported` / `replay_failed`
22
+ * / `invalid_request`) must reach the caller verbatim so the fusion
23
+ * layer can degrade honestly (design §4.b.2).
24
+ * - `subscribe` answers with an ack line `{"ok":true,"op":"subscribe"}`
25
+ * and then streams JSONL event objects until either side disconnects
26
+ * (`sidecar/daemon.py` `_serve_subscription`). An optional
27
+ * `{"agents":[...]}` allowlist asks the daemon to stream only those
28
+ * agents' events (server-side filter, daemon `_parse_subscribe_agents`);
29
+ * the ack then echoes the sorted list. The per-subscriber queue is
30
+ * bounded (256, drop-oldest) and drops are NOT signalled on the wire
31
+ * (`sidecar/bus.py`), which is why the stream is a trigger signal only;
32
+ * `status` snapshots remain the source of truth (design §4.b / ADR-2).
33
+ * - Daemon-declared errors arrive as `{"ok":false,"error":{code,message}}`.
34
+ *
35
+ * @module
36
+ */
37
+
38
+ import { createConnection, type Socket } from 'node:net'
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Wire types (mirroring sidecar/model.py and sidecar/daemon.py responses).
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** Health of the subscribe stream as observed by the host. */
45
+ export type StreamHealth = 'ok' | 'degraded' | 'unknown'
46
+
47
+ /** HTTP listener details advertised by `ping` (daemon `_http_ping_payload`). */
48
+ export interface HttpPingInfo {
49
+ enabled: boolean
50
+ host?: string
51
+ port?: number
52
+ }
53
+
54
+ /** Typed `ping` response (pid/version/http self-description). */
55
+ export interface PingInfo {
56
+ pid: number
57
+ version: string
58
+ http: HttpPingInfo
59
+ }
60
+
61
+ /** One session row from a `status` snapshot (`Session.to_dict`). */
62
+ export interface SessionRow {
63
+ agent: string
64
+ session_id: string
65
+ project: string
66
+ transcript: string
67
+ updated_at: number
68
+ title: string
69
+ status: string
70
+ extra: Record<string, unknown>
71
+ parent_id: string | null
72
+ }
73
+
74
+ /** One normalized event from the subscribe stream (`Event.to_dict`). */
75
+ export interface SidecarEvent {
76
+ ts: string
77
+ agent: string
78
+ session_id: string
79
+ kind: string
80
+ text: string
81
+ extra: Record<string, unknown>
82
+ }
83
+
84
+ /** Parsed `status` response. */
85
+ export interface StatusSnapshot {
86
+ sessions: SessionRow[]
87
+ scanErrors: Array<Record<string, unknown>>
88
+ tailErrors: Array<Record<string, unknown>>
89
+ diagnostics: Array<Record<string, unknown>>
90
+ }
91
+
92
+ /** One parsed page of the `replay` op (daemon `_replay_response`). */
93
+ export interface ReplayPage {
94
+ sessionId: string
95
+ agent: string
96
+ /** The request cursor this page starts after. */
97
+ afterSeq: number
98
+ events: SidecarEvent[]
99
+ /** Daemon-reported event count of this page. */
100
+ count: number
101
+ /** Highest raw-record seq seen by the daemon; the next-page cursor. */
102
+ lastSeq: number | null
103
+ /** True when the daemon hit the page limit (more records may exist). */
104
+ truncated: boolean
105
+ }
106
+
107
+ /**
108
+ * Coded failure of a request/response op. `code` carries the daemon error
109
+ * vocabulary verbatim (`invalid_request`, `unknown_session`,
110
+ * `replay_unsupported`, `replay_failed`, ...) or one of the client-side
111
+ * transport codes: `timeout`, `connection_failed`, `connection_closed`,
112
+ * `invalid_response`.
113
+ */
114
+ export class SidecarDaemonError extends Error {
115
+ readonly code: string
116
+
117
+ constructor(code: string, detail: string) {
118
+ super(`${code}: ${detail}`)
119
+ this.name = 'SidecarDaemonError'
120
+ this.code = code
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Client-side drop reasons. The daemon never signals its own queue drops
126
+ * (`sidecar/bus.py` keeps `dropped` server-internal), so these only cover
127
+ * lines this client had to discard to protect itself.
128
+ */
129
+ export type DropReason = 'line_too_long' | 'invalid_json' | 'invalid_event'
130
+
131
+ /** Callbacks for one subscribe stream. Never invoked synchronously from `subscribe()`. */
132
+ export interface SubscribeHandlers {
133
+ onEvent(ev: SidecarEvent): void
134
+ /** Called once after the daemon ack has been validated. */
135
+ onReady?(): void
136
+ onDrop?(reason: DropReason): void
137
+ /** Called exactly once when the stream ends (error, disconnect, or `close()`). */
138
+ onClose?(err?: Error): void
139
+ }
140
+
141
+ /** Handle for one live subscription. */
142
+ export interface Subscription {
143
+ close(): void
144
+ }
145
+
146
+ /** Per-stream options of {@link SidecarSocketClient.subscribe}. */
147
+ export interface SubscribeOptions {
148
+ /**
149
+ * Optional agent allowlist forwarded on the wire
150
+ * (`{"op":"subscribe","agents":[...]}`); the daemon then streams only
151
+ * events from those agents. Omitting it keeps the full stream. An empty
152
+ * list or empty names throw a RangeError (mirrors sidecar/client.py).
153
+ */
154
+ agents?: readonly string[]
155
+ }
156
+
157
+ export interface SidecarSocketClientOptions {
158
+ /** Absolute path to `daemon.sock` (default runtime dir is `~/.agent_sidecar`, redirectable via AGENT_SIDECAR_RUNTIME_DIR — resolved by the caller). */
159
+ socketPath: string
160
+ /** Connect/handshake/response bound; the stream idles unbounded after the subscribe ack, matching sidecar/client.py. */
161
+ timeoutMs?: number
162
+ /** Response bound for the `replay` op (bounded transcript decode is slower than ping/status). */
163
+ replayTimeoutMs?: number
164
+ /** Bound for a single JSONL line (sidecar caps responses at 32 MiB). */
165
+ maxLineBytes?: number
166
+ }
167
+
168
+ /** Matches `DEFAULT_TIMEOUT = 1.0` in sidecar/client.py. */
169
+ export const DEFAULT_TIMEOUT_MS = 1000
170
+ /** Matches `DEFAULT_REPLAY_TIMEOUT = 15.0` in sidecar/client.py. */
171
+ export const DEFAULT_REPLAY_TIMEOUT_MS = 15_000
172
+ /** Matches `MAX_RESPONSE_BYTES` in sidecar/client.py. */
173
+ export const DEFAULT_MAX_LINE_BYTES = 32 * 1024 * 1024
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Parsing helpers.
177
+ // ---------------------------------------------------------------------------
178
+
179
+ function isRecord(value: unknown): value is Record<string, unknown> {
180
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
181
+ }
182
+
183
+ function parseHttpPingInfo(value: unknown): HttpPingInfo | null {
184
+ if (value === null || value === undefined) return { enabled: false }
185
+ if (!isRecord(value) || typeof value['enabled'] !== 'boolean') return null
186
+ if (value['enabled'] === false) return { enabled: false }
187
+ const host = value['host']
188
+ const port = value['port']
189
+ if (typeof host !== 'string' || host === '') return null
190
+ if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) return null
191
+ return { enabled: true, host, port }
192
+ }
193
+
194
+ function parsePingInfo(value: unknown): PingInfo | null {
195
+ if (!isRecord(value) || value['ok'] !== true || value['op'] !== 'ping') return null
196
+ const pid = value['pid']
197
+ if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return null
198
+ const rawVersion = value['version']
199
+ let version: string
200
+ if (rawVersion === null || rawVersion === undefined) version = ''
201
+ else if (typeof rawVersion === 'string') version = rawVersion
202
+ else return null
203
+ const http = parseHttpPingInfo(value['http'])
204
+ if (http === null) return null
205
+ return { pid, version, http }
206
+ }
207
+
208
+ /**
209
+ * Normalize one raw status row. Rows without a usable `session_id` are
210
+ * skipped by the caller (the daemon model guarantees the field, so this
211
+ * only defends against wire corruption).
212
+ */
213
+ function parseSessionRow(value: unknown): SessionRow | null {
214
+ if (!isRecord(value)) return null
215
+ const sessionId = value['session_id']
216
+ if (typeof sessionId !== 'string' || sessionId === '') return null
217
+ const updatedAt = value['updated_at']
218
+ return {
219
+ agent: typeof value['agent'] === 'string' ? value['agent'] : '',
220
+ session_id: sessionId,
221
+ project: typeof value['project'] === 'string' ? value['project'] : '',
222
+ transcript: typeof value['transcript'] === 'string' ? value['transcript'] : '',
223
+ updated_at: typeof updatedAt === 'number' && Number.isFinite(updatedAt) ? updatedAt : 0,
224
+ title: typeof value['title'] === 'string' ? value['title'] : '',
225
+ status: typeof value['status'] === 'string' ? value['status'] : 'idle',
226
+ extra: isRecord(value['extra']) ? value['extra'] : {},
227
+ parent_id: typeof value['parent_id'] === 'string' ? value['parent_id'] : null,
228
+ }
229
+ }
230
+
231
+ function parseRecordList(value: unknown): Array<Record<string, unknown>> | null {
232
+ if (value === undefined) return []
233
+ if (!Array.isArray(value)) return null
234
+ const out: Array<Record<string, unknown>> = []
235
+ for (const item of value) {
236
+ if (!isRecord(item)) return null
237
+ out.push(item)
238
+ }
239
+ return out
240
+ }
241
+
242
+ function parseStatusSnapshot(value: unknown): StatusSnapshot | null {
243
+ if (!isRecord(value) || value['ok'] !== true) return null
244
+ const rawSessions = value['sessions']
245
+ if (!Array.isArray(rawSessions)) return null
246
+ const sessions: SessionRow[] = []
247
+ for (const raw of rawSessions) {
248
+ // Mirror sidecar/client.py: a non-object row invalidates the response.
249
+ if (!isRecord(raw)) return null
250
+ const row = parseSessionRow(raw)
251
+ if (row !== null) sessions.push(row)
252
+ }
253
+ const scanErrors = parseRecordList(value['scan_errors'])
254
+ const tailErrors = parseRecordList(value['tail_errors'])
255
+ if (scanErrors === null || tailErrors === null) return null
256
+ const diagnostics = parseRecordList(value['diagnostics'])
257
+ return { sessions, scanErrors, tailErrors, diagnostics: diagnostics ?? [] }
258
+ }
259
+
260
+ function parseEvent(value: Record<string, unknown>): SidecarEvent | null {
261
+ const ts = value['ts']
262
+ const agent = value['agent']
263
+ const sessionId = value['session_id']
264
+ const kind = value['kind']
265
+ const text = value['text']
266
+ if (
267
+ typeof ts !== 'string' ||
268
+ typeof agent !== 'string' ||
269
+ typeof sessionId !== 'string' ||
270
+ typeof kind !== 'string' ||
271
+ typeof text !== 'string'
272
+ ) {
273
+ return null
274
+ }
275
+ return {
276
+ ts,
277
+ agent,
278
+ session_id: sessionId,
279
+ kind,
280
+ text,
281
+ extra: isRecord(value['extra']) ? value['extra'] : {},
282
+ }
283
+ }
284
+
285
+ function daemonError(value: Record<string, unknown>): SidecarDaemonError {
286
+ const error = value['error']
287
+ if (isRecord(error)) {
288
+ const code = String(error['code'] ?? 'daemon_error')
289
+ const message = String(error['message'] ?? code)
290
+ return new SidecarDaemonError(code, message)
291
+ }
292
+ return new SidecarDaemonError('daemon_error', String(error ?? 'daemon_error'))
293
+ }
294
+
295
+ /**
296
+ * Parse one `replay` response page. Mirrors sidecar/client.py strictness:
297
+ * a non-object entry in `events` invalidates the whole response, while an
298
+ * object entry missing normalized fields is skipped defensively (the
299
+ * daemon model guarantees them).
300
+ */
301
+ function parseReplayPage(value: unknown): ReplayPage | null {
302
+ if (!isRecord(value) || value['ok'] !== true || value['op'] !== 'replay') return null
303
+ const sessionId = value['session_id']
304
+ const agent = value['agent']
305
+ const rawEvents = value['events']
306
+ if (typeof sessionId !== 'string' || typeof agent !== 'string') return null
307
+ if (!Array.isArray(rawEvents)) return null
308
+ const events: SidecarEvent[] = []
309
+ for (const raw of rawEvents) {
310
+ if (!isRecord(raw)) return null
311
+ const event = parseEvent(raw)
312
+ if (event !== null) events.push(event)
313
+ }
314
+ const afterSeq = value['after_seq']
315
+ const count = value['count']
316
+ const lastSeq = value['last_seq']
317
+ return {
318
+ sessionId,
319
+ agent,
320
+ afterSeq:
321
+ typeof afterSeq === 'number' && Number.isInteger(afterSeq) && afterSeq >= 0 ? afterSeq : 0,
322
+ events,
323
+ count: typeof count === 'number' && Number.isInteger(count) ? count : events.length,
324
+ lastSeq: typeof lastSeq === 'number' && Number.isInteger(lastSeq) ? lastSeq : null,
325
+ truncated: value['truncated'] === true,
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Build the subscribe request line, validating an optional agents filter
331
+ * up front (before any socket exists) so misuse throws synchronously.
332
+ */
333
+ function buildSubscribeRequest(agents: readonly string[] | undefined): string {
334
+ if (agents === undefined) return '{"op":"subscribe"}\n'
335
+ if (agents.length === 0 || agents.some((name) => typeof name !== 'string' || name === '')) {
336
+ throw new RangeError('agents must be a nonempty list of nonempty agent names')
337
+ }
338
+ return `${JSON.stringify({ op: 'subscribe', agents })}\n`
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // Bounded JSONL line splitter.
343
+ // ---------------------------------------------------------------------------
344
+
345
+ const NEWLINE = 0x0a
346
+ const EMPTY = Buffer.alloc(0)
347
+
348
+ /**
349
+ * Splits a byte stream into newline-terminated lines with a hard size
350
+ * bound. An over-long line is discarded (signalled once via `onOverflow`)
351
+ * and the splitter resynchronizes at the next newline, so one oversized
352
+ * record cannot take down the whole stream or balloon memory.
353
+ */
354
+ class LineBuffer {
355
+ private pending: Buffer = EMPTY
356
+ private dropping = false
357
+
358
+ constructor(
359
+ private readonly maxBytes: number,
360
+ private readonly onLine: (line: Buffer) => void,
361
+ private readonly onOverflow: () => void,
362
+ ) {}
363
+
364
+ push(chunk: Buffer): void {
365
+ this.pending = this.pending.length === 0 ? chunk : Buffer.concat([this.pending, chunk])
366
+ for (;;) {
367
+ const idx = this.pending.indexOf(NEWLINE)
368
+ if (idx < 0) {
369
+ if (this.pending.length > this.maxBytes) {
370
+ this.pending = EMPTY
371
+ if (!this.dropping) {
372
+ this.dropping = true
373
+ this.onOverflow()
374
+ }
375
+ }
376
+ return
377
+ }
378
+ const line = this.pending.subarray(0, idx)
379
+ this.pending = this.pending.subarray(idx + 1)
380
+ if (this.dropping) {
381
+ // Tail of a line that already overflowed; resynchronize silently.
382
+ this.dropping = false
383
+ continue
384
+ }
385
+ if (line.length > this.maxBytes) {
386
+ this.onOverflow()
387
+ continue
388
+ }
389
+ this.onLine(line)
390
+ }
391
+ }
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // Socket client.
396
+ // ---------------------------------------------------------------------------
397
+
398
+ /**
399
+ * Minimal daemon client: one fresh connection per op (matching the
400
+ * semantics of `sidecar/client.py`), single-line JSON requests, JSONL
401
+ * responses, bounded reads. All request/response failures resolve to
402
+ * `null` instead of throwing — the caller (Reconciler/Supervisor) owns
403
+ * the health policy.
404
+ */
405
+ export class SidecarSocketClient {
406
+ readonly socketPath: string
407
+ readonly timeoutMs: number
408
+ readonly replayTimeoutMs: number
409
+ readonly maxLineBytes: number
410
+
411
+ constructor(opts: SidecarSocketClientOptions) {
412
+ this.socketPath = opts.socketPath
413
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
414
+ this.replayTimeoutMs = opts.replayTimeoutMs ?? DEFAULT_REPLAY_TIMEOUT_MS
415
+ this.maxLineBytes = opts.maxLineBytes ?? DEFAULT_MAX_LINE_BYTES
416
+ if (this.timeoutMs <= 0 || this.replayTimeoutMs <= 0 || this.maxLineBytes <= 0) {
417
+ throw new RangeError('client bounds are invalid')
418
+ }
419
+ }
420
+
421
+ /** `ping` op; `null` on refusal, timeout, or an invalid/error response. */
422
+ async ping(): Promise<PingInfo | null> {
423
+ return parsePingInfo(await this.requestLine('ping'))
424
+ }
425
+
426
+ /** `status` op; `null` on refusal, timeout, or an invalid/error response. */
427
+ async status(): Promise<StatusSnapshot | null> {
428
+ return parseStatusSnapshot(await this.requestLine('status'))
429
+ }
430
+
431
+ /**
432
+ * `replay` op (T5.2): one bounded page of normalized historical events
433
+ * after `afterSeq`. Unlike ping/status this REJECTS with a coded
434
+ * {@link SidecarDaemonError} — daemon codes pass through verbatim and
435
+ * transport failures get client codes — because the caller (FusionQuery
436
+ * seam) distinguishes degradation reasons instead of polling health.
437
+ * Local misuse throws a RangeError (mirrors sidecar/client.py's
438
+ * ValueError). `limit` is forwarded as-is; the daemon enforces its own
439
+ * 1..1024 bound and answers `invalid_request` beyond it.
440
+ */
441
+ async replay(sessionId: string, afterSeq = 0, limit?: number): Promise<ReplayPage> {
442
+ if (typeof sessionId !== 'string' || sessionId === '') {
443
+ throw new RangeError('sessionId must be a nonempty string')
444
+ }
445
+ if (!Number.isInteger(afterSeq) || afterSeq < 0) {
446
+ throw new RangeError('afterSeq must be a nonnegative integer')
447
+ }
448
+ if (limit !== undefined && (!Number.isInteger(limit) || limit <= 0)) {
449
+ throw new RangeError('limit must be a positive integer')
450
+ }
451
+ const payload: Record<string, unknown> = {
452
+ op: 'replay',
453
+ session_id: sessionId,
454
+ after_seq: afterSeq,
455
+ }
456
+ if (limit !== undefined) payload['limit'] = limit
457
+ const value = await this.requestObject(payload, this.replayTimeoutMs)
458
+ if (isRecord(value) && value['ok'] === false) throw daemonError(value)
459
+ const page = parseReplayPage(value)
460
+ if (page === null) {
461
+ throw new SidecarDaemonError(
462
+ 'invalid_response',
463
+ 'daemon replay response has no valid events list',
464
+ )
465
+ }
466
+ return page
467
+ }
468
+
469
+ /**
470
+ * Open a subscribe stream: write the op, validate the ack, then deliver
471
+ * each JSONL event through `handlers.onEvent`. After the ack the
472
+ * connection may idle indefinitely (no timeout), matching
473
+ * sidecar/client.py which disables its socket timeout post-handshake.
474
+ * An `opts.agents` allowlist becomes the daemon-side stream filter.
475
+ */
476
+ subscribe(handlers: SubscribeHandlers, opts: SubscribeOptions = {}): Subscription {
477
+ // Validate (and possibly throw) before any socket exists.
478
+ const request = buildSubscribeRequest(opts.agents)
479
+ let closed = false
480
+ let ready = false
481
+ const socket: Socket = createConnection({ path: this.socketPath })
482
+
483
+ const finish = (err?: Error): void => {
484
+ if (closed) return
485
+ closed = true
486
+ socket.destroy()
487
+ // Defer so `close()` calls from inside subscribe setup can never
488
+ // observe a synchronous onClose.
489
+ queueMicrotask(() => handlers.onClose?.(err))
490
+ }
491
+
492
+ const lines = new LineBuffer(
493
+ this.maxLineBytes,
494
+ (line) => {
495
+ if (closed) return
496
+ let value: unknown
497
+ try {
498
+ value = JSON.parse(line.toString('utf8'))
499
+ } catch {
500
+ if (!ready) {
501
+ finish(new Error('daemon returned an invalid subscribe acknowledgement'))
502
+ return
503
+ }
504
+ handlers.onDrop?.('invalid_json')
505
+ return
506
+ }
507
+ if (!ready) {
508
+ if (isRecord(value) && value['ok'] === true && value['op'] === 'subscribe' && !('error' in value)) {
509
+ ready = true
510
+ socket.setTimeout(0)
511
+ handlers.onReady?.()
512
+ } else if (isRecord(value) && value['ok'] === false) {
513
+ finish(daemonError(value))
514
+ } else {
515
+ finish(new Error('daemon returned an invalid subscribe acknowledgement'))
516
+ }
517
+ return
518
+ }
519
+ if (!isRecord(value)) {
520
+ handlers.onDrop?.('invalid_event')
521
+ return
522
+ }
523
+ if (value['ok'] === false) {
524
+ finish(daemonError(value))
525
+ return
526
+ }
527
+ const event = parseEvent(value)
528
+ if (event === null) {
529
+ handlers.onDrop?.('invalid_event')
530
+ return
531
+ }
532
+ handlers.onEvent(event)
533
+ },
534
+ () => {
535
+ if (!closed) handlers.onDrop?.('line_too_long')
536
+ },
537
+ )
538
+
539
+ socket.setTimeout(this.timeoutMs)
540
+ socket.once('timeout', () => {
541
+ if (!ready) finish(new Error('subscribe handshake timed out'))
542
+ })
543
+ socket.once('error', (err: Error) => finish(err))
544
+ socket.once('close', () => finish())
545
+ socket.once('connect', () => {
546
+ socket.write(request)
547
+ })
548
+ socket.on('data', (chunk: Buffer) => lines.push(chunk))
549
+
550
+ return { close: () => finish() }
551
+ }
552
+
553
+ /**
554
+ * Send one single-line JSON request and read one JSONL response line,
555
+ * REJECTING with a coded {@link SidecarDaemonError} on every transport
556
+ * failure (the replay path needs error provenance, not just null).
557
+ */
558
+ private requestObject(
559
+ payload: Record<string, unknown>,
560
+ timeoutMs: number,
561
+ ): Promise<unknown> {
562
+ return new Promise<unknown>((resolve, reject) => {
563
+ let settled = false
564
+ const socket: Socket = createConnection({ path: this.socketPath })
565
+ const finish = (settle: () => void): void => {
566
+ if (settled) return
567
+ settled = true
568
+ socket.destroy()
569
+ settle()
570
+ }
571
+ const fail = (code: string, detail: string): void => {
572
+ finish(() => reject(new SidecarDaemonError(code, detail)))
573
+ }
574
+ const lines = new LineBuffer(
575
+ this.maxLineBytes,
576
+ (line) => {
577
+ let value: unknown
578
+ try {
579
+ value = JSON.parse(line.toString('utf8'))
580
+ } catch {
581
+ fail('invalid_response', 'daemon returned an unparsable response line')
582
+ return
583
+ }
584
+ finish(() => resolve(value))
585
+ },
586
+ () => fail('invalid_response', 'daemon response line exceeded the size bound'),
587
+ )
588
+ socket.setTimeout(timeoutMs)
589
+ socket.once('timeout', () => fail('timeout', 'daemon did not answer within the bound'))
590
+ socket.once('error', (err: Error) => fail('connection_failed', err.message))
591
+ socket.once('close', () =>
592
+ fail('connection_closed', 'connection closed before a response line'),
593
+ )
594
+ socket.once('connect', () => {
595
+ socket.write(`${JSON.stringify(payload)}\n`)
596
+ })
597
+ socket.on('data', (chunk: Buffer) => lines.push(chunk))
598
+ })
599
+ }
600
+
601
+ /** Send one single-line JSON request and read one JSONL response line. */
602
+ private requestLine(op: 'ping' | 'status'): Promise<unknown> {
603
+ return new Promise<unknown>((resolve) => {
604
+ let settled = false
605
+ const socket: Socket = createConnection({ path: this.socketPath })
606
+ const finish = (value: unknown): void => {
607
+ if (settled) return
608
+ settled = true
609
+ socket.destroy()
610
+ resolve(value)
611
+ }
612
+ const lines = new LineBuffer(
613
+ this.maxLineBytes,
614
+ (line) => {
615
+ let value: unknown
616
+ try {
617
+ value = JSON.parse(line.toString('utf8'))
618
+ } catch {
619
+ value = null
620
+ }
621
+ finish(value)
622
+ },
623
+ () => finish(null),
624
+ )
625
+ socket.setTimeout(this.timeoutMs)
626
+ socket.once('timeout', () => finish(null))
627
+ socket.once('error', () => finish(null))
628
+ socket.once('close', () => finish(null))
629
+ socket.once('connect', () => {
630
+ socket.write(JSON.stringify({ op }) + '\n')
631
+ })
632
+ socket.on('data', (chunk: Buffer) => lines.push(chunk))
633
+ })
634
+ }
635
+ }
636
+
637
+ // ---------------------------------------------------------------------------
638
+ // Reconciler: snapshots are truth, the stream is a trigger (ADR-2).
639
+ // ---------------------------------------------------------------------------
640
+
641
+ /** What the Reconciler needs from the session cache (structurally satisfied by SessionStore). */
642
+ export interface ReconcilerStore {
643
+ applySnapshot(rows: SessionRow[]): void
644
+ applyEvent(ev: SidecarEvent): void
645
+ setStreamHealth(health: StreamHealth): void
646
+ hasWorkingSessions(): boolean
647
+ }
648
+
649
+ /** What the Reconciler needs from the client (structurally satisfied by SidecarSocketClient). */
650
+ export interface ReconcilerClient {
651
+ status(): Promise<StatusSnapshot | null>
652
+ subscribe(handlers: SubscribeHandlers): Subscription
653
+ }
654
+
655
+ export interface ReconcilerOptions {
656
+ /** Snapshot cadence while any session is `working`. */
657
+ activeMs?: number
658
+ /** Snapshot cadence otherwise. */
659
+ idleMs?: number
660
+ /** Debounce for the event-triggered early reconcile. */
661
+ debounceMs?: number
662
+ /** First reconnect delay after the subscribe stream drops. */
663
+ reconnectMinMs?: number
664
+ /** Reconnect delay cap (bounded backoff, never circuit-broken). */
665
+ reconnectMaxMs?: number
666
+ /** First retry delay after a failed reconcile (doubles per consecutive failure, capped at the cadence). */
667
+ failureBackoffMs?: number
668
+ }
669
+
670
+ export const DEFAULT_ACTIVE_MS = 2000
671
+ export const DEFAULT_IDLE_MS = 10000
672
+ export const DEFAULT_DEBOUNCE_MS = 200
673
+ export const DEFAULT_RECONNECT_MIN_MS = 1000
674
+ export const DEFAULT_RECONNECT_MAX_MS = 30000
675
+ export const DEFAULT_FAILURE_BACKOFF_MS = 250
676
+
677
+ /**
678
+ * Dual-cadence status reconciliation plus subscribe-stream supervision:
679
+ * - `status` snapshots run on an active (any working session) or idle
680
+ * cadence and are applied as the authoritative full state.
681
+ * - each subscribe event is folded into the store as a hint and schedules
682
+ * one debounced early reconcile.
683
+ * - a FAILED snapshot (daemon absent or not yet ready) retries on a short
684
+ * backoff (250ms doubling, capped at the current cadence) instead of
685
+ * sleeping a whole cadence period — a cold start where the very first
686
+ * `status` races the daemon socket must not cost a full `idleMs`
687
+ * (M1 acceptance ②). A success resets the streak to the steady cadence.
688
+ * - `reconcileNow()` is public so the supervisor can hand off "daemon just
689
+ * became reachable" (ADOPTED/HOSTED are ping-gated) as one immediate
690
+ * reconcile.
691
+ * - a dropped stream marks `streamHealth=degraded` and reconnects with
692
+ * bounded exponential backoff (1s doubling to a 30s cap, retrying
693
+ * forever); a validated ack restores `streamHealth=ok` and resets the
694
+ * backoff.
695
+ */
696
+ export class Reconciler {
697
+ private readonly activeMs: number
698
+ private readonly idleMs: number
699
+ private readonly debounceMs: number
700
+ private readonly reconnectMinMs: number
701
+ private readonly reconnectMaxMs: number
702
+ private readonly failureBackoffMs: number
703
+
704
+ private running = false
705
+ private backoffMs: number
706
+ /** Consecutive failed reconciles; drives the short retry backoff. */
707
+ private failStreak = 0
708
+ private pollTimer: ReturnType<typeof setTimeout> | null = null
709
+ private kickTimer: ReturnType<typeof setTimeout> | null = null
710
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
711
+ private subscription: Subscription | null = null
712
+ private reconcileInFlight = false
713
+ private reconcileQueued = false
714
+
715
+ constructor(
716
+ private readonly client: ReconcilerClient,
717
+ private readonly store: ReconcilerStore,
718
+ opts: ReconcilerOptions = {},
719
+ ) {
720
+ this.activeMs = opts.activeMs ?? DEFAULT_ACTIVE_MS
721
+ this.idleMs = opts.idleMs ?? DEFAULT_IDLE_MS
722
+ this.debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS
723
+ this.reconnectMinMs = opts.reconnectMinMs ?? DEFAULT_RECONNECT_MIN_MS
724
+ this.reconnectMaxMs = opts.reconnectMaxMs ?? DEFAULT_RECONNECT_MAX_MS
725
+ this.failureBackoffMs = opts.failureBackoffMs ?? DEFAULT_FAILURE_BACKOFF_MS
726
+ this.backoffMs = this.reconnectMinMs
727
+ }
728
+
729
+ start(): void {
730
+ if (this.running) return
731
+ this.running = true
732
+ this.backoffMs = this.reconnectMinMs
733
+ this.failStreak = 0
734
+ this.openSubscription()
735
+ void this.reconcileNow()
736
+ }
737
+
738
+ stop(): void {
739
+ if (!this.running) return
740
+ this.running = false
741
+ if (this.pollTimer !== null) clearTimeout(this.pollTimer)
742
+ if (this.kickTimer !== null) clearTimeout(this.kickTimer)
743
+ if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer)
744
+ this.pollTimer = null
745
+ this.kickTimer = null
746
+ this.reconnectTimer = null
747
+ const subscription = this.subscription
748
+ this.subscription = null
749
+ subscription?.close()
750
+ }
751
+
752
+ /**
753
+ * Run one immediate `status` reconcile and reschedule the next poll from
754
+ * its outcome. Public as the supervisor hand-off seam: the plugin entry
755
+ * calls this on the ADOPTED/HOSTED transition (both are gated on a
756
+ * successful ping, so the socket is known-reachable at that moment).
757
+ * Coalesces with an in-flight reconcile; a no-op when stopped.
758
+ */
759
+ async reconcileNow(): Promise<void> {
760
+ if (!this.running) return
761
+ if (this.reconcileInFlight) {
762
+ this.reconcileQueued = true
763
+ return
764
+ }
765
+ this.reconcileInFlight = true
766
+ try {
767
+ const snapshot = await this.client.status()
768
+ if (snapshot === null) {
769
+ this.failStreak += 1
770
+ } else {
771
+ this.failStreak = 0
772
+ if (this.running) this.store.applySnapshot(snapshot.sessions)
773
+ }
774
+ } finally {
775
+ this.reconcileInFlight = false
776
+ }
777
+ if (!this.running) return
778
+ if (this.reconcileQueued) {
779
+ this.reconcileQueued = false
780
+ void this.reconcileNow()
781
+ return
782
+ }
783
+ this.scheduleNext()
784
+ }
785
+
786
+ private scheduleNext(): void {
787
+ if (this.pollTimer !== null) clearTimeout(this.pollTimer)
788
+ const cadence = this.store.hasWorkingSessions() ? this.activeMs : this.idleMs
789
+ // After a failure, retry on the short doubling backoff; the cadence cap
790
+ // means a persistently absent daemon converges to the steady-state poll
791
+ // rate instead of adding load.
792
+ const delay =
793
+ this.failStreak > 0
794
+ ? Math.min(this.failureBackoffMs * 2 ** (this.failStreak - 1), cadence)
795
+ : cadence
796
+ this.pollTimer = setTimeout(() => {
797
+ this.pollTimer = null
798
+ void this.reconcileNow()
799
+ }, delay)
800
+ }
801
+
802
+ /** Schedule one debounced early reconcile (subscribe events are hints). */
803
+ private kick(): void {
804
+ if (!this.running || this.kickTimer !== null) return
805
+ this.kickTimer = setTimeout(() => {
806
+ this.kickTimer = null
807
+ void this.reconcileNow()
808
+ }, this.debounceMs)
809
+ }
810
+
811
+ private openSubscription(): void {
812
+ if (!this.running) return
813
+ this.subscription = this.client.subscribe({
814
+ onReady: () => {
815
+ if (!this.running) return
816
+ this.backoffMs = this.reconnectMinMs
817
+ this.store.setStreamHealth('ok')
818
+ },
819
+ onEvent: (ev) => {
820
+ if (!this.running) return
821
+ this.store.applyEvent(ev)
822
+ this.kick()
823
+ },
824
+ onDrop: () => {
825
+ // A discarded line means missed information: reconcile early.
826
+ this.kick()
827
+ },
828
+ onClose: () => {
829
+ this.subscription = null
830
+ if (!this.running) return
831
+ this.store.setStreamHealth('degraded')
832
+ const delay = this.backoffMs
833
+ this.backoffMs = Math.min(this.backoffMs * 2, this.reconnectMaxMs)
834
+ this.reconnectTimer = setTimeout(() => {
835
+ this.reconnectTimer = null
836
+ this.openSubscription()
837
+ }, delay)
838
+ },
839
+ })
840
+ }
841
+ }