@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/fusion.ts ADDED
@@ -0,0 +1,988 @@
1
+ /**
2
+ * FusionQuery — bypass-analysis data-fusion layer of the host half
3
+ * (design §4.e / M3, T5.1): merges the in-process dsh session-event feed
4
+ * with sidecar socket data into one cross-agent query surface.
5
+ *
6
+ * API facts verified against the installed SDK (authoritative over the
7
+ * design sketch; both design assumptions CONFIRMED):
8
+ *
9
+ * - In-process dsh event feed: `@deepseek-ai/dsh-session` augments the
10
+ * cordis `Events` map with `'session/event'(session, event)`
11
+ * (lib/types/index.d.ts:66 — post-commit, fire-and-forget append feed),
12
+ * plus `'session/created'` (:44) and `'session/disposed'` (:54). A
13
+ * root-scoped listener receives all sessions (scope filtering only
14
+ * narrows agent-scoped listeners). `Session` exposes `id` (:122),
15
+ * `header` (:120 — `cwd?`/`parentSession?`/`createdAt`,
16
+ * types.d.ts:40-78) and the on-demand immutable log snapshot `events`
17
+ * (:174). `SessionEvent` is `{type, seq, time(epoch ms), data}`
18
+ * (types.d.ts:425-457). Titles are NOT header fields: they are
19
+ * log-only `'session/title'` events with `{title: string}` data
20
+ * (`@deepseek-ai/dsh-session-title` lib/types/index.d.ts:37-45,73),
21
+ * folded latest-wins here.
22
+ * - Deep-query service: `@deepseek-ai/dsh-session-query` augments
23
+ * `Context` with `sessionQuery: SessionQueryEngine`
24
+ * (lib/types/index.d.ts:19-23). Methods used: `traceSession` (:123 →
25
+ * `SessionLineageTrace`, types.d.ts:59-76), `readSession` (:62 → full
26
+ * raw log) and `searchSessions` (:42 → `SessionSearchPage
27
+ * <SessionSearchHit>`, types.d.ts:224-235/:255-258). Headless or
28
+ * trimmed compositions may not mount it, so it is resolved lazily on
29
+ * every use and every capability degrades instead of throwing.
30
+ * - Sidecar side: session rows come from the SessionStore board state
31
+ * (`plugin/src/session-store.ts`); `updated_at` is epoch SECONDS
32
+ * (sidecar/model.py:50) while dsh `time` is epoch ms, normalized here
33
+ * to ms. The dsh adapter reuses the raw dsh session id as
34
+ * `session_id` (sidecar/adapters/dsh.py:311-372), which is what makes
35
+ * `session_id` a valid dedup key, and conditionally mirrors the native
36
+ * seq into `extra.seq` (dsh.py:263-269). One dsh record may normalize
37
+ * into several events sharing that seq (content blocks), so timeline
38
+ * dedup identity is seq+kind+text, never seq alone (F1).
39
+ * `SessionView` today drops `extra` and
40
+ * `parent_id`; the store face marks them optional so the extra
41
+ * supplement activates when the wiring supplies fuller rows.
42
+ *
43
+ * Fusion rules (design §4.e):
44
+ * - Same `session_id` dsh session: the in-process feed is the
45
+ * authoritative primary source (real-time); the sidecar row only
46
+ * supplements fields the feed lacks (status estimate, `extra`
47
+ * stats/plan, normalized last-event summary) and serves as the cold
48
+ * fallback when the session is not live in this dsh process. Non-dsh
49
+ * agents only ever have the sidecar source.
50
+ * - Cross-agent correlation key: normalized project path + time window
51
+ * (`getProjectGroups`).
52
+ * - No bulk event retention: dsh timelines are read on demand from the
53
+ * live session's own log snapshot (or `readSession` when cold);
54
+ * sidecar events are kept only in a small bounded per-session ring
55
+ * fed by the wiring, and {@link SidecarReplayFace} is the seam for the
56
+ * daemon `replay` op (T5.2 provides, T5.3 consumes).
57
+ *
58
+ * Pure DI: no cordis/dsh imports. All faces are minimal structural
59
+ * shapes extracted from the d.ts; method-syntax members keep parameter
60
+ * checks bivariant, so the SDK's branded `SessionId` and wider request
61
+ * types remain assignable (same pattern as dsh-inject.ts).
62
+ *
63
+ * @module
64
+ */
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // In-process dsh faces (see header for d.ts provenance).
68
+ // ---------------------------------------------------------------------------
69
+
70
+ /** `SessionHeader` subset (dsh-session types.d.ts:40-78). */
71
+ export interface DshSessionHeaderFace {
72
+ readonly createdAt: number
73
+ readonly cwd?: string
74
+ readonly parentSession?: string
75
+ }
76
+
77
+ /** `SessionEvent` subset (dsh-session types.d.ts:425-457). */
78
+ export interface DshSessionEventFace {
79
+ readonly type: string
80
+ /** Monotonic sequence number within the session. */
81
+ readonly seq: number
82
+ /** Unix epoch milliseconds. */
83
+ readonly time: number
84
+ readonly data: unknown
85
+ }
86
+
87
+ /** `Session` subset: identity, header, on-demand log snapshot (index.d.ts:106-174). */
88
+ export interface DshSessionFace {
89
+ readonly id: string
90
+ readonly header: DshSessionHeaderFace
91
+ readonly events: readonly DshSessionEventFace[]
92
+ }
93
+
94
+ /**
95
+ * In-process dsh session-event subscription face — the `ctx.on` shape
96
+ * for the three `@deepseek-ai/dsh-session` events this layer consumes
97
+ * (index.d.ts:44/:54/:66). Each call returns the disposer.
98
+ */
99
+ export interface DshEventFace {
100
+ on(
101
+ event: 'session/event',
102
+ handler: (session: DshSessionFace, ev: DshSessionEventFace) => void,
103
+ ): () => void
104
+ on(event: 'session/created', handler: (session: DshSessionFace) => void): () => void
105
+ on(event: 'session/disposed', handler: (session: DshSessionFace) => void): () => void
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // sessionQuery faces (dsh-session-query d.ts; lazily resolved, optional).
110
+ // ---------------------------------------------------------------------------
111
+
112
+ /** `SessionRecord` subset (session-query types.d.ts:14-21). */
113
+ export interface DshLineageRecordFace {
114
+ readonly header: {
115
+ readonly id: string
116
+ readonly createdAt: number
117
+ readonly cwd?: string
118
+ readonly parentSession?: string
119
+ }
120
+ readonly live: boolean
121
+ readonly persisted: boolean
122
+ }
123
+
124
+ /** `SessionLineageNode` subset (types.d.ts:52-57). */
125
+ export interface DshLineageNodeFace {
126
+ readonly session: DshLineageRecordFace
127
+ readonly descendants: readonly DshLineageNodeFace[]
128
+ }
129
+
130
+ /**
131
+ * `SessionLineageTrace` subset (types.d.ts:59-76). The SDK type is a
132
+ * discriminated union on `complete`; this face widens it so both
133
+ * branches are assignable.
134
+ */
135
+ export interface DshLineageTraceFace {
136
+ readonly target: DshLineageRecordFace
137
+ readonly ancestors: readonly DshLineageRecordFace[]
138
+ readonly descendants: readonly DshLineageNodeFace[]
139
+ readonly complete: boolean
140
+ readonly root?: DshLineageRecordFace
141
+ readonly unresolvedParentId?: string
142
+ }
143
+
144
+ /** `SessionLogSnapshot` subset (types.d.ts:32-37). */
145
+ export interface DshSessionLogFace {
146
+ readonly events: readonly DshSessionEventFace[]
147
+ }
148
+
149
+ /** One cross-session full-text hit (`SessionSearchHit`, types.d.ts:255-258). */
150
+ export interface DshSearchHitFace {
151
+ readonly header: { readonly id: string }
152
+ readonly bestMatch: { readonly seq: number; readonly snippet: string }
153
+ }
154
+
155
+ /**
156
+ * Minimal `ctx.sessionQuery` (`SessionQueryEngine`) face: lineage
157
+ * (index.d.ts:123), cold raw-log read (:62), full-text search (:42).
158
+ */
159
+ export interface SessionQueryFace {
160
+ traceSession(sessionId: string, signal?: AbortSignal): Promise<DshLineageTraceFace>
161
+ readSession(sessionId: string): Promise<DshSessionLogFace>
162
+ searchSessions(request: {
163
+ query: string
164
+ limit?: number
165
+ }): Promise<{ items: readonly DshSearchHitFace[] }>
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Sidecar faces (wire shapes of bridge.ts / session-store.ts, kept local
170
+ // per module-ownership rules; the real objects satisfy them structurally).
171
+ // ---------------------------------------------------------------------------
172
+
173
+ /** One normalized sidecar event (`Event.to_dict`; bridge.ts `SidecarEvent`). */
174
+ export interface SidecarEventFace {
175
+ readonly ts: string
176
+ readonly agent: string
177
+ readonly session_id: string
178
+ readonly kind: string
179
+ readonly text: string
180
+ readonly extra?: Record<string, unknown>
181
+ }
182
+
183
+ /**
184
+ * One sidecar board row. `SessionView` (session-store.ts) satisfies the
185
+ * required fields; `extra`/`parent_id` are optional because the current
186
+ * board projection drops them — a fuller wiring adapter can supply them
187
+ * and the extra supplement activates automatically.
188
+ */
189
+ export interface SidecarSessionRowFace {
190
+ readonly agent: string
191
+ readonly session_id: string
192
+ readonly status: string
193
+ readonly title: string
194
+ readonly project: string
195
+ /** Unix epoch SECONDS (sidecar/model.py:50). */
196
+ readonly updated_at: number
197
+ readonly last_event?: { readonly ts: string; readonly kind: string; readonly text: string } | null
198
+ readonly gap?: boolean
199
+ readonly extra?: Record<string, unknown>
200
+ readonly parent_id?: string | null
201
+ }
202
+
203
+ /** What fusion needs from the session cache (satisfied by SessionStore). */
204
+ export interface FusionStoreFace {
205
+ getBoardState(): {
206
+ sessions: readonly SidecarSessionRowFace[]
207
+ streamHealth: string
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Seam for the daemon `replay {session_id, after_seq}` op (T5.2). Absent
213
+ * until wired; the timeline then relies on the bounded ring alone.
214
+ */
215
+ export interface SidecarReplayFace {
216
+ replay(request: {
217
+ sessionId: string
218
+ afterSeq?: number | null
219
+ }): Promise<readonly SidecarEventFace[]>
220
+ }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Fused result types.
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /** Provenance of one unified session row. */
227
+ export type UnifiedOrigin = 'dsh-live' | 'sidecar' | 'merged'
228
+
229
+ /** One deduplicated cross-agent session row. */
230
+ export interface UnifiedSession {
231
+ agent: string
232
+ sessionId: string
233
+ origin: UnifiedOrigin
234
+ /** True when the session is live in this dsh process (in-process feed). */
235
+ live: boolean
236
+ /** Sidecar-inferred status; `'unknown'` when only the live feed knows the session. */
237
+ status: string
238
+ title: string
239
+ project: string
240
+ /** Unix epoch ms of the freshest signal across both sources. */
241
+ lastActivityAt: number
242
+ /** Sidecar last-event summary (normalized text), when known. */
243
+ lastEvent: { readonly ts: string; readonly kind: string; readonly text: string } | null
244
+ /** Latest known dsh seq (in-process preferred, sidecar `extra.seq` fallback). */
245
+ lastSeq: number | null
246
+ /** Sidecar-observed seq discontinuity flag. */
247
+ gap: boolean
248
+ parentId: string | null
249
+ /** Sidecar extra supplement (stats/plan/...); `{}` when unknown. */
250
+ extra: Record<string, unknown>
251
+ }
252
+
253
+ /** One project correlation group (cross-agent view, design §4.e.2). */
254
+ export interface ProjectGroup {
255
+ project: string
256
+ /** Distinct agents active in the group, sorted. */
257
+ agents: string[]
258
+ /** Member sessions, most recent first. */
259
+ sessions: UnifiedSession[]
260
+ lastActivityAt: number
261
+ }
262
+
263
+ /** One merged timeline entry. */
264
+ export interface TimelineEntry {
265
+ origin: 'dsh' | 'sidecar'
266
+ /** dsh log seq (native or mirrored via sidecar `extra.seq`); null when unknown. */
267
+ seq: number | null
268
+ /** Unix epoch ms. */
269
+ ts: number
270
+ /** dsh native event type for dsh entries, normalized kind for sidecar entries. */
271
+ kind: string
272
+ /** Normalized sidecar text; `''` for dsh entries without a sidecar twin. */
273
+ text: string
274
+ /** Raw dsh event data (undefined for sidecar-only entries). */
275
+ data: unknown
276
+ /** Sidecar extra of this entry or of the deduplicated twin. */
277
+ extra: Record<string, unknown> | null
278
+ }
279
+
280
+ /** Backward-pagination cursor: identity of the oldest entry of a page. */
281
+ export interface TimelineCursor {
282
+ seq: number | null
283
+ ts: number
284
+ }
285
+
286
+ /** Which sources contributed to a timeline page. */
287
+ export interface TimelineSources {
288
+ dshLive: boolean
289
+ dshCold: boolean
290
+ sidecarReplay: boolean
291
+ sidecarBuffer: boolean
292
+ }
293
+
294
+ /** One on-demand timeline page (entries ascending). */
295
+ export interface TimelinePage {
296
+ sessionId: string
297
+ entries: TimelineEntry[]
298
+ /** Cursor for the next older page; null when the page reached the log start. */
299
+ cursor: TimelineCursor | null
300
+ sources: TimelineSources
301
+ }
302
+
303
+ /** `getLineage` result; degrades instead of throwing (design §4.e.4). */
304
+ export interface LineageResult {
305
+ available: boolean
306
+ trace: DshLineageTraceFace | null
307
+ reason: 'session_query_unavailable' | 'trace_failed' | null
308
+ detail?: string
309
+ }
310
+
311
+ /** Capability advertisement for the UI (honest degradation labelling). */
312
+ export interface FusionCapabilities {
313
+ dshEvents: { available: boolean; liveSessions: number }
314
+ sessionQuery: { available: boolean; reason: 'session_query_unavailable' | null }
315
+ search: { mode: 'full-text' | 'filter-only' }
316
+ }
317
+
318
+ /** One search match. */
319
+ export interface SearchMatch {
320
+ session: UnifiedSession
321
+ matchedBy: 'full-text' | 'title' | 'project'
322
+ /** Best-match excerpt (full-text hits only). */
323
+ snippet: string | null
324
+ }
325
+
326
+ /** Search result; `filter-only` is the sessionQuery-absent degradation. */
327
+ export interface SearchResult {
328
+ mode: 'full-text' | 'filter-only'
329
+ items: SearchMatch[]
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // Tunables.
334
+ // ---------------------------------------------------------------------------
335
+
336
+ /** Default timeline page size. */
337
+ export const DEFAULT_TIMELINE_LIMIT = 100
338
+ /** Default project correlation window (matches `ui.time-window-hours` = 24). */
339
+ export const DEFAULT_PROJECT_WINDOW_MS = 24 * 60 * 60 * 1000
340
+ /** Default search page size. */
341
+ export const DEFAULT_SEARCH_LIMIT = 50
342
+ /** Per-session bound of the sidecar event ring (hints, not a store). */
343
+ export const DEFAULT_MAX_BUFFERED_EVENTS_PER_SESSION = 200
344
+ /** Bound on distinct session rings (least-recently-fed evicted first). */
345
+ export const DEFAULT_MAX_BUFFERED_SESSIONS = 256
346
+
347
+ const DSH_AGENT = 'dsh'
348
+ const KEY_SEP = '\u0000'
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Internals.
352
+ // ---------------------------------------------------------------------------
353
+
354
+ interface DshLiveEntry {
355
+ session: DshSessionFace
356
+ /** Latest-wins fold of `session/title` events; null before the first title. */
357
+ title: string | null
358
+ lastSeq: number | null
359
+ /** Unix epoch ms of the latest observed event. */
360
+ lastEventAt: number | null
361
+ }
362
+
363
+ function integerOrNull(value: unknown): number | null {
364
+ return typeof value === 'number' && Number.isInteger(value) ? value : null
365
+ }
366
+
367
+ function secondsToMs(seconds: number): number {
368
+ return typeof seconds === 'number' && Number.isFinite(seconds) ? Math.round(seconds * 1000) : 0
369
+ }
370
+
371
+ function parseTs(ts: string): number {
372
+ const ms = Date.parse(ts)
373
+ return Number.isFinite(ms) ? ms : 0
374
+ }
375
+
376
+ /** Latest-wins `session/title` payload fold (`{title: string}`). */
377
+ function extractTitle(data: unknown): string | null {
378
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) return null
379
+ const title = (data as Record<string, unknown>)['title']
380
+ return typeof title === 'string' && title !== '' ? title : null
381
+ }
382
+
383
+ /** Correlation-key normalization: strip trailing slashes (keep root `/`). */
384
+ function normalizeProject(project: string): string {
385
+ if (project.length > 1 && project.endsWith('/')) {
386
+ const stripped = project.replace(/\/+$/, '')
387
+ return stripped === '' ? '/' : stripped
388
+ }
389
+ return project
390
+ }
391
+
392
+ function describeError(error: unknown): string {
393
+ return error instanceof Error ? error.message : String(error)
394
+ }
395
+
396
+ /**
397
+ * Dedup identity of one sidecar event within a single session's timeline.
398
+ * The dsh adapter legally normalizes ONE dsh record into SEVERAL events
399
+ * sharing the same `extra.seq` (reasoning+text blocks of an assistant
400
+ * message, multi-block user messages, spliced inbox inserts —
401
+ * sidecar/adapters/dsh.py content_block_events), and no per-block ordinal
402
+ * exists on the wire — so seq alone would silently drop sibling events.
403
+ * The identity is therefore `seq+kind+text`: the same underlying event
404
+ * seen through both replay and the ring still collapses (identical
405
+ * normalized kind/text), while same-seq siblings stay distinct. Must stay
406
+ * in sync with the client mirror (client/detail/logic.ts `entryKey`).
407
+ */
408
+ function sidecarEventKey(ev: SidecarEventFace): string {
409
+ const seq = integerOrNull(ev.extra?.['seq'])
410
+ return seq !== null
411
+ ? `s:${seq}${KEY_SEP}${ev.kind}${KEY_SEP}${ev.text}`
412
+ : `t:${ev.ts}${KEY_SEP}${ev.kind}${KEY_SEP}${ev.text}`
413
+ }
414
+
415
+ /** Strictly-older-than-cursor predicate over the merged ascending order. */
416
+ function isBeforeCursor(entry: TimelineEntry, cursor: TimelineCursor): boolean {
417
+ if (cursor.seq !== null && entry.seq !== null) return entry.seq < cursor.seq
418
+ return entry.ts < cursor.ts
419
+ }
420
+
421
+ /** One seq-carrying sidecar event as its own timeline entry. */
422
+ function sidecarSeqEntry(seq: number, ev: SidecarEventFace): TimelineEntry {
423
+ return {
424
+ origin: 'sidecar',
425
+ seq,
426
+ ts: parseTs(ev.ts),
427
+ kind: ev.kind,
428
+ text: ev.text,
429
+ data: undefined,
430
+ extra: ev.extra ?? null,
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Merge dsh events (authoritative seq domain) with sidecar events.
436
+ * One dsh record can normalize into several sidecar events sharing the
437
+ * same `extra.seq` (multi-block messages), so twins are grouped per seq:
438
+ * the FIRST twin folds into the matching dsh entry (normalized text +
439
+ * extra supplement, dsh primary) and every further sibling stays its own
440
+ * entry — dropping siblings would silently lose blocks (F1). Seq-carrying
441
+ * entries keep exact seq order (same-seq groups keep dsh-then-block
442
+ * arrival order via the stable sort); seq-less entries interleave by
443
+ * timestamp.
444
+ */
445
+ function mergeTimeline(
446
+ dshEvents: readonly DshSessionEventFace[],
447
+ sidecarEvents: readonly SidecarEventFace[],
448
+ ): TimelineEntry[] {
449
+ const twinsBySeq = new Map<number, SidecarEventFace[]>()
450
+ const unseqed: TimelineEntry[] = []
451
+ for (const ev of sidecarEvents) {
452
+ const seq = integerOrNull(ev.extra?.['seq'])
453
+ if (seq !== null) {
454
+ const group = twinsBySeq.get(seq)
455
+ if (group === undefined) twinsBySeq.set(seq, [ev])
456
+ else group.push(ev)
457
+ } else {
458
+ unseqed.push({
459
+ origin: 'sidecar',
460
+ seq: null,
461
+ ts: parseTs(ev.ts),
462
+ kind: ev.kind,
463
+ text: ev.text,
464
+ data: undefined,
465
+ extra: ev.extra ?? null,
466
+ })
467
+ }
468
+ }
469
+
470
+ const seqDomain: TimelineEntry[] = []
471
+ const dshSeqs = new Set<number>()
472
+ for (const ev of dshEvents) {
473
+ const twins = dshSeqs.has(ev.seq) ? undefined : twinsBySeq.get(ev.seq)
474
+ dshSeqs.add(ev.seq)
475
+ const first = twins?.[0]
476
+ seqDomain.push({
477
+ origin: 'dsh',
478
+ seq: ev.seq,
479
+ ts: ev.time,
480
+ kind: ev.type,
481
+ text: first?.text ?? '',
482
+ data: ev.data,
483
+ extra: first?.extra ?? null,
484
+ })
485
+ if (twins !== undefined) {
486
+ for (let i = 1; i < twins.length; i += 1) {
487
+ const sibling = twins[i]
488
+ if (sibling !== undefined) seqDomain.push(sidecarSeqEntry(ev.seq, sibling))
489
+ }
490
+ }
491
+ }
492
+ for (const [seq, twins] of twinsBySeq) {
493
+ if (dshSeqs.has(seq)) continue
494
+ for (const ev of twins) seqDomain.push(sidecarSeqEntry(seq, ev))
495
+ }
496
+ // Array.prototype.sort is stable: same-seq entries keep their push
497
+ // order (dsh entry first, then siblings in block/arrival order).
498
+ seqDomain.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
499
+ unseqed.sort((a, b) => a.ts - b.ts)
500
+
501
+ // Two-pointer merge by ts (tie: seq domain first) preserves exact seq
502
+ // order even when event clocks tie or wobble within the seq domain.
503
+ const out: TimelineEntry[] = []
504
+ let i = 0
505
+ let j = 0
506
+ for (;;) {
507
+ const a = seqDomain[i]
508
+ const b = unseqed[j]
509
+ if (a === undefined && b === undefined) break
510
+ if (b === undefined || (a !== undefined && a.ts <= b.ts)) {
511
+ if (a !== undefined) {
512
+ out.push(a)
513
+ i += 1
514
+ }
515
+ } else {
516
+ out.push(b)
517
+ j += 1
518
+ }
519
+ }
520
+ return out
521
+ }
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // FusionQuery.
525
+ // ---------------------------------------------------------------------------
526
+
527
+ export interface FusionQueryOptions {
528
+ /** Sidecar session cache (read-only board state). */
529
+ store: FusionStoreFace
530
+ /** In-process dsh event feed; absent → sidecar-only fusion (no error). */
531
+ dshEvents?: DshEventFace | null
532
+ /**
533
+ * Lazy `ctx.sessionQuery` resolver, re-evaluated on every use (the
534
+ * service may mount late or never); undefined/null/throw → absent.
535
+ */
536
+ getSessionQuery?: (() => SessionQueryFace | null | undefined) | null
537
+ /** Daemon replay op seam (T5.2); absent until wired. */
538
+ replay?: SidecarReplayFace | null
539
+ /** Clock override for tests. */
540
+ now?: () => number
541
+ maxBufferedEventsPerSession?: number
542
+ maxBufferedSessions?: number
543
+ }
544
+
545
+ /**
546
+ * The fused query surface. Lifecycle: `start()` subscribes to the dsh
547
+ * feed, `stop()` disposes subscriptions and drops all cached state; the
548
+ * wiring feeds the sidecar subscribe stream through
549
+ * {@link ingestSidecarEvent}. All query methods are on-demand pulls.
550
+ */
551
+ export class FusionQuery {
552
+ private readonly store: FusionStoreFace
553
+ private readonly dshEvents: DshEventFace | null
554
+ private readonly getSessionQueryThunk: (() => SessionQueryFace | null | undefined) | null
555
+ private readonly replaySource: SidecarReplayFace | null
556
+ private readonly now: () => number
557
+ private readonly maxEventsPerSession: number
558
+ private readonly maxSessions: number
559
+
560
+ /** Live in-process dsh sessions keyed by session id. */
561
+ private readonly live = new Map<string, DshLiveEntry>()
562
+ /** Bounded per-session sidecar event rings; insertion order = feed recency. */
563
+ private readonly buffers = new Map<string, SidecarEventFace[]>()
564
+ private disposers: Array<() => void> = []
565
+ private started = false
566
+
567
+ constructor(opts: FusionQueryOptions) {
568
+ this.store = opts.store
569
+ this.dshEvents = opts.dshEvents ?? null
570
+ this.getSessionQueryThunk = opts.getSessionQuery ?? null
571
+ this.replaySource = opts.replay ?? null
572
+ this.now = opts.now ?? Date.now
573
+ this.maxEventsPerSession =
574
+ opts.maxBufferedEventsPerSession ?? DEFAULT_MAX_BUFFERED_EVENTS_PER_SESSION
575
+ this.maxSessions = opts.maxBufferedSessions ?? DEFAULT_MAX_BUFFERED_SESSIONS
576
+ if (this.maxEventsPerSession <= 0 || this.maxSessions <= 0) {
577
+ throw new RangeError('fusion buffer bounds are invalid')
578
+ }
579
+ }
580
+
581
+ /** Subscribe to the in-process feed (idempotent). */
582
+ start(): void {
583
+ if (this.started) return
584
+ this.started = true
585
+ if (this.dshEvents === null) return
586
+ this.disposers.push(
587
+ this.dshEvents.on('session/created', (session) => {
588
+ this.ensureLive(session)
589
+ }),
590
+ this.dshEvents.on('session/event', (session, ev) => {
591
+ this.handleDshEvent(session, ev)
592
+ }),
593
+ this.dshEvents.on('session/disposed', (session) => {
594
+ this.live.delete(session.id)
595
+ }),
596
+ )
597
+ }
598
+
599
+ /** Dispose subscriptions and drop all cached state (idempotent). */
600
+ stop(): void {
601
+ if (!this.started) return
602
+ this.started = false
603
+ const disposers = this.disposers
604
+ this.disposers = []
605
+ for (const dispose of disposers) dispose()
606
+ this.live.clear()
607
+ this.buffers.clear()
608
+ }
609
+
610
+ /**
611
+ * Feed one sidecar subscribe-stream event into the bounded ring
612
+ * (timeline hints only; the stream stays a trigger signal, ADR-2).
613
+ */
614
+ ingestSidecarEvent(ev: SidecarEventFace): void {
615
+ if (typeof ev.session_id !== 'string' || ev.session_id === '') return
616
+ let ring = this.buffers.get(ev.session_id)
617
+ if (ring === undefined) {
618
+ ring = []
619
+ } else {
620
+ // Delete + re-set keeps Map insertion order as feed recency.
621
+ this.buffers.delete(ev.session_id)
622
+ }
623
+ ring.push(ev)
624
+ if (ring.length > this.maxEventsPerSession) {
625
+ ring.splice(0, ring.length - this.maxEventsPerSession)
626
+ }
627
+ this.buffers.set(ev.session_id, ring)
628
+ while (this.buffers.size > this.maxSessions) {
629
+ const oldest = this.buffers.keys().next()
630
+ if (oldest.done === true) break
631
+ this.buffers.delete(oldest.value)
632
+ }
633
+ }
634
+
635
+ /**
636
+ * Deduplicated cross-agent session list, most recently active first.
637
+ * dsh sessions live in this process win over their sidecar rows
638
+ * (which then only supplement); cold dsh sessions and non-dsh agents
639
+ * come from the sidecar alone.
640
+ */
641
+ getUnifiedSessions(): UnifiedSession[] {
642
+ const board = this.store.getBoardState()
643
+ const out = new Map<string, UnifiedSession>()
644
+ const mergedIds = new Set<string>()
645
+ for (const row of board.sessions) {
646
+ const liveEntry = row.agent === DSH_AGENT ? this.live.get(row.session_id) : undefined
647
+ if (liveEntry !== undefined) {
648
+ mergedIds.add(row.session_id)
649
+ out.set(`${row.agent}${KEY_SEP}${row.session_id}`, this.mergeRow(liveEntry, row))
650
+ } else {
651
+ out.set(`${row.agent}${KEY_SEP}${row.session_id}`, fromSidecarRow(row))
652
+ }
653
+ }
654
+ for (const [id, entry] of this.live) {
655
+ if (mergedIds.has(id)) continue
656
+ out.set(`${DSH_AGENT}${KEY_SEP}${id}`, fromDshLive(entry))
657
+ }
658
+ const sessions = [...out.values()]
659
+ sessions.sort(
660
+ (a, b) => b.lastActivityAt - a.lastActivityAt || a.sessionId.localeCompare(b.sessionId),
661
+ )
662
+ return sessions
663
+ }
664
+
665
+ /**
666
+ * Cross-agent project correlation groups within a time window
667
+ * (project path + window is the correlation key, design §4.e.2).
668
+ */
669
+ getProjectGroups(opts: { windowMs?: number; now?: number } = {}): ProjectGroup[] {
670
+ const windowMs = opts.windowMs ?? DEFAULT_PROJECT_WINDOW_MS
671
+ const cutoff = (opts.now ?? this.now()) - windowMs
672
+ const groups = new Map<string, ProjectGroup>()
673
+ for (const session of this.getUnifiedSessions()) {
674
+ if (session.lastActivityAt < cutoff) continue
675
+ const project = normalizeProject(session.project)
676
+ let group = groups.get(project)
677
+ if (group === undefined) {
678
+ group = { project, agents: [], sessions: [], lastActivityAt: 0 }
679
+ groups.set(project, group)
680
+ }
681
+ group.sessions.push(session)
682
+ if (!group.agents.includes(session.agent)) group.agents.push(session.agent)
683
+ if (session.lastActivityAt > group.lastActivityAt) {
684
+ group.lastActivityAt = session.lastActivityAt
685
+ }
686
+ }
687
+ const out = [...groups.values()]
688
+ for (const group of out) group.agents.sort()
689
+ out.sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.project.localeCompare(b.project))
690
+ return out
691
+ }
692
+
693
+ /**
694
+ * One merged timeline page for a session, ascending, deduplicated by
695
+ * event identity (seq+kind+text for seq-carrying events — same-seq
696
+ * sibling events from multi-block records all survive), newest window
697
+ * first with a backward cursor. Sources are pulled on demand; a
698
+ * missing/failing source silently narrows the page (provenance is
699
+ * reported in `sources`).
700
+ */
701
+ async getSessionTimeline(
702
+ sessionId: string,
703
+ opts: { limit?: number; before?: TimelineCursor | null } = {},
704
+ ): Promise<TimelinePage> {
705
+ const limit = Math.max(1, Math.floor(opts.limit ?? DEFAULT_TIMELINE_LIMIT))
706
+ const sources: TimelineSources = {
707
+ dshLive: false,
708
+ dshCold: false,
709
+ sidecarReplay: false,
710
+ sidecarBuffer: false,
711
+ }
712
+
713
+ let dshEvents: readonly DshSessionEventFace[] = []
714
+ const liveEntry = this.live.get(sessionId)
715
+ if (liveEntry !== undefined) {
716
+ dshEvents = liveEntry.session.events
717
+ sources.dshLive = true
718
+ } else {
719
+ const engine = this.resolveSessionQuery()
720
+ if (engine !== null) {
721
+ try {
722
+ dshEvents = (await engine.readSession(sessionId)).events
723
+ sources.dshCold = true
724
+ } catch {
725
+ // Cold dsh log unavailable (unknown id / persistence off):
726
+ // the sidecar side still yields an honest partial timeline.
727
+ }
728
+ }
729
+ }
730
+
731
+ const sidecarEvents: SidecarEventFace[] = []
732
+ const seen = new Set<string>()
733
+ const addSidecar = (ev: SidecarEventFace): boolean => {
734
+ const key = sidecarEventKey(ev)
735
+ if (seen.has(key)) return false
736
+ seen.add(key)
737
+ sidecarEvents.push(ev)
738
+ return true
739
+ }
740
+ if (this.replaySource !== null) {
741
+ try {
742
+ const replayed = await this.replaySource.replay({ sessionId })
743
+ for (const ev of replayed) addSidecar(ev)
744
+ sources.sidecarReplay = true
745
+ } catch {
746
+ // Replay op unavailable — fall through to the bounded ring.
747
+ }
748
+ }
749
+ const ring = this.buffers.get(sessionId)
750
+ if (ring !== undefined && ring.length > 0) {
751
+ sources.sidecarBuffer = true
752
+ for (const ev of ring) addSidecar(ev)
753
+ }
754
+
755
+ const entries = mergeTimeline(dshEvents, sidecarEvents)
756
+
757
+ let endIdx = entries.length
758
+ const before = opts.before ?? null
759
+ if (before !== null) {
760
+ endIdx = 0
761
+ while (endIdx < entries.length) {
762
+ const entry = entries[endIdx]
763
+ if (entry === undefined || !isBeforeCursor(entry, before)) break
764
+ endIdx += 1
765
+ }
766
+ }
767
+ let startIdx = Math.max(0, endIdx - limit)
768
+ // Never split a same-seq sibling group across the page boundary: the
769
+ // cursor identifies entries by seq (strictly-older predicate), so a
770
+ // group straddling it would lose its older siblings to pagination.
771
+ // Widening keeps the cursor group-aligned at a small bounded overshoot.
772
+ const boundary = entries[startIdx]
773
+ if (boundary !== undefined && boundary.seq !== null) {
774
+ while (startIdx > 0 && entries[startIdx - 1]?.seq === boundary.seq) startIdx -= 1
775
+ }
776
+ const window = entries.slice(startIdx, endIdx)
777
+ const first = window[0]
778
+ const cursor =
779
+ startIdx > 0 && first !== undefined ? { seq: first.seq, ts: first.ts } : null
780
+ return { sessionId, entries: window, cursor, sources }
781
+ }
782
+
783
+ /**
784
+ * dsh lineage via `sessionQuery.traceSession`; degrades to
785
+ * `trace: null` + reason when the service is absent or the trace
786
+ * fails (never throws).
787
+ */
788
+ async getLineage(sessionId: string): Promise<LineageResult> {
789
+ const engine = this.resolveSessionQuery()
790
+ if (engine === null) {
791
+ return { available: false, trace: null, reason: 'session_query_unavailable' }
792
+ }
793
+ try {
794
+ const trace = await engine.traceSession(sessionId)
795
+ return { available: true, trace, reason: null }
796
+ } catch (error) {
797
+ return {
798
+ available: false,
799
+ trace: null,
800
+ reason: 'trace_failed',
801
+ detail: describeError(error),
802
+ }
803
+ }
804
+ }
805
+
806
+ /**
807
+ * Cross-agent search. With sessionQuery mounted, dsh sessions get
808
+ * full-text ranking (hits first, engine order); without it — or when
809
+ * the engine call fails — the deep query degrades to title/project
810
+ * substring filtering over the unified view (`filter-only`), without
811
+ * error. Non-dsh agents always use the filter path (the sidecar has
812
+ * no search API).
813
+ */
814
+ async searchSessions(query: string, opts: { limit?: number } = {}): Promise<SearchResult> {
815
+ const limit = Math.max(1, Math.floor(opts.limit ?? DEFAULT_SEARCH_LIMIT))
816
+ const needle = query.trim().toLowerCase()
817
+ const engine = this.resolveSessionQuery()
818
+ let mode: SearchResult['mode'] = engine !== null ? 'full-text' : 'filter-only'
819
+ if (needle === '') return { mode, items: [] }
820
+
821
+ const unified = this.getUnifiedSessions()
822
+ const items: SearchMatch[] = []
823
+ const seen = new Set<string>()
824
+
825
+ if (engine !== null) {
826
+ try {
827
+ const page = await engine.searchSessions({ query, limit })
828
+ const dshById = new Map<string, UnifiedSession>()
829
+ for (const session of unified) {
830
+ if (session.agent === DSH_AGENT) dshById.set(session.sessionId, session)
831
+ }
832
+ for (const hit of page.items) {
833
+ const session = dshById.get(hit.header.id)
834
+ // Hits outside the unified view (evicted from every local
835
+ // source) have nothing to attach to and are skipped.
836
+ if (session === undefined) continue
837
+ const key = `${session.agent}${KEY_SEP}${session.sessionId}`
838
+ if (seen.has(key)) continue
839
+ seen.add(key)
840
+ items.push({ session, matchedBy: 'full-text', snippet: hit.bestMatch.snippet })
841
+ }
842
+ } catch {
843
+ mode = 'filter-only'
844
+ }
845
+ }
846
+
847
+ for (const session of unified) {
848
+ const key = `${session.agent}${KEY_SEP}${session.sessionId}`
849
+ if (seen.has(key)) continue
850
+ if (session.title.toLowerCase().includes(needle)) {
851
+ seen.add(key)
852
+ items.push({ session, matchedBy: 'title', snippet: null })
853
+ } else if (session.project.toLowerCase().includes(needle)) {
854
+ seen.add(key)
855
+ items.push({ session, matchedBy: 'project', snippet: null })
856
+ }
857
+ }
858
+ return { mode, items: items.slice(0, limit) }
859
+ }
860
+
861
+ /** Current capability face (sessionQuery re-resolved on every call). */
862
+ getCapabilities(): FusionCapabilities {
863
+ const engineAvailable = this.resolveSessionQuery() !== null
864
+ return {
865
+ dshEvents: { available: this.dshEvents !== null, liveSessions: this.live.size },
866
+ sessionQuery: {
867
+ available: engineAvailable,
868
+ reason: engineAvailable ? null : 'session_query_unavailable',
869
+ },
870
+ search: { mode: engineAvailable ? 'full-text' : 'filter-only' },
871
+ }
872
+ }
873
+
874
+ // -------------------------------------------------------------------------
875
+
876
+ private resolveSessionQuery(): SessionQueryFace | null {
877
+ if (this.getSessionQueryThunk === null) return null
878
+ try {
879
+ return this.getSessionQueryThunk() ?? null
880
+ } catch {
881
+ return null
882
+ }
883
+ }
884
+
885
+ /**
886
+ * Register a live session (first `session/created` or, when the feed
887
+ * attached late, first `session/event`), folding title/seq facts from
888
+ * the existing log tail without copying it.
889
+ */
890
+ private ensureLive(session: DshSessionFace): DshLiveEntry {
891
+ let entry = this.live.get(session.id)
892
+ if (entry !== undefined) return entry
893
+ const events = session.events
894
+ const tail = events.length > 0 ? events[events.length - 1] : undefined
895
+ let title: string | null = null
896
+ for (let i = events.length - 1; i >= 0; i -= 1) {
897
+ const ev = events[i]
898
+ if (ev !== undefined && ev.type === 'session/title') {
899
+ const candidate = extractTitle(ev.data)
900
+ if (candidate !== null) {
901
+ title = candidate
902
+ break
903
+ }
904
+ }
905
+ }
906
+ entry = {
907
+ session,
908
+ title,
909
+ lastSeq: tail !== undefined ? tail.seq : null,
910
+ lastEventAt: tail !== undefined ? tail.time : null,
911
+ }
912
+ this.live.set(session.id, entry)
913
+ return entry
914
+ }
915
+
916
+ private handleDshEvent(session: DshSessionFace, ev: DshSessionEventFace): void {
917
+ const entry = this.ensureLive(session)
918
+ if (entry.lastSeq === null || ev.seq > entry.lastSeq) entry.lastSeq = ev.seq
919
+ if (entry.lastEventAt === null || ev.time > entry.lastEventAt) entry.lastEventAt = ev.time
920
+ if (ev.type === 'session/title') {
921
+ const title = extractTitle(ev.data)
922
+ if (title !== null) entry.title = title
923
+ }
924
+ }
925
+
926
+ /** Merge one live dsh entry with its sidecar row (dsh primary). */
927
+ private mergeRow(liveEntry: DshLiveEntry, row: SidecarSessionRowFace): UnifiedSession {
928
+ const header = liveEntry.session.header
929
+ const dshActivityMs = liveEntry.lastEventAt ?? header.createdAt
930
+ return {
931
+ agent: DSH_AGENT,
932
+ sessionId: liveEntry.session.id,
933
+ origin: 'merged',
934
+ live: true,
935
+ // The in-process feed has no status estimator; the sidecar's
936
+ // inferred status stays the best-known value (supplement role).
937
+ status: row.status,
938
+ title: liveEntry.title ?? row.title,
939
+ project: header.cwd ?? row.project,
940
+ lastActivityAt: Math.max(dshActivityMs, secondsToMs(row.updated_at)),
941
+ lastEvent: row.last_event ?? null,
942
+ lastSeq: liveEntry.lastSeq ?? integerOrNull(row.extra?.['seq']),
943
+ gap: row.gap === true,
944
+ parentId:
945
+ header.parentSession ?? (typeof row.parent_id === 'string' ? row.parent_id : null),
946
+ extra: row.extra ?? {},
947
+ }
948
+ }
949
+ }
950
+
951
+ /** Cold fallback / non-dsh row: sidecar is the only source. */
952
+ function fromSidecarRow(row: SidecarSessionRowFace): UnifiedSession {
953
+ return {
954
+ agent: row.agent,
955
+ sessionId: row.session_id,
956
+ origin: 'sidecar',
957
+ live: false,
958
+ status: row.status,
959
+ title: row.title,
960
+ project: row.project,
961
+ lastActivityAt: secondsToMs(row.updated_at),
962
+ lastEvent: row.last_event ?? null,
963
+ lastSeq: integerOrNull(row.extra?.['seq']),
964
+ gap: row.gap === true,
965
+ parentId: typeof row.parent_id === 'string' ? row.parent_id : null,
966
+ extra: row.extra ?? {},
967
+ }
968
+ }
969
+
970
+ /** Live dsh session the sidecar has not (yet) observed on disk. */
971
+ function fromDshLive(entry: DshLiveEntry): UnifiedSession {
972
+ const header = entry.session.header
973
+ return {
974
+ agent: DSH_AGENT,
975
+ sessionId: entry.session.id,
976
+ origin: 'dsh-live',
977
+ live: true,
978
+ status: 'unknown',
979
+ title: entry.title ?? '',
980
+ project: header.cwd ?? '',
981
+ lastActivityAt: entry.lastEventAt ?? header.createdAt,
982
+ lastEvent: null,
983
+ lastSeq: entry.lastSeq,
984
+ gap: false,
985
+ parentId: header.parentSession ?? null,
986
+ extra: {},
987
+ }
988
+ }