@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,556 @@
1
+ /**
2
+ * Pure view-model logic for the cross-agent session board (design §5.1
3
+ * view 1) and the footer status widget (view 4). No React, no I/O, no
4
+ * imports from the data layer (api/sse) — everything arrives as plain
5
+ * values and leaves as plain values, so this whole module is unit-testable
6
+ * in a bare node environment.
7
+ *
8
+ * Decoupling contract (T2.2 ↔ T2.4): the types below are this module's OWN
9
+ * view models. The integration task maps the host wire shapes
10
+ * (`SessionView` / `StateSnapshot` from the state endpoint) onto them.
11
+ * Notably every timestamp here is **epoch milliseconds** — the sidecar
12
+ * snapshot carries epoch seconds (`updated_at`), so the mapping layer
13
+ * multiplies by 1000 once at the boundary.
14
+ *
15
+ * @module
16
+ */
17
+
18
+ import { BOARD_STRINGS } from './strings.ts'
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // View-model types (the props contract consumed by Board.tsx / widget.tsx).
22
+ // ---------------------------------------------------------------------------
23
+
24
+ /** Normalized session status vocabulary; anything else maps to 'unknown'. */
25
+ export type SessionStatusToken = 'working' | 'waiting' | 'idle' | 'dead' | 'unknown'
26
+
27
+ /** Mirror of the host's StreamHealth union (kept local by design). */
28
+ export type StreamHealthToken = 'ok' | 'degraded' | 'unknown'
29
+
30
+ /** Mirror of the host's SupervisorState union (kept local by design). */
31
+ export type DaemonStateToken =
32
+ | 'probe'
33
+ | 'adopted'
34
+ | 'defer'
35
+ | 'reprobe'
36
+ | 'hosting'
37
+ | 'hosted'
38
+ | 'backoff'
39
+ | 'failed'
40
+
41
+ /** Color-token buckets the CSS maps to `--dsw-alias-state-*` variables. */
42
+ export type BadgeTone = 'success' | 'warn' | 'neutral' | 'muted' | 'danger'
43
+
44
+ /** One session card as fed by the integration layer (raw, underived). */
45
+ export interface SessionCardVM {
46
+ agent: string
47
+ sessionId: string
48
+ /** Raw observed status string from the sidecar snapshot (open vocabulary). */
49
+ status: string
50
+ title: string
51
+ /** Working directory path; empty string groups under 未知项目. */
52
+ project: string
53
+ /** Epoch milliseconds (mapping layer converts sidecar epoch seconds). */
54
+ updatedAtMs: number
55
+ /** Most recent normalized event summary, if any. */
56
+ lastEvent: { kind: string; text: string } | null
57
+ /** True when a dsh seq discontinuity was observed since the last reconcile. */
58
+ gap: boolean
59
+ }
60
+
61
+ /** Board filter controls (wired to ui.time-window-hours / ui.show-dead). */
62
+ export interface BoardFilterState {
63
+ timeWindowHours: number
64
+ showDead: boolean
65
+ }
66
+
67
+ /** Derived status badge: color token + label + attention marker. */
68
+ export interface StatusBadgeVM {
69
+ status: SessionStatusToken
70
+ tone: BadgeTone
71
+ label: string
72
+ /** 'gap' (per-session data hole) outranks 'stale' (global stream health). */
73
+ attention: 'gap' | 'stale' | null
74
+ attentionLabel: string | null
75
+ }
76
+
77
+ /** A session card with every render-ready derivation attached. */
78
+ export interface DerivedSessionCardVM extends SessionCardVM {
79
+ badge: StatusBadgeVM
80
+ glyph: string
81
+ shortId: string
82
+ relativeTime: string
83
+ /** Hover title: observed-value disclaimer + raw status + lastReconcileAt. */
84
+ hoverTitle: string
85
+ }
86
+
87
+ /** One project section on the board. */
88
+ export interface ProjectGroupVM<T extends SessionCardVM = DerivedSessionCardVM> {
89
+ /** Raw project path; '' for the unknown-project bucket. */
90
+ key: string
91
+ /** Display name (path basename, or 未知项目). */
92
+ label: string
93
+ /** Full path for the hover title; '' when unknown. */
94
+ fullPath: string
95
+ cards: T[]
96
+ }
97
+
98
+ export interface BoardBannerVM {
99
+ tone: 'danger' | 'warn'
100
+ text: string
101
+ }
102
+
103
+ export type BoardEmptyKind = 'daemon-failed' | 'daemon-defer' | 'filtered' | 'no-sessions'
104
+
105
+ export interface BoardEmptyStateVM {
106
+ kind: BoardEmptyKind
107
+ title: string
108
+ hint: string
109
+ }
110
+
111
+ export interface DaemonBadgeVM {
112
+ tone: BadgeTone
113
+ label: string
114
+ }
115
+
116
+ /** Footer widget connection dot state. */
117
+ export type WidgetConnection = 'ok' | 'degraded' | 'off'
118
+
119
+ /** Everything `buildBoardViewModel` needs (all plain values). */
120
+ export interface BoardComputeInput {
121
+ sessions: SessionCardVM[]
122
+ filters: BoardFilterState
123
+ daemonState: DaemonStateToken
124
+ streamHealth: StreamHealthToken
125
+ /** Epoch ms of the last authoritative snapshot reconcile, or null. */
126
+ lastReconcileAtMs: number | null
127
+ /** Clock injection (epoch ms) for deterministic derivation. */
128
+ nowMs: number
129
+ }
130
+
131
+ /** Fully derived board render model. */
132
+ export interface BoardViewModel {
133
+ groups: Array<ProjectGroupVM<DerivedSessionCardVM>>
134
+ banner: BoardBannerVM | null
135
+ emptyState: BoardEmptyStateVM | null
136
+ daemonBadge: DaemonBadgeVM
137
+ streamLabel: string
138
+ streamTone: BadgeTone
139
+ visibleCount: number
140
+ totalCount: number
141
+ workingCount: number
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Small shared helpers.
146
+ // ---------------------------------------------------------------------------
147
+
148
+ const MINUTE_MS = 60_000
149
+ const HOUR_MS = 3_600_000
150
+ const DAY_MS = 86_400_000
151
+
152
+ /** Default board time window, aligned with the ui.time-window-hours setting. */
153
+ export const DEFAULT_TIME_WINDOW_HOURS = 48
154
+
155
+ /** Resolve `{name}` placeholders in a message template. */
156
+ export function formatTemplate(
157
+ template: string,
158
+ params: Record<string, string | number>,
159
+ ): string {
160
+ return template.replace(/\{(\w+)\}/g, (match, key: string) => {
161
+ const value = params[key]
162
+ return value === undefined ? match : String(value)
163
+ })
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Status normalization and ordering.
168
+ // ---------------------------------------------------------------------------
169
+
170
+ const KNOWN_STATUSES: readonly SessionStatusToken[] = ['working', 'waiting', 'idle', 'dead']
171
+
172
+ /** Map a raw observed status onto the badge vocabulary ('unknown' fallback). */
173
+ export function normalizeStatus(raw: string): SessionStatusToken {
174
+ const cleaned = raw.trim().toLowerCase()
175
+ return (KNOWN_STATUSES as readonly string[]).includes(cleaned)
176
+ ? (cleaned as SessionStatusToken)
177
+ : 'unknown'
178
+ }
179
+
180
+ const STATUS_RANK: Record<SessionStatusToken, number> = {
181
+ working: 0,
182
+ waiting: 1,
183
+ idle: 2,
184
+ unknown: 3,
185
+ dead: 4,
186
+ }
187
+
188
+ /** Sort rank: working > waiting > idle > unknown > dead (lower sorts first). */
189
+ export function statusRank(status: SessionStatusToken): number {
190
+ return STATUS_RANK[status]
191
+ }
192
+
193
+ const STATUS_TONE: Record<SessionStatusToken, BadgeTone> = {
194
+ working: 'success',
195
+ waiting: 'warn',
196
+ idle: 'neutral',
197
+ unknown: 'neutral',
198
+ dead: 'muted',
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Time-window filtering.
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /**
206
+ * Visibility rules (task spec):
207
+ * - dead sessions are hidden unless `showDead`;
208
+ * - working sessions are always visible (even outside the window);
209
+ * - everything else hides once `updatedAtMs` falls strictly beyond the
210
+ * window (age === window is still visible);
211
+ * - a non-finite or non-positive window disables the age filter entirely.
212
+ */
213
+ export function isSessionVisible(
214
+ session: SessionCardVM,
215
+ filters: BoardFilterState,
216
+ nowMs: number,
217
+ ): boolean {
218
+ const status = normalizeStatus(session.status)
219
+ if (status === 'dead' && !filters.showDead) return false
220
+ if (status === 'working') return true
221
+ const windowMs = filters.timeWindowHours * HOUR_MS
222
+ if (!Number.isFinite(windowMs) || windowMs <= 0) return true
223
+ return nowMs - session.updatedAtMs <= windowMs
224
+ }
225
+
226
+ /** Apply {@link isSessionVisible} across a session list (order preserved). */
227
+ export function filterSessions<T extends SessionCardVM>(
228
+ sessions: readonly T[],
229
+ filters: BoardFilterState,
230
+ nowMs: number,
231
+ ): T[] {
232
+ return sessions.filter((s) => isSessionVisible(s, filters, nowMs))
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Project grouping.
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /** Human display name for a project path ('' → 未知项目). */
240
+ export function projectDisplayName(project: string): string {
241
+ const trimmed = project.trim().replace(/[/\\]+$/, '')
242
+ if (trimmed === '') return BOARD_STRINGS.unknownProject
243
+ const segments = trimmed.split(/[/\\]/)
244
+ const base = segments[segments.length - 1] ?? ''
245
+ return base === '' ? trimmed : base
246
+ }
247
+
248
+ /** Card ordering inside a group: status rank, then recency, then id. */
249
+ export function compareCards(a: SessionCardVM, b: SessionCardVM): number {
250
+ const rankDelta = statusRank(normalizeStatus(a.status)) - statusRank(normalizeStatus(b.status))
251
+ if (rankDelta !== 0) return rankDelta
252
+ if (a.updatedAtMs !== b.updatedAtMs) return b.updatedAtMs - a.updatedAtMs
253
+ return a.sessionId.localeCompare(b.sessionId)
254
+ }
255
+
256
+ /**
257
+ * Group sessions by project. Empty/whitespace projects share the
258
+ * unknown-project bucket (key ''). Groups are ordered by their most recent
259
+ * `updatedAtMs` descending, except the unknown bucket which always sorts
260
+ * last (named projects are more actionable than the catch-all). Cards
261
+ * inside each group follow {@link compareCards}.
262
+ */
263
+ export function groupSessions<T extends SessionCardVM>(
264
+ sessions: readonly T[],
265
+ ): Array<ProjectGroupVM<T>> {
266
+ const buckets = new Map<string, T[]>()
267
+ for (const session of sessions) {
268
+ const key = session.project.trim() === '' ? '' : session.project
269
+ const bucket = buckets.get(key)
270
+ if (bucket === undefined) buckets.set(key, [session])
271
+ else bucket.push(session)
272
+ }
273
+ const groups: Array<ProjectGroupVM<T>> = []
274
+ for (const [key, cards] of buckets) {
275
+ cards.sort(compareCards)
276
+ groups.push({
277
+ key,
278
+ label: key === '' ? BOARD_STRINGS.unknownProject : projectDisplayName(key),
279
+ fullPath: key,
280
+ cards,
281
+ })
282
+ }
283
+ const newest = (group: ProjectGroupVM<T>): number =>
284
+ group.cards.reduce((max, card) => Math.max(max, card.updatedAtMs), Number.NEGATIVE_INFINITY)
285
+ groups.sort((a, b) => {
286
+ if (a.key === '') return b.key === '' ? 0 : 1
287
+ if (b.key === '') return -1
288
+ return newest(b) - newest(a) || a.key.localeCompare(b.key)
289
+ })
290
+ return groups
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Badge derivation.
295
+ // ---------------------------------------------------------------------------
296
+
297
+ /**
298
+ * status + gap + streamHealth → badge tone/label/attention.
299
+ *
300
+ * Priority: a per-session `gap` marker (a known data hole for THIS session)
301
+ * outranks the global stale marker (stream reconnecting affects everyone
302
+ * and is already surfaced by the top banner). Unknown raw statuses keep
303
+ * their raw text as the label — the board never invents a state.
304
+ */
305
+ export function deriveBadge(
306
+ rawStatus: string,
307
+ gap: boolean,
308
+ streamHealth: StreamHealthToken,
309
+ ): StatusBadgeVM {
310
+ const status = normalizeStatus(rawStatus)
311
+ const trimmed = rawStatus.trim()
312
+ const label =
313
+ status === 'unknown'
314
+ ? trimmed === ''
315
+ ? BOARD_STRINGS.status.unknown
316
+ : trimmed
317
+ : BOARD_STRINGS.status[status]
318
+ let attention: StatusBadgeVM['attention'] = null
319
+ if (gap) attention = 'gap'
320
+ else if (streamHealth !== 'ok') attention = 'stale'
321
+ return {
322
+ status,
323
+ tone: STATUS_TONE[status],
324
+ label,
325
+ attention,
326
+ attentionLabel: attention === null ? null : BOARD_STRINGS.attention[attention],
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Hover title for a status badge: the observed-value disclaimer (design
332
+ * §5.3 wording), the raw observed status, and the last reconcile time.
333
+ */
334
+ export function badgeHoverTitle(
335
+ rawStatus: string,
336
+ lastReconcileAtMs: number | null,
337
+ nowMs: number,
338
+ ): string {
339
+ const observed = rawStatus.trim() === '' ? BOARD_STRINGS.status.unknown : rawStatus.trim()
340
+ const reconcile =
341
+ lastReconcileAtMs === null
342
+ ? BOARD_STRINGS.card.neverReconciled
343
+ : formatTemplate(BOARD_STRINGS.card.lastReconcile, {
344
+ time: formatRelativeTime(lastReconcileAtMs, nowMs),
345
+ })
346
+ return [
347
+ BOARD_STRINGS.card.observedDisclaimer,
348
+ formatTemplate(BOARD_STRINGS.card.observedValue, { status: observed }),
349
+ reconcile,
350
+ ].join('\n')
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // Time formatting.
355
+ // ---------------------------------------------------------------------------
356
+
357
+ /**
358
+ * Coarse relative time: <60s (including clock skew into the future) is
359
+ * 刚刚, then whole minutes/hours/days. Non-finite input renders empty.
360
+ */
361
+ export function formatRelativeTime(thenMs: number, nowMs: number): string {
362
+ if (!Number.isFinite(thenMs)) return ''
363
+ const delta = nowMs - thenMs
364
+ if (delta < MINUTE_MS) return BOARD_STRINGS.time.justNow
365
+ if (delta < HOUR_MS) {
366
+ return formatTemplate(BOARD_STRINGS.time.minutesAgo, { n: Math.floor(delta / MINUTE_MS) })
367
+ }
368
+ if (delta < DAY_MS) {
369
+ return formatTemplate(BOARD_STRINGS.time.hoursAgo, { n: Math.floor(delta / HOUR_MS) })
370
+ }
371
+ return formatTemplate(BOARD_STRINGS.time.daysAgo, { n: Math.floor(delta / DAY_MS) })
372
+ }
373
+
374
+ /** Label for a time-window option: whole days as 天, otherwise 小时. */
375
+ export function timeWindowLabel(hours: number): string {
376
+ if (hours >= 24 && hours % 24 === 0) {
377
+ return formatTemplate(BOARD_STRINGS.timeWindow.days, { n: hours / 24 })
378
+ }
379
+ return formatTemplate(BOARD_STRINGS.timeWindow.hours, { n: hours })
380
+ }
381
+
382
+ // ---------------------------------------------------------------------------
383
+ // Top bar / banner / empty-state derivation.
384
+ // ---------------------------------------------------------------------------
385
+
386
+ const DAEMON_TONE: Record<DaemonStateToken, BadgeTone> = {
387
+ probe: 'neutral',
388
+ adopted: 'success',
389
+ defer: 'warn',
390
+ reprobe: 'neutral',
391
+ hosting: 'neutral',
392
+ hosted: 'success',
393
+ backoff: 'warn',
394
+ failed: 'danger',
395
+ }
396
+
397
+ /** Daemon state → top-bar badge (tone + label). */
398
+ export function deriveDaemonBadge(state: DaemonStateToken): DaemonBadgeVM {
399
+ return { tone: DAEMON_TONE[state], label: BOARD_STRINGS.daemon[state] }
400
+ }
401
+
402
+ /** Stream health → indicator tone. */
403
+ export function streamHealthTone(health: StreamHealthToken): BadgeTone {
404
+ if (health === 'ok') return 'success'
405
+ if (health === 'degraded') return 'warn'
406
+ return 'neutral'
407
+ }
408
+
409
+ /**
410
+ * Top banner: daemon FAILED (red, last-snapshot notice) outranks a
411
+ * degraded stream (yellow, may-lag notice). 'unknown' stream health alone
412
+ * raises no banner — it is the pre-first-connect startup state and a
413
+ * warning bar on every mount would be noise.
414
+ */
415
+ export function deriveBanner(
416
+ daemonState: DaemonStateToken,
417
+ streamHealth: StreamHealthToken,
418
+ ): BoardBannerVM | null {
419
+ if (daemonState === 'failed') {
420
+ return { tone: 'danger', text: BOARD_STRINGS.banner.daemonFailed }
421
+ }
422
+ if (streamHealth === 'degraded') {
423
+ return { tone: 'warn', text: BOARD_STRINGS.banner.streamDegraded }
424
+ }
425
+ return null
426
+ }
427
+
428
+ /**
429
+ * Empty-state guidance, only when nothing is visible. Daemon trouble
430
+ * (failed > defer) explains an empty board better than filter settings;
431
+ * otherwise distinguish "all filtered out" from "nothing observed yet".
432
+ */
433
+ export function deriveEmptyState(
434
+ daemonState: DaemonStateToken,
435
+ visibleCount: number,
436
+ totalCount: number,
437
+ ): BoardEmptyStateVM | null {
438
+ if (visibleCount > 0) return null
439
+ if (daemonState === 'failed') {
440
+ return {
441
+ kind: 'daemon-failed',
442
+ title: BOARD_STRINGS.empty.daemonFailedTitle,
443
+ hint: BOARD_STRINGS.empty.daemonFailedHint,
444
+ }
445
+ }
446
+ if (daemonState === 'defer') {
447
+ return {
448
+ kind: 'daemon-defer',
449
+ title: BOARD_STRINGS.empty.daemonDeferTitle,
450
+ hint: BOARD_STRINGS.empty.daemonDeferHint,
451
+ }
452
+ }
453
+ if (totalCount > 0) {
454
+ return {
455
+ kind: 'filtered',
456
+ title: BOARD_STRINGS.empty.filteredTitle,
457
+ hint: BOARD_STRINGS.empty.filteredHint,
458
+ }
459
+ }
460
+ return {
461
+ kind: 'no-sessions',
462
+ title: BOARD_STRINGS.empty.noSessionsTitle,
463
+ hint: BOARD_STRINGS.empty.noSessionsHint,
464
+ }
465
+ }
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // Card presentation helpers.
469
+ // ---------------------------------------------------------------------------
470
+
471
+ const AGENT_GLYPHS: Record<string, string> = {
472
+ dsh: '◆',
473
+ claude: '✳',
474
+ codex: '▣',
475
+ cursor: '▮',
476
+ 'cursor-cli': '▮',
477
+ 'cursor-ide': '▮',
478
+ copilot: '◉',
479
+ kimi: '◐',
480
+ }
481
+
482
+ /** Single-character agent marker; unknown agents get a neutral dot. */
483
+ export function agentGlyph(agent: string): string {
484
+ return AGENT_GLYPHS[agent.trim().toLowerCase()] ?? '●'
485
+ }
486
+
487
+ /** Head…tail abbreviation for long session ids (full id goes in `title`). */
488
+ export function abbreviateSessionId(id: string, max = 20): string {
489
+ if (id.length <= max) return id
490
+ return `${id.slice(0, 12)}…${id.slice(-6)}`
491
+ }
492
+
493
+ // ---------------------------------------------------------------------------
494
+ // Footer widget derivation.
495
+ // ---------------------------------------------------------------------------
496
+
497
+ /**
498
+ * Connection dot: green only when the daemon is connected (adopted/hosted)
499
+ * AND the event stream is healthy; FAILED is the only hard-off state;
500
+ * every transitional state shows the cautious yellow.
501
+ */
502
+ export function deriveWidgetConnection(
503
+ daemonState: DaemonStateToken,
504
+ streamHealth: StreamHealthToken,
505
+ ): WidgetConnection {
506
+ if (daemonState === 'failed') return 'off'
507
+ if ((daemonState === 'adopted' || daemonState === 'hosted') && streamHealth === 'ok') {
508
+ return 'ok'
509
+ }
510
+ return 'degraded'
511
+ }
512
+
513
+ /** Count of sessions currently observed as working. */
514
+ export function countWorking(sessions: ReadonlyArray<{ status: string }>): number {
515
+ let count = 0
516
+ for (const session of sessions) {
517
+ if (normalizeStatus(session.status) === 'working') count += 1
518
+ }
519
+ return count
520
+ }
521
+
522
+ /** Widget hover/aria text: connection state, plus the count when nonzero. */
523
+ export function widgetTitle(connection: WidgetConnection, workingCount: number): string {
524
+ const base = `${BOARD_STRINGS.widget.label}: ${BOARD_STRINGS.widget.connection[connection]}`
525
+ if (workingCount <= 0) return base
526
+ return `${base} · ${formatTemplate(BOARD_STRINGS.widget.working, { n: workingCount })}`
527
+ }
528
+
529
+ // ---------------------------------------------------------------------------
530
+ // Full pipeline.
531
+ // ---------------------------------------------------------------------------
532
+
533
+ /** filter → group → per-card derive; the one call Board.tsx renders from. */
534
+ export function buildBoardViewModel(input: BoardComputeInput): BoardViewModel {
535
+ const { sessions, filters, daemonState, streamHealth, lastReconcileAtMs, nowMs } = input
536
+ const visible = filterSessions(sessions, filters, nowMs)
537
+ const derived: DerivedSessionCardVM[] = visible.map((session) => ({
538
+ ...session,
539
+ badge: deriveBadge(session.status, session.gap, streamHealth),
540
+ glyph: agentGlyph(session.agent),
541
+ shortId: abbreviateSessionId(session.sessionId),
542
+ relativeTime: formatRelativeTime(session.updatedAtMs, nowMs),
543
+ hoverTitle: badgeHoverTitle(session.status, lastReconcileAtMs, nowMs),
544
+ }))
545
+ return {
546
+ groups: groupSessions(derived),
547
+ banner: deriveBanner(daemonState, streamHealth),
548
+ emptyState: deriveEmptyState(daemonState, visible.length, sessions.length),
549
+ daemonBadge: deriveDaemonBadge(daemonState),
550
+ streamLabel: BOARD_STRINGS.stream[streamHealth],
551
+ streamTone: streamHealthTone(streamHealth),
552
+ visibleCount: visible.length,
553
+ totalCount: sessions.length,
554
+ workingCount: countWorking(sessions),
555
+ }
556
+ }