@gotcos/glasses-server 6.98.0 → 6.99.12

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 (89) hide show
  1. package/CHANGELOG.md +158 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +147 -9
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/display-bus.ts +3 -0
  8. package/server/lib/drop-destinations.ts +134 -0
  9. package/server/lib/drop-history-search.ts +97 -0
  10. package/server/lib/drop-p2-boot.ts +60 -0
  11. package/server/lib/drop-p2-runtime.ts +237 -0
  12. package/server/lib/drop-p2-settings.ts +174 -0
  13. package/server/lib/drop-resolution.ts +168 -0
  14. package/server/lib/drop-router.ts +325 -0
  15. package/server/lib/drop-service.ts +739 -0
  16. package/server/lib/drop-settings.ts +45 -0
  17. package/server/lib/drop-store.ts +428 -0
  18. package/server/lib/drop-when.ts +265 -0
  19. package/server/lib/glasses-pairing.ts +15 -0
  20. package/server/lib/learning-needs-you.ts +111 -0
  21. package/server/lib/live-cues-v2-data.ts +336 -26
  22. package/server/lib/live-cues-v2-settings.ts +2 -1
  23. package/server/lib/live-cues-v2.ts +9 -5
  24. package/server/lib/meeting-consolidation-run.ts +169 -0
  25. package/server/lib/meeting-store.ts +61 -0
  26. package/server/lib/meeting-title-steer.ts +200 -0
  27. package/server/lib/network-policy.ts +84 -6
  28. package/server/lib/permission-broker.ts +11 -0
  29. package/server/lib/ping-budget.ts +149 -0
  30. package/server/lib/ping-engine.ts +245 -0
  31. package/server/lib/ping-feed-sink.ts +33 -0
  32. package/server/lib/ping-sources.ts +192 -0
  33. package/server/lib/query-job-coordinator.ts +5 -2
  34. package/server/lib/query-job-runtime.ts +45 -4
  35. package/server/lib/query-job-types.ts +10 -1
  36. package/server/lib/reminder-scheduler.ts +153 -0
  37. package/server/lib/tailnet-self-name.ts +103 -0
  38. package/server/lib/tailscale-nudge.ts +40 -2
  39. package/server/lib/task-store.ts +32 -0
  40. package/server/lib/web-ask-admission.ts +56 -0
  41. package/server/lib/web-live-chunks.ts +273 -0
  42. package/server/lib/web-memories-bridge.ts +323 -0
  43. package/server/lib/web-session-auth.ts +492 -0
  44. package/server/lib/web-usage-bridge.ts +154 -0
  45. package/server/lib/work-search.ts +14 -1
  46. package/server/lib/your-move-count.ts +58 -0
  47. package/server/routes/display.ts +11 -1
  48. package/server/routes/drops.ts +260 -0
  49. package/server/routes/health.ts +16 -0
  50. package/server/routes/live-cues.ts +5 -4
  51. package/server/routes/meeting.ts +61 -5
  52. package/server/routes/permission-broker.ts +23 -3
  53. package/server/routes/transcribe-stream.ts +34 -3
  54. package/server/routes/web-control.ts +569 -0
  55. package/server/routes/welcome-context.ts +2 -1
  56. package/server/web-control/app.css +519 -0
  57. package/server/web-control/app.js +3643 -0
  58. package/server/web-control/control.css +312 -0
  59. package/server/web-control/fonts/DMSans.ttf +0 -0
  60. package/server/web-control/fonts/Fraunces.ttf +0 -0
  61. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  62. package/server/web-control/index.html +275 -0
  63. package/server/web-control/mark.svg +1 -0
  64. package/server/web-control/memories/d3.min.js +2 -0
  65. package/server/web-control/memories/graph-explorer.css +200 -0
  66. package/server/web-control/memories/graph-explorer.js +748 -0
  67. package/server/web-control/memories/memories-app.js +1561 -0
  68. package/server/web-control/memories/memories-theme.css +73 -0
  69. package/server/web-control/memories/memories.html +72 -0
  70. package/server/web-control/memories/memory-stewardship.js +63 -0
  71. package/server/web-control/memories/memory-workspace.js +216 -0
  72. package/server/web-control/panes/cos-chat.css +157 -0
  73. package/server/web-control/panes/cos-chat.js +973 -0
  74. package/server/web-control/panes/dictation.js +173 -0
  75. package/server/web-control/panes/meetings.css +257 -0
  76. package/server/web-control/panes/meetings.js +2264 -0
  77. package/server/web-control/panes/messages.js +711 -0
  78. package/server/web-control/panes/questions.css +32 -0
  79. package/server/web-control/panes/questions.js +275 -0
  80. package/server/web-control/panes/sessions.css +452 -0
  81. package/server/web-control/panes/settings.js +292 -0
  82. package/server/web-control/panes/speakers.css +269 -0
  83. package/server/web-control/panes/speakers.js +2755 -0
  84. package/server/web-control/panes/threads.js +200 -0
  85. package/server/web-control/panes/work.css +261 -0
  86. package/server/web-control/panes/work.js +1667 -0
  87. package/server/web-control/shell.js +294 -0
  88. package/server/web-control/theme.js +25 -0
  89. package/server/web-control/vendor/qrcode-encoder.js +10 -0
@@ -0,0 +1,58 @@
1
+ /**
2
+ * 6.99.10 (Drop it plan section 12): how many Work items in Your move wait on you, for `waiting` on
3
+ * GET /api/inbox/summary. It is the open-work list GET /api/work-board/activity/open serves (projectOpenWork), counting
4
+ * the items that want an answer or a look (needsInput, attention, done) and leaving out the ones still running: a run
5
+ * in progress is not waiting on you.
6
+ *
7
+ * The summary is polled, and the board is a bridge read, so one count is shared for `ttlMs` and two callers at once
8
+ * share one read. A read that fails answers null (the summary then counts the chat alone) and is not cached, so the
9
+ * next poll tries again.
10
+ */
11
+ import { projectOpenWork, readWorkJournal, type OpenWorkGroup } from './work-activity.js'
12
+
13
+ type Journal = ReturnType<typeof readWorkJournal>
14
+ type Rows = Parameters<typeof projectOpenWork>[1]
15
+
16
+ export interface YourMoveCounterDeps {
17
+ journal: () => Journal
18
+ /** The board rows, or null when the board cannot be read in time. */
19
+ rows: () => Promise<Rows | null>
20
+ now?: () => number
21
+ ttlMs?: number
22
+ }
23
+
24
+ export const YOUR_MOVE_TTL_MS = 30_000
25
+ const WAITING_GROUPS: readonly OpenWorkGroup[] = ['needsInput', 'attention', 'done']
26
+
27
+ /** The pure count: open-work items whose group waits on you. */
28
+ export function countYourMove(journal: Journal, rows: Rows, nowMs: number): number {
29
+ if (!journal.available) return 0
30
+ return projectOpenWork(journal, rows, nowMs).items.filter(item => WAITING_GROUPS.includes(item.group)).length
31
+ }
32
+
33
+ export function createYourMoveCounter(deps: YourMoveCounterDeps): () => Promise<number | null> {
34
+ const now = deps.now ?? (() => Date.now())
35
+ const ttl = deps.ttlMs ?? YOUR_MOVE_TTL_MS
36
+ let cached: { at: number; value: number } | null = null
37
+ let inflight: Promise<number | null> | null = null
38
+ return () => {
39
+ if (cached && now() - cached.at < ttl) return Promise.resolve(cached.value)
40
+ if (inflight) return inflight
41
+ inflight = (async () => {
42
+ try {
43
+ const journal = deps.journal()
44
+ if (!journal.available) return null
45
+ const rows = await deps.rows()
46
+ if (!rows) return null
47
+ const value = countYourMove(journal, rows, now())
48
+ cached = { at: now(), value }
49
+ return value
50
+ } catch {
51
+ return null
52
+ } finally {
53
+ inflight = null
54
+ }
55
+ })()
56
+ return inflight
57
+ }
58
+ }
@@ -13,6 +13,8 @@ import {
13
13
  } from '../lib/display-bus.js'
14
14
  import { type DisplayTicketVerdict, explainDisplayTicket } from '../lib/display-ticket.js'
15
15
  import { timingSafeTokenEqual } from '../lib/token-auth.js'
16
+ import { isWebSessionAuthorized } from '../lib/web-session-auth.js'
17
+ import { getTailnetSelfName } from '../lib/tailnet-self-name.js'
16
18
 
17
19
  export const displayRouter = Router()
18
20
 
@@ -183,7 +185,15 @@ export function __resetDisplayStreamLogForTests(): void {
183
185
  * counter with connections that were never the problem.
184
186
  */
185
187
  function headerAuthorized(req: Request): boolean {
186
- return timingSafeTokenEqual(req.headers['x-cos-token'], process.env.COS_API_TOKEN ?? '')
188
+ const apiToken = process.env.COS_API_TOKEN ?? ''
189
+ if (timingSafeTokenEqual(req.headers['x-cos-token'], apiToken)) return true
190
+ // A COS Control Web browser: same origin rules as the /api gate (review note).
191
+ const port = Number.parseInt(process.env.PORT ?? '3141', 10)
192
+ const httpsPort = Number.parseInt(process.env.HTTPS_PORT ?? '3143', 10)
193
+ return isWebSessionAuthorized(req, apiToken, {
194
+ selfDnsName: getTailnetSelfName().currentSelfDnsName(),
195
+ allowedPorts: [port, httpsPort],
196
+ })
187
197
  }
188
198
 
189
199
  /**
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Drop it P1 routes (plan sections 1 to 3 and 6). Auth comes from the parent /api mount. All answers are
3
+ * `Cache-Control: private, no-store`.
4
+ *
5
+ * Off by default (lib/drop-settings.ts): while off, every route here except /drops/settings answers
6
+ * 404 `{ error: { code: 'drop_it_off' } }`, and no drop or feed file is created. The stores open on the first request
7
+ * after the feature is turned on. GET /api/sessions/today/all-messages, /live-chats and /api/message-counter are not
8
+ * touched by anything here.
9
+ *
10
+ * GET /drops/settings { protocol, enabled, source, router }
11
+ * PUT /drops/settings { enabled } same answer
12
+ * POST /drops { dropId, text, source, hint?: { sessionId, label? }, file? } DropAnswer
13
+ * GET /drops ?q&kind&status&limit&before History
14
+ * GET /drops/:id DropAnswer
15
+ * POST /drops/:id/undo DropAnswer
16
+ * POST /drops/:id/move { dest, target?: { id, label, domain? } } DropAnswer
17
+ * POST /drops/:id/ask { clientJobId, generation } DropAnswer
18
+ * POST /drops/:id/sent DropAnswer
19
+ * GET /inbox/summary { unread, waiting, lastLine, lastAt, version }
20
+ * GET /inbox/feed ?after&limit { items, version, more }
21
+ * POST /inbox/read { through } { unread, waiting, version }
22
+ * POST /inbox/action { itemId, action: 'choose:<id>' | 'undo' } DropAnswer
23
+ * POST /inbox/action { itemId, action: 'dismiss' } { unread, waiting, version }
24
+ * P2:
25
+ * POST /inbox/action { itemId, action: 'when:tomorrow_9' | 'when:monday_9' | 'when:<ISO>' } DropAnswer
26
+ * POST /inbox/action { itemId, action: 'reminder_done' | 'snooze_1h' | 'snooze_tomorrow' } { item, drop, unread, waiting, version }
27
+ * GET /inbox/banners ?bootId&seq { bootId, seq, banners }
28
+ * GET /drops/reminders { reminders: [{ id, at, title, notifyId }] }
29
+ * POST /drops/search { q } DropSearchResult
30
+ *
31
+ * `waiting` (6.99.10, plan section 12) is a plain whole number: live questions, live items marked needsReply, and the
32
+ * Work items in Your move. It clears only when answered, done or dismissed; `unread` clears when the chat is opened.
33
+ */
34
+ import { Router, type Response } from 'express'
35
+ import { DropError, DropService, type DropInput, type HistoryStatus } from '../lib/drop-service.js'
36
+ import { DROP_DESTS, DROP_KINDS, DropStore, DropStoreError, InboxFeedStore, parseLink, type DropDest, type DropKind } from '../lib/drop-store.js'
37
+ import { dropItCapability, dropItEnabled, dropItEnvValue, writeDropItSetting } from '../lib/drop-settings.js'
38
+ import { dropDailyCap, dropRouterEnabled } from '../lib/drop-router.js'
39
+ import type { DropP2Runtime } from '../lib/drop-p2-runtime.js'
40
+ import type { DropHistorySearcher } from '../lib/drop-history-search.js'
41
+ import { pendingReminders } from '../lib/reminder-scheduler.js'
42
+ import { unavailable } from '../lib/work-search.js'
43
+ import { mergeP2Settings, P2_DEFAULTS, P2_SETTINGS_PROTOCOL, type P2Settings } from '../lib/drop-p2-settings.js'
44
+
45
+ export interface DropsRouterDependencies {
46
+ /** The service, opened on first use. Null when its stores cannot open (locked, unsafe, unreadable). */
47
+ service: () => DropService | null
48
+ enabled: () => boolean
49
+ routerStatus?: () => { on: boolean; dailyCap: number; usedToday?: number; breakerOpenUntil?: string | null }
50
+ writeSetting?: (enabled: boolean) => void
51
+ /**
52
+ * 6.99.10: how many Work items are in Your move, or null when the board cannot be read. Counted into `waiting`.
53
+ * Optional: without it `waiting` counts the chat alone. It must answer quickly (index.ts caches it).
54
+ */
55
+ yourMove?: () => Promise<number | null>
56
+ /** P2: the pings, reminders and follow-ups runtime, when it is running. Its banner outbox is what Control reads. */
57
+ p2?: () => DropP2Runtime | null
58
+ /** P2: History search by meaning. Absent: the search answers search_off and word search carries on. */
59
+ search?: () => DropHistorySearcher | null
60
+ /** 6.99.11: P2 settings (drop-p2-settings.ts createP2SettingsReader). Absent: GET answers the defaults, PUT 503. */
61
+ p2Settings?: { read(): P2Settings; write(next: P2Settings): P2Settings }
62
+ }
63
+ const P2_SECTIONS = new Set(['pings', 'followUps', 'checkin', 'reminders', 'banners', 'steer'])
64
+
65
+ /** How long the summary waits for the Your move count before answering without it. */
66
+ export const YOUR_MOVE_WAIT_MS = 1_500
67
+ async function boundedYourMove(read: (() => Promise<number | null>) | undefined): Promise<number | null> {
68
+ if (!read) return null
69
+ let timer: ReturnType<typeof setTimeout> | undefined
70
+ try {
71
+ return await Promise.race([read().catch(() => null), new Promise<null>(done => { timer = setTimeout(() => done(null), YOUR_MOVE_WAIT_MS) })])
72
+ } finally { if (timer) clearTimeout(timer) }
73
+ }
74
+
75
+ const HISTORY_STATUSES: readonly HistoryStatus[] = ['open', 'in_progress', 'done', 'undone']
76
+ const send = (res: Response, error: unknown) => {
77
+ if (error instanceof DropError || error instanceof DropStoreError) {
78
+ return res.status(error.status).json({ error: { code: error.code, message: error.message === error.code ? undefined : error.message } })
79
+ }
80
+ console.error('[drops] request failed:', error instanceof Error ? error.name : 'error')
81
+ return res.status(500).json({ error: { code: 'drop_failed' } })
82
+ }
83
+ const bad = (res: Response, code: string, message?: string) => res.status(400).json({ error: { code, ...(message ? { message } : {}) } })
84
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
85
+
86
+ /** Opens the two stores once, on the first request that needs them. */
87
+ export function lazyDropService(open: () => DropService): () => DropService | null {
88
+ let service: DropService | null = null
89
+ return () => {
90
+ if (service) return service
91
+ try { service = open() } catch (e) {
92
+ console.error('[drops] store unavailable:', e instanceof DropStoreError ? e.code : e instanceof Error ? e.name : 'error')
93
+ return null
94
+ }
95
+ return service
96
+ }
97
+ }
98
+ export function openDropService(dataRoot: (part: string) => string, deps: Omit<ConstructorParameters<typeof DropService>[0], 'drops' | 'feed'>): DropService {
99
+ const drops = new DropStore(dataRoot('drops'))
100
+ let feed: InboxFeedStore
101
+ try { feed = new InboxFeedStore(dataRoot('inbox')) } catch (e) { drops.close(); throw e }
102
+ return new DropService({ ...deps, drops, feed })
103
+ }
104
+
105
+ export function createDropsRouter(deps: DropsRouterDependencies): Router {
106
+ const router = Router()
107
+ router.use(['/drops', '/inbox'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
108
+
109
+ const settingsView = () => ({ ...dropItCapability(), router: deps.routerStatus?.() ?? { on: dropRouterEnabled(), dailyCap: dropDailyCap() } })
110
+ router.get('/drops/settings', (_req, res) => res.json(settingsView()))
111
+ router.put('/drops/settings', (req, res) => {
112
+ if (!isRecord(req.body) || typeof req.body.enabled !== 'boolean' || Object.keys(req.body).length !== 1) return bad(res, 'invalid_settings', 'Send { "enabled": true } or { "enabled": false }.')
113
+ try { (deps.writeSetting ?? writeDropItSetting)(req.body.enabled) } catch {
114
+ return res.status(503).json({ error: { code: 'settings_unwritable' } })
115
+ }
116
+ // An environment value wins (COS_DROP_IT); the answer says so through `source`.
117
+ return res.json({ ...settingsView(), ...(dropItEnvValue() !== null ? { savedButEnvWins: true } : {}) })
118
+ })
119
+
120
+ // 6.99.11 P2 settings, readable and writable while Drop it is off (as the switch is), so they can be set first.
121
+ const p2View = (settings: P2Settings) => ({ protocol: P2_SETTINGS_PROTOCOL, settings, defaults: P2_DEFAULTS })
122
+ router.get('/drops/settings/p2', (_req, res) => res.json(p2View(deps.p2Settings?.read() ?? P2_DEFAULTS)))
123
+ router.put('/drops/settings/p2', (req, res) => {
124
+ const body = req.body
125
+ if (!isRecord(body) || !Object.keys(body).length || Object.keys(body).some(k => !P2_SECTIONS.has(k)) || Object.values(body).some(v => !isRecord(v))) {
126
+ return bad(res, 'invalid_settings', 'Send one or more sections: pings, followUps, checkin, reminders, banners, steer.')
127
+ }
128
+ if (!deps.p2Settings) return res.status(503).json({ error: { code: 'settings_unwritable' } })
129
+ try { return res.json(p2View(deps.p2Settings.write(mergeP2Settings(deps.p2Settings.read(), body)))) } catch {
130
+ return res.status(503).json({ error: { code: 'settings_unwritable' } })
131
+ }
132
+ })
133
+
134
+ // Everything else exists only while the feature is on.
135
+ router.use(['/drops', '/inbox'], (_req, res, next) => {
136
+ if (!deps.enabled()) return res.status(404).json({ error: { code: 'drop_it_off', message: 'Drop it is off on this server.' } })
137
+ next()
138
+ })
139
+ const withService = (res: Response, fn: (s: DropService) => unknown) => {
140
+ const service = deps.service()
141
+ if (!service) return res.status(503).json({ error: { code: 'drop_store_unavailable' } })
142
+ return fn(service)
143
+ }
144
+
145
+ router.post('/drops', (req, res) => withService(res, async service => {
146
+ const b = req.body
147
+ if (!isRecord(b) || Object.keys(b).some(k => !['dropId', 'text', 'source', 'hint', 'file'].includes(k))) return bad(res, 'invalid_drop')
148
+ try { return res.json(await service.submit(b as unknown as DropInput)) } catch (e) { return send(res, e) }
149
+ }))
150
+
151
+ router.get('/drops', (req, res) => withService(res, async service => {
152
+ const q = typeof req.query.q === 'string' ? req.query.q.trim() : undefined
153
+ const kind = req.query.kind, status = req.query.status
154
+ if (q !== undefined && q.length > 200) return bad(res, 'invalid_query')
155
+ if (kind !== undefined && !(DROP_KINDS as readonly unknown[]).includes(kind)) return bad(res, 'invalid_kind')
156
+ if (status !== undefined && !(HISTORY_STATUSES as readonly unknown[]).includes(status)) return bad(res, 'invalid_status')
157
+ const limit = req.query.limit === undefined ? undefined : Number(req.query.limit)
158
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return bad(res, 'invalid_limit')
159
+ const before = typeof req.query.before === 'string' ? req.query.before : undefined
160
+ try { return res.json(await service.history({ q: q || undefined, kind: kind as DropKind | undefined, status: status as HistoryStatus | undefined, limit, before })) }
161
+ catch (e) { return send(res, e) }
162
+ }))
163
+
164
+ // P2, registered before /drops/:id so 'reminders' is never read as a drop id.
165
+ router.get('/drops/reminders', (_req, res) => withService(res, async service => {
166
+ try { return res.json({ reminders: pendingReminders(service.p2Stores().drops) }) } catch (e) { return send(res, e) }
167
+ }))
168
+ router.get('/drops/:id', (req, res) => withService(res, service => {
169
+ try { return res.json(service.get(req.params.id)) } catch (e) { return send(res, e) }
170
+ }))
171
+ router.post('/drops/:id/undo', (req, res) => withService(res, async service => {
172
+ try { return res.json(await service.undo(req.params.id)) } catch (e) { return send(res, e) }
173
+ }))
174
+ router.post('/drops/:id/move', (req, res) => withService(res, async service => {
175
+ const b = req.body
176
+ if (!isRecord(b) || !(DROP_DESTS as readonly unknown[]).includes(b.dest)) return bad(res, 'invalid_move')
177
+ let target: { id: string; label: string; domain?: string } | undefined
178
+ if (b.target !== undefined) {
179
+ const t = b.target
180
+ if (!isRecord(t)) return bad(res, 'invalid_move')
181
+ target = { id: t.id as string, label: t.label as string, ...(t.domain !== undefined ? { domain: t.domain as string } : {}) }
182
+ // The store's own link rule (length, no control characters, id shape), checked before anything is undone or written.
183
+ try { parseLink({ kind: 'card', ...target }) } catch { return bad(res, 'invalid_move', 'That destination cannot be used.') }
184
+ }
185
+ const dest = b.dest as DropDest
186
+ if ((dest === 'new_card' && !target?.domain) || ((dest === 'existing_card') && (!target?.domain)) || ((dest === 'thread' || dest === 'session') && !target)) return bad(res, 'invalid_move', 'This destination needs a target.')
187
+ // A new card takes its domain as its target id.
188
+ const resolved = dest === 'new_card' && target ? { ...target, id: target.domain! } : target
189
+ try { return res.json(await service.move(req.params.id, { dest, target: resolved })) } catch (e) { return send(res, e) }
190
+ }))
191
+ router.post('/drops/:id/ask', (req, res) => withService(res, async service => {
192
+ const b = req.body
193
+ if (!isRecord(b) || typeof b.clientJobId !== 'string' || !Number.isSafeInteger(b.generation)) return bad(res, 'invalid_ask_link')
194
+ try { return res.json(await service.linkAsk(req.params.id, b.clientJobId, b.generation as number)) } catch (e) { return send(res, e) }
195
+ }))
196
+ router.post('/drops/:id/sent', (req, res) => withService(res, async service => {
197
+ try { return res.json(await service.holdSent(req.params.id)) } catch (e) { return send(res, e) }
198
+ }))
199
+
200
+ router.get('/inbox/summary', (_req, res) => withService(res, async service => {
201
+ try { await service.syncAsks() } catch { /* a summary never fails for a lookup */ }
202
+ const yourMove = await boundedYourMove(deps.yourMove)
203
+ try { return res.json(service.summary({ yourMove })) } catch (e) { return send(res, e) }
204
+ }))
205
+ router.get('/inbox/feed', (req, res) => withService(res, async service => {
206
+ const after = typeof req.query.after === 'string' ? req.query.after : undefined
207
+ const limit = req.query.limit === undefined ? undefined : Number(req.query.limit)
208
+ if (limit !== undefined && (!Number.isInteger(limit) || limit < 1 || limit > 200)) return bad(res, 'invalid_limit')
209
+ try { await service.syncAsks() } catch { /* the chat still reads */ }
210
+ try { return res.json(service.feed({ after, limit })) } catch (e) { return send(res, e) }
211
+ }))
212
+ router.post('/inbox/read', (req, res) => withService(res, async service => {
213
+ if (!isRecord(req.body) || typeof req.body.through !== 'string') return bad(res, 'invalid_read')
214
+ let out: { unread: number; waiting: number; version: number }
215
+ try { out = service.markRead(req.body.through) } catch (e) { return send(res, e) }
216
+ // The same `waiting` the summary gives, Work's Your move included.
217
+ const yourMove = await boundedYourMove(deps.yourMove)
218
+ return res.json({ ...out, waiting: service.summary({ yourMove }).waiting })
219
+ }))
220
+ router.post('/inbox/action', (req, res) => withService(res, async service => {
221
+ const b = req.body
222
+ if (!isRecord(b) || typeof b.itemId !== 'string' || typeof b.action !== 'string') return bad(res, 'invalid_action')
223
+ try {
224
+ if (b.action.startsWith('choose:')) return res.json(await service.choose(b.itemId, b.action.slice('choose:'.length)))
225
+ if (b.action === 'undo') {
226
+ return res.json(await service.undo(service.threadOf(b.itemId)))
227
+ }
228
+ if (b.action.startsWith('when:')) return res.json(await service.setReminderTime(b.itemId, b.action.slice('when:'.length)))
229
+ if (b.action === 'reminder_done' || b.action === 'snooze_1h' || b.action === 'snooze_tomorrow') {
230
+ const out = service.reminderAction(b.itemId, b.action)
231
+ const yourMove = await boundedYourMove(deps.yourMove)
232
+ const sum = service.summary({ yourMove })
233
+ return res.json({ ...out, unread: sum.unread, waiting: sum.waiting, version: sum.version })
234
+ }
235
+ if (b.action === 'dismiss') {
236
+ const out = service.dismiss(b.itemId)
237
+ const yourMove = await boundedYourMove(deps.yourMove)
238
+ return res.json({ ...out, waiting: service.summary({ yourMove }).waiting })
239
+ }
240
+ return bad(res, 'invalid_action')
241
+ } catch (e) { return send(res, e) }
242
+ }))
243
+ // ── P2 ──
244
+ router.get('/inbox/banners', (req, res) => withService(res, async () => {
245
+ const runtime = deps.p2?.()
246
+ if (!runtime) return res.status(503).json({ error: { code: 'pings_unavailable' } })
247
+ const bootId = typeof req.query.bootId === 'string' ? req.query.bootId : undefined
248
+ const seq = typeof req.query.seq === 'string' && /^\d{1,9}$/.test(req.query.seq) ? Number(req.query.seq) : undefined
249
+ return res.json(runtime.banners({ bootId, seq }))
250
+ }))
251
+ router.post('/drops/search', (req, res) => withService(res, async service => {
252
+ const searcher = deps.search?.()
253
+ if (!searcher) return res.json(unavailable('search_off'))
254
+ const q = isRecord(req.body) ? req.body.q : undefined
255
+ try { return res.json(await searcher.search(q, service.p2Stores().drops.list())) } catch (e) { return send(res, e) }
256
+ }))
257
+ return router
258
+ }
259
+
260
+ export { dropItEnabled }
@@ -1,3 +1,4 @@
1
+ import { dropItCapability } from '../lib/drop-settings.js'
1
2
  import { phoneMicrophoneMeetingsCapability } from '../lib/phone-recording.js'
2
3
  import { macLiveVoicesSnapshot } from '../lib/mac-window-replay.js'
3
4
  import { DICTATION_CLEAN_MODELS } from '../lib/dictation-clean-models.js'
@@ -77,8 +78,10 @@ import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
77
78
  import { liveCuesCapability } from '../lib/live-cues-capability.js'
78
79
  import { liveCuesV2Advertised } from '../lib/live-cues-v2.js'
79
80
  import { liveMeetingCapability } from '../lib/live-provisional.js'
81
+ import { TITLE_STEER_CAPABILITY } from '../lib/meeting-title-steer.js'
80
82
  import { CUE_KINDS } from '../../shared/live-cues-v2.js'
81
83
  import { tailscaleNudgeHealthField } from '../lib/tailscale-nudge.js'
84
+ import { WEB_PHONE_LINK_CAPABILITY, webControlEnabled } from './web-control.js'
82
85
 
83
86
  /** 6.86.0: `capabilities.liveCues.kinds`, the cue kinds this server can write (additive; an older server omits it, which
84
87
  * is how a client knows to expect only reinforce and challenge). Which kinds a given meeting gets is in its arm answer. */
@@ -503,6 +506,10 @@ healthRouter.get('/health', async (_req, res) => {
503
506
  // reads it before it holds a token, and an older server omits it, which is how
504
507
  // the phone knows to say "Update COS on your Mac (server 6.67 or newer)".
505
508
  pairing: { version: PAIRING_PROTOCOL_VERSION },
509
+ // Open COS on the web from the glasses app: POST /api/web-session/phone-link (routes/web-control.ts).
510
+ // Present only while COS Control Web is on, which is when the route is mounted. An older
511
+ // server omits it, and the app then hides its row.
512
+ ...(webControlEnabled() ? { webPhoneLink: WEB_PHONE_LINK_CAPABILITY } : {}),
506
513
  // Advertised on the PUBLIC health route on purpose: a client deciding whether
507
514
  // to request a display ticket may not hold a usable token yet, and an old
508
515
  // server simply omits this key, which is how a new client detects it.
@@ -527,6 +534,10 @@ healthRouter.get('/health', async (_req, res) => {
527
534
  liveCues: withCueKinds(liveCues),
528
535
  ...(liveCuesV2Advertised() ? { liveCuesV2: liveCuesV2Advertised() } : {}),
529
536
  liveMeeting: liveMeetingCapability(),
537
+ // 6.99.0: GET/PUT /api/meeting/title-steer (lib/meeting-title-steer.ts). Absent on an older server.
538
+ titleSteer: TITLE_STEER_CAPABILITY,
539
+ // Drop it P1: the pinned COS chat (routes/drops.ts). Codes only; `enabled` is false unless turned on. Absent on an older server.
540
+ dropIt: dropItCapability(),
530
541
  // 6.77.0: codes only (this route is public): the protocol and whether the switch is on.
531
542
  transcriptCleanup: transcriptCleanupAdvertised(),
532
543
  richMedia: {
@@ -708,6 +719,7 @@ healthRouter.get('/models', async (req, res) => {
708
719
  // 6.78.0 Messages steer: present only with COS_MESSAGES_STEER on and a Claude CLI that
709
720
  // takes stream-json input (probed once). Absent, the app shows today's UI.
710
721
  ...steerCapabilityField(durableJobs.enabled),
722
+ ...(durableJobs.enabled ? { maintenanceWorkflows: ['meeting-consolidation-v1'] } : {}),
711
723
  },
712
724
  // 6.52.0: the session questions API and its client contract. A client polls
713
725
  // `GET /api/session-questions?client=glasses|phone` every `pollIntervalMs`; one
@@ -736,6 +748,10 @@ healthRouter.get('/models', async (req, res) => {
736
748
  liveCues: withCueKinds(liveCuesCapability()),
737
749
  ...(liveCuesV2Advertised() ? { liveCuesV2: liveCuesV2Advertised() } : {}),
738
750
  liveMeeting: liveMeetingCapability(),
751
+ // 6.99.0: GET/PUT /api/meeting/title-steer (lib/meeting-title-steer.ts). Absent on an older server.
752
+ titleSteer: TITLE_STEER_CAPABILITY,
753
+ // Drop it P1: the pinned COS chat (routes/drops.ts). Codes only; `enabled` is false unless turned on. Absent on an older server.
754
+ dropIt: dropItCapability(),
739
755
  richMedia: {
740
756
  video: richMedia.video,
741
757
  maxPromptVisuals: MAX_MODEL_IMAGE_INPUTS,
@@ -27,7 +27,7 @@ import {
27
27
  LIVE_CUES_GENERAL_LIMIT_BOUNDS, LIVE_CUES_GENERAL_LIMIT_NAMES, LIVE_CUES_V2_LIMIT_BOUNDS, LIVE_CUES_V2_LIMIT_NAMES, generalLimitInBounds, isCueMode, isGrounding, limitInBounds,
28
28
  type LiveCuesGeneralStoredLimits, type LiveCuesV2StoredLimits,
29
29
  } from '../lib/live-cues-v2-settings.js'
30
- import { LIVE_CUES_V2_LIMITS, LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2MeetingCounters, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
30
+ import { LiveCuesV2ArmError, resolveLiveCuesV2Limits, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2MeetingCounters, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
31
31
  import { LIVE_CUE_VERDICTS_PROTOCOL, LIVE_CUE_VERDICT_LIMITS, VerdictsUnreadableError, isCueVerdict, liveCueVerdicts } from '../lib/live-cue-verdicts.js'
32
32
 
33
33
  const SESSION_ID = /^[A-Za-z0-9:_-]{3,96}$/
@@ -183,13 +183,14 @@ liveCuesRouter.get('/live-cues/history', (req, res) => {
183
183
  try {
184
184
  const verdicts = liveCueVerdicts()
185
185
  const history = verdicts.history({ days, limit, unrated: unrated === '1', ...(sessionIds.length ? { sessionIds } : {}) })
186
- const C = LIVE_CUES_V2_LIMITS
186
+ // 6.99.0: the cap as set now (Settings or env), not the built-in 25: the cap can be raised to 50, writer calls stay twice it.
187
+ const capNow = resolveLiveCuesV2Limits().maxCuesPerMeeting.value, writerCapNow = 2 * capNow
187
188
  const meetings = sessionIds.length ? Object.fromEntries(sessionIds.map(id => {
188
189
  // The engine's counters expire 24 h after a meeting; past that the log's count stands in and writerCalls is unknown.
189
190
  const counters = liveCuesV2MeetingCounters(id)
190
- return [id, { cues: counters?.cues ?? verdicts.loggedCues(id), cap: C.maxCuesPerMeeting, writerCalls: counters?.writerCalls ?? null, writerCap: C.maxWriterCallsPerMeeting }]
191
+ return [id, { cues: counters?.cues ?? verdicts.loggedCues(id), cap: capNow, writerCalls: counters?.writerCalls ?? null, writerCap: writerCapNow }]
191
192
  })) : undefined
192
- return res.json({ ...history, maxCuesPerMeeting: C.maxCuesPerMeeting, ...(meetings ? { meetings } : {}) })
193
+ return res.json({ ...history, maxCuesPerMeeting: capNow, ...(meetings ? { meetings } : {}) })
193
194
  } catch (error) {
194
195
  if (error instanceof VerdictsUnreadableError) return res.status(503).json({ error: 'the verdicts file is unreadable', reason: error.code })
195
196
  return res.status(500).json({ error: 'history_unavailable' })
@@ -19,7 +19,7 @@ import { existsSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync } f
19
19
  import { isWorthRecovering } from '../lib/quarantine-auto-recover.js'
20
20
  import { basename, resolve } from 'node:path'
21
21
  import { createHash } from 'node:crypto'
22
- import { Router, type RequestHandler } from 'express'
22
+ import { Router, type RequestHandler, type Response } from 'express'
23
23
  import { emitDisplay } from '../lib/display-bus.js'
24
24
  import { createStopRequestStore, isStopRequestRefusal, STOP_REQUEST_CLAIM_WINDOW_MS, type StopRequest, type StopRequestStore } from '../lib/meeting-stop-requests.js'
25
25
  import { appendAutonomousSaveLog, getStopIntentStore, type StopIntent, type StopIntentStore } from '../lib/meeting-stop-intents.js'
@@ -153,6 +153,7 @@ import {
153
153
  getSessionChunks,
154
154
  getSessionProviderCandidates,
155
155
  getSessionStartTime,
156
+ publishLiveTitleSteer,
156
157
  getSessionTranscript,
157
158
  getMeetingSessionStatus,
158
159
  getStrandedCaptures,
@@ -175,6 +176,7 @@ import {
175
176
  import { derivedSourcesFor, findDerivedRecord } from '../lib/imported-library-rows.js'
176
177
  import { assertVoiceEvidenceSource } from '../lib/voice-evidence-guard.js'
177
178
  import { domainForMeeting, resolveDomains } from '../lib/domains.js'
179
+ import { effectiveSaveTitle, getTitleSteerStore, normalizeTitleSteer, TitleSteerError, type TitleSteerStore } from '../lib/meeting-title-steer.js'
178
180
  import { getOwnerSpeakerLabel } from '../lib/profile.js'
179
181
  import {
180
182
  DEATTRIBUTED_PREFIX,
@@ -319,6 +321,8 @@ interface MeetingSessionSource {
319
321
  delete(sessionId: string, options?: { preserveAudio?: boolean }): void
320
322
  /** 6.53.3: live, closed within its retention, or unknown (`getMeetingSessionStatus`). */
321
323
  getState?(sessionId: string): 'active' | 'closed' | 'missing'
324
+ /** 6.99.0: write a changed title steer into the live session's record (transcribe-stream publishLiveTitleSteer). */
325
+ publishTitleSteer?(sessionId: string): boolean
322
326
  /** 6.81.0: the received/ASR ledger the server's own save of a stopped meeting reads. */
323
327
  getLedger?(sessionId: string): SessionLedger
324
328
  }
@@ -340,6 +344,8 @@ function defaultSessionLedger(sessionId: string): SessionLedger {
340
344
 
341
345
  export interface MeetingRouteDependencies {
342
346
  store?: MeetingStore
347
+ /** 6.99.0: the live title steer ledger (lib/meeting-title-steer.ts). Tests inject one on a scratch path. */
348
+ titleSteer?: TitleSteerStore
343
349
  /** 6.93.0: is any meeting capture (glasses, Mac or iPhone) live right now? Assigning a Mac voice to a profile is refused while one is. */
344
350
  captureActive?: () => boolean
345
351
  sessions?: MeetingSessionSource
@@ -656,6 +662,7 @@ const defaultSessionSource: MeetingSessionSource = {
656
662
  moveAudioToPending: moveSessionAudioToPending,
657
663
  delete: deleteSession,
658
664
  getState: defaultSessionState,
665
+ publishTitleSteer: publishLiveTitleSteer,
659
666
  }
660
667
 
661
668
  function countWords(text: string): number {
@@ -794,6 +801,7 @@ const macAssignInFlight = new Set<string>()
794
801
 
795
802
  export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router {
796
803
  const store = deps.store ?? getMeetingStore()
804
+ const titleSteer = deps.titleSteer ?? getTitleSteerStore()
797
805
  const sessions = deps.sessions ?? defaultSessionSource
798
806
  const runBatch = deps.runBatch ?? runMeetingBatchPipeline
799
807
  const scheduleBackground = deps.scheduleBackground ?? (task => { void task })
@@ -886,6 +894,43 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
886
894
  })
887
895
  autoDeps.onCreate?.(autonomousFinalizer)
888
896
 
897
+ // ── 6.99.0: title steer ─────────────────────────────────────────────────────────────
898
+ // A name typed in COS Control while a Mac recording or glasses meeting is live. It is a hint the title model receives
899
+ // at save (and the title itself when the save sends none), never the final word. See lib/meeting-title-steer.ts.
900
+ // The title is never logged here or anywhere: names and companies. Length only.
901
+ const steerFailure = (res: Response, error: unknown, sessionId: string) => {
902
+ if (error instanceof TitleSteerError) {
903
+ res.status(error.status).json({ error: error.reason, reason: error.reason, ...(error.reason === 'title_changed' ? { current: titleSteer.get(sessionId) } : {}) })
904
+ return
905
+ }
906
+ console.error('[meeting/title-steer] Unexpected failure:', error instanceof Error ? error.name : 'unknown')
907
+ res.status(500).json({ error: 'title_steer_error', reason: 'title_steer_error' })
908
+ }
909
+ router.get('/meeting/title-steer', (req, res) => {
910
+ const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId : ''
911
+ res.set('Cache-Control', 'private, no-store')
912
+ try { res.json(titleSteer.get(sessionId)) } catch (error) { steerFailure(res, error, sessionId) }
913
+ })
914
+ router.put('/meeting/title-steer', (req, res) => {
915
+ const body = (req.body ?? {}) as Record<string, unknown>
916
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
917
+ res.set('Cache-Control', 'private, no-store')
918
+ try {
919
+ // Malformed input is a 400 whether or not the session is live; only a well-formed request earns a 404/409.
920
+ titleSteer.get(sessionId)
921
+ normalizeTitleSteer(body.title)
922
+ if (!Number.isSafeInteger(body.expectedRevision) || (body.expectedRevision as number) < 0) throw new TitleSteerError('invalid_revision', 400)
923
+ if ((sessions.getState ?? defaultSessionState)(sessionId) !== 'active') throw new TitleSteerError('session_not_live', 404)
924
+ // The save reads the steer once, then awaits before it closes the session. A rename in that gap would answer 200 and miss the
925
+ // filed meeting, so it is refused: the meeting is being filed under the name it had.
926
+ if (savingSessions.has(sessionId)) throw new TitleSteerError('save_in_progress', 409)
927
+ const view = titleSteer.set(sessionId, body.title, body.expectedRevision)
928
+ ;(sessions.publishTitleSteer ?? (() => false))(sessionId)
929
+ console.log(`[meeting/title-steer] ${view.title ? 'set' : 'cleared'} ${sessionId} length=${view.title ? Array.from(view.title).length : 0} revision=${view.revision}`)
930
+ res.json(view)
931
+ } catch (error) { steerFailure(res, error, sessionId) }
932
+ })
933
+
889
934
  let recoveryPendingLogged = false
890
935
  router.get('/meeting/sessions/:sessionId/status', (req, res) => {
891
936
  const sessionId = String(req.params.sessionId ?? '')
@@ -963,6 +1008,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
963
1008
  disposition: publicDisposition(disposition),
964
1009
  // 6.81.0, additive: the Stop the server holds for this meeting, if any. Older clients ignore it.
965
1010
  stopIntent: intent ? publicStopIntent(intent) : null,
1011
+ // 6.99.0, additive: the name typed while it recorded (null = none). Older clients ignore it.
1012
+ titleSteer: titleSteer.title(sessionId),
966
1013
  })
967
1014
  })
968
1015
 
@@ -1371,14 +1418,19 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
1371
1418
  // save — infer from the content instead of filing everything under one
1372
1419
  // domain. Keyword scoring, no model call: this runs on every save, and a
1373
1420
  // per-meeting LLM call would breach the recurring-caller rule.
1421
+ // 6.99.0: the title steer (a name typed while it recorded) is the title when the request sends none, and is filed
1422
+ // beside the title either way so the title model can read it. It feeds the domain through the title, as a title does.
1423
+ const steer = titleSteer.title(sessionId)
1424
+ const savedTitle = effectiveSaveTitle(body?.title, steer)
1374
1425
  const filedDomain = domainForMeeting(
1375
1426
  body?.domain as string | undefined,
1376
- `${(body?.title as string | undefined) ?? ''}\n${transcript}`,
1427
+ `${savedTitle ?? ''}\n${transcript}`,
1377
1428
  resolveCosOperationsDir(),
1378
1429
  )
1379
1430
  const saved = store.save({
1380
1431
  sessionId,
1381
- title: body?.title as string | undefined,
1432
+ title: savedTitle,
1433
+ ...(steer ? { titleSteer: steer } : {}),
1382
1434
  domain: filedDomain,
1383
1435
  transcript: cleanFinalTranscript(transcript),
1384
1436
  startTime,
@@ -3496,12 +3548,16 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
3496
3548
  words: item.words,
3497
3549
  canonical: true,
3498
3550
  }))
3551
+ // 6.99.0: same rule as POST /meeting/save: the title steer names the meeting when the request does not.
3552
+ const steer = titleSteer.title(sessionId)
3553
+ const recoveredTitle = effectiveSaveTitle(body.title, steer)
3499
3554
  const saved = store.save({
3500
3555
  sessionId,
3501
- title: typeof body.title === 'string' && body.title.trim() ? body.title : undefined,
3556
+ title: recoveredTitle,
3557
+ ...(steer ? { titleSteer: steer } : {}),
3502
3558
  domain: domainForMeeting(
3503
3559
  typeof body.domain === 'string' && body.domain.trim() ? body.domain : undefined,
3504
- `${typeof body.title === 'string' ? body.title : ''}\n${transcript}`,
3560
+ `${recoveredTitle ?? ''}\n${transcript}`,
3505
3561
  resolveCosOperationsDir(),
3506
3562
  ),
3507
3563
  transcript,
@@ -30,6 +30,13 @@
30
30
  // so a surface that only shows a count can poll without making the Mac hold questions it
31
31
  // cannot answer. It checks X-Cos-Token itself as well, in constant time, and a request
32
32
  // without it is refused and never counted, whatever sits in front of this router.
33
+ // 6.99.12: COS Control Web reads the same list with its signed browser session (the
34
+ // `cos_web_session` cookie, verified by `webSessionAuthorized`, the check the /api gate runs).
35
+ // The hook token still opens nothing here. A browser poll counts as a live answering client
36
+ // only with `?client=web`, which the page sends only while it shows the cards (a visible tab);
37
+ // the same 30 s window as the phone. A cookie poll naming `glasses` or `phone`, or no client,
38
+ // never counts, and a token poll naming `web` never counts either. A browser never reports
39
+ // wearer presence, so it never enables the away hold.
33
40
  // 6.60.0: a poll with `&hold=1&presenceAgeMs=<int>` (COS Glasses 6.9.563 and later) records
34
41
  // wearer presence for the away hold instead of the 6.59.0 liveness; a `hold=1` poll without
35
42
  // a valid `presenceAgeMs` makes no client live and enables no hold. The response adds
@@ -40,6 +47,7 @@ import { timingSafeTokenEqual } from '../lib/token-auth.js'
40
47
  import {
41
48
  ANSWERING_CLIENTS,
42
49
  CLIENT_LIVE_WINDOW_MS,
50
+ WEB_ANSWERING_CLIENT,
43
51
  SESSION_QUESTIONS_PROTOCOL_VERSION,
44
52
  type BrokerAdmission,
45
53
  type HookReplyChannel,
@@ -138,8 +146,13 @@ export function createPermissionBrokerHookRouter(deps: PermissionBrokerHookRoute
138
146
 
139
147
  export interface SessionQuestionsRouterDeps {
140
148
  broker: PermissionBroker
141
- /** The pairing token (X-Cos-Token). A poll counts as a live client only with it. */
149
+ /** The pairing token (X-Cos-Token). A poll counts as a live client only with it, or with the web session below. */
142
150
  apiToken: () => string
151
+ /**
152
+ * 6.99.12: whether this request carries a valid COS Control Web session (the cookie, its epoch, and the origin
153
+ * rules: `isWebSessionAuthorized` in production). Absent: only the token opens the list, as before.
154
+ */
155
+ webSessionAuthorized?: (req: Request) => boolean
143
156
  /** Where a counted poll is recorded; `noteQuestionsPoll` in production. */
144
157
  notePoll?: (at: number, poll: QuestionsPollFacts) => void
145
158
  now?: () => number
@@ -171,14 +184,21 @@ export function createSessionQuestionsRouter(deps: SessionQuestionsRouterDeps):
171
184
  res.set('Cache-Control', 'private, no-store')
172
185
  let expected = ''
173
186
  try { expected = deps.apiToken() } catch { expected = '' }
187
+ const byToken = timingSafeTokenEqual(req.headers['x-cos-token'], expected)
188
+ let byWebSession = false
189
+ if (!byToken && deps.webSessionAuthorized) {
190
+ try { byWebSession = deps.webSessionAuthorized(req) === true } catch { byWebSession = false }
191
+ }
174
192
  // A hook token, or nothing: refused, and never a live client.
175
- if (!timingSafeTokenEqual(req.headers['x-cos-token'], expected)) {
193
+ if (!byToken && !byWebSession) {
176
194
  res.status(401).json({ error: 'unauthorized' })
177
195
  return
178
196
  }
179
197
  // Only a client that says it can answer (and shows the cards) counts as live.
180
198
  const client = typeof req.query.client === 'string' ? req.query.client : ''
181
- if (ANSWERING_CLIENTS.has(client)) notePoll(now(), questionsPollFacts(req.query as Record<string, unknown>))
199
+ if (byToken && ANSWERING_CLIENTS.has(client)) notePoll(now(), questionsPollFacts(req.query as Record<string, unknown>))
200
+ // 6.99.12: a browser counts only as `web`, and never with presence (no away hold from a tab).
201
+ else if (byWebSession && client === WEB_ANSWERING_CLIENT) notePoll(now(), {})
182
202
  const health = deps.broker.health()
183
203
  // Listed first: a deadline the timer has not caught yet is settled by the listing.
184
204
  const items = deps.broker.list()