@gotcos/glasses-server 6.99.29 → 7.0.7

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 (80) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +475 -0
  3. package/README.md +3 -1
  4. package/managed-runtime-contract.json +3 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +29 -7
  7. package/server/lib/agent-session-output-images.ts +627 -49
  8. package/server/lib/agent-session-store.ts +24 -11
  9. package/server/lib/agent-session-tool-images.ts +224 -0
  10. package/server/lib/agent-session-turns.ts +19 -2
  11. package/server/lib/api-auth.ts +6 -0
  12. package/server/lib/ask-work-plan.ts +3 -1
  13. package/server/lib/calendar-source.ts +491 -0
  14. package/server/lib/codex-bridge.ts +4 -1
  15. package/server/lib/codex-run-ledger.ts +29 -0
  16. package/server/lib/context-builder.ts +2 -1
  17. package/server/lib/drop-p2-runtime.ts +1 -1
  18. package/server/lib/drop-store.ts +10 -4
  19. package/server/lib/learning-needs-you.ts +24 -7
  20. package/server/lib/live-events.ts +533 -0
  21. package/server/lib/local-path-redact.ts +272 -0
  22. package/server/lib/meeting-ask-orientation.ts +40 -6
  23. package/server/lib/meeting-ask-runner.ts +30 -10
  24. package/server/lib/morning-brief-runtime.ts +4 -2
  25. package/server/lib/native-mac-caller.ts +29 -0
  26. package/server/lib/ping-engine.ts +1 -1
  27. package/server/lib/ping-sources.ts +11 -6
  28. package/server/lib/preview-content.ts +172 -0
  29. package/server/lib/preview-error-code.ts +10 -0
  30. package/server/lib/preview-folders-setting.ts +133 -0
  31. package/server/lib/preview-log-throttle.ts +18 -0
  32. package/server/lib/preview-thumbs.ts +139 -0
  33. package/server/lib/secret-path-guard.ts +80 -0
  34. package/server/lib/session-files-setting.ts +88 -0
  35. package/server/lib/session-links.ts +280 -0
  36. package/server/lib/thread-turn-queue.ts +10 -0
  37. package/server/lib/today-brief.ts +119 -28
  38. package/server/lib/web-memories-bridge.ts +3 -1
  39. package/server/lib/work-activity.ts +56 -6
  40. package/server/lib/work-session-activity.ts +260 -0
  41. package/server/lib/your-move-count.ts +9 -3
  42. package/server/routes/agent-session-bindings.ts +13 -0
  43. package/server/routes/agent-session-stream.ts +16 -0
  44. package/server/routes/agent-sessions.ts +146 -11
  45. package/server/routes/ask-work-plan.ts +4 -1
  46. package/server/routes/claude-sessions.ts +5 -1
  47. package/server/routes/health.ts +35 -0
  48. package/server/routes/live-events.ts +329 -0
  49. package/server/routes/permission-broker.ts +69 -5
  50. package/server/routes/session-preview.ts +200 -0
  51. package/server/routes/thread-turn-queue.ts +207 -3
  52. package/server/routes/today-brief.ts +44 -9
  53. package/server/routes/welcome-context.ts +23 -3
  54. package/server/routes/work-board.ts +9 -2
  55. package/server/web-control/app.css +79 -9
  56. package/server/web-control/app.js +1543 -65
  57. package/server/web-control/control.css +37 -6
  58. package/server/web-control/index.html +8 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +51 -6
  61. package/server/web-control/panes/cos-chat.js +268 -16
  62. package/server/web-control/panes/meetings.css +23 -4
  63. package/server/web-control/panes/meetings.js +25 -9
  64. package/server/web-control/panes/memories.css +18 -3
  65. package/server/web-control/panes/memories.js +130 -50
  66. package/server/web-control/panes/messages.js +21 -2
  67. package/server/web-control/panes/preview.js +318 -0
  68. package/server/web-control/panes/questions.css +20 -4
  69. package/server/web-control/panes/questions.js +127 -26
  70. package/server/web-control/panes/reader-media.js +409 -23
  71. package/server/web-control/panes/sessions.css +50 -10
  72. package/server/web-control/panes/settings.js +96 -5
  73. package/server/web-control/panes/speakers.css +3 -5
  74. package/server/web-control/panes/speakers.js +7 -4
  75. package/server/web-control/panes/threads.js +3 -2
  76. package/server/web-control/panes/today-actions.css +49 -18
  77. package/server/web-control/panes/today-actions.js +217 -50
  78. package/server/web-control/panes/work.css +11 -1
  79. package/server/web-control/panes/work.js +10 -2
  80. package/server/web-control/shell.js +20 -14
@@ -12,13 +12,32 @@ import { execFile } from 'node:child_process'
12
12
  import { existsSync } from 'node:fs'
13
13
  import { join } from 'node:path'
14
14
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
15
+ // The one "Needs review" predicate, shared with the Memories pane (window.COSMemoryReview there). Plain script: it
16
+ // sets globalThis.COSMemoryReview when imported here.
17
+ import '../web-control/memory-review.js'
18
+
19
+ interface MemoryReviewApi {
20
+ RULE_BAR: number
21
+ barOf(block: unknown): number
22
+ reviewState(gate: unknown, confidence?: unknown, decision?: unknown, bar?: unknown): 'needs_review' | 'learning' | 'history'
23
+ itemState(item: unknown, decision?: unknown, blockBar?: unknown): 'needs_review' | 'learning' | 'history'
24
+ needsReview(item: unknown, decision?: unknown, blockBar?: unknown): boolean
25
+ normalizeBlock<T>(block: T): T
26
+ openItems(block: unknown, decisions?: Record<string, string>): unknown[]
27
+ count(block: unknown, decisions?: Record<string, string>): number | null
28
+ total(block: unknown): number | null
29
+ learningItems(block: unknown, decisions?: Record<string, string>): unknown[]
30
+ }
31
+ export const memoryReview = (globalThis as unknown as { COSMemoryReview: MemoryReviewApi }).COSMemoryReview
15
32
 
16
33
  export const NEEDS_YOU_TIMEOUT_MS = 10_000
17
34
  /** Control: needsYouCacheTTL = 90 s (HelperSources main.swift 10969). */
18
35
  export const NEEDS_YOU_TTL_MS = 90_000
19
36
  /** Control: needsYouStaleFlagLimit = 3600 s (main.swift 10970): an older stale block loses reward_enabled. */
20
37
  export const NEEDS_YOU_STALE_FLAG_LIMIT_MS = 3_600_000
21
- const KEYS = ['count', 'gates', 'system_backlog', 'window_days', 'limit', 'partial', 'not_implemented', 'items', 'overflow', 'reward_enabled'] as const
38
+ // `learning`, `learning_count` and `bar` (2026-11-08): below-bar suggestions, shown under Learning and never counted.
39
+ const KEYS = ['count', 'gates', 'system_backlog', 'window_days', 'limit', 'partial', 'not_implemented', 'items', 'overflow', 'reward_enabled',
40
+ 'learning', 'learning_count', 'bar'] as const
22
41
 
23
42
  export type NeedsYouBlock = Partial<Record<(typeof KEYS)[number], unknown>>
24
43
 
@@ -44,12 +63,9 @@ export function parseJsonObject(output: string): Record<string, unknown> | null
44
63
  return start >= 0 && end > start ? attempt(text.slice(start, end + 1)) : null
45
64
  }
46
65
 
47
- /** Control's count for the rail and the To review tab: count, capped by limit when both are numbers. */
66
+ /** The count Today, the rail and the To review tab show: the shown items that need review (memory-review.js count). */
48
67
  export function needsYouCount(block: NeedsYouBlock | null): number | null {
49
- if (!block) return null
50
- const count = typeof block.count === 'number' ? block.count : Array.isArray(block.items) ? block.items.length : null
51
- if (count === null) return null
52
- return typeof block.limit === 'number' ? Math.min(count, block.limit) : count
68
+ return memoryReview.count(block)
53
69
  }
54
70
 
55
71
  function defaultRun(file: string, args: string[], timeoutMs: number): Promise<{ code: number; stdout: string }> {
@@ -82,7 +98,8 @@ export function createNeedsYouReader(deps: NeedsYouDeps = {}) {
82
98
  if (!payload || (payload.count === undefined && payload.items === undefined)) return null
83
99
  const block: NeedsYouBlock = {}
84
100
  for (const key of KEYS) if (payload[key] !== undefined) block[key] = payload[key]
85
- return Object.keys(block).length ? block : null
101
+ // An older learning_events.py lists below-bar suggestions as `low_confidence`; the predicate moves them to Learning.
102
+ return Object.keys(block).length ? memoryReview.normalizeBlock(block) : null
86
103
  }
87
104
 
88
105
  function stale(): NeedsYouBlock | null {
@@ -0,0 +1,533 @@
1
+ /**
2
+ * 7.0.1 live state: the hub behind `GET /api/events` (docs/live-events-contract.md).
3
+ *
4
+ * Miles, 2026-11-07: Control Web feels out of date. The pet shows what was running at the last fetch, not now. This hub
5
+ * keeps a small live model of the server's OWN session state (which sessions are open, working, waiting, idle or
6
+ * ended, when the turn started, replies, questions, queued messages) and hands every open page a frame when it moves.
7
+ *
8
+ * WHAT FEEDS IT. Nothing new polls the Mac on its own:
9
+ * - PUSHED: the hook signal store (`sources.subscribe`). A hook event marks the model dirty and a flush runs within
10
+ * LIVE_FLUSH_MS, so a prompt, a permission prompt, a Stop or a SessionEnd reaches the page in well under a second.
11
+ * A push rebuilds only the hook-derived readings (`sessions(now, { hooksOnly: true })`, from the in-memory signal
12
+ * store and the registry as last read): no registry folder, broker or queue file is read on a push (ghost review W4:
13
+ * dozens of busy sessions fire PreToolUse and PostToolUse many times a second). Those wait for the tick.
14
+ * - A BOUNDED DIFF TICK every LIVE_TICK_MS, ONLY WHILE A STREAM IS OPEN, for the sources with no change signal: the
15
+ * Claude registry files, the permission broker's in-memory list, the queue files, and the clock itself (an open turn
16
+ * whose engine goes quiet past the activity cap stops being `working` with no event at all). With no stream open the
17
+ * tick does not run and nothing is read.
18
+ * - The reply counter is the one thing that runs with no stream open: a Map increment per Stop, so `replies` means
19
+ * "turns ended since this server started", not "since somebody opened a page".
20
+ *
21
+ * PRIVACY. A frame carries ids, states, counts and times. Never a title, prompt, reply, tool, file name, path, cwd or
22
+ * model. `projectSession` builds every session frame from an allowlist of fields, so a source that grows a text field
23
+ * cannot leak it here.
24
+ *
25
+ * BOUNDED EVERYWHERE. The ring holds LIVE_EVENTS_RING_SIZE frames. The route caps streams and drops a client whose
26
+ * unsent bytes pass a ceiling. The model is capped at LIVE_MAX_SESSIONS sessions. A source that throws marks itself
27
+ * degraded and keeps the last good value; it never empties the model (an empty read is not "nothing is running").
28
+ */
29
+
30
+ export type LiveProvider = 'claude' | 'codex' | 'cursor'
31
+ export type LiveState = 'working' | 'waiting' | 'idle' | 'ended'
32
+
33
+ /** The wire shape of one session. Every field is an id, a state, a count or a time. */
34
+ export interface LiveSession {
35
+ provider: LiveProvider
36
+ sessionId: string
37
+ state: LiveState
38
+ startedAt: number | null
39
+ turnStartedAt: number | null
40
+ updatedAt: number | null
41
+ replies: number
42
+ questions: number
43
+ queued: number
44
+ source: 'hook' | 'registry'
45
+ failed?: true
46
+ }
47
+
48
+ /** What a session source reports per tick, before the hub adds replies, questions and queued. */
49
+ export interface LiveSessionReading {
50
+ provider: LiveProvider
51
+ sessionId: string
52
+ state: LiveState
53
+ startedAt: number | null
54
+ turnStartedAt: number | null
55
+ updatedAt: number | null
56
+ source: 'hook' | 'registry'
57
+ failed?: boolean
58
+ }
59
+
60
+ export interface LiveCountRow { provider: LiveProvider; sessionId: string; count: number }
61
+ export interface LiveQuestionReading { provider: LiveProvider; sessionId: string; kind: 'question' | 'approval' }
62
+ export interface LiveQueueReading { provider: LiveProvider; sessionId: string; count: number }
63
+
64
+ export interface LiveQuestions { total: number; approvals: number; bySession: LiveCountRow[] }
65
+ export interface LiveQueue { total: number; bySession: LiveCountRow[] }
66
+ export interface LiveDegraded { registry: boolean; questions: boolean; queue: boolean }
67
+
68
+ interface FrameBase { seq: number; epoch: number; at: number }
69
+ export type LiveFrame =
70
+ | (FrameBase & { kind: 'snapshot'; sessions: LiveSession[]; questions: LiveQuestions; queue: LiveQueue; degraded: LiveDegraded })
71
+ | (FrameBase & { kind: 'session'; change: 'added' | 'updated'; session: LiveSession })
72
+ | (FrameBase & { kind: 'session.removed'; provider: LiveProvider; sessionId: string })
73
+ | (FrameBase & { kind: 'questions' } & LiveQuestions)
74
+ | (FrameBase & { kind: 'queue' } & LiveQueue)
75
+ | (FrameBase & { kind: 'heartbeat' })
76
+ | (FrameBase & { kind: 'resume'; replayed: number })
77
+
78
+ /** A frame the ring remembers and a resume replays: every state frame, never heartbeat, snapshot or resume. */
79
+ export type LiveStateFrame = Extract<LiveFrame, { kind: 'session' | 'session.removed' | 'questions' | 'queue' }>
80
+
81
+ export interface LiveSources {
82
+ /**
83
+ * The open sessions right now. Throwing (or `degraded`) keeps the previous model. `hooksOnly`: rebuild from the hook
84
+ * signals and the registry as last read, reading no file (a hook push).
85
+ */
86
+ sessions(now: number, opts?: { hooksOnly?: boolean }): Promise<{ sessions: LiveSessionReading[]; degraded: boolean }>
87
+ /** The broker's pending items; null when it cannot be read. */
88
+ questions(now: number): LiveQuestionReading[] | null
89
+ /** Waiting follow-ups per thread; null when they cannot be read. */
90
+ queue(now: number): LiveQueueReading[] | null
91
+ /** Hook pushes. `ended` is a turn end (Stop or StopFailure) from the tab itself, never a COS child. */
92
+ subscribe(listener: (change: { sessionId: string; turnEnded: boolean }) => void): () => void
93
+ }
94
+
95
+ export const LIVE_TICK_MS = 2_000
96
+ export const LIVE_FLUSH_MS = 150
97
+ export const LIVE_EVENTS_RING_SIZE = 512
98
+ export const LIVE_ACTIVITY_MIN_MS = 5_000
99
+ export const LIVE_ENDED_LINGER_MS = 10 * 60_000
100
+ export const LIVE_MAX_SESSIONS = 200
101
+
102
+ export interface LiveHubOptions {
103
+ sources: LiveSources
104
+ now?: () => number
105
+ tickMs?: number
106
+ flushMs?: number
107
+ ringSize?: number
108
+ activityMinMs?: number
109
+ endedLingerMs?: number
110
+ epoch?: number
111
+ setTimer?: (fn: () => void, ms: number) => unknown
112
+ clearTimer?: (handle: unknown) => void
113
+ log?: (line: string) => void
114
+ }
115
+
116
+ export type LiveListener = (frame: LiveFrame) => void
117
+
118
+ function keyOf(provider: string, sessionId: string): string {
119
+ return `${provider}:${sessionId.toLowerCase()}`
120
+ }
121
+
122
+ const PROVIDERS = new Set<LiveProvider>(['claude', 'codex', 'cursor'])
123
+ const STATES = new Set<LiveState>(['working', 'waiting', 'idle', 'ended'])
124
+ const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
125
+
126
+ function msOrNull(value: unknown): number | null {
127
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.trunc(value) : null
128
+ }
129
+
130
+ /**
131
+ * The ONLY way a session reaches the wire: an allowlist of fields, each coerced to its type. An id that is not a
132
+ * plain token (a path, a title someone put in an id slot) is refused rather than sent.
133
+ */
134
+ export function projectSession(reading: LiveSessionReading, extra: { replies: number; questions: number; queued: number }): LiveSession | null {
135
+ if (!PROVIDERS.has(reading.provider)) return null
136
+ const sessionId = String(reading.sessionId ?? '').toLowerCase()
137
+ if (!SAFE_ID.test(sessionId)) return null
138
+ const state: LiveState = STATES.has(reading.state) ? reading.state : 'idle'
139
+ const out: LiveSession = {
140
+ provider: reading.provider,
141
+ sessionId,
142
+ state,
143
+ startedAt: msOrNull(reading.startedAt),
144
+ // A turn clock only while a turn is in progress, so a client can never tick an idle session.
145
+ turnStartedAt: state === 'working' || state === 'waiting' ? msOrNull(reading.turnStartedAt) : null,
146
+ updatedAt: msOrNull(reading.updatedAt),
147
+ replies: Math.max(0, Math.trunc(extra.replies) || 0),
148
+ questions: Math.max(0, Math.trunc(extra.questions) || 0),
149
+ queued: Math.max(0, Math.trunc(extra.queued) || 0),
150
+ source: reading.source === 'hook' ? 'hook' : 'registry',
151
+ }
152
+ if (reading.failed === true && state === 'idle') out.failed = true
153
+ return out
154
+ }
155
+
156
+ /** Every field but `updatedAt`: a change here is sent at once; an `updatedAt`-only change is coalesced. */
157
+ function materialStamp(s: LiveSession): string {
158
+ return `${s.state}|${s.startedAt ?? ''}|${s.turnStartedAt ?? ''}|${s.replies}|${s.questions}|${s.queued}|${s.source}|${s.failed ? 1 : 0}`
159
+ }
160
+
161
+ function sortRows(rows: LiveCountRow[]): LiveCountRow[] {
162
+ return rows.sort((a, b) => keyOf(a.provider, a.sessionId).localeCompare(keyOf(b.provider, b.sessionId)))
163
+ }
164
+
165
+ function countsStamp(rows: LiveCountRow[]): string {
166
+ return rows.map(r => `${keyOf(r.provider, r.sessionId)}=${r.count}`).join(',')
167
+ }
168
+
169
+ /**
170
+ * The parsed resume cursor: `<epoch>.<seq>`, both plain integers. Anything else is null and the caller snapshots.
171
+ */
172
+ export function parseLiveCursor(raw: unknown): { epoch: number; seq: number } | null {
173
+ if (typeof raw !== 'string') return null
174
+ const m = /^(\d{1,16})\.(\d{1,12})$/.exec(raw.trim())
175
+ if (!m) return null
176
+ return { epoch: Number(m[1]), seq: Number(m[2]) }
177
+ }
178
+
179
+ export class LiveEventsHub {
180
+ readonly epoch: number
181
+ private readonly sources: LiveSources
182
+ private readonly now: () => number
183
+ private readonly tickMs: number
184
+ private readonly flushMs: number
185
+ private readonly ringSize: number
186
+ private readonly activityMinMs: number
187
+ private readonly endedLingerMs: number
188
+ private readonly setTimer: (fn: () => void, ms: number) => unknown
189
+ private readonly clearTimer: (handle: unknown) => void
190
+ private readonly log: (line: string) => void
191
+
192
+ private seq = 0
193
+ private readonly ring: LiveStateFrame[] = []
194
+ private readonly listeners = new Set<LiveListener>()
195
+ /** The model as last sent. */
196
+ private readonly sessions = new Map<string, LiveSession>()
197
+ private readonly lastSentAt = new Map<string, number>()
198
+ /** Sessions that left the open set: kept as `ended` until this time, then removed. */
199
+ private readonly endedUntil = new Map<string, number>()
200
+ /** Removed after their linger while a source still reports them ended: not re-added until they open again. */
201
+ private readonly retired = new Set<string>()
202
+ private readonly replies = new Map<string, number>()
203
+ private questions: LiveQuestions = { total: 0, approvals: 0, bySession: [] }
204
+ private queue: LiveQueue = { total: 0, bySession: [] }
205
+ private degraded: LiveDegraded = { registry: false, questions: false, queue: false }
206
+ private lastReadings: LiveSessionReading[] = []
207
+
208
+ private tickHandle: unknown = null
209
+ private flushHandle: unknown = null
210
+ private unsubscribe: (() => void) | null = null
211
+ private running: Promise<void> | null = null
212
+ private rerun = false
213
+ /** A full read was asked for while a run was in progress: the follow-up run is full. */
214
+ private rerunFull = false
215
+ private fullReads = 0
216
+ private hookReads = 0
217
+ private primed = false
218
+
219
+ constructor(options: LiveHubOptions) {
220
+ this.sources = options.sources
221
+ this.now = options.now ?? (() => Date.now())
222
+ this.tickMs = options.tickMs ?? LIVE_TICK_MS
223
+ this.flushMs = options.flushMs ?? LIVE_FLUSH_MS
224
+ this.ringSize = Math.max(1, options.ringSize ?? LIVE_EVENTS_RING_SIZE)
225
+ this.activityMinMs = options.activityMinMs ?? LIVE_ACTIVITY_MIN_MS
226
+ this.endedLingerMs = options.endedLingerMs ?? LIVE_ENDED_LINGER_MS
227
+ this.epoch = options.epoch ?? this.now()
228
+ this.setTimer = options.setTimer ?? ((fn, ms) => { const t = setTimeout(fn, ms); (t as { unref?: () => void }).unref?.(); return t })
229
+ this.clearTimer = options.clearTimer ?? (h => clearTimeout(h as ReturnType<typeof setTimeout>))
230
+ this.log = options.log ?? (line => console.log(line))
231
+ // The reply counter listens always (one Map increment per turn end); everything else waits for a stream.
232
+ this.unsubscribe = this.sources.subscribe(change => this.onPush(change))
233
+ }
234
+
235
+ get newestSeq(): number { return this.seq }
236
+ /** Test seam: how many reads touched every source, and how many rebuilt the hook readings only. */
237
+ get readCounts(): { full: number; hooks: number } { return { full: this.fullReads, hooks: this.hookReads } }
238
+ get streamCount(): number { return this.listeners.size }
239
+
240
+ /** Ring bounds for a resume decision; null when empty. */
241
+ ringBounds(): { oldest: number; newest: number } | null {
242
+ if (this.ring.length === 0) return null
243
+ return { oldest: this.ring[0]!.seq, newest: this.ring[this.ring.length - 1]!.seq }
244
+ }
245
+
246
+ /**
247
+ * The frames after `cursor`, or null when the hub cannot honour it (another epoch, older than the ring, from the
248
+ * future): the caller then sends a snapshot. An empty array means "you are current".
249
+ */
250
+ replayAfter(cursor: { epoch: number; seq: number } | null): LiveStateFrame[] | null {
251
+ if (!cursor || cursor.epoch !== this.epoch) return null
252
+ if (cursor.seq > this.seq) return null
253
+ if (cursor.seq === this.seq) return []
254
+ const bounds = this.ringBounds()
255
+ if (!bounds || bounds.oldest > cursor.seq + 1) return null
256
+ return this.ring.filter(f => f.seq > cursor.seq)
257
+ }
258
+
259
+ snapshotFrame(): LiveFrame {
260
+ return {
261
+ kind: 'snapshot', seq: this.seq, epoch: this.epoch, at: this.now(),
262
+ sessions: [...this.sessions.values()].map(s => ({ ...s })),
263
+ questions: { ...this.questions, bySession: this.questions.bySession.map(r => ({ ...r })) },
264
+ queue: { ...this.queue, bySession: this.queue.bySession.map(r => ({ ...r })) },
265
+ degraded: { ...this.degraded },
266
+ }
267
+ }
268
+
269
+ heartbeatFrame(): LiveFrame {
270
+ return { kind: 'heartbeat', seq: this.seq, epoch: this.epoch, at: this.now() }
271
+ }
272
+
273
+ resumeFrame(replayed: number): LiveFrame {
274
+ return { kind: 'resume', seq: this.seq, epoch: this.epoch, at: this.now(), replayed }
275
+ }
276
+
277
+ /**
278
+ * Join a stream. The first one starts the tick and primes the model (awaited, so the snapshot a fresh page gets is
279
+ * a real reading rather than an empty model). The returned detach stops the tick with the last stream.
280
+ */
281
+ async attach(listener: LiveListener): Promise<() => void> {
282
+ this.listeners.add(listener)
283
+ if (!this.primed) {
284
+ await this.refresh()
285
+ this.primed = true
286
+ }
287
+ this.scheduleTick()
288
+ let attached = true
289
+ return () => {
290
+ if (!attached) return
291
+ attached = false
292
+ this.listeners.delete(listener)
293
+ if (this.listeners.size === 0) this.stopTicking()
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Read the sources once and emit what changed: every source (`full`, the tick and the first stream), or the hook
299
+ * readings only (a push). Concurrent calls share one run, plus one follow-up, which is full if any caller asked.
300
+ */
301
+ refresh(full = true): Promise<void> {
302
+ if (this.running) { this.rerun = true; if (full) this.rerunFull = true; return this.running }
303
+ this.running = (async () => {
304
+ try {
305
+ let next = full
306
+ do {
307
+ this.rerun = false
308
+ this.rerunFull = false
309
+ await this.readOnce(next)
310
+ next = this.rerunFull
311
+ } while (this.rerun)
312
+ } finally {
313
+ this.running = null
314
+ }
315
+ })()
316
+ return this.running
317
+ }
318
+
319
+ dispose(): void {
320
+ this.stopTicking()
321
+ if (this.flushHandle !== null) { this.clearTimer(this.flushHandle); this.flushHandle = null }
322
+ this.unsubscribe?.()
323
+ this.unsubscribe = null
324
+ this.listeners.clear()
325
+ }
326
+
327
+ private onPush(change: { sessionId: string; turnEnded: boolean }): void {
328
+ if (change.turnEnded) {
329
+ const id = String(change.sessionId ?? '').toLowerCase()
330
+ if (id) {
331
+ this.replies.set(id, (this.replies.get(id) ?? 0) + 1)
332
+ // Bounded: a long-lived server sees many sessions; the oldest counters go first.
333
+ if (this.replies.size > 4 * LIVE_MAX_SESSIONS) {
334
+ const first = this.replies.keys().next().value
335
+ if (first !== undefined) this.replies.delete(first)
336
+ }
337
+ }
338
+ }
339
+ if (this.listeners.size === 0) return
340
+ if (this.flushHandle !== null) return
341
+ this.flushHandle = this.setTimer(() => {
342
+ this.flushHandle = null
343
+ void this.refresh(false).catch(() => { /* readOnce never throws; belt and braces */ })
344
+ }, this.flushMs)
345
+ }
346
+
347
+ private scheduleTick(): void {
348
+ if (this.tickHandle !== null || this.listeners.size === 0) return
349
+ this.tickHandle = this.setTimer(() => {
350
+ this.tickHandle = null
351
+ void this.refresh().finally(() => this.scheduleTick())
352
+ }, this.tickMs)
353
+ }
354
+
355
+ private stopTicking(): void {
356
+ if (this.tickHandle !== null) { this.clearTimer(this.tickHandle); this.tickHandle = null }
357
+ if (this.flushHandle !== null) { this.clearTimer(this.flushHandle); this.flushHandle = null }
358
+ // A model nobody watches goes stale; the next stream re-primes it, and the diff against the old model still
359
+ // produces the right frames for anyone resuming across the gap.
360
+ this.primed = false
361
+ }
362
+
363
+ private emit(frame: LiveStateFrame): void {
364
+ this.ring.push(frame)
365
+ if (this.ring.length > this.ringSize) this.ring.splice(0, this.ring.length - this.ringSize)
366
+ for (const listener of this.listeners) {
367
+ try { listener(frame) } catch (error) {
368
+ this.log(`[live-events] listener failed: ${error instanceof Error ? error.message : String(error)}`)
369
+ }
370
+ }
371
+ }
372
+
373
+ private nextBase(): FrameBase {
374
+ return { seq: ++this.seq, epoch: this.epoch, at: this.now() }
375
+ }
376
+
377
+ private async readOnce(full = true): Promise<void> {
378
+ const now = this.now()
379
+ if (full) this.fullReads++
380
+ else this.hookReads++
381
+
382
+ let readings = this.lastReadings
383
+ try {
384
+ const read = await this.sources.sessions(now, full ? undefined : { hooksOnly: true })
385
+ if (read.degraded) this.degraded = { ...this.degraded, registry: true }
386
+ else this.degraded = { ...this.degraded, registry: false }
387
+ // A degraded read still carries whatever it did see; it is merged over the last good list, never replacing
388
+ // it with less (a quiet registry read is not "nothing is running").
389
+ readings = read.degraded ? mergeReadings(this.lastReadings, read.sessions) : read.sessions
390
+ this.lastReadings = readings
391
+ } catch (error) {
392
+ this.degraded = { ...this.degraded, registry: true }
393
+ this.log(`[live-events] session read failed: ${error instanceof Error ? error.message : String(error)}`)
394
+ }
395
+
396
+ // A hook push reads neither the broker nor the queue files: both keep their last value until the tick.
397
+ let questionRows: LiveQuestionReading[] | null | undefined
398
+ let queueRows: LiveQueueReading[] | null | undefined
399
+ if (full) {
400
+ try { questionRows = this.sources.questions(now) } catch { questionRows = null }
401
+ try { queueRows = this.sources.queue(now) } catch { queueRows = null }
402
+ }
403
+
404
+ // Questions and queue: a null read keeps the last value and flags it.
405
+ if (questionRows === undefined) { /* not read on a push */ }
406
+ else if (questionRows === null) this.degraded = { ...this.degraded, questions: true }
407
+ else {
408
+ this.degraded = { ...this.degraded, questions: false }
409
+ const per = new Map<string, LiveCountRow>()
410
+ let approvals = 0
411
+ for (const q of questionRows) {
412
+ if (!PROVIDERS.has(q.provider)) continue
413
+ const id = String(q.sessionId ?? '').toLowerCase()
414
+ if (!SAFE_ID.test(id)) continue
415
+ if (q.kind === 'approval') approvals++
416
+ const k = keyOf(q.provider, id)
417
+ const row = per.get(k) ?? { provider: q.provider, sessionId: id, count: 0 }
418
+ row.count++
419
+ per.set(k, row)
420
+ }
421
+ const next: LiveQuestions = { total: [...per.values()].reduce((n, r) => n + r.count, 0), approvals, bySession: sortRows([...per.values()]) }
422
+ if (next.total !== this.questions.total || next.approvals !== this.questions.approvals || countsStamp(next.bySession) !== countsStamp(this.questions.bySession)) {
423
+ this.questions = next
424
+ this.emit({ ...this.nextBase(), kind: 'questions', total: next.total, approvals: next.approvals, bySession: next.bySession.map(r => ({ ...r })) })
425
+ }
426
+ }
427
+ if (queueRows === undefined) { /* not read on a push */ }
428
+ else if (queueRows === null) this.degraded = { ...this.degraded, queue: true }
429
+ else {
430
+ this.degraded = { ...this.degraded, queue: false }
431
+ const per = new Map<string, LiveCountRow>()
432
+ for (const q of queueRows) {
433
+ if (!PROVIDERS.has(q.provider) || !(q.count > 0)) continue
434
+ const id = String(q.sessionId ?? '').toLowerCase()
435
+ if (!SAFE_ID.test(id)) continue
436
+ const k = keyOf(q.provider, id)
437
+ const row = per.get(k) ?? { provider: q.provider, sessionId: id, count: 0 }
438
+ row.count += Math.trunc(q.count)
439
+ per.set(k, row)
440
+ }
441
+ const next: LiveQueue = { total: [...per.values()].reduce((n, r) => n + r.count, 0), bySession: sortRows([...per.values()]) }
442
+ if (next.total !== this.queue.total || countsStamp(next.bySession) !== countsStamp(this.queue.bySession)) {
443
+ this.queue = next
444
+ this.emit({ ...this.nextBase(), kind: 'queue', total: next.total, bySession: next.bySession.map(r => ({ ...r })) })
445
+ }
446
+ }
447
+
448
+ // Sessions.
449
+ const questionsFor = (provider: LiveProvider, id: string): number => countFor(this.questions.bySession, provider, id)
450
+ const queuedFor = (provider: LiveProvider, id: string): number => countFor(this.queue.bySession, provider, id)
451
+ const seen = new Set<string>()
452
+ const nextModel = new Map<string, LiveSession>()
453
+ for (const reading of readings.slice(0, LIVE_MAX_SESSIONS)) {
454
+ const id = String(reading.sessionId ?? '').toLowerCase()
455
+ const projected = projectSession(reading, {
456
+ replies: this.replies.get(id) ?? 0,
457
+ questions: questionsFor(reading.provider, id),
458
+ queued: queuedFor(reading.provider, id),
459
+ })
460
+ if (!projected) continue
461
+ const k = keyOf(projected.provider, projected.sessionId)
462
+ if (seen.has(k)) continue
463
+ seen.add(k)
464
+ if (projected.state === 'ended' && this.retired.has(k)) continue
465
+ if (projected.state !== 'ended') this.retired.delete(k)
466
+ nextModel.set(k, projected)
467
+ if (projected.state === 'ended') {
468
+ if (!this.endedUntil.has(k)) this.endedUntil.set(k, now + this.endedLingerMs)
469
+ } else {
470
+ this.endedUntil.delete(k)
471
+ }
472
+ }
473
+ // A session that left the open set without saying it ended is ended now; it lingers, then goes.
474
+ for (const [k, prev] of this.sessions) {
475
+ if (nextModel.has(k)) continue
476
+ if (!this.endedUntil.has(k)) this.endedUntil.set(k, now + this.endedLingerMs)
477
+ nextModel.set(k, { ...prev, state: 'ended', turnStartedAt: null, questions: questionsFor(prev.provider, prev.sessionId), queued: queuedFor(prev.provider, prev.sessionId), replies: this.replies.get(prev.sessionId) ?? prev.replies })
478
+ }
479
+ for (const [k, until] of this.endedUntil) {
480
+ if (now < until) continue
481
+ this.endedUntil.delete(k)
482
+ nextModel.delete(k)
483
+ this.retired.add(k)
484
+ if (this.retired.size > 4 * LIVE_MAX_SESSIONS) {
485
+ const first = this.retired.values().next().value
486
+ if (first !== undefined) this.retired.delete(first)
487
+ }
488
+ }
489
+
490
+ for (const [k, prev] of this.sessions) {
491
+ if (nextModel.has(k)) continue
492
+ this.sessions.delete(k)
493
+ this.lastSentAt.delete(k)
494
+ this.emit({ ...this.nextBase(), kind: 'session.removed', provider: prev.provider, sessionId: prev.sessionId })
495
+ }
496
+ for (const [k, nextRaw] of nextModel) {
497
+ const prev = this.sessions.get(k)
498
+ // When a session started does not change while it is in the model: the registry and the hooks can disagree by
499
+ // a few seconds (the tab's own start against the first hook), and a flip between them is not news.
500
+ const next = prev && prev.startedAt !== null && nextRaw.startedAt !== prev.startedAt ? { ...nextRaw, startedAt: prev.startedAt } : nextRaw
501
+ if (!prev) {
502
+ if (this.sessions.size >= LIVE_MAX_SESSIONS) continue
503
+ this.sessions.set(k, next)
504
+ this.lastSentAt.set(k, now)
505
+ this.emit({ ...this.nextBase(), kind: 'session', change: 'added', session: { ...next } })
506
+ continue
507
+ }
508
+ const material = materialStamp(prev) !== materialStamp(next)
509
+ const touched = prev.updatedAt !== next.updatedAt
510
+ if (!material && !touched) continue
511
+ if (!material && now - (this.lastSentAt.get(k) ?? 0) < this.activityMinMs) continue
512
+ this.sessions.set(k, next)
513
+ this.lastSentAt.set(k, now)
514
+ this.emit({ ...this.nextBase(), kind: 'session', change: 'updated', session: { ...next } })
515
+ }
516
+ }
517
+ }
518
+
519
+ function countFor(rows: LiveCountRow[], provider: LiveProvider, id: string): number {
520
+ const exact = rows.find(r => r.provider === provider && r.sessionId === id)
521
+ if (exact) return exact.count
522
+ // The broker and the queue can key a Claude tab by its 8-character registry id; join on the prefix when it is
523
+ // unambiguous, the same rule as queuedWaitingForSession.
524
+ const short = id.slice(0, 8)
525
+ const prefixed = rows.filter(r => r.provider === provider && (r.sessionId.startsWith(short) || short.startsWith(r.sessionId)) && (r.sessionId.length === 8 || id.length === 8))
526
+ return prefixed.length === 1 ? prefixed[0]!.count : 0
527
+ }
528
+
529
+ function mergeReadings(prev: LiveSessionReading[], next: LiveSessionReading[]): LiveSessionReading[] {
530
+ const byKey = new Map(prev.map(r => [keyOf(r.provider, r.sessionId), r]))
531
+ for (const r of next) byKey.set(keyOf(r.provider, r.sessionId), r)
532
+ return [...byKey.values()]
533
+ }