@gotcos/glasses-server 6.99.33 → 7.0.18

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 (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
@@ -0,0 +1,334 @@
1
+ // GET /api/events (7.0.1 live state). The contract is docs/live-events-contract.md; the hub is lib/live-events.ts.
2
+ //
3
+ // ONE stream per open Control Web page. It tells the page when the server's own session state changes (a session
4
+ // opens, works, waits, idles or ends; a reply lands; a question opens or closes; the queue moves), so the pet, the
5
+ // session rows, Today's counts and the running clocks stop drifting between polls.
6
+ //
7
+ // TRANSPORT, copied from the per-session stream that already works through Tailscale Serve and the https listener
8
+ // (agent-session-stream.ts): the same four headers, `flushHeaders()`, `retry:` first, one JSON object per `data:`
9
+ // line with no named events and no comment keepalives, a heartbeat every 15 s, and every refusal BEFORE the headers
10
+ // as ordinary JSON so a client sees a failed request, never a socket that never speaks.
11
+ //
12
+ // AUTH is the global /api gate (token header or the Control Web cookie), exactly as for `/stream`. Nothing here
13
+ // re-checks it, so nothing here can drift from it.
14
+ //
15
+ // SWITCH: COS_WEB_LIVE_EVENTS. Absent means on. `0` registers no route at all (404), which is also what a client
16
+ // sees from a server older than 7.0.1, so both take the same polling path.
17
+
18
+ import { streamSignInHolds } from '../lib/api-auth.js'
19
+ import { Router, type Request, type Response } from 'express'
20
+ import { readSwitch } from '../lib/env-switch.js'
21
+ import {
22
+ LIVE_ENDED_LINGER_MS,
23
+ LiveEventsHub,
24
+ parseLiveCursor,
25
+ type LiveFrame,
26
+ type LiveProvider,
27
+ type LiveQuestionReading,
28
+ type LiveQueueReading,
29
+ type LiveSessionReading,
30
+ type LiveSources,
31
+ type LiveState,
32
+ } from '../lib/live-events.js'
33
+ import { claudeSessionsDir, claudeSessionsEnabled, readClaudePeerRecords, registryFacts } from './claude-sessions.js'
34
+ import type { ClaudePeerRecord } from '../lib/claude-session-registry.js'
35
+ import { deriveForRow, sessionHooksEnabled, sessionSignalStore, signalFor } from '../lib/session-hooks-runtime.js'
36
+ import type { SessionSignal } from '../lib/session-signal-store.js'
37
+ import { isAttachedTurnActive, sessionStreamKey } from '../lib/session-stream-bus.js'
38
+ import { runningHintFor } from './agent-sessions.js'
39
+ import { queuedThreadKeys, readQueue } from '../lib/thread-turn-queue-store.js'
40
+ import type { PermissionBroker } from '../lib/permission-broker.js'
41
+
42
+ /** Inside a 30 s client deadline with room for one lost write; the same cadence as `/stream`. */
43
+ export const LIVE_EVENTS_HEARTBEAT_MS = 15_000
44
+ /**
45
+ * Open streams per server. A phone, a laptop and a few tabs each hold one; 16 is room plus a ceiling. At the ceiling the
46
+ * OLDEST stream is closed to make room (ghost review W5): a peer that vanished behind Tailscale holds its slot until TCP
47
+ * gives up, and the newest request is usually the person who just opened the page. A closed live client reconnects
48
+ * with its last id.
49
+ */
50
+ export const LIVE_EVENTS_MAX_STREAMS = 16
51
+ /** Unsent bytes past this close the stream: a slow client is dropped, never buffered without limit. */
52
+ export const LIVE_EVENTS_MAX_BUFFER_BYTES = 256 * 1024
53
+ /** A hook-only session with no event for this long (and no open turn or wait) is not open. The deriver's open-turn ceiling. */
54
+ export const LIVE_SIGNAL_OPEN_WINDOW_MS = 30 * 60_000
55
+
56
+ /** Absent means on; `0` (or false/off) means no route. Read at server start. */
57
+ export function liveEventsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
58
+ return readSwitch(env.COS_WEB_LIVE_EVENTS, true)
59
+ }
60
+
61
+ function newestOf(...values: Array<number | null | undefined>): number | null {
62
+ let out: number | null = null
63
+ for (const v of values) if (typeof v === 'number' && Number.isFinite(v) && v > 0 && (out === null || v > out)) out = v
64
+ return out
65
+ }
66
+
67
+ /**
68
+ * One session's reading, from the same derivation the list and the per-session stream use (deriveForRow, then
69
+ * runningHintFor), so `working` here is the list's `running_active` and the pet never disagrees with the rows.
70
+ *
71
+ * Exported for tests. Pure apart from the deriver's own reads of the signal store (remember: false, so it never
72
+ * touches the row readers' two-scan memory).
73
+ */
74
+ export function readingFor(provider: LiveProvider, sessionId: string, peer: ClaudePeerRecord | undefined, signal: SessionSignal | undefined, now: number): LiveSessionReading {
75
+ const id = sessionId.toLowerCase()
76
+ const derived = deriveForRow({
77
+ sessionId: id,
78
+ registry: peer ? registryFacts(peer) : undefined,
79
+ now,
80
+ remember: false,
81
+ attachedTurn: isAttachedTurnActive(sessionStreamKey(provider, id)),
82
+ })
83
+ const hookClock = signal ? newestOf(signal.lastEventAt, signal.lastThought?.at) : null
84
+ let state: LiveState
85
+ let source: 'hook' | 'registry' = derived?.state_source === 'hook' ? 'hook' : 'registry'
86
+ if (!derived) {
87
+ // Hooks off: the registry's own status is all there is.
88
+ source = 'registry'
89
+ state = peer?.status === 'busy' ? 'working' : peer?.status === 'waiting' ? 'waiting' : 'idle'
90
+ } else if (derived.agent_state === 'ended') {
91
+ state = 'ended'
92
+ } else {
93
+ const hint = runningHintFor(provider, derived, derived.state_source === 'hook' ? hookClock : null, now, null)
94
+ if (hint?.active) state = 'working'
95
+ // A registry `busy` with no hooks: the list reads its transcript clock here; the registry's word is the closest
96
+ // thing this stream has without a file read, and a busy tab writes its transcript continuously.
97
+ else if (!hint && derived.agent_state === 'running' && derived.state_source !== 'hook') state = 'working'
98
+ else if (derived.agent_state === 'waiting') state = 'waiting'
99
+ else state = 'idle'
100
+ }
101
+ const registryTurn = peer && (peer.status === 'busy' || peer.status === 'waiting') ? peer.statusUpdatedAt : null
102
+ return {
103
+ provider,
104
+ sessionId: id,
105
+ state,
106
+ startedAt: newestOf(peer?.startedAt) ?? newestOf(signal?.firstSeenAt),
107
+ turnStartedAt: signal?.turnOpen && signal.turnStartedAt ? signal.turnStartedAt : registryTurn,
108
+ updatedAt: newestOf(hookClock, peer?.statusUpdatedAt),
109
+ source,
110
+ failed: derived?.agent_state === 'failed',
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Which sessions are open, from the two things the server holds without walking transcripts: the Claude registry
116
+ * (alive records) and the hook signal store. Exported for tests with injected inputs.
117
+ */
118
+ export function liveReadings(input: { peers: ClaudePeerRecord[] | null; signals: SessionSignal[]; now: number; signalFor: (id: string) => SessionSignal | undefined; reading?: typeof readingFor }): LiveSessionReading[] {
119
+ const reading = input.reading ?? readingFor
120
+ const out: LiveSessionReading[] = []
121
+ const included = new Set<string>()
122
+ const registryKnown = input.peers !== null
123
+ for (const peer of input.peers ?? []) {
124
+ if (!peer.alive) continue
125
+ const id = peer.sessionId.toLowerCase()
126
+ const key = `claude:${id}`
127
+ if (included.has(key)) continue
128
+ included.add(key)
129
+ out.push(reading('claude', id, peer, input.signalFor(id), input.now))
130
+ }
131
+ for (const signal of input.signals) {
132
+ if (signal.keepWarm) continue
133
+ const provider: LiveProvider = signal.provider === 'codex' || signal.provider === 'cursor' ? signal.provider : 'claude'
134
+ const id = signal.sessionId.toLowerCase()
135
+ const key = `${provider}:${id}`
136
+ if (included.has(key)) continue
137
+ const endedRecently = !!signal.ended && input.now - signal.ended.at <= LIVE_ENDED_LINGER_MS
138
+ const open = !signal.ended && (signal.turnOpen || !!signal.waiting || input.now - signal.lastEventAt <= LIVE_SIGNAL_OPEN_WINDOW_MS)
139
+ // A Claude tab is open while its registry record is alive. With the registry readable and no alive record, a
140
+ // hook record that never said SessionEnd is a tab that died (or a COS child that finished): not open.
141
+ if (provider === 'claude' && registryKnown && !endedRecently) continue
142
+ if (!open && !endedRecently) continue
143
+ included.add(key)
144
+ const r = reading(provider, id, undefined, signal, input.now)
145
+ out.push(endedRecently && r.state !== 'ended' ? { ...r, state: 'ended', turnStartedAt: null } : r)
146
+ }
147
+ return out
148
+ }
149
+
150
+ /**
151
+ * The server's own sources. `broker` is read lazily so the route can mount before the broker exists. `readPeers` is a
152
+ * test seam for the registry read.
153
+ */
154
+ export function realLiveSources(deps: { broker: () => PermissionBroker | null; readPeers?: () => Promise<ClaudePeerRecord[]> }): LiveSources {
155
+ const readPeers = deps.readPeers ?? (() => readClaudePeerRecords(claudeSessionsDir(), undefined, undefined, true))
156
+ // The registry as last read: a hook push rebuilds against it instead of reading the folder (ghost review W4).
157
+ let last: { peers: ClaudePeerRecord[] | null; degraded: boolean } | null = null
158
+ return {
159
+ async sessions(now, opts) {
160
+ let peers: ClaudePeerRecord[] | null = null
161
+ let degraded = false
162
+ if (opts?.hooksOnly && last) {
163
+ peers = last.peers
164
+ degraded = last.degraded
165
+ } else if (claudeSessionsEnabled()) {
166
+ // A folder that cannot be read throws: degraded, and the hub keeps its last good model rather than marking
167
+ // every tab ended (ghost review note 8).
168
+ try { peers = await readPeers() } catch { peers = null; degraded = true }
169
+ last = { peers, degraded }
170
+ } else {
171
+ last = { peers: null, degraded: false }
172
+ }
173
+ const signals = sessionHooksEnabled() ? sessionSignalStore.snapshot() : []
174
+ return { sessions: liveReadings({ peers, signals, now, signalFor }), degraded }
175
+ },
176
+ questions() {
177
+ const broker = deps.broker()
178
+ if (!broker) return []
179
+ return broker.list().map((q): LiveQuestionReading => ({ provider: q.provider, sessionId: q.sessionId, kind: q.kind === 'approval' ? 'approval' : 'question' }))
180
+ },
181
+ queue(now) {
182
+ const rows: LiveQueueReading[] = []
183
+ for (const { provider, threadId } of queuedThreadKeys()) {
184
+ if (provider !== 'claude' && provider !== 'codex' && provider !== 'cursor') continue
185
+ const count = readQueue(provider, threadId, now).filter(t => t.status === 'waiting').length
186
+ if (count > 0) rows.push({ provider, sessionId: threadId, count })
187
+ }
188
+ return rows
189
+ },
190
+ subscribe(listener) {
191
+ if (!sessionHooksEnabled()) return () => {}
192
+ return sessionSignalStore.subscribe((signal, env, child) => {
193
+ listener({ sessionId: signal.sessionId, turnEnded: !child && (env.event === 'Stop' || env.event === 'StopFailure') })
194
+ })
195
+ },
196
+ }
197
+ }
198
+
199
+ export interface LiveEventsRouterDeps {
200
+ hub: LiveEventsHub
201
+ enabled?: boolean
202
+ maxStreams?: number
203
+ heartbeatMs?: number
204
+ maxBufferBytes?: number
205
+ log?: (line: string) => void
206
+ }
207
+
208
+ /**
209
+ * The stream frame on the wire: an `id:` line on every frame a resume can start after (the state frames, and the
210
+ * snapshot, whose seq is the state it describes), then one `data:` line. Heartbeat and resume carry none: they repeat
211
+ * the newest seq, and an id on them would only restate the last one.
212
+ *
213
+ * The snapshot's id means the browser's OWN EventSource reconnect resumes from the snapshot instead of asking for a
214
+ * second whole snapshot when nothing changed in between.
215
+ */
216
+ export function frameText(frame: LiveFrame): string {
217
+ const remembered = frame.kind === 'snapshot' || frame.kind === 'session' || frame.kind === 'session.removed' || frame.kind === 'questions' || frame.kind === 'queue'
218
+ return `${remembered ? `id: ${frame.epoch}.${frame.seq}\n` : ''}data: ${JSON.stringify(frame)}\n\n`
219
+ }
220
+
221
+ export function createLiveEventsRouter(deps: LiveEventsRouterDeps): Router {
222
+ const router = Router()
223
+ if (deps.enabled === false) return router
224
+ router.get('/events', liveEventsHandler(deps))
225
+ return router
226
+ }
227
+
228
+ /** The handler, exported so the backpressure and heartbeat guards can be driven with a fake response. */
229
+ export function liveEventsHandler(deps: LiveEventsRouterDeps): (req: Request, res: Response) => Promise<void> {
230
+ const maxStreams = deps.maxStreams ?? LIVE_EVENTS_MAX_STREAMS
231
+ const heartbeatMs = deps.heartbeatMs ?? LIVE_EVENTS_HEARTBEAT_MS
232
+ const maxBuffer = deps.maxBufferBytes ?? LIVE_EVENTS_MAX_BUFFER_BYTES
233
+ const log = deps.log ?? (line => console.log(line))
234
+ let open = 0
235
+ /** Each open stream's teardown, oldest first (a Map keeps insertion order). */
236
+ const streams = new Map<number, (why: string) => void>()
237
+ let nextStream = 0
238
+
239
+ return async (req: Request, res: Response) => {
240
+ res.set('Cache-Control', 'private, no-store')
241
+ if (open >= maxStreams) {
242
+ const oldest = streams.entries().next().value as [number, (why: string) => void] | undefined
243
+ if (oldest) {
244
+ log(`[live-events] stream cap ${maxStreams} reached: closing the oldest stream to admit a new one (open ${open})`)
245
+ oldest[1]('evicted')
246
+ }
247
+ if (open >= maxStreams) {
248
+ log(`[live-events] stream cap ${maxStreams} reached: refused a new stream, none could be closed (open ${open})`)
249
+ res.status(503).json({ error: 'too many live streams', reason: 'live_events_capacity' })
250
+ return
251
+ }
252
+ }
253
+ open++
254
+ const streamId = nextStream++
255
+ let closed = false
256
+ let detach: (() => void) | null = null
257
+ let heartbeat: ReturnType<typeof setInterval> | null = null
258
+ // Frames that arrive while the snapshot is being prepared, written after it in order. Bounded like the buffer.
259
+ const pending: LiveFrame[] = []
260
+ let deliver: (frame: LiveFrame) => void = frame => {
261
+ pending.push(frame)
262
+ if (pending.length > 512) pending.shift()
263
+ }
264
+
265
+ const teardown = (why = 'closed'): void => {
266
+ if (closed) return
267
+ closed = true
268
+ open--
269
+ streams.delete(streamId)
270
+ if (heartbeat !== null) clearInterval(heartbeat)
271
+ detach?.()
272
+ if (why === 'slow') log(`[live-events] dropped a stream that stopped reading (${res.writableLength} bytes unsent, open ${open})`)
273
+ try { res.end() } catch { /* already gone */ }
274
+ }
275
+ streams.set(streamId, teardown)
276
+ const write = (frame: LiveFrame): void => {
277
+ if (closed) return
278
+ try {
279
+ res.write(frameText(frame))
280
+ // BACKPRESSURE: a client that is not reading is dropped. It reconnects with its last id and resumes or
281
+ // snapshots; holding its frames in memory without limit is how one stuck phone grows the server.
282
+ if (res.writableLength > maxBuffer) teardown('slow')
283
+ } catch {
284
+ teardown()
285
+ }
286
+ }
287
+
288
+ req.on('close', () => teardown())
289
+ res.on('error', () => teardown())
290
+
291
+ try {
292
+ detach = await deps.hub.attach(frame => deliver(frame))
293
+ } catch {
294
+ if (!closed) { closed = true; open--; streams.delete(streamId); res.status(503).json({ error: 'live events unavailable', reason: 'live_events_unavailable' }) }
295
+ return
296
+ }
297
+ if (closed) { detach(); return }
298
+
299
+ res.writeHead(200, {
300
+ 'Content-Type': 'text/event-stream',
301
+ 'Cache-Control': 'no-cache',
302
+ 'Connection': 'keep-alive',
303
+ 'X-Accel-Buffering': 'no',
304
+ })
305
+ res.flushHeaders()
306
+ res.write('retry: 3000\n\n')
307
+
308
+ // The query wins over the header: a manual reconnect sets `?after=`, the browser's own reconnect sends the header.
309
+ const cursor = parseLiveCursor(typeof req.query.after === 'string' ? req.query.after : undefined) ?? parseLiveCursor(req.get('last-event-id'))
310
+ const replay = deps.hub.replayAfter(cursor)
311
+ if (replay === null) {
312
+ write(deps.hub.snapshotFrame())
313
+ // Anything queued while attaching is already in the snapshot when its seq is not newer.
314
+ const snapSeq = deps.hub.newestSeq
315
+ for (const frame of pending.splice(0)) if (frame.seq > snapSeq) write(frame)
316
+ } else {
317
+ for (const frame of replay) write(frame)
318
+ const last = replay.length ? replay[replay.length - 1]!.seq : cursor!.seq
319
+ for (const frame of pending.splice(0)) if (frame.seq > last) write(frame)
320
+ write(deps.hub.resumeFrame(replay.length))
321
+ }
322
+ deliver = write
323
+ // A write during the snapshot or replay can tear the stream down (a throw, or the buffer cap): no heartbeat then,
324
+ // or its interval would outlive the stream (review N1).
325
+ if (closed) return
326
+
327
+ // Final review S1: each heartbeat first asks whether the browser sign-in that opened the stream still verifies.
328
+ heartbeat = setInterval(() => {
329
+ if (!streamSignInHolds(res)) { teardown('signed_out'); return }
330
+ write(deps.hub.heartbeatFrame())
331
+ }, heartbeatMs)
332
+ ;(heartbeat as { unref?: () => void }).unref?.()
333
+ }
334
+ }
@@ -50,6 +50,7 @@
50
50
 
51
51
  import express, { Router, type NextFunction, type Request, type Response } from 'express'
52
52
  import { timingSafeTokenEqual } from '../lib/token-auth.js'
53
+ import { turnEndAskStats } from '../lib/session-hooks-runtime.js'
53
54
  import {
54
55
  ANSWERING_CLIENTS,
55
56
  CLIENT_LIVE_WINDOW_MS,
@@ -60,8 +61,9 @@ import {
60
61
  type PermissionBroker,
61
62
  } from '../lib/permission-broker.js'
62
63
  import { noteQuestionsPoll, noteWebPresence, validPresenceAgeMs, type QuestionsPollFacts } from '../lib/client-liveness.js'
63
- import { hasProxyForwardedHeaders, isLoopbackAddress, normalizeRemoteIp, reachableIpv4Addresses } from '../lib/network-policy.js'
64
+ import { hasProxyForwardedHeaders, isLoopbackAddress, isTailscaleIpv4, isTailscaleIpv6, normalizeRemoteIp, reachableIpv4Addresses } from '../lib/network-policy.js'
64
65
  import { networkInterfaces } from 'node:os'
66
+ import { isIP } from 'node:net'
65
67
 
66
68
  export const PERMISSION_BROKER_HOOK_PATH = '/api/permission-requests/ask'
67
69
  /** The hook posts its spool envelope, capped at 1 MiB by the script itself. */
@@ -161,6 +163,8 @@ export interface SessionQuestionsRouterDeps {
161
163
  * rules: `isWebSessionAuthorized` in production). Absent: only the token opens the list, as before.
162
164
  */
163
165
  webSessionAuthorized?: (req: Request) => boolean
166
+ /** 7.0.18 (X1): the turn-end ask counters (lib/turn-end-ask.ts), null while COS_TURN_END_ASKS is off. Defaults to the runtime's. */
167
+ turnEndAskStats?: () => Record<string, number> | null
164
168
  /** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
165
169
  notePoll?: (at: number, poll: QuestionsPollFacts) => void
166
170
  /** 6.99.26: where COS Control Web's presence is recorded; `noteWebPresence` in production. */
@@ -188,23 +192,92 @@ export function questionsPollFacts(query: Record<string, unknown>): QuestionsPol
188
192
  return { hold, presenceAgeMs: hold && validPresenceAgeMs(parsed) ? parsed : null }
189
193
  }
190
194
 
195
+ /**
196
+ * The Mac's own addresses: every external IPv4 (its tailnet address first) and its Tailscale IPv6 (fd7a:115c:a1e0::/48).
197
+ * Safari on the Mac often reaches its own MagicDNS name over IPv6, so the IPv6 tailnet address is the Mac too.
198
+ */
199
+ export function selfAddressesFromInterfaces(
200
+ nets: ReturnType<typeof networkInterfaces> = networkInterfaces(),
201
+ ): string[] {
202
+ const out = reachableIpv4Addresses(nets).map(a => a.address)
203
+ for (const infos of Object.values(nets)) {
204
+ for (const info of infos ?? []) {
205
+ // Node 18.0-18.3 reported the family as a number.
206
+ const v6 = String(info.family) === 'IPv6' || String(info.family) === '6'
207
+ if (v6 && isTailscaleIpv6(info.address)) out.push(info.address.toLowerCase())
208
+ }
209
+ }
210
+ return out
211
+ }
212
+
213
+ /** Headers `tailscale serve` adds to a hop it forwards (ipn/ipnlocal/serve.go). Anything else proxy-shaped is not Serve. */
214
+ const SERVE_HOP_HEADERS = new Set([
215
+ 'x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto',
216
+ 'tailscale-user-login', 'tailscale-user-name', 'tailscale-user-profile-pic', 'tailscale-headers-info',
217
+ ])
218
+
219
+ /**
220
+ * W12 (QA week of 11/01-11/08): a `tailscale serve` hop from the Mac to itself. Control Web opened at the Mac's own
221
+ * https://<mac>.ts.net is the desk, but Serve forwards it over loopback with proxy headers, so 6.99.26 stored its touch
222
+ * as away and a web-only hold waited up to 10 minutes. It is the Mac only when ALL of these hold:
223
+ * - the socket is loopback: the local Serve proxy connected (a peer reaching 3141 directly has its own address);
224
+ * - no `Forwarded` or `X-Real-IP` (Serve never sets either);
225
+ * - exactly one `X-Forwarded-For` address, no list. Serve SETS that header to the hop's real source (Header.Set, and
226
+ * Go's ReverseProxy drops the incoming X-Forwarded-* before its Rewrite runs), so a remote device's spoofed value
227
+ * never survives; a proxy that appended instead would make a list, which is refused;
228
+ * - that address is loopback or one of the Mac's own TAILSCALE addresses (Serve listens on the tailnet only).
229
+ */
230
+ export function serveHopFromThisMac(
231
+ req: { socket?: { remoteAddress?: string | undefined } | null; headers?: Record<string, unknown> | null },
232
+ selfAddresses: () => readonly string[],
233
+ ): boolean {
234
+ const headers = req.headers
235
+ if (!headers || typeof headers !== 'object') return false
236
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) return false
237
+ for (const name of ['forwarded', 'x-real-ip']) {
238
+ const v = headers[name]
239
+ if (v !== undefined && v !== null && !(typeof v === 'string' && v.trim() === '')) return false
240
+ }
241
+ // Only Serve's own headers: an unknown proxy-shaped header means a hop this check does not understand.
242
+ for (const name of Object.keys(headers)) {
243
+ if (/^(x-forwarded-|tailscale-)/i.test(name) && !SERVE_HOP_HEADERS.has(name.toLowerCase())) return false
244
+ }
245
+ const xff = headers['x-forwarded-for']
246
+ if (typeof xff !== 'string') return false
247
+ // One address and nothing else: a list ("a, b"), a padded pair or a name is not an IP, so it is refused here.
248
+ const ip = normalizeRemoteIp(xff.trim()).toLowerCase()
249
+ if (isIP(ip) === 0) return false
250
+ if (isLoopbackAddress(ip)) return true
251
+ if (!isTailscaleIpv4(ip) && !isTailscaleIpv6(ip)) return false
252
+ let own: readonly string[] = []
253
+ try { own = selfAddresses() } catch { own = [] }
254
+ return own.some(a => normalizeRemoteIp(String(a)).toLowerCase() === ip)
255
+ }
256
+
191
257
  /**
192
258
  * 6.99.26 (QA round 1): a request from this Mac itself: its socket is loopback or one of the Mac's own addresses (its
193
259
  * tailnet address first), and no proxy header says it was forwarded for someone else (a `tailscale serve` hop arrives on
194
260
  * loopback for a remote peer). Control Web open on the Mac is the desk: its touch never makes the Mac away.
261
+ * W12: a proxied request is the Mac only as a Serve hop from the Mac to itself (serveHopFromThisMac).
195
262
  */
196
263
  export function requestFromThisMac(
197
264
  req: { socket?: { remoteAddress?: string | undefined } | null; headers?: Record<string, unknown> | null },
198
- selfAddresses: () => readonly string[] = () => reachableIpv4Addresses(networkInterfaces()).map(a => a.address),
265
+ selfAddresses: () => readonly string[] = () => selfAddressesFromInterfaces(),
199
266
  ): boolean {
200
- if (hasProxyForwardedHeaders(req.headers)) return false
267
+ if (hasProxyForwardedHeaders(req.headers)) return serveHopFromThisMac(req, selfAddresses)
201
268
  const remote = req.socket?.remoteAddress
202
269
  if (isLoopbackAddress(remote)) return true
203
- const ip = normalizeRemoteIp(remote ?? '')
270
+ const ip = normalizeRemoteIp(remote ?? '').toLowerCase()
204
271
  if (!ip) return false
205
272
  let own: readonly string[] = []
206
273
  try { own = selfAddresses() } catch { own = [] }
207
- return own.includes(ip)
274
+ return own.some(a => normalizeRemoteIp(String(a)).toLowerCase() === ip)
275
+ }
276
+
277
+ function turnEndAskCounters(deps: SessionQuestionsRouterDeps): { turnEndAsks?: Record<string, number> } {
278
+ let counters: Record<string, number> | null = null
279
+ try { counters = (deps.turnEndAskStats ?? turnEndAskStats)() } catch { counters = null }
280
+ return counters ? { turnEndAsks: counters } : {}
208
281
  }
209
282
 
210
283
  export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps): Router {
@@ -261,6 +334,13 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
261
334
  // forgotten (the server started then, or dropped it while it still waited). Missing from
262
335
  // `settled` and settled after it: the session moved past it.
263
336
  waitingAtMacForgottenBefore: new Date(deps.broker.waitingAtMacForgottenBeforeMs()).toISOString(),
337
+ // X1, additive and only while a full-hold switch is on: every question a live session waits on (held, at the
338
+ // Mac, or just answered), with its words while it waits, and what a web page must send with an answer right now.
339
+ // With both off the response is 7.0.13's, key for key.
340
+ ...(deps.broker.extendedQuestionsView() ? { waiting: deps.broker.waitingList(), webAnswer: deps.broker.webAnswerRules() } : {}),
341
+ // 7.0.18 (X1), additive and only while COS_TURN_END_ASKS is on: how many turn-end asks armed, were shown,
342
+ // were answered, were overtaken by a new turn, or aged out. Counts only, never words.
343
+ ...turnEndAskCounters(deps),
264
344
  })
265
345
  })
266
346
  router.post('/session-questions/:id/answer', (req: Request, res: Response) => {
@@ -273,5 +353,15 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
273
353
  const result = deps.broker.answer(String(req.params.id ?? ''), req.body, by)
274
354
  res.status(result.status).json(result.body)
275
355
  })
356
+ // X1: the web door's confirm step for an approval (lib/permission-broker.ts `confirm`). The /api gate let the
357
+ // request in; the door is labelled exactly as for an answer, and only the web door gets a confirm id.
358
+ router.post('/session-questions/:id/confirm', (req: Request, res: Response) => {
359
+ res.set('Cache-Control', 'private, no-store')
360
+ let expected = ''
361
+ try { expected = deps.apiToken() } catch { expected = '' }
362
+ const by = timingSafeTokenEqual(req.headers['x-cos-token'], expected) ? 'app' : 'web'
363
+ const result = deps.broker.confirm(String(req.params.id ?? ''), req.body, by)
364
+ res.status(result.status).json(result.body)
365
+ })
276
366
  return router
277
367
  }
@@ -1,3 +1,4 @@
1
+ import { streamSignInHolds } from '../lib/api-auth.js'
1
2
  import { Router, type Request, type Response } from 'express'
2
3
  import { durableQueryJobsEnabled, messagesSteerEnabled } from '../lib/query-job-feature.js'
3
4
  import { ACTIVE_QUERY_JOBS_LIST_MAX, ACTIVE_QUERY_JOBS_PROTOCOL } from '../lib/query-job-active.js'
@@ -429,6 +430,8 @@ export function createQueryJobsRouter(
429
430
  if (closed) return
430
431
 
431
432
  heartbeat = setInterval(() => {
433
+ // Final review S1: a stream opened by a browser sign-in ends once that sign-in no longer verifies.
434
+ if (!streamSignInHolds(res)) { finish(); return }
432
435
  if (!closed && !res.writableEnded) res.write(': keepalive\n\n')
433
436
  }, heartbeatMs)
434
437
  heartbeat.unref?.()