@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
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Centralized user-facing strings for the session-detail view (design §5.1
3
+ * view 2, §5.3 honesty wording). Deliberately a module-local table — NOT
4
+ * part of the main locale registry (locales/zh.ts / locales/en.ts): the
5
+ * locale tables are owned by parallel tasks and the S7 integration wave
6
+ * folds this table in centrally. Same posture as board/strings.ts.
7
+ *
8
+ * Chinese is the primary UI language. Templates use `{name}` placeholders
9
+ * resolved by `formatTemplate` in `logic.ts`.
10
+ *
11
+ * Pure data module: no imports, no logic.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ export const DETAIL_STRINGS = {
17
+ /** Header block (session meta + controls). */
18
+ header: {
19
+ close: '返回看板',
20
+ listenOn: '监听中',
21
+ listenOff: '监听',
22
+ listenHint: '开启后新事件将实时追加并高亮',
23
+ untitled: '(无标题)',
24
+ unknownProject: '未知项目',
25
+ /** Design §5.3 / SKILL.md wording: statuses are inferred observations. */
26
+ observedDisclaimer: '状态为从持久化数据推断的观察值,可能滞后',
27
+ },
28
+ /** Session status badge labels (observed values, see disclaimer). */
29
+ status: {
30
+ working: '工作中',
31
+ waiting: '等待中',
32
+ idle: '空闲',
33
+ dead: '已结束',
34
+ unknown: '未知',
35
+ },
36
+ /** Timeline source badges (provenance of the merged page, honest labels). */
37
+ sources: {
38
+ title: '数据来源',
39
+ dshLive: 'dsh 实时',
40
+ dshCold: 'dsh 冷读',
41
+ sidecarReplay: 'sidecar 重放',
42
+ sidecarBuffer: 'sidecar 缓冲',
43
+ none: '来源未知',
44
+ },
45
+ /** Normalized event-kind labels; unknown kinds keep their raw text. */
46
+ kind: {
47
+ user: '用户消息',
48
+ assistant: '助手回复',
49
+ thinking: '思考',
50
+ toolCall: '工具调用',
51
+ toolResult: '工具结果',
52
+ turn: '回合',
53
+ step: '步骤',
54
+ error: '错误',
55
+ other: '事件',
56
+ },
57
+ /** Seq-discontinuity marker row (design §4.b.3 honest presentation). */
58
+ gap: {
59
+ label: '缺口:可能有 {n} 条事件未捕获(256 队列上限或未持久化)',
60
+ },
61
+ /** Timeline list chrome. */
62
+ timeline: {
63
+ loadMore: '加载更多历史',
64
+ loadingMore: '加载中…',
65
+ noMore: '已到时间线起点',
66
+ expand: '展开',
67
+ collapse: '收起',
68
+ newBadge: '新',
69
+ seq: 'seq {n}',
70
+ hiddenNotice: '为保持流畅,较早的 {n} 条已折叠',
71
+ showAll: '全部显示',
72
+ },
73
+ /** Loading / empty / error body states. */
74
+ states: {
75
+ loadingTitle: '正在加载时间线…',
76
+ emptyTitle: '暂无事件',
77
+ emptyHint: '该会话还没有可展示的规范化事件。',
78
+ errorTitle: '时间线加载失败',
79
+ /** Fallback template when the reason code has no friendly mapping. */
80
+ errorFallback: '错误码:{reason}',
81
+ errors: {
82
+ session_not_found: '会话不存在或已不可见',
83
+ invalid_cursor: '分页游标无效,请重新打开详情',
84
+ fusion_not_wired: '当前 host 未启用时间线能力',
85
+ network_error: '网络错误,无法联系 dsh host',
86
+ request_timeout: '请求超时',
87
+ },
88
+ },
89
+ /** Relative time templates (coarse buckets, matches the board wording). */
90
+ time: {
91
+ justNow: '刚刚',
92
+ minutesAgo: '{n} 分钟前',
93
+ hoursAgo: '{n} 小时前',
94
+ daysAgo: '{n} 天前',
95
+ },
96
+ } as const
97
+
98
+ export type DetailStrings = typeof DETAIL_STRINGS
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Transport helpers for the detail view's M3 read endpoints. api.ts (T2.1)
3
+ * predates M3: its `fetchSession` is typed for the M1 placeholder response
4
+ * and it has no timeline-pagination call — and per the task boundary its
5
+ * existing exports must not change (the S7 integration wave unifies the
6
+ * data layer). So this module carries the missing calls with the same
7
+ * posture as api.ts (same-origin relative paths, bounded timeout,
8
+ * normalized ApiError, injectable primitives), reusing api.ts's exported
9
+ * building blocks instead of redefining them.
10
+ *
11
+ * Read-only surface, no retry policy here (transport, not policy).
12
+ *
13
+ * @module
14
+ */
15
+
16
+ import {
17
+ API_PREFIX,
18
+ ApiError,
19
+ DEFAULT_TIMEOUT_MS,
20
+ type AbortControllerLike,
21
+ type FetchLike,
22
+ type RequestOptions,
23
+ type ResponseLike,
24
+ type SessionView,
25
+ type TimerHandle,
26
+ } from '../api.ts'
27
+ import type { TimelinePageWire } from './logic.ts'
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Wire mirrors of the M3 `GET session/<id>` body (host: routes.ts
31
+ // handleSession with fusion wired; unified row: fusion.ts UnifiedSession).
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** One deduplicated cross-agent session row (host: fusion.ts). */
35
+ export interface UnifiedSessionWire {
36
+ agent: string
37
+ sessionId: string
38
+ origin: 'dsh-live' | 'sidecar' | 'merged'
39
+ live: boolean
40
+ status: string
41
+ title: string
42
+ project: string
43
+ /** Unix epoch ms of the freshest signal across both sources. */
44
+ lastActivityAt: number
45
+ lastEvent: { ts: string; kind: string; text: string } | null
46
+ lastSeq: number | null
47
+ gap: boolean
48
+ parentId: string | null
49
+ extra: Record<string, unknown>
50
+ }
51
+
52
+ /** Body of `GET session/<id>` on an M3 host (fusion wired). */
53
+ export interface SessionDetailWire {
54
+ /** Sidecar board row; null for dsh-live sessions the sidecar has not seen. */
55
+ session: SessionView | null
56
+ /** Fused row; null when only the board knows the session. */
57
+ unified: UnifiedSessionWire | null
58
+ /** Newest timeline page; null only on a pre-M3 host (placeholder contract). */
59
+ timeline: TimelinePageWire | null
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Bounded same-origin GET (api.ts `request` is module-private; this is the
64
+ // same discipline in miniature: timeout, external abort, ApiError taxonomy).
65
+ // ---------------------------------------------------------------------------
66
+
67
+ const defaultSetTimeout = (fn: () => void, ms: number): TimerHandle =>
68
+ globalThis.setTimeout(fn, ms)
69
+
70
+ const defaultClearTimeout = (handle: TimerHandle): void => {
71
+ globalThis.clearTimeout(handle as ReturnType<typeof globalThis.setTimeout>)
72
+ }
73
+
74
+ const defaultCreateAbortController = (): AbortControllerLike => new AbortController()
75
+
76
+ function resolveFetch(opts: RequestOptions): FetchLike {
77
+ if (opts.fetch !== undefined) return opts.fetch
78
+ return globalThis.fetch as unknown as FetchLike
79
+ }
80
+
81
+ async function getJson(path: string, opts: RequestOptions): Promise<unknown> {
82
+ const doFetch = resolveFetch(opts)
83
+ const controller = (opts.createAbortController ?? defaultCreateAbortController)()
84
+ const setT = opts.setTimeout ?? defaultSetTimeout
85
+ const clearT = opts.clearTimeout ?? defaultClearTimeout
86
+
87
+ let timedOut = false
88
+ let externallyAborted = false
89
+ const timer = setT(() => {
90
+ timedOut = true
91
+ controller.abort()
92
+ }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS)
93
+
94
+ const external = opts.signal
95
+ const onExternalAbort = (): void => {
96
+ externallyAborted = true
97
+ controller.abort()
98
+ }
99
+ if (external !== undefined) {
100
+ if (external.aborted) onExternalAbort()
101
+ else external.addEventListener('abort', onExternalAbort)
102
+ }
103
+
104
+ try {
105
+ let res: ResponseLike
106
+ try {
107
+ res = await doFetch(path, { method: 'GET', signal: controller.signal })
108
+ } catch (err) {
109
+ if (timedOut) throw new ApiError('timeout', 'request_timeout', null, err)
110
+ if (externallyAborted) throw new ApiError('aborted', 'request_aborted', null, err)
111
+ throw new ApiError('network', 'network_error', null, err)
112
+ }
113
+ if (!res.ok) {
114
+ let reason = `http_${res.status}`
115
+ try {
116
+ const body = await res.json()
117
+ if (typeof body === 'object' && body !== null) {
118
+ const value = (body as Record<string, unknown>)['reason']
119
+ if (typeof value === 'string' && value !== '') reason = value
120
+ }
121
+ } catch {
122
+ // Non-JSON error body: the status-derived reason stands.
123
+ }
124
+ throw new ApiError('http', reason, res.status)
125
+ }
126
+ try {
127
+ return await res.json()
128
+ } catch (err) {
129
+ if (timedOut) throw new ApiError('timeout', 'request_timeout', null, err)
130
+ throw new ApiError('parse', 'invalid_json', res.status, err)
131
+ }
132
+ } finally {
133
+ clearT(timer)
134
+ if (external !== undefined) external.removeEventListener('abort', onExternalAbort)
135
+ }
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Public surface.
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /**
143
+ * `GET <prefix>/session/<id>` typed for the M3 body. Unknown ids reject
144
+ * with an ApiError carrying the server's `session_not_found` reason; a
145
+ * pre-M3 host answers `timeline: null` (the caller degrades honestly).
146
+ */
147
+ export async function fetchSessionDetail(
148
+ sessionId: string,
149
+ opts: RequestOptions = {},
150
+ ): Promise<SessionDetailWire> {
151
+ const path = `${API_PREFIX}/session/${encodeURIComponent(sessionId)}`
152
+ return (await getJson(path, opts)) as SessionDetailWire
153
+ }
154
+
155
+ /**
156
+ * `GET <prefix>/session/<id>/timeline?cursor=&limit=` — one older history
157
+ * page. `cursor` is the opaque `nextCursor` token from a previous page,
158
+ * passed through verbatim (the server rejects tampered tokens with 400
159
+ * `invalid_cursor`); omit it for the newest window (listen-mode refetch).
160
+ */
161
+ export async function fetchTimelinePage(
162
+ sessionId: string,
163
+ opts: RequestOptions & { cursor?: string | null; limit?: number } = {},
164
+ ): Promise<TimelinePageWire> {
165
+ const params = new URLSearchParams()
166
+ if (opts.cursor !== undefined && opts.cursor !== null && opts.cursor !== '') {
167
+ params.set('cursor', opts.cursor)
168
+ }
169
+ if (opts.limit !== undefined) params.set('limit', String(opts.limit))
170
+ const query = params.toString()
171
+ const path = `${API_PREFIX}/session/${encodeURIComponent(sessionId)}/timeline${
172
+ query === '' ? '' : `?${query}`
173
+ }`
174
+ return (await getJson(path, opts)) as TimelinePageWire
175
+ }
@@ -0,0 +1,397 @@
1
+ /**
2
+ * Session-detail data glue (T5.10b): one framework-free store per opened
3
+ * detail view, feeding the controlled SessionDetail / LineageTree
4
+ * components. Owns transport orchestration only — accumulation and
5
+ * presentation stay in detail/logic.ts and dsh-tools/logic.ts:
6
+ *
7
+ * - initial load via detail/transport.ts `fetchSessionDetail` (header +
8
+ * newest timeline page in one round-trip);
9
+ * - older-history pagination via `fetchTimelinePage(cursor)`;
10
+ * - listen mode: the controller's SSE `state` frames carry no per-session
11
+ * events (ADR-2/ADR-3), so the integration calls {@link
12
+ * DetailStore.notifySnapshot} on every frame and the store refetches the
13
+ * newest window — coalesced so at most one refetch is in flight and at
14
+ * most one more is queued;
15
+ * - dsh-exclusive lineage: fetched only for dsh sessions; non-dsh agents
16
+ * get the client-minted `not_dsh_session` degradation without dialing
17
+ * (dsh-tools/logic.ts `externalLineageFallback`).
18
+ *
19
+ * Same store discipline as controller.ts: subscribe/getState for
20
+ * `useSyncExternalStore`, immutable state snapshots, `dispose()` makes
21
+ * late settlements no-ops. All transport is injectable for node tests.
22
+ *
23
+ * @module
24
+ */
25
+
26
+ import { isApiError, type RequestOptions } from './api.ts'
27
+ import {
28
+ fetchSessionDetail,
29
+ fetchTimelinePage,
30
+ type SessionDetailWire,
31
+ } from './detail/transport.ts'
32
+ import { fetchLineage } from './m3-transport.ts'
33
+ import {
34
+ applyListenPage,
35
+ applyTimelinePage,
36
+ createTimelineVM,
37
+ type TimelinePageWire,
38
+ type TimelineVM,
39
+ } from './detail/logic.ts'
40
+ import {
41
+ externalLineageFallback,
42
+ type LineageResponseVM,
43
+ type LineageTraceVM,
44
+ } from './dsh-tools/logic.ts'
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // State shapes.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Header seed carried from the surface that opened the detail view (board
52
+ * card / project row); structurally SessionDetailHeaderVM. The
53
+ * authoritative header comes from the fetched detail body — the hint just
54
+ * paints the first frame and covers degraded loads.
55
+ */
56
+ export interface DetailHeaderHint {
57
+ agent: string
58
+ title: string
59
+ project: string
60
+ status: string
61
+ }
62
+
63
+ /** Lineage panel slice (mirrors LineageTree props). */
64
+ export interface LineageSliceState {
65
+ /** True until the first resolution (fetch settle or client degrade). */
66
+ loading: boolean
67
+ /** Transport/HTTP failure reason code, or null. */
68
+ error: string | null
69
+ available: boolean
70
+ reason: string | null
71
+ detail: string | null
72
+ trace: LineageTraceVM | null
73
+ }
74
+
75
+ /** Everything the detail view renders (mirrors SessionDetail props). */
76
+ export interface DetailGlueState {
77
+ sessionId: string
78
+ header: DetailHeaderHint
79
+ timeline: TimelineVM
80
+ /** True while the initial load or an older-page fetch is in flight. */
81
+ loading: boolean
82
+ /** Machine reason code of the last failure, or null. */
83
+ error: string | null
84
+ hasMore: boolean
85
+ listening: boolean
86
+ /** True once the initial load succeeded (timeline usable). */
87
+ ready: boolean
88
+ lineage: LineageSliceState
89
+ }
90
+
91
+ const EMPTY_HEADER: DetailHeaderHint = { agent: '', title: '', project: '', status: '' }
92
+
93
+ const INITIAL_LINEAGE: LineageSliceState = {
94
+ loading: true,
95
+ error: null,
96
+ available: false,
97
+ reason: null,
98
+ detail: null,
99
+ trace: null,
100
+ }
101
+
102
+ /** Map any settlement failure to a stable machine reason code. */
103
+ function reasonOf(err: unknown): string {
104
+ return isApiError(err) ? err.reason : 'network_error'
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Injectable transport (defaults are the real calls).
109
+ // ---------------------------------------------------------------------------
110
+
111
+ export interface DetailStoreOptions {
112
+ /** Header seed from the opening surface; null paints an empty header. */
113
+ hint?: DetailHeaderHint | null
114
+ /** Newest-window size for listen-mode refetches (server default if unset). */
115
+ listenLimit?: number
116
+ fetchDetailFn?: (sessionId: string, opts?: RequestOptions) => Promise<SessionDetailWire>
117
+ fetchPageFn?: (
118
+ sessionId: string,
119
+ opts?: RequestOptions & { cursor?: string | null; limit?: number },
120
+ ) => Promise<TimelinePageWire>
121
+ fetchLineageFn?: (sessionId: string, opts?: RequestOptions) => Promise<LineageResponseVM>
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // The store.
126
+ // ---------------------------------------------------------------------------
127
+
128
+ export class DetailStore {
129
+ private state: DetailGlueState
130
+ private readonly listeners = new Set<() => void>()
131
+ private readonly fetchDetailFn: NonNullable<DetailStoreOptions['fetchDetailFn']>
132
+ private readonly fetchPageFn: NonNullable<DetailStoreOptions['fetchPageFn']>
133
+ private readonly fetchLineageFn: NonNullable<DetailStoreOptions['fetchLineageFn']>
134
+ private readonly listenLimit: number | undefined
135
+ private disposed = false
136
+ private opened = false
137
+ private paging = false
138
+ private listenInFlight = false
139
+ private listenQueued = false
140
+ private lineageStarted = false
141
+
142
+ constructor(sessionId: string, options: DetailStoreOptions = {}) {
143
+ this.fetchDetailFn = options.fetchDetailFn ?? fetchSessionDetail
144
+ this.fetchPageFn = options.fetchPageFn ?? fetchTimelinePage
145
+ this.fetchLineageFn = options.fetchLineageFn ?? fetchLineage
146
+ this.listenLimit = options.listenLimit
147
+ this.state = {
148
+ sessionId,
149
+ header: options.hint ?? EMPTY_HEADER,
150
+ timeline: createTimelineVM(sessionId),
151
+ loading: false,
152
+ error: null,
153
+ hasMore: false,
154
+ listening: false,
155
+ ready: false,
156
+ lineage: INITIAL_LINEAGE,
157
+ }
158
+ }
159
+
160
+ subscribe = (fn: () => void): (() => void) => {
161
+ this.listeners.add(fn)
162
+ return () => { this.listeners.delete(fn) }
163
+ }
164
+
165
+ getState = (): DetailGlueState => this.state
166
+
167
+ private setState(patch: Partial<DetailGlueState>): void {
168
+ if (this.disposed) return
169
+ this.state = { ...this.state, ...patch }
170
+ for (const fn of [...this.listeners]) fn()
171
+ }
172
+
173
+ /** Initial load: header + newest timeline page, then lineage. Idempotent. */
174
+ async open(): Promise<void> {
175
+ if (this.opened || this.disposed) return
176
+ this.opened = true
177
+ this.setState({ loading: true, error: null })
178
+ try {
179
+ const wire = await this.fetchDetailFn(this.state.sessionId)
180
+ if (this.disposed) return
181
+ const header = headerFromDetailWire(wire) ?? this.state.header
182
+ if (wire.timeline === null) {
183
+ // Pre-M3 host placeholder contract: card data only, no timeline.
184
+ this.setState({ header, loading: false, error: 'fusion_not_wired' })
185
+ } else {
186
+ const timeline = applyTimelinePage(
187
+ createTimelineVM(this.state.sessionId),
188
+ wire.timeline,
189
+ )
190
+ this.setState({
191
+ header,
192
+ timeline,
193
+ loading: false,
194
+ error: null,
195
+ hasMore: timeline.nextCursor !== null,
196
+ ready: true,
197
+ })
198
+ }
199
+ void this.loadLineage(header.agent)
200
+ } catch (err) {
201
+ if (this.disposed) return
202
+ this.setState({ loading: false, error: reasonOf(err) })
203
+ // The board hint still tells the agent kind; resolve the lineage
204
+ // slice anyway so the panel degrades instead of spinning forever.
205
+ void this.loadLineage(this.state.header.agent)
206
+ }
207
+ }
208
+
209
+ /** Fetch one older history page via the accumulated cursor. */
210
+ async loadMore(): Promise<void> {
211
+ const { timeline } = this.state
212
+ if (this.disposed || this.paging || !this.state.ready) return
213
+ if (timeline.nextCursor === null) return
214
+ this.paging = true
215
+ this.setState({ loading: true, error: null })
216
+ try {
217
+ const page = await this.fetchPageFn(this.state.sessionId, {
218
+ cursor: timeline.nextCursor,
219
+ })
220
+ if (this.disposed) return
221
+ const next = applyTimelinePage(this.state.timeline, page)
222
+ this.setState({
223
+ timeline: next,
224
+ loading: false,
225
+ hasMore: next.nextCursor !== null,
226
+ })
227
+ } catch (err) {
228
+ if (this.disposed) return
229
+ // Entries already shown stay visible; the view renders the reason
230
+ // as an inline banner (deriveDetailBodyState 'list' arm).
231
+ this.setState({ loading: false, error: reasonOf(err) })
232
+ } finally {
233
+ this.paging = false
234
+ }
235
+ }
236
+
237
+ /** Flip listen mode; turning it on refetches the newest window at once. */
238
+ toggleListen(): void {
239
+ const listening = !this.state.listening
240
+ this.setState({ listening })
241
+ if (listening) this.scheduleListenRefetch()
242
+ }
243
+
244
+ /**
245
+ * SSE `state` frame hook (one call per controller notification). Refreshes
246
+ * the header from the live board card when given, and in listen mode
247
+ * triggers a coalesced newest-window refetch.
248
+ */
249
+ notifySnapshot(card: DetailHeaderHint | null): void {
250
+ if (this.disposed) return
251
+ if (card !== null) {
252
+ const h = this.state.header
253
+ if (
254
+ card.agent !== h.agent ||
255
+ card.title !== h.title ||
256
+ card.project !== h.project ||
257
+ card.status !== h.status
258
+ ) {
259
+ this.setState({ header: card })
260
+ }
261
+ }
262
+ if (this.state.listening && this.state.ready) this.scheduleListenRefetch()
263
+ }
264
+
265
+ /** At most one refetch in flight; at most one more queued (idempotence). */
266
+ private scheduleListenRefetch(): void {
267
+ if (this.disposed || !this.state.ready) return
268
+ if (this.listenInFlight) {
269
+ this.listenQueued = true
270
+ return
271
+ }
272
+ this.listenInFlight = true
273
+ void this.runListenRefetch()
274
+ }
275
+
276
+ private async runListenRefetch(): Promise<void> {
277
+ try {
278
+ const page = await this.fetchPageFn(this.state.sessionId, {
279
+ ...(this.listenLimit !== undefined ? { limit: this.listenLimit } : {}),
280
+ })
281
+ if (this.disposed) return
282
+ this.setState({ timeline: applyListenPage(this.state.timeline, page) })
283
+ } catch {
284
+ // Listen refetch is best-effort: the next SSE frame retries; the
285
+ // already-rendered timeline must not degrade into an error state.
286
+ } finally {
287
+ this.listenInFlight = false
288
+ if (this.listenQueued && !this.disposed) {
289
+ this.listenQueued = false
290
+ this.scheduleListenRefetch()
291
+ }
292
+ }
293
+ }
294
+
295
+ /** Resolve the lineage slice once (dsh-only capability; see module doc). */
296
+ private async loadLineage(agent: string): Promise<void> {
297
+ if (this.lineageStarted || this.disposed) return
298
+ if (agent.trim() === '') {
299
+ // Agent unknown (load failed before the header resolved): degrade
300
+ // as non-dsh rather than dialing a lineage endpoint blind.
301
+ this.lineageStarted = true
302
+ this.setState({
303
+ lineage: {
304
+ loading: false, error: null, available: false,
305
+ reason: 'not_dsh_session', detail: null, trace: null,
306
+ },
307
+ })
308
+ return
309
+ }
310
+ this.lineageStarted = true
311
+ const fallback = externalLineageFallback(agent)
312
+ if (fallback !== null) {
313
+ this.setState({
314
+ lineage: {
315
+ loading: false,
316
+ error: null,
317
+ available: fallback.available,
318
+ reason: fallback.reason,
319
+ detail: null,
320
+ trace: fallback.trace,
321
+ },
322
+ })
323
+ return
324
+ }
325
+ try {
326
+ const body = await this.fetchLineageFn(this.state.sessionId)
327
+ if (this.disposed) return
328
+ this.setState({
329
+ lineage: {
330
+ loading: false,
331
+ error: null,
332
+ available: body.available,
333
+ reason: body.reason,
334
+ detail: body.detail ?? null,
335
+ trace: body.trace,
336
+ },
337
+ })
338
+ } catch (err) {
339
+ if (this.disposed) return
340
+ this.setState({ lineage: { ...INITIAL_LINEAGE, loading: false, error: reasonOf(err) } })
341
+ }
342
+ }
343
+
344
+ /** Late settlements become no-ops; subscribers are dropped. Idempotent. */
345
+ dispose(): void {
346
+ this.disposed = true
347
+ this.listeners.clear()
348
+ }
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // Pure helpers (exported for tests and for the mount integration).
353
+ // ---------------------------------------------------------------------------
354
+
355
+ /**
356
+ * Authoritative header from the M3 detail body: the fused row wins (it
357
+ * merges both sources), the sidecar board row covers fusion-less hosts;
358
+ * null when the body carries neither (caller keeps its hint).
359
+ */
360
+ export function headerFromDetailWire(wire: SessionDetailWire): DetailHeaderHint | null {
361
+ if (wire.unified !== null) {
362
+ return {
363
+ agent: wire.unified.agent,
364
+ title: wire.unified.title,
365
+ project: wire.unified.project,
366
+ status: wire.unified.status,
367
+ }
368
+ }
369
+ if (wire.session !== null) {
370
+ return {
371
+ agent: wire.session.agent,
372
+ title: wire.session.title,
373
+ project: wire.session.project,
374
+ status: wire.session.status,
375
+ }
376
+ }
377
+ return null
378
+ }
379
+
380
+ /**
381
+ * Find the live board card of a session (controller SessionCardVM rows) →
382
+ * header hint, or null when off-board.
383
+ */
384
+ export function findCardHint(
385
+ sessions: ReadonlyArray<{
386
+ agent: string
387
+ sessionId: string
388
+ title: string
389
+ project: string
390
+ status: string
391
+ }>,
392
+ sessionId: string,
393
+ ): DetailHeaderHint | null {
394
+ const card = sessions.find((s) => s.sessionId === sessionId)
395
+ if (card === undefined) return null
396
+ return { agent: card.agent, title: card.title, project: card.project, status: card.status }
397
+ }