@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.
- package/.env.example +18 -0
- package/CHANGELOG.md +475 -0
- package/README.md +3 -1
- package/managed-runtime-contract.json +3 -0
- package/package.json +1 -1
- package/server/index.ts +29 -7
- package/server/lib/agent-session-output-images.ts +627 -49
- package/server/lib/agent-session-store.ts +24 -11
- package/server/lib/agent-session-tool-images.ts +224 -0
- package/server/lib/agent-session-turns.ts +19 -2
- package/server/lib/api-auth.ts +6 -0
- package/server/lib/ask-work-plan.ts +3 -1
- package/server/lib/calendar-source.ts +491 -0
- package/server/lib/codex-bridge.ts +4 -1
- package/server/lib/codex-run-ledger.ts +29 -0
- package/server/lib/context-builder.ts +2 -1
- package/server/lib/drop-p2-runtime.ts +1 -1
- package/server/lib/drop-store.ts +10 -4
- package/server/lib/learning-needs-you.ts +24 -7
- package/server/lib/live-events.ts +533 -0
- package/server/lib/local-path-redact.ts +272 -0
- package/server/lib/meeting-ask-orientation.ts +40 -6
- package/server/lib/meeting-ask-runner.ts +30 -10
- package/server/lib/morning-brief-runtime.ts +4 -2
- package/server/lib/native-mac-caller.ts +29 -0
- package/server/lib/ping-engine.ts +1 -1
- package/server/lib/ping-sources.ts +11 -6
- package/server/lib/preview-content.ts +172 -0
- package/server/lib/preview-error-code.ts +10 -0
- package/server/lib/preview-folders-setting.ts +133 -0
- package/server/lib/preview-log-throttle.ts +18 -0
- package/server/lib/preview-thumbs.ts +139 -0
- package/server/lib/secret-path-guard.ts +80 -0
- package/server/lib/session-files-setting.ts +88 -0
- package/server/lib/session-links.ts +280 -0
- package/server/lib/thread-turn-queue.ts +10 -0
- package/server/lib/today-brief.ts +119 -28
- package/server/lib/web-memories-bridge.ts +3 -1
- package/server/lib/work-activity.ts +56 -6
- package/server/lib/work-session-activity.ts +260 -0
- package/server/lib/your-move-count.ts +9 -3
- package/server/routes/agent-session-bindings.ts +13 -0
- package/server/routes/agent-session-stream.ts +16 -0
- package/server/routes/agent-sessions.ts +146 -11
- package/server/routes/ask-work-plan.ts +4 -1
- package/server/routes/claude-sessions.ts +5 -1
- package/server/routes/health.ts +35 -0
- package/server/routes/live-events.ts +329 -0
- package/server/routes/permission-broker.ts +69 -5
- package/server/routes/session-preview.ts +200 -0
- package/server/routes/thread-turn-queue.ts +207 -3
- package/server/routes/today-brief.ts +44 -9
- package/server/routes/welcome-context.ts +23 -3
- package/server/routes/work-board.ts +9 -2
- package/server/web-control/app.css +79 -9
- package/server/web-control/app.js +1543 -65
- package/server/web-control/control.css +37 -6
- package/server/web-control/index.html +8 -2
- package/server/web-control/memory-review.js +100 -0
- package/server/web-control/panes/cos-chat.css +51 -6
- package/server/web-control/panes/cos-chat.js +268 -16
- package/server/web-control/panes/meetings.css +23 -4
- package/server/web-control/panes/meetings.js +25 -9
- package/server/web-control/panes/memories.css +18 -3
- package/server/web-control/panes/memories.js +130 -50
- package/server/web-control/panes/messages.js +21 -2
- package/server/web-control/panes/preview.js +318 -0
- package/server/web-control/panes/questions.css +20 -4
- package/server/web-control/panes/questions.js +127 -26
- package/server/web-control/panes/reader-media.js +409 -23
- package/server/web-control/panes/sessions.css +50 -10
- package/server/web-control/panes/settings.js +96 -5
- package/server/web-control/panes/speakers.css +3 -5
- package/server/web-control/panes/speakers.js +7 -4
- package/server/web-control/panes/threads.js +3 -2
- package/server/web-control/panes/today-actions.css +49 -18
- package/server/web-control/panes/today-actions.js +217 -50
- package/server/web-control/panes/work.css +11 -1
- package/server/web-control/panes/work.js +10 -2
- 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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|