@gotcos/glasses-server 7.0.7 → 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 (87) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +170 -0
  3. package/docs/web-key-verifier-contract.md +378 -0
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -3
  6. package/server/bootstrap.ts +10 -0
  7. package/server/index.ts +126 -23
  8. package/server/lib/agent-session-recent.ts +321 -0
  9. package/server/lib/agent-session-store.ts +3 -3
  10. package/server/lib/api-auth.ts +37 -1
  11. package/server/lib/calendar-meetings.ts +70 -0
  12. package/server/lib/calendar-source.ts +54 -9
  13. package/server/lib/claude-hooks-installer.ts +111 -12
  14. package/server/lib/conversation.ts +56 -28
  15. package/server/lib/cors-reject-log.ts +51 -0
  16. package/server/lib/disk-io-meter.ts +202 -0
  17. package/server/lib/drop-service.ts +47 -12
  18. package/server/lib/drop-store.ts +21 -1
  19. package/server/lib/event-loop-stall.ts +62 -0
  20. package/server/lib/full-hold-engine.ts +114 -0
  21. package/server/lib/full-hold.ts +124 -0
  22. package/server/lib/g2-turn-provenance.ts +128 -9
  23. package/server/lib/listener-startup.ts +18 -0
  24. package/server/lib/meeting-batch-transcribe.ts +32 -5
  25. package/server/lib/permission-broker.ts +443 -11
  26. package/server/lib/secret-path-guard.ts +4 -1
  27. package/server/lib/session-cache-writer.ts +8 -1
  28. package/server/lib/session-hook-events.ts +23 -1
  29. package/server/lib/session-hooks-runtime.ts +34 -0
  30. package/server/lib/session-log.ts +313 -4
  31. package/server/lib/session-state-derive.ts +36 -0
  32. package/server/lib/session-stream-events.ts +25 -1
  33. package/server/lib/today-brief.ts +522 -58
  34. package/server/lib/turn-end-ask.ts +320 -0
  35. package/server/lib/turn-footer.ts +3 -3
  36. package/server/lib/web-control-integrity.ts +160 -0
  37. package/server/lib/web-control-offnet-page.ts +58 -0
  38. package/server/lib/web-control-static.ts +53 -9
  39. package/server/lib/web-key-authority.ts +372 -0
  40. package/server/lib/web-key-envelope.ts +98 -0
  41. package/server/lib/web-key-handoff.ts +135 -0
  42. package/server/lib/web-key-origin.ts +21 -0
  43. package/server/lib/web-key-passkey.ts +155 -0
  44. package/server/lib/web-key-registry.ts +825 -0
  45. package/server/lib/web-key-signer-app.ts +152 -0
  46. package/server/lib/web-session-auth.ts +133 -15
  47. package/server/lib/whisper-local.ts +93 -7
  48. package/server/lib/whisper-preview.ts +5 -3
  49. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  50. package/server/lib/work-review-runtime.ts +17 -4
  51. package/server/routes/agent-session-bindings.ts +16 -1
  52. package/server/routes/agent-session-stream.ts +7 -0
  53. package/server/routes/drops.ts +7 -2
  54. package/server/routes/g2-turns.ts +35 -0
  55. package/server/routes/health.ts +11 -1
  56. package/server/routes/live-events.ts +6 -1
  57. package/server/routes/permission-broker.ts +26 -0
  58. package/server/routes/query-jobs.ts +3 -0
  59. package/server/routes/sessions.ts +15 -24
  60. package/server/routes/thread-turn-queue.ts +16 -9
  61. package/server/routes/today-brief.ts +91 -36
  62. package/server/routes/web-control.ts +37 -4
  63. package/server/routes/web-key.ts +284 -0
  64. package/server/routes/welcome-context.ts +6 -2
  65. package/server/routes/work-orchestrator.ts +12 -7
  66. package/server/scripts/write-web-control-manifest.mjs +43 -0
  67. package/server/web-control/.manifest.json +44 -0
  68. package/server/web-control/app.css +36 -0
  69. package/server/web-control/app.js +953 -57
  70. package/server/web-control/apple-touch-icon.png +0 -0
  71. package/server/web-control/index.html +14 -1
  72. package/server/web-control/panes/cos-chat.css +52 -6
  73. package/server/web-control/panes/cos-chat.js +364 -32
  74. package/server/web-control/panes/questions.js +135 -14
  75. package/server/web-control/panes/settings.js +1 -1
  76. package/server/web-control/panes/today-actions.css +35 -0
  77. package/server/web-control/panes/today-actions.js +75 -6
  78. package/server/web-control/panes/work.css +3 -0
  79. package/server/web-control/panes/work.js +178 -35
  80. package/server/web-signer/.manifest.json +11 -0
  81. package/server/web-signer/index.html +74 -0
  82. package/server/web-signer/signer-proof.js +348 -0
  83. package/server/web-signer/signer-store.js +39 -0
  84. package/server/web-signer/signer.css +23 -0
  85. package/server/web-signer/signer.js +341 -0
  86. package/shared/web-approval-packets.golden.json +124 -0
  87. package/shared/web-turn-envelope.ts +271 -0
@@ -0,0 +1,321 @@
1
+ /**
2
+ * "Recent activity first" for the Today summary (Miles, 2026-11-09): what was said INTO a session lately and where its
3
+ * latest reply left off, with when, so the summary describes what a session is doing now instead of its first prompt.
4
+ *
5
+ * Why: each session's list `line` (composeDiscussionSummary) is its FIRST prompt plus the latest reply, cut at 180
6
+ * characters. On 11/09 the Codex orchestrator's line led with an ask from 10/11, 29 days old, and the model wrote
7
+ * "following up a transcription bakeoff" about a session that had just confirmed a release was live.
8
+ *
9
+ * readRecentActivity(provider, file, now) answers the last few asks (each with when and how it arrived), the latest
10
+ * reply and when the session last moved. Read-only display evidence: never imported by an attach, queue or write gate.
11
+ *
12
+ * - Claude: the last 8 MB of the transcript, memoized on mtime and size like readClaudeSubagents. User turns follow
13
+ * parseAgentSession's rules: sidechains, `isMeta` and `isCompactSummary` rows are not asks (a peer row is, unwrapped
14
+ * by unwrapPeerMessage), the verify footer comes off (withoutTurnFooter), wrapper tags go (STRIPPABLE_TAG_RE) and a
15
+ * pasted JSON block becomes "[pasted data]". A peer row, or a row that carried a COS verify footer, arrived from
16
+ * another screen or session: `via: 'peer'`, never attributed to the person. A peer row that is agent-to-agent
17
+ * protocol (a cross-session or agent-message envelope) is not an ask at all.
18
+ * - Codex: rollouts reach 32 GB with single lines of 44 MB (measured 11/09). An incremental forward scan keeps its byte
19
+ * offset in memory; the first pass reads at most the last 256 MB; a line over 1 MB is skipped without being decoded
20
+ * or parsed; reads are async, 4 MB at a time, and the scan yields between chunks. One scan per file at a time: a
21
+ * second caller waits on the first, and a scan the Today route stopped waiting for still finishes and fills the memo.
22
+ * Codex rows carry no sender marker, so only a COS verify footer marks one as sent in from another screen.
23
+ * - Cursor: the existing tail peek (the last user query and reply); its transcript has no timestamps, so every time is
24
+ * the file's mtime.
25
+ *
26
+ * `startedAt` and `firstAsk` are set only for a session whose file was created within the last day, for the
27
+ * "started today as" note. Opens refuse symlinks and non-regular files. Never throws: null when unreadable.
28
+ */
29
+ import { constants } from 'node:fs'
30
+ import { open, stat } from 'node:fs/promises'
31
+ import {
32
+ STRIPPABLE_TAG_RE, createdFromCodexFilename, firstClaudeUserTitle, firstCursorUserTitle, isWrapperPrompt, parseJsonLine,
33
+ payloadText, codexUserText, peekCodexMeta, peekCursorDiscussion, proseSnippet, type AgentProvider,
34
+ } from './agent-session-store.js'
35
+ import { unwrapPeerMessage } from './session-stream-events.js'
36
+ import { stripG2TurnFooter } from './turn-footer.js'
37
+
38
+ export type RecentVia = 'typed' | 'peer'
39
+ export interface RecentAsk { at: number; text: string; via: RecentVia }
40
+ export interface RecentReply { at: number; text: string }
41
+ export interface RecentActivity {
42
+ /** The newest asks into the session, oldest first, at most RECENT_ASKS_KEPT. */
43
+ asks: RecentAsk[]
44
+ /** The newest assistant reply (Codex: its newest final answer, not commentary). */
45
+ reply: RecentReply | null
46
+ /** The newest record's time (ms), 0 when none was seen. */
47
+ movedAt: number
48
+ /** Only for a session created within the last day: when, and its first ask. */
49
+ startedAt?: number
50
+ firstAsk?: string
51
+ }
52
+
53
+ export const RECENT_ASKS_KEPT = 3
54
+ export const CLAUDE_RECENT_TAIL_BYTES = 8 * 1024 * 1024
55
+ export const CODEX_FIRST_PASS_BYTES = 256 * 1024 * 1024
56
+ /** A line longer than this is skipped unparsed (a 44 MB Codex line holds tool output, never an ask). */
57
+ export const RECENT_LINE_MAX_BYTES = 1024 * 1024
58
+ export const RECENT_CHUNK_BYTES = 4 * 1024 * 1024
59
+ const TEXT_KEEP = 1000
60
+ const MEMO_MAX = 64
61
+ const DAY_MS = 24 * 60 * 60 * 1000
62
+
63
+ // ── text ───────────────────────────────────────────────────────────────────
64
+
65
+ const PASTED = /<pasted_content\b[^>]*>([\s\S]*?)<\/pasted_content\b[^>]*>/gi
66
+ const WRAPPER_BLOCKS = /<(task-notification|system-reminder|relevant-memories|cache-health|daily-bulletin|cos-alarms|device-handoff|now|memory-stored|local-command-stdout|local-command-stderr|command-name|command-message|command-args|user_query)\b[^>]*>[\s\S]*?<\/\1>/gi
67
+ /** A peer row that is agent-to-agent protocol, not words for the session. */
68
+ const AGENT_ENVELOPE = /^\s*(?:<(?:cross-session-message|agent-message)\b|Message Type:|Subagent hand-?back\b)/i
69
+ /** A paragraph that is pasted data: it opens with { or [ and holds a "key": pair. */
70
+ const JSON_PARAGRAPH = /^\s*[[{][\s\S]*"\s*:/
71
+
72
+ /** One ask's words: pasted data replaced, wrapper blocks and tags out, one line, bounded. '' when nothing is left. */
73
+ export function cleanAskText(raw: string): string {
74
+ // Whole wrapper blocks go first (a background task's notification, a reminder, a slash command's parts). What is left
75
+ // is then judged as parseAgentSession's collectTurn judges a turn: still a wrapper (it opens with a tag the block list
76
+ // does not know), it is not an ask, and no tag is taken off so that its insides could read as words.
77
+ let s = raw.replace(WRAPPER_BLOCKS, ' ').replace(PASTED, (_m, inner: string) => {
78
+ const t = String(inner).trim()
79
+ return t.startsWith('{') || t.startsWith('[') ? '\n\n[pasted data]\n\n' : ` ${t} `
80
+ })
81
+ if (!s.trim() || isWrapperPrompt(s)) return ''
82
+ s = s.split(/\n\s*\n/).map(p => (JSON_PARAGRAPH.test(p) ? '[pasted data]' : p)).join('\n\n')
83
+ s = s.replace(STRIPPABLE_TAG_RE, ' ').replace(/\s+/g, ' ').trim()
84
+ if (!s || isWrapperPrompt(s) || s.startsWith('[Request interrupted') || s.startsWith('[image: source')) return ''
85
+ return s.slice(0, TEXT_KEEP)
86
+ }
87
+
88
+ /** An ask's words and how it arrived: a footer means another screen (glasses, phone or web) sent it. */
89
+ function footerVia(text: string, peer: boolean): { text: string; via: RecentVia } {
90
+ const f = stripG2TurnFooter(text)
91
+ return { text: f.text, via: peer || f.turnId ? 'peer' : 'typed' }
92
+ }
93
+
94
+ const tsOf = (v: unknown): number => (typeof v === 'string' ? Date.parse(v) || 0 : 0)
95
+
96
+ interface Collector { asks: RecentAsk[]; reply: RecentReply | null; movedAt: number }
97
+ const emptyCollector = (): Collector => ({ asks: [], reply: null, movedAt: 0 })
98
+ function addAsk(c: Collector, ask: RecentAsk) {
99
+ c.asks.push(ask)
100
+ if (c.asks.length > RECENT_ASKS_KEPT) c.asks.shift()
101
+ }
102
+ const moved = (c: Collector, at: number) => { if (at > c.movedAt) c.movedAt = at }
103
+
104
+ /** One Claude record into the collector (parseAgentSession's user-turn rules). */
105
+ export function collectClaudeRecord(c: Collector, obj: Record<string, unknown>): void {
106
+ if (obj.isSidechain === true) return
107
+ const at = tsOf(obj.timestamp)
108
+ if (at && (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'system')) moved(c, at)
109
+ if (obj.type !== 'user' && obj.type !== 'assistant') return
110
+ if (obj.type === 'user' && obj.toolUseResult) return
111
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
112
+ const text = message ? payloadText(message) : null
113
+ if (!text) return
114
+ if (obj.type === 'assistant') {
115
+ const prose = proseSnippet(text, TEXT_KEEP)
116
+ if (prose) c.reply = { at, text: prose }
117
+ return
118
+ }
119
+ const isPeer = (obj.origin as { kind?: unknown } | undefined)?.kind === 'peer'
120
+ const peerWords = isPeer ? unwrapPeerMessage(text) : null
121
+ // Injected rows (a slash-command body, a compaction preamble) are not asks; a peer row is, once unwrapped.
122
+ if (peerWords === null && (obj.isMeta === true || obj.isCompactSummary === true)) return
123
+ if (peerWords !== null && AGENT_ENVELOPE.test(peerWords)) return
124
+ const { text: words, via } = footerVia(peerWords ?? text, peerWords !== null)
125
+ const clean = cleanAskText(words)
126
+ if (clean) addAsk(c, { at, text: clean, via })
127
+ }
128
+
129
+ /** One Codex rollout line into the collector. The caller has already checked its size. */
130
+ export function collectCodexLine(c: Collector, line: Buffer): void {
131
+ const head = line.subarray(0, 512).toString('utf8')
132
+ const stamp = /"timestamp":"([^"]{10,40})"/.exec(head)
133
+ const at = stamp ? Date.parse(stamp[1]!) || 0 : 0
134
+ if (at) moved(c, at)
135
+ // Role and type sit before the content in a rollout line, so the head decides without decoding the rest.
136
+ if (!head.includes('"type":"message"') || !(head.includes('"role":"user"') || head.includes('"role":"assistant"'))) return
137
+ const obj = parseJsonLine(line.toString('utf8'))
138
+ if (!obj || obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') return
139
+ const p = obj.payload as Record<string, unknown>
140
+ if (p.type !== 'message') return
141
+ if (p.role === 'assistant') {
142
+ if (p.phase === 'commentary') return
143
+ const text = payloadText(p)
144
+ const prose = text ? proseSnippet(text, TEXT_KEEP) : ''
145
+ if (prose) c.reply = { at, text: prose }
146
+ return
147
+ }
148
+ if (p.role !== 'user') return
149
+ const text = codexUserText(p)
150
+ if (!text || isWrapperPrompt(text)) return
151
+ const { text: words, via } = footerVia(text, false)
152
+ const clean = cleanAskText(words)
153
+ if (clean) addAsk(c, { at, text: clean, via })
154
+ }
155
+
156
+ // ── reads ──────────────────────────────────────────────────────────────────
157
+
158
+ type Handle = Awaited<ReturnType<typeof open>>
159
+ async function openRegular(file: string): Promise<{ handle: Handle; size: number; mtimeMs: number; birthtimeMs: number; ino: number } | null> {
160
+ let handle: Handle | null = null
161
+ try {
162
+ handle = await open(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0))
163
+ const st = await handle.stat()
164
+ if (!st.isFile()) { await handle.close().catch(() => {}); return null }
165
+ return { handle, size: st.size, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, ino: st.ino }
166
+ } catch {
167
+ await handle?.close().catch(() => {})
168
+ return null
169
+ }
170
+ }
171
+ const yieldLoop = () => new Promise<void>(resolve => setImmediate(resolve))
172
+
173
+ const claudeMemo = new Map<string, { mtimeMs: number; size: number; value: Collector }>()
174
+ const remember = <V>(memo: Map<string, V>, key: string, value: V) => {
175
+ if (!memo.has(key) && memo.size >= MEMO_MAX) memo.delete(memo.keys().next().value!)
176
+ memo.set(key, value)
177
+ }
178
+
179
+ /** A Claude transcript's tail (8 MB), memoized on mtime and size. */
180
+ async function readClaude(file: string, tailBytes = CLAUDE_RECENT_TAIL_BYTES): Promise<{ c: Collector; birthtimeMs: number } | null> {
181
+ const opened = await openRegular(file)
182
+ if (!opened) return null
183
+ const { handle, size, mtimeMs, birthtimeMs } = opened
184
+ try {
185
+ const hit = claudeMemo.get(file)
186
+ if (hit && hit.mtimeMs === mtimeMs && hit.size === size) return { c: hit.value, birthtimeMs }
187
+ const length = Math.min(size, tailBytes), start = size - length
188
+ const buffer = Buffer.alloc(length)
189
+ const read = await handle.read(buffer, 0, length, start)
190
+ const data = buffer.subarray(0, read.bytesRead)
191
+ const c = emptyCollector()
192
+ let pos = 0, n = 0
193
+ // A tail that starts mid-file has a cut first line: it fails to parse and is skipped like any bad line.
194
+ while (pos < data.length) {
195
+ let end = data.indexOf(0x0a, pos)
196
+ if (end < 0) end = data.length
197
+ if (end - pos > 0 && end - pos <= RECENT_LINE_MAX_BYTES) {
198
+ const obj = parseJsonLine(data.subarray(pos, end).toString('utf8'))
199
+ if (obj) collectClaudeRecord(c, obj)
200
+ }
201
+ pos = end + 1
202
+ if (++n % 2000 === 0) await yieldLoop()
203
+ }
204
+ remember(claudeMemo, file, { mtimeMs, size, value: c })
205
+ return { c, birthtimeMs }
206
+ } catch {
207
+ return null
208
+ } finally {
209
+ await handle.close().catch(() => {})
210
+ }
211
+ }
212
+
213
+ interface CodexScan { offset: number; size: number; partial: Buffer | null; skipping: boolean; c: Collector; running: Promise<void> | null; ino: number }
214
+ const codexMemo = new Map<string, CodexScan>()
215
+
216
+ /** Test seam: forget every memo (a new server process starts with none). */
217
+ export function resetRecentActivityMemo(): void { claudeMemo.clear(); codexMemo.clear() }
218
+ /** Test seam: the bytes of an unfinished line a Codex scan holds for its next read (never more than the line bound). */
219
+ export function codexScanHeldBytes(file: string): number { return codexMemo.get(file)?.partial?.length ?? 0 }
220
+
221
+ /**
222
+ * Scan a Codex rollout forward from where the last scan stopped. A first pass, or a gap past `firstPassBytes`, starts
223
+ * that far from the end and drops the line it lands inside.
224
+ */
225
+ async function scanCodex(file: string, firstPassBytes: number, chunkBytes: number): Promise<CodexScan | null> {
226
+ const opened = await openRegular(file)
227
+ if (!opened) return null
228
+ const { handle, size, ino } = opened
229
+ try {
230
+ let scan = codexMemo.get(file)
231
+ if (scan && (scan.ino !== ino || size < scan.offset)) scan = undefined // replaced or truncated: start over
232
+ if (!scan) {
233
+ scan = { offset: 0, size: 0, partial: null, skipping: false, c: emptyCollector(), running: null, ino }
234
+ remember(codexMemo, file, scan)
235
+ }
236
+ if (scan.running) { await scan.running; return scan }
237
+ const s = scan
238
+ const run = (async () => {
239
+ if (size - s.offset > firstPassBytes) {
240
+ s.offset = size - firstPassBytes
241
+ s.partial = null
242
+ s.skipping = s.offset > 0 // landed mid-line: that line is not whole
243
+ }
244
+ while (s.offset < size) {
245
+ const length = Math.min(chunkBytes, size - s.offset)
246
+ const chunk = Buffer.alloc(length)
247
+ const { bytesRead } = await handle.read(chunk, 0, length, s.offset)
248
+ if (bytesRead <= 0) break
249
+ s.offset += bytesRead
250
+ let buf = chunk.subarray(0, bytesRead)
251
+ if (s.partial) { buf = Buffer.concat([s.partial, buf]); s.partial = null }
252
+ let pos = 0
253
+ for (let nl = buf.indexOf(0x0a, pos); nl >= 0; nl = buf.indexOf(0x0a, pos)) {
254
+ if (s.skipping) s.skipping = false
255
+ else if (nl - pos > 0 && nl - pos <= RECENT_LINE_MAX_BYTES) collectCodexLine(s.c, buf.subarray(pos, nl))
256
+ pos = nl + 1
257
+ }
258
+ const rest = buf.subarray(pos)
259
+ // An unfinished line past the bound is never kept: the rest of it is skipped up to its newline. While skipping
260
+ // (no newline in this chunk at all), nothing is kept either.
261
+ if (!s.skipping) {
262
+ if (rest.length > RECENT_LINE_MAX_BYTES) s.skipping = true
263
+ else if (rest.length) s.partial = Buffer.from(rest)
264
+ }
265
+ await yieldLoop()
266
+ }
267
+ s.size = size
268
+ })()
269
+ s.running = run
270
+ try { await run } finally { s.running = null }
271
+ return s
272
+ } catch {
273
+ return null
274
+ } finally {
275
+ await handle.close().catch(() => {})
276
+ }
277
+ }
278
+
279
+ export interface RecentReadOptions { claudeTailBytes?: number; codexFirstPassBytes?: number; chunkBytes?: number }
280
+
281
+ /** What was said into one session lately, its latest reply and when it last moved, or null when it cannot be read. */
282
+ export async function readRecentActivity(provider: AgentProvider, file: string, now = Date.now(), opts: RecentReadOptions = {}): Promise<RecentActivity | null> {
283
+ try {
284
+ let c: Collector, createdAt = 0, firstAsk = ''
285
+ if (provider === 'claude') {
286
+ const r = await readClaude(file, opts.claudeTailBytes)
287
+ if (!r) return null
288
+ c = r.c
289
+ createdAt = r.birthtimeMs
290
+ if (createdAt && now - createdAt < DAY_MS) firstAsk = (await firstClaudeUserTitle(file)) ?? ''
291
+ } else if (provider === 'codex') {
292
+ const s = await scanCodex(file, opts.codexFirstPassBytes ?? CODEX_FIRST_PASS_BYTES, opts.chunkBytes ?? RECENT_CHUNK_BYTES)
293
+ if (!s) return null
294
+ c = s.c
295
+ createdAt = Date.parse(createdFromCodexFilename(file.split('/').pop() ?? '') ?? '') || 0
296
+ if (createdAt && now - createdAt < DAY_MS) firstAsk = (await peekCodexMeta(file))?.title ?? ''
297
+ } else {
298
+ const st = await stat(file).catch(() => null)
299
+ if (!st?.isFile()) return null
300
+ const peek = await peekCursorDiscussion(file)
301
+ const at = st.mtimeMs
302
+ c = { asks: [], reply: null, movedAt: at }
303
+ const ask = peek.lastUser ? cleanAskText(peek.lastUser) : ''
304
+ if (ask) c.asks.push({ at, text: ask, via: 'typed' })
305
+ const reply = peek.latestAssistant ? proseSnippet(peek.latestAssistant, TEXT_KEEP) : ''
306
+ if (reply) c.reply = { at, text: reply }
307
+ createdAt = st.birthtimeMs
308
+ if (createdAt && now - createdAt < DAY_MS) firstAsk = (await firstCursorUserTitle(file)) ?? ''
309
+ }
310
+ // Copies, so a caller can never edit the memo.
311
+ const out: RecentActivity = {
312
+ asks: c.asks.filter(a => a.at <= now).map(a => ({ ...a })),
313
+ reply: c.reply && c.reply.at <= now ? { ...c.reply } : null,
314
+ movedAt: c.movedAt,
315
+ }
316
+ if (firstAsk && createdAt) { out.startedAt = createdAt; out.firstAsk = firstAsk }
317
+ return out
318
+ } catch {
319
+ return null
320
+ }
321
+ }
@@ -250,7 +250,7 @@ const STRIPPABLE_TAGS = [
250
250
  'local-command-stdout', 'local-command-stderr', 'thinking',
251
251
  ]
252
252
 
253
- const STRIPPABLE_TAG_RE = new RegExp(`</?(?:${STRIPPABLE_TAGS.join('|')})(?:\\s[^>]*)?/?>`, 'gi')
253
+ export const STRIPPABLE_TAG_RE = new RegExp(`</?(?:${STRIPPABLE_TAGS.join('|')})(?:\\s[^>]*)?/?>`, 'gi')
254
254
 
255
255
  /**
256
256
  * Fenced code and known tags out. Shared so `proseSnippet` and `latestAssistantReply`
@@ -995,7 +995,7 @@ export async function peekCodexMeta(path: string): Promise<{ id: string; cwd: st
995
995
  return { id, cwd, title, subagent, created }
996
996
  }
997
997
 
998
- async function peekCursorDiscussion(path: string): Promise<{ lastUser: string | null; latestAssistant: string }> {
998
+ export async function peekCursorDiscussion(path: string): Promise<{ lastUser: string | null; latestAssistant: string }> {
999
999
  const text = await readWindow(path, true)
1000
1000
  let lastUser: string | null = null
1001
1001
  for (const line of text.split('\n')) {
@@ -1010,7 +1010,7 @@ async function peekCursorDiscussion(path: string): Promise<{ lastUser: string |
1010
1010
  return { lastUser, latestAssistant: latestAssistantFromWindow(text) }
1011
1011
  }
1012
1012
 
1013
- async function firstCursorUserTitle(path: string): Promise<string | null> {
1013
+ export async function firstCursorUserTitle(path: string): Promise<string | null> {
1014
1014
  const text = await readWindow(path, false)
1015
1015
  let fallback: string | null = null
1016
1016
  for (const line of text.split('\n')) {
@@ -1,7 +1,7 @@
1
1
  import type { RequestHandler } from 'express'
2
2
  import { timingSafeTokenEqual } from './token-auth.js'
3
3
  import { PAIRING_NONCE_BODY } from './glasses-pairing.js'
4
- import { isWebSessionAuthorized } from './web-session-auth.js'
4
+ import { formatSetWebSessionCookie, isWebSessionAuthorized, renewWebSessionCookieValue, verifyWebSessionCookieValue, webSessionCookieFrom } from './web-session-auth.js'
5
5
 
6
6
  // Recovery/setup clients need these availability surfaces before they have a
7
7
  // usable token. Keep private provider state and every mutation route out.
@@ -44,9 +44,31 @@ const DISPLAY_STREAM_CAPABILITY_PATH = /^\/display-stream\/\d{1,15}\.[0-9a-f]{64
44
44
  // Minting, status and decisions stay behind the token AND a loopback socket.
45
45
  const PAIRING_POLL_PATH = new RegExp(`^/pairing/claim/${PAIRING_NONCE_BODY}$`)
46
46
 
47
+ /** Set on a response whose request renewed its browser sign-in: `<new expiry ISO> <30-day cap ISO>`. The page reads it. */
48
+ export const WEB_SESSION_RENEWED_HEADER = 'X-COS-Web-Session'
49
+
47
50
  /** `res.locals` key the gate sets to `'token'` when the X-COS-Token header matched. */
48
51
  export const API_AUTH_BY_LOCAL = 'cosApiAuthBy'
49
52
 
53
+ /**
54
+ * Final review S1 (lead 2026-11-10 02:09): `res.locals` key the gate sets ONLY for a request it let in by the browser's
55
+ * sign-in cookie: a check that the same cookie still verifies now (its expiry, the epoch "Sign out every browser" bumps,
56
+ * the 30-day cap). A stream that outlives its opening request calls it on every heartbeat (`streamSignInHolds`).
57
+ */
58
+ export const WEB_SESSION_RECHECK_LOCAL = 'cosWebSessionRecheck'
59
+
60
+ /**
61
+ * For a long-lived stream, on each heartbeat: false once the browser sign-in that opened it no longer verifies, so the
62
+ * stream tears down. A request the API token let in has no re-check and always holds, as does one that never passed
63
+ * the gate (a route mounted without it in a test). The browser reconnects with whatever cookie it holds now; a renewed
64
+ * one verifies there.
65
+ */
66
+ export function streamSignInHolds(res: { locals?: Record<string, unknown> }): boolean {
67
+ const recheck = res.locals?.[WEB_SESSION_RECHECK_LOCAL]
68
+ if (typeof recheck !== 'function') return true
69
+ try { return recheck() === true } catch { return false }
70
+ }
71
+
50
72
  export function isPublicApiRequest(method: string, path: string): boolean {
51
73
  if (PUBLIC_API_PATHS.has(path)) return true
52
74
  if (method === 'POST' && path === '/pairing/claim') return true
@@ -92,6 +114,20 @@ export function requireApiToken(apiToken: string, options: RequireApiTokenOption
92
114
  allowedPorts: options.allowedPorts?.(),
93
115
  })
94
116
  if (cookieAuth) {
117
+ // Final review S1: the cookie this request came in with, checked again by any stream it opens (above).
118
+ const opened = webSessionCookieFrom(req.headers.cookie)
119
+ res.locals[WEB_SESSION_RECHECK_LOCAL] = (): boolean => verifyWebSessionCookieValue(opened, apiToken, Date.now())
120
+ // 7.0.x (Miles 2026-11-09, "Renew while in use, 30-day cap"): only here, after the socket, cookie, CSRF and Origin
121
+ // checks above all passed, a cookie in its last 2 days gets a fresh one, never past 30 days from its first sign-in.
122
+ // Appended, so a route's own Set-Cookie (none today on gated routes) is never overwritten. A renewal that cannot
123
+ // be made (outside the window, at the cap) changes nothing.
124
+ try {
125
+ const renewed = renewWebSessionCookieValue(webSessionCookieFrom(req.headers.cookie), apiToken)
126
+ if (renewed) {
127
+ res.append('Set-Cookie', formatSetWebSessionCookie(renewed.value, Math.floor((renewed.expiresAtMs - Date.now()) / 1000), req.secure))
128
+ res.setHeader(WEB_SESSION_RENEWED_HEADER, new Date(renewed.expiresAtMs).toISOString() + ' ' + new Date(renewed.capAtMs).toISOString())
129
+ }
130
+ } catch { /* a failed renewal leaves the request and the old cookie as they were */ }
95
131
  return next()
96
132
  }
97
133
  return res.status(401).json({
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Today round 2 (2026-11-09): which calendar rows are distinct, real meetings. ONE helper for the calendar API
3
+ * (calendar-source.ts calendarToday: next, remainingCount, endedRecently) and the Today summary (today-brief.ts
4
+ * withCalendar), so the two never disagree.
5
+ *
6
+ * - Canceled: a title that starts "Canceled:" or "Cancelled:" (how Exchange and Google rename a canceled occurrence),
7
+ * or a row whose `status` says canceled, or `canceled: true` (the snapshot's EventKit status, when it carries one;
8
+ * the 11/09 snapshot had no status field on any of its 154 events).
9
+ * - Distinct: the same title (trimmed, case-folded, spaces collapsed) at the same start is one meeting, whichever
10
+ * calendar it is on. The 11/09 live snapshot had 13 such pairs or triples, 12 of them on ONE Exchange calendar with
11
+ * different external ids (the same invite delivered twice), so "across calendars" alone would have left 12 of them.
12
+ * The first row of each wins, so callers keep their own order.
13
+ * No dependencies: the summary imports it without the calendar reader.
14
+ */
15
+ export const CANCELED_TITLE = /^\s*(?:canceled|cancelled)\s*:/i
16
+ const CANCELED_STATUS = new Set(['canceled', 'cancelled'])
17
+
18
+ export interface MeetingFields { title: unknown; start: unknown; status?: unknown; canceled?: unknown }
19
+
20
+ export function isCanceledMeeting(row: MeetingFields): boolean {
21
+ if (row.canceled === true) return true
22
+ if (typeof row.status === 'string' && CANCELED_STATUS.has(row.status.trim().toLowerCase())) return true
23
+ return typeof row.title === 'string' && CANCELED_TITLE.test(row.title)
24
+ }
25
+
26
+ /** The identity of one meeting: its title and its start instant. */
27
+ export function meetingKey(row: MeetingFields): string {
28
+ const title = typeof row.title === 'string' ? row.title.trim().toLowerCase().replace(/\s+/g, ' ') : ''
29
+ const start = typeof row.start === 'number' ? row.start : Date.parse(String(row.start ?? ''))
30
+ return `${title}|${Number.isFinite(start) ? start : String(row.start ?? '')}`
31
+ }
32
+
33
+ /** The rows that are real meetings, one per meeting, in the order given. */
34
+ export function distinctMeetings<T>(rows: readonly T[], fields: (row: T) => MeetingFields): T[] {
35
+ const seen = new Set<string>()
36
+ const out: T[] = []
37
+ for (const row of rows) {
38
+ const f = fields(row)
39
+ if (isCanceledMeeting(f)) continue
40
+ const key = meetingKey(f)
41
+ if (seen.has(key)) continue
42
+ seen.add(key)
43
+ out.push(row)
44
+ }
45
+ return out
46
+ }
47
+
48
+ /**
49
+ * Prep on every Next up row (Miles typed 2026-11-09 13:00 CST): a calendar hold or status block is not a meeting to
50
+ * prep for. ONE list for the server: /api/calendar/today marks each row `hold`, /api/welcome-context marks nextEvent
51
+ * `hold`, and Control Web and desktop Control read that flag instead of keeping their own copy of the words.
52
+ *
53
+ * A title is a hold when, after leading punctuation ("[Hold] ...", "* Busy"), it starts with one of these words or
54
+ * phrases as a whole word, and it does not name someone with "with" ("Lunch with Jeremy" and "Focus group with the
55
+ * Jewel360 board" are meetings). Case and repeated spaces do not matter. A false "not a hold" only offers Prep on a hold;
56
+ * a false "hold" would hide Prep on a real meeting, so the rule stays narrow.
57
+ */
58
+ export const CALENDAR_HOLD_WORDS: readonly string[] = Object.freeze([
59
+ 'blocked', 'block', 'busy', 'hold', 'holds', 'focus', 'travel', 'personal', 'private', 'ooo', 'out of office', 'lunch',
60
+ 'pto', 'vacation', 'commute', 'dnd', 'do not book', 'do not schedule',
61
+ ])
62
+ // Letters and single spaces only, so the words need no escaping.
63
+ const HOLD_START = new RegExp(`^(?:${CALENDAR_HOLD_WORDS.join('|')})(?![a-z0-9])`)
64
+
65
+ export function isCalendarHold(title: unknown): boolean {
66
+ if (typeof title !== 'string') return false
67
+ const t = title.trim().toLowerCase().replace(/\s+/g, ' ').replace(/^[^a-z0-9]+/, '')
68
+ if (!t || !HOLD_START.test(t)) return false
69
+ return !/(?:^|[^a-z0-9])with(?![a-z0-9])/.test(t)
70
+ }
@@ -22,6 +22,7 @@
22
22
  import { lstatSync, readFileSync } from 'node:fs'
23
23
  import { dataPath } from './data-dir.js'
24
24
  import { callPython, pythonBridgeAvailable } from './python-bridge.js'
25
+ import { distinctMeetings, isCalendarHold, type MeetingFields } from './calendar-meetings.js'
25
26
 
26
27
  export const SNAPSHOT_FRESH_MS = 2 * 3600_000
27
28
  export const SNAPSHOT_MAX_AGE_MS = 24 * 3600_000
@@ -61,6 +62,8 @@ export interface SnapshotEvent {
61
62
  attendees: SnapshotAttendee[]
62
63
  /** Allowlisted hosts only (zoom.us, teams.microsoft.com, meet.google.com). */
63
64
  meetingUrl: string | null
65
+ /** Today round 2: EventKit's status said canceled (only when the writer sends `status: "canceled"`). */
66
+ canceled?: boolean
64
67
  }
65
68
 
66
69
  export interface CalendarSnapshot {
@@ -159,6 +162,7 @@ export function parseSnapshot(raw: unknown): CalendarSnapshot | null {
159
162
  isOrganizer: r.isOrganizer === true,
160
163
  attendees,
161
164
  meetingUrl: url,
165
+ ...(typeof r.status === 'string' && /^cancel+ed$/i.test(r.status.trim()) ? { canceled: true } : {}),
162
166
  })
163
167
  }
164
168
  return { version: 1, writtenAt, windowStart, windowEnd, access, calendars, events }
@@ -340,6 +344,7 @@ export function bridgeEvent(e: SnapshotEvent, calendar: SnapshotCalendar | undef
340
344
  event_id: e.eventId,
341
345
  external_id: e.externalId,
342
346
  occurrence_start: e.occurrenceStart,
347
+ ...(e.canceled ? { canceled: true } : {}),
343
348
  }
344
349
  }
345
350
 
@@ -454,32 +459,52 @@ export function countsAsMeeting(row: Record<string, unknown> | null | undefined)
454
459
  }
455
460
 
456
461
  /**
457
- * Today for Control Web and desktop: selected calendars only, the next 3 timed meetings that count. QA 7.0.0: all-day
458
- * and non-prep events (declined, holidays, attendee-less shared events) are filtered out BEFORE the 3 are taken, so they
459
- * never use up the slots Today's "Later" shows. QA W23: remainingCount counts the same rows, so a declined meeting or
460
- * a family flight is not "remaining".
462
+ * Today for Control Web and desktop: selected calendars only, the next 5 timed meetings that count (Today round 2,
463
+ * Miles 2026-11-09: was 3). QA 7.0.0: all-day and non-prep events (declined, holidays, attendee-less shared events) are
464
+ * filtered out BEFORE the 5 are taken, so they never use up the slots Today's "Later" shows. QA W23: remainingCount
465
+ * counts the same rows, so a declined meeting or a family flight is not "remaining".
466
+ *
467
+ * Today round 2: `endedRecently` lists the meetings that count which ended within the last CALENDAR_ENDED_RECENT_MS
468
+ * (2 h), newest end first, at most CALENDAR_ENDED_MAX. They come from `today_events` (the snapshot's and the bridge's
469
+ * own list of every event today, past ones included), with the same end rule the snapshot uses (no end: an hour after
470
+ * the start). The Today summary uses it to know a meeting is over, so its prep is past. Additive: the older fields keep
471
+ * their names and meaning (desktop Control 0.5.384 reads next, remainingCount, stale and asOf).
472
+ *
473
+ * Today round 2 follow-up: canceled events and duplicates (the same title at the same start) are dropped BEFORE next,
474
+ * remainingCount and endedRecently are taken (calendar-meetings.ts, the helper the Today summary uses too), so
475
+ * remainingCount counts real, distinct meetings and no slot is spent twice on one.
461
476
  */
462
- export async function calendarToday(now = Date.now()): Promise<{
477
+ export const CALENDAR_NEXT_MAX = 5
478
+ export const CALENDAR_ENDED_RECENT_MS = 2 * 3600_000
479
+ export const CALENDAR_ENDED_MAX = 5
480
+ /** `hold` (Prep on every Next up row, 11/09): a hold or status block by its title (calendar-meetings.ts isCalendarHold). */
481
+ export interface CalendarTodayRow { title: string; start: string; end: string | null; allDay: boolean; calendar: string | null; meetingUrl: string | null; hold: boolean }
482
+ export interface CalendarEndedRow { title: string; start: string; end: string }
483
+ export interface CalendarTodayView {
463
484
  connected: boolean
464
485
  source: CalendarSourceName
465
486
  asOf: string | null
466
487
  stale: boolean
467
488
  remainingCount: number
468
- next: Array<{ title: string; start: string; end: string | null; allDay: boolean; calendar: string | null; meetingUrl: string | null }>
469
- }> {
489
+ next: CalendarTodayRow[]
490
+ endedRecently: CalendarEndedRow[]
491
+ }
492
+ export async function calendarToday(now = Date.now()): Promise<CalendarTodayView> {
470
493
  const read = await readCalendar(8_000, now)
471
494
  const data = read.data as Record<string, unknown> | null
472
495
  const connected = !!data && typeof data === 'object' && !('error' in data) && (read.source !== 'bridge' || pythonBridgeAvailable())
473
496
  const rows = connected && Array.isArray(data!.today_remaining) ? data!.today_remaining as Array<Record<string, unknown>> : []
474
- const counted = rows.filter(countsAsMeeting)
475
- const next = counted.slice(0, 3).map(r => ({
497
+ const counted = distinctMeetings(rows.filter(countsAsMeeting), bridgeMeetingFields)
498
+ const next = counted.slice(0, CALENDAR_NEXT_MAX).map(r => ({
476
499
  title: typeof r.title === 'string' ? r.title : 'Event',
477
500
  start: String(r.start_time ?? ''),
478
501
  end: typeof r.end_time === 'string' ? r.end_time : null,
479
502
  allDay: r.is_all_day === true,
480
503
  calendar: typeof r.calendar_name === 'string' ? r.calendar_name : null,
481
504
  meetingUrl: typeof r.meeting_url === 'string' ? r.meeting_url : null,
505
+ hold: isCalendarHold(r.title),
482
506
  }))
507
+ const all = connected && Array.isArray(data!.today_events) ? data!.today_events as Array<Record<string, unknown>> : []
483
508
  return {
484
509
  connected,
485
510
  source: read.source,
@@ -487,5 +512,25 @@ export async function calendarToday(now = Date.now()): Promise<{
487
512
  stale: data?.is_stale === true,
488
513
  remainingCount: connected ? counted.length : 0,
489
514
  next,
515
+ endedRecently: endedRecently(all, now),
516
+ }
517
+ }
518
+
519
+ /** A bridge-shaped row's meeting identity, for calendar-meetings.ts. */
520
+ export function bridgeMeetingFields(r: Record<string, unknown>): MeetingFields {
521
+ return { title: r.title, start: r.start_time, status: r.status, canceled: r.canceled }
522
+ }
523
+
524
+ /** Distinct, not canceled meetings that count and ended within CALENDAR_ENDED_RECENT_MS of `now`, newest end first. */
525
+ export function endedRecently(rows: Array<Record<string, unknown>>, now: number): CalendarEndedRow[] {
526
+ const out: Array<CalendarEndedRow & { endMs: number }> = []
527
+ for (const r of distinctMeetings(rows.filter(countsAsMeeting), bridgeMeetingFields)) {
528
+ const startMs = Date.parse(String(r.start_time ?? ''))
529
+ if (!Number.isFinite(startMs)) continue
530
+ const endRaw = typeof r.end_time === 'string' && r.end_time ? Date.parse(r.end_time) : NaN
531
+ const endMs = Number.isFinite(endRaw) ? endRaw : startMs + 3600_000
532
+ if (endMs > now || now - endMs > CALENDAR_ENDED_RECENT_MS) continue
533
+ out.push({ title: typeof r.title === 'string' ? r.title : 'Event', start: new Date(startMs).toISOString(), end: new Date(endMs).toISOString(), endMs })
490
534
  }
535
+ return out.sort((a, b) => b.endMs - a.endMs).slice(0, CALENDAR_ENDED_MAX).map(({ endMs: _e, ...r }) => r)
491
536
  }