@gotcos/glasses-server 7.0.7 → 7.0.27

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 (97) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +305 -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 +129 -23
  8. package/server/lib/agent-session-older.ts +218 -0
  9. package/server/lib/agent-session-recent.ts +321 -0
  10. package/server/lib/agent-session-store.ts +102 -20
  11. package/server/lib/api-auth.ts +37 -1
  12. package/server/lib/calendar-meetings.ts +70 -0
  13. package/server/lib/calendar-source.ts +54 -9
  14. package/server/lib/claude-hooks-installer.ts +111 -12
  15. package/server/lib/conversation.ts +56 -28
  16. package/server/lib/cors-reject-log.ts +51 -0
  17. package/server/lib/cos-started-sessions.ts +101 -0
  18. package/server/lib/disk-io-meter.ts +202 -0
  19. package/server/lib/drop-service.ts +47 -12
  20. package/server/lib/drop-store.ts +21 -1
  21. package/server/lib/event-loop-stall.ts +62 -0
  22. package/server/lib/full-hold-engine.ts +114 -0
  23. package/server/lib/full-hold.ts +124 -0
  24. package/server/lib/g2-turn-provenance.ts +128 -9
  25. package/server/lib/list-model-memo.ts +85 -0
  26. package/server/lib/listener-startup.ts +18 -0
  27. package/server/lib/meeting-batch-transcribe.ts +32 -5
  28. package/server/lib/model-efforts.ts +40 -0
  29. package/server/lib/permission-broker.ts +443 -11
  30. package/server/lib/secret-path-guard.ts +4 -1
  31. package/server/lib/session-cache-writer.ts +8 -1
  32. package/server/lib/session-hook-events.ts +23 -1
  33. package/server/lib/session-hooks-runtime.ts +34 -0
  34. package/server/lib/session-log.ts +313 -4
  35. package/server/lib/session-organizer.ts +305 -0
  36. package/server/lib/session-state-derive.ts +36 -0
  37. package/server/lib/session-stream-events.ts +39 -3
  38. package/server/lib/thread-turn-queue.ts +24 -0
  39. package/server/lib/today-brief.ts +522 -58
  40. package/server/lib/turn-end-ask.ts +320 -0
  41. package/server/lib/turn-footer.ts +3 -3
  42. package/server/lib/web-control-integrity.ts +160 -0
  43. package/server/lib/web-control-offnet-page.ts +58 -0
  44. package/server/lib/web-control-static.ts +53 -9
  45. package/server/lib/web-key-authority.ts +372 -0
  46. package/server/lib/web-key-envelope.ts +98 -0
  47. package/server/lib/web-key-handoff.ts +135 -0
  48. package/server/lib/web-key-origin.ts +21 -0
  49. package/server/lib/web-key-passkey.ts +155 -0
  50. package/server/lib/web-key-registry.ts +825 -0
  51. package/server/lib/web-key-signer-app.ts +152 -0
  52. package/server/lib/web-session-auth.ts +133 -15
  53. package/server/lib/whisper-local.ts +93 -7
  54. package/server/lib/whisper-preview.ts +5 -3
  55. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  56. package/server/lib/work-review-runtime.ts +17 -4
  57. package/server/routes/agent-session-bindings.ts +16 -1
  58. package/server/routes/agent-session-stream.ts +7 -0
  59. package/server/routes/agent-sessions.ts +106 -2
  60. package/server/routes/drops.ts +7 -2
  61. package/server/routes/g2-turns.ts +35 -0
  62. package/server/routes/health.ts +14 -1
  63. package/server/routes/live-events.ts +6 -1
  64. package/server/routes/permission-broker.ts +26 -0
  65. package/server/routes/query-jobs.ts +3 -0
  66. package/server/routes/session-organizer.ts +149 -0
  67. package/server/routes/sessions.ts +15 -24
  68. package/server/routes/thread-turn-queue.ts +50 -14
  69. package/server/routes/today-brief.ts +91 -36
  70. package/server/routes/web-control.ts +37 -4
  71. package/server/routes/web-key.ts +284 -0
  72. package/server/routes/welcome-context.ts +6 -2
  73. package/server/routes/work-orchestrator.ts +12 -7
  74. package/server/scripts/write-web-control-manifest.mjs +43 -0
  75. package/server/web-control/.manifest.json +44 -0
  76. package/server/web-control/app.css +45 -0
  77. package/server/web-control/app.js +1680 -108
  78. package/server/web-control/apple-touch-icon.png +0 -0
  79. package/server/web-control/index.html +54 -17
  80. package/server/web-control/panes/cos-chat.css +52 -6
  81. package/server/web-control/panes/cos-chat.js +364 -32
  82. package/server/web-control/panes/questions.js +135 -14
  83. package/server/web-control/panes/sessions.css +280 -0
  84. package/server/web-control/panes/settings.js +1 -1
  85. package/server/web-control/panes/today-actions.css +35 -0
  86. package/server/web-control/panes/today-actions.js +121 -6
  87. package/server/web-control/panes/work.css +3 -0
  88. package/server/web-control/panes/work.js +185 -38
  89. package/server/web-control/theme.js +33 -0
  90. package/server/web-signer/.manifest.json +11 -0
  91. package/server/web-signer/index.html +74 -0
  92. package/server/web-signer/signer-proof.js +348 -0
  93. package/server/web-signer/signer-store.js +39 -0
  94. package/server/web-signer/signer.css +23 -0
  95. package/server/web-signer/signer.js +341 -0
  96. package/shared/web-approval-packets.golden.json +124 -0
  97. package/shared/web-turn-envelope.ts +271 -0
@@ -1,9 +1,7 @@
1
1
  // Session endpoints — recent list, full history, existence check, client-format messages, context breaks, end
2
2
  import { Router } from 'express'
3
- import { readFileSync } from 'fs'
4
- import { join } from 'path'
5
3
  import { getRecentSessions, getHistory, sessionExists, addContextBreak, endSession, getSessionRaw, getActiveSessions, resolveExchangePairModel, ensureArchiveMirrorForDay } from '../lib/conversation.js'
6
- import { buildSessionLogEntry, writeSessionLog } from '../lib/session-log.js'
4
+ import { buildSessionLogEntry, lookupRangesFromHistory, readSessionLogRanges, writeSessionLogIfChanged } from '../lib/session-log.js'
7
5
  import { getArchiveDayMessages, listArchiveDateStrings } from '../lib/archive.js'
8
6
  import { recentMessagesLimit, selectRecentMessages } from '../lib/recent-messages.js'
9
7
  import { localDay } from '../lib/local-day.js'
@@ -135,7 +133,7 @@ sessionsRouter.get('/sessions/:id/messages', (req, res) => {
135
133
  // instead of waiting for the 2hr TTL expiry.
136
134
  // POST /api/sessions/lookup — batch resolve timestamps to session IDs
137
135
  // Used to retroactively stamp messages that predate the sessionId feature
138
- sessionsRouter.post('/sessions/lookup', (req, res) => {
136
+ sessionsRouter.post('/sessions/lookup', async (req, res) => {
139
137
  const { timestamps } = req.body as { timestamps: number[] }
140
138
  if (!timestamps || !Array.isArray(timestamps)) {
141
139
  res.status(400).json({ error: 'timestamps[] required' })
@@ -143,7 +141,6 @@ sessionsRouter.post('/sessions/lookup', (req, res) => {
143
141
  }
144
142
 
145
143
  // Build time ranges from BOTH JSONL history AND live server sessions
146
- const logPath = join(process.env.COS_SCRIPTS_DIR || '', '.glasses_sessions.jsonl')
147
144
  const sessionRanges: Array<{ sid: string; start: number; end: number }> = []
148
145
 
149
146
  // 1. Live server sessions (always current — no snapshot lag)
@@ -155,25 +152,17 @@ sessionsRouter.post('/sessions/lookup', (req, res) => {
155
152
  }
156
153
  }
157
154
 
158
- // 2. JSONL history (ended sessions + snapshots)
155
+ // 2. JSONL history (ended sessions + snapshots). S1 (2026-11-09): streamed and incremental
156
+ // (session-log.ts readSessionLogRanges); the old readFileSync().toString() threw on the 1.94 GB
157
+ // ledger and the catch read that as "no file". The range rule is the old one, unchanged: a
158
+ // session's end is widened by later lines only when it is NOT in memory (lookupRangesFromHistory).
159
159
  try {
160
- const lines = readFileSync(logPath, 'utf-8').trim().split('\n')
161
- for (const line of lines) {
162
- const d = JSON.parse(line)
163
- if (d.session_id && d.created_at) {
164
- const start = new Date(d.created_at).getTime()
165
- const end = d.ended_at ? new Date(d.ended_at).getTime() : start + 7200_000
166
- // Live sessions take priority. For JSONL, keep the widest (latest) time range per session.
167
- const existing = sessionRanges.find(s => s.sid === d.session_id)
168
- if (!existing) {
169
- sessionRanges.push({ sid: d.session_id, start, end })
170
- } else if (end > existing.end && !getSessionRaw(d.session_id)) {
171
- // Widen the JSONL range (but don't overwrite live sessions which extend to NOW)
172
- existing.end = end
173
- }
174
- }
175
- }
176
- } catch { /* no log file */ }
160
+ const live = new Set(sessionRanges.map(s => s.sid))
161
+ const history = await readSessionLogRanges()
162
+ sessionRanges.push(...lookupRangesFromHistory(history, sid => Boolean(getSessionRaw(sid)), live))
163
+ } catch (err) {
164
+ console.warn('[sessions] lookup could not read the session ledger:', err instanceof Error ? err.message : err)
165
+ }
177
166
 
178
167
  // Match each timestamp to a session (live sessions checked first, then JSONL)
179
168
  const results: Record<number, string | null> = {}
@@ -242,7 +231,9 @@ sessionsRouter.post('/sessions/:id/snapshot', (req, res) => {
242
231
  slug: `[LIVE] ${(session.exchanges.find(e => e.role === 'user')?.content ?? '').slice(0, 50)}`,
243
232
  })
244
233
 
245
- const logged = writeSessionLog(entry)
234
+ // Logged means the ledger holds this content: written now, or already there.
235
+ const result = writeSessionLogIfChanged(entry, 'snapshot')
236
+ const logged = result === 'written' || result === 'unchanged'
246
237
  res.json({
247
238
  ok: true,
248
239
  logged,
@@ -21,7 +21,7 @@ import { CONTINUE_NOTE_HEADER, continueNoteAcknowledged } from '../lib/continue-
21
21
  import { nativeQueueControl, plainQueueText, type NativeQueueControl } from '../lib/codex-queue-control.js'
22
22
  import { CLIENT_TURN_ID_RE, COS_SESSION_ID_RE, DEFAULT_MAX_PROMPT_CHARS } from './agent-session-bindings.js'
23
23
  import {
24
- admitToQueue, drainDecision, queueableRefusal, queuePosition,
24
+ admitToQueue, drainDecision, holdReasonFor, queueableRefusal, queuePosition,
25
25
  MAX_DELIVERY_ATTEMPTS, type DrainObservation, type QueuedThreadTurn,
26
26
  queueReconcileEnabled, queueResendEnabled, queuedTurnReasonCopy, sessionSteerAvailability, sessionSteerEnabled,
27
27
  steerMidturnEnabled, type SessionSteerAvailability,
@@ -38,11 +38,11 @@ import { defaultTurnAttachmentStore, keepTurnAttachments, parseTurnAttachmentIds
38
38
  * the attachment digest its origin is sealed to. Files whose content cannot be read leave the turn
39
39
  * unvouched (it still parks); no files is the empty digest and reads nothing.
40
40
  */
41
- async function sealFiles(ids: readonly string[] | undefined, verdict: OriginVerdict, store: TurnAttachmentStore | undefined): Promise<{ verdict: OriginVerdict; attachments: string }> {
42
- if (!Array.isArray(ids) || ids.length === 0) return { verdict, attachments: '' }
41
+ async function sealFiles(ids: readonly string[] | undefined, verdict: OriginVerdict, store: TurnAttachmentStore | undefined): Promise<{ verdict: OriginVerdict; attachments: string; manifest: ReadonlyArray<{ id: string; sha256: string }> | null }> {
42
+ if (!Array.isArray(ids) || ids.length === 0) return { verdict, attachments: '', manifest: [] }
43
43
  const manifest = await turnAttachmentManifest(ids, store)
44
- if (!manifest.ok) return { verdict: unvouched(verdict, 'attachments_unsealable'), attachments: '' }
45
- return { verdict, attachments: attachmentsDigest(manifest.manifest) }
44
+ if (!manifest.ok) return { verdict: unvouched(verdict, 'attachments_unsealable'), attachments: '', manifest: null }
45
+ return { verdict, attachments: attachmentsDigest(manifest.manifest), manifest: manifest.manifest }
46
46
  }
47
47
 
48
48
  export interface ThreadTurnQueueDeps {
@@ -153,6 +153,14 @@ export function steerAvailabilityFor(deps: Pick<ThreadTurnQueueDeps, 'steerProbe
153
153
  return sessionSteerAvailability({ provider, gateReason, continueLive, peerInbox: probe.peerInbox, waitingOnPrompt: probe.waitingOnPrompt })
154
154
  }
155
155
 
156
+ /**
157
+ * When a sweep last decided each held row (epoch ms), in memory only: the row file is written when its hold
158
+ * reason CHANGES, not every 20 s. Bounded; a settled row is forgotten.
159
+ */
160
+ const holdCheckedAt = new Map<string, number>()
161
+ const HOLD_CHECKED_MAX = 1_000
162
+ export function __resetHoldCheckedForTests(): void { holdCheckedAt.clear() }
163
+
156
164
  /** Public shape of a queued turn. No prompt echo beyond a short preview. */
157
165
  export function publicRow(turn: QueuedThreadTurn, position: number): Record<string, unknown> {
158
166
  const row: Record<string, unknown> = {
@@ -169,6 +177,12 @@ export function publicRow(turn: QueuedThreadTurn, position: number): Record<stri
169
177
  ...(turn.settledAt ? { settledAt: turn.settledAt } : {}),
170
178
  // 11/07: how many files wait with the words (never their ids or paths). Absent without files.
171
179
  ...(Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0 ? { attachments: turn.attachmentIds.length } : {}),
180
+ // 2026-11-11: why a waiting row is held (a code), since when, and when a sweep last decided it. Additive.
181
+ ...(turn.status === 'waiting' && typeof turn.holdReason === 'string' ? {
182
+ waiting_reason: turn.holdReason,
183
+ ...(typeof turn.holdSince === 'number' ? { waiting_since: turn.holdSince } : {}),
184
+ ...(holdCheckedAt.has(turn.clientTurnId) ? { waiting_checked_at: holdCheckedAt.get(turn.clientTurnId) } : {}),
185
+ } : {}),
172
186
  }
173
187
  // 6.78.0: additive, and only while a P3 flag is on, so the row is the 6.77 bytes otherwise.
174
188
  // `resendable` is the per-row capability: present only when Send again can act on it.
@@ -242,7 +256,7 @@ function localDrainVerdict(at: number): OriginVerdict {
242
256
  }
243
257
 
244
258
  /** What one mid-turn attempt did to its row: delivered, settled some other way, or left waiting. */
245
- type MidTurnOutcome = 'delivered' | 'settled' | 'held'
259
+ type MidTurnOutcome = 'delivered' | 'settled' | 'held' | 'held_files'
246
260
 
247
261
  /**
248
262
  * Steer mid-turn (COS_STEER_MIDTURN, Miles 2026-11-07): put ONE waiting row into the running turn
@@ -279,9 +293,11 @@ async function steerHeldTurn(
279
293
  deps: ThreadTurnQueueDeps,
280
294
  ): Promise<MidTurnOutcome> {
281
295
  if (turn.status !== 'waiting' || turn.attempts !== 0) return 'held'
282
- if (Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0) return 'held'
283
296
  const availability = midTurnAvailabilityFor(deps, provider, threadId, gateReason)
284
297
  if (!availability || !availability.available) return 'held'
298
+ // Files never go into a running turn (the peer inbox carries words only). Checked AFTER availability, so
299
+ // `held_files` means exactly this: the turn could have gone in now but for its files (the row's hold reason).
300
+ if (Array.isArray(turn.attachmentIds) && turn.attachmentIds.length > 0) return 'held_files'
285
301
  const { clientTurnId, prompt } = turn
286
302
 
287
303
  const rewrite = (apply: (row: QueuedThreadTurn) => void): QueuedThreadTurn | null => {
@@ -465,11 +481,19 @@ export async function drainThread(
465
481
  // which also means the hold is the busy GATE: the rule answers only for a refusal that
466
482
  // names a desk run (an attachable thread has no reason). Anything else holds as before.
467
483
  const cancelHeld = typeof seen.cancelHoldUntil === 'number' && Number.isFinite(seen.cancelHoldUntil) && deps.now() < seen.cancelHoldUntil
484
+ let reason = holdReasonFor(seen, deps.now())
468
485
  if (!midTurnOrderHeld && !cancelHeld) {
469
486
  const outcome = await steerHeldTurn(provider, threadId, turn, gate.reason, deps)
470
- if (outcome === 'delivered') { delivered += 1; continue }
471
- if (outcome === 'settled') { retired += 1; continue }
487
+ if (outcome === 'delivered') { holdCheckedAt.delete(turn.clientTurnId); delivered += 1; continue }
488
+ if (outcome === 'settled') { holdCheckedAt.delete(turn.clientTurnId); retired += 1; continue }
489
+ if (outcome === 'held_files') reason = 'files_wait_for_turn_end'
472
490
  }
491
+ // The hold reason: written only when it changes; the check time is kept in memory (2026-11-11).
492
+ const at = deps.now()
493
+ if (turn.holdReason !== reason) { turn.holdReason = reason; turn.holdSince = at; persist(turn) }
494
+ holdCheckedAt.delete(turn.clientTurnId)
495
+ holdCheckedAt.set(turn.clientTurnId, at)
496
+ while (holdCheckedAt.size > HOLD_CHECKED_MAX) holdCheckedAt.delete(holdCheckedAt.keys().next().value!)
473
497
  midTurnOrderHeld = true
474
498
  held += 1
475
499
  continue
@@ -480,6 +504,7 @@ export async function drainThread(
480
504
  turn.settledAt = deps.now()
481
505
  retired += 1; persist(turn)
482
506
  forgetIfSettled(turn)
507
+ holdCheckedAt.delete(turn.clientTurnId)
483
508
  continue
484
509
  }
485
510
 
@@ -488,6 +513,10 @@ export async function drainThread(
488
513
  // forever; the ceiling only bounds anything if it survives the crash it is bounding.
489
514
  turn.attempts += 1
490
515
  turn.status = 'delivering'
516
+ // No longer held: the reason belonged to the wait that just ended.
517
+ delete turn.holdReason
518
+ delete turn.holdSince
519
+ holdCheckedAt.delete(turn.clientTurnId)
491
520
  // 6.78.0: the stamp the restart reconcile reads. `journal: 1` says this build keeps an
492
521
  // attempt record for the delivery that follows; `deliveringBootId` says which boot owns
493
522
  // it, so a sweep never touches a row this process is delivering right now. Durable:
@@ -879,7 +908,9 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
879
908
  // with its content hash), so the drain can carry it (QUEUED_ORIGIN_TTL_MS at most).
880
909
  if (deps.provenance && verdict) {
881
910
  const sealed = await sealFiles(attach.ids, verdict, deps.turnAttachmentStore)
882
- verdict = sealed.verdict
911
+ // 6.99.31: a signed web turn is judged here, once parking is decided, against this provider and
912
+ // thread (where it will drain), these exact words and these files. No envelope: unchanged.
913
+ verdict = deps.provenance.acceptWebKey(sealed.verdict, { provider, sessionId: threadId, turnId: clientTurnId, words: prompt, attachments: sealed.manifest })
883
914
  deps.provenance.noteQueued(provider, threadId, clientTurnId, prompt, verdict, sealed.attachments)
884
915
  }
885
916
 
@@ -990,8 +1021,11 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
990
1021
  if (deps.provenance && resendVerdict) {
991
1022
  // After the write: the read-modify-write above stays one synchronous step. Sealed to the copy's files.
992
1023
  const sealed = await sealFiles(copy.attachmentIds, resendVerdict, deps.turnAttachmentStore)
993
- deps.provenance.noteQueued(provider, threadId, newId, copy.prompt, sealed.verdict, sealed.attachments)
994
- deps.provenance.record({ turnId: newId, prompt: copy.prompt, verdict: sealed.verdict, provider, sessionId: threadId, via: 'queue-resend', resendOf: original.clientTurnId })
1024
+ // 6.99.31: Send again from the phone needs a fresh signature over the copy (its new id, the words,
1025
+ // the files); without one the copy stays unvouched ('resend_not_reverified').
1026
+ const copyVerdict = deps.provenance.acceptWebKey(sealed.verdict, { provider, sessionId: threadId, turnId: newId, words: copy.prompt, attachments: sealed.manifest })
1027
+ deps.provenance.noteQueued(provider, threadId, newId, copy.prompt, copyVerdict, sealed.attachments)
1028
+ deps.provenance.record({ turnId: newId, prompt: copy.prompt, verdict: copyVerdict, provider, sessionId: threadId, via: 'queue-resend', resendOf: original.clientTurnId })
995
1029
  }
996
1030
  console.log(`[thread-turn-queue] resend provider=${provider} from=${original.clientTurnId} as=${newId} unknown=${original.deliveryState === 'unknown'}`)
997
1031
  return res.status(200).json(publicRow(copy, queuePosition(admitted.queue, newId)))
@@ -1061,9 +1095,11 @@ export function createThreadTurnQueueRouter(deps: ThreadTurnQueueDeps): Router {
1061
1095
  if (deps.provenance && verdict) {
1062
1096
  // Sealed to the row's files as they are now (an edit changes only the words).
1063
1097
  const sealed = await sealFiles(row.attachmentIds, verdict, deps.turnAttachmentStore)
1064
- deps.provenance.noteQueued(provider, threadId, id, prompt, sealed.verdict, sealed.attachments)
1098
+ // 6.99.31: an edit needs a fresh signature over the new words (same id, same files), or it is unvouched.
1099
+ const editVerdict = deps.provenance.acceptWebKey(sealed.verdict, { provider, sessionId: threadId, turnId: id, words: prompt, attachments: sealed.manifest })
1100
+ deps.provenance.noteQueued(provider, threadId, id, prompt, editVerdict, sealed.attachments)
1065
1101
  // The queue's own row, re-judged: it continues the record parked under this id.
1066
- deps.provenance.record({ turnId: id, prompt, verdict: sealed.verdict, provider, sessionId: threadId, via: 'queue-edit', continues: true })
1102
+ deps.provenance.record({ turnId: id, prompt, verdict: editVerdict, provider, sessionId: threadId, via: 'queue-edit', continues: true })
1067
1103
  }
1068
1104
  return res.json({ updated: true })
1069
1105
  })
@@ -25,49 +25,87 @@ import { dataPath } from '../lib/data-dir.js'
25
25
  import { runClaudeText } from '../lib/claude-text-runner.js'
26
26
  import { logTokenAudit } from '../lib/token-audit.js'
27
27
  import type { TaskBoardRow } from '../lib/task-store.js'
28
- import { agentSessionRoots, findAgentSessionFile } from '../lib/agent-session-store.js'
28
+ import type { TaskDateFields } from '../lib/task-dates.js'
29
+ import { agentSessionRoots, codexSessionRootsOf, findAgentSessionFile, listCodexJsonlFiles, loadClaudeDesktopAliases, loadClaudeTranscriptIndex, type AgentProvider } from '../lib/agent-session-store.js'
29
30
  import { readClaudeSubagents, summarize, type SessionSubagents } from '../lib/session-subagents.js'
30
31
  import { readCodexSubagentDetails } from '../lib/codex-subagent-activity.js'
32
+ import { readRecentActivity, type RecentActivity } from '../lib/agent-session-recent.js'
31
33
  import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
32
34
  import { isRecordingLive } from '../lib/stt-runtime.js'
33
35
  import {
34
36
  TODAY_BRIEF_BODY_MAX_BYTES, TODAY_BRIEF_EFFORT, TODAY_BRIEF_SETTINGS_FILE, TODAY_BRIEF_STATE_FILE, TODAY_BRIEF_TIMEOUT_MS,
35
- TodayBriefInputError, createTodayBrief, normalizeTodayBriefInputs, subagentLookups, todayBriefFingerprint, withSubagents, type BriefWork, type SubagentReading, type TodayBrief,
37
+ TodayBriefInputError, createTodayBrief, normalizeTodayBriefInputs, sessionContextLookups, todayBriefFingerprint, withCalendar, withRecentActivity, withSubagents, type BriefWork, type CalendarReading, type SubagentReading, type TodayBrief,
36
38
  } from '../lib/today-brief.js'
37
39
 
38
40
  export interface TodayBriefRouterDeps {
39
41
  brief: TodayBrief
40
- /** The Work board as the server already reads it (a shared, bounded read), or null when it cannot be read. */
41
- board: () => Promise<TaskBoardRow[] | null>
42
- /** Sub-agents for up to 8 sessions, by "provider|id". Missing keys mean "not read"; the page's counts stay. */
43
- subagents?: (keys: string[]) => Promise<Map<string, SubagentReading>>
42
+ /** The Work board, dated when it can be (2026-11-09: listBoardWithDates, so a card's last move is known), or null
43
+ * when it cannot be read. */
44
+ board: () => Promise<Array<TaskBoardRow & Partial<TaskDateFields>> | null>
45
+ /** Sub-agents and recent activity for up to 12 sessions, by "provider|id", written into `into` as each session is
46
+ * read, so an ask that stops waiting keeps what was read by then. Missing keys mean "not read": the page's counts
47
+ * and `line` stay, the line marked "may be old". */
48
+ context?: (keys: string[], into: SessionContext) => Promise<unknown>
49
+ /** Today round 2: today's calendar (lib/calendar-source.ts calendarToday). Read with the session reads, inside the
50
+ * same wait; a failed, slow or unconnected read means no calendar in the prompt. */
51
+ calendar?: () => Promise<CalendarReading | null>
44
52
  now?: () => number
45
53
  }
46
54
 
47
- /** How long the sub-agent reads may hold the ask before it goes on without them. */
55
+ /** How long the session reads may hold the ask before it goes on with what was read by then. */
48
56
  export const TODAY_BRIEF_SUBAGENT_WAIT_MS = 2_500
49
57
 
58
+ /** What the server read per session: its sub-agents and its recent activity, by "provider|id". */
59
+ export interface SessionContext { agents: Map<string, SubagentReading>; recent: Map<string, RecentActivity> }
60
+ export const emptySessionContext = (): SessionContext => ({ agents: new Map(), recent: new Map() })
61
+
50
62
  /**
51
- * The server's own sub-agent reads: Claude from the session's transcript tail (lib/session-subagents.ts), Codex from
52
- * the spawn-edge snapshot (readCodexSubagentDetails, server home only). Cursor has none. Sequential, bounded, read-only.
63
+ * 2026-11-09: readSubagentsFor became this. For each key, most important first: the sub-agents (Claude from the
64
+ * transcript tail, Codex from the spawn-edge snapshot) and the recent activity (lib/agent-session-recent.ts). Claude
65
+ * and Cursor read one after another; Codex reads in parallel with them, because a Codex rollout's first scan is the
66
+ * slow one (32 GB files) and must not starve the rest. Results land in `into` as they arrive. Read-only.
53
67
  */
54
- export async function readSubagentsFor(keys: string[], now = Date.now()): Promise<Map<string, SubagentReading>> {
55
- const out = new Map<string, SubagentReading>()
68
+ export async function readSessionContext(keys: string[], into: SessionContext = emptySessionContext(), now = Date.now()): Promise<SessionContext> {
56
69
  const roots = agentSessionRoots()
57
- const codex = keys.filter(k => k.startsWith('codex|')).map(k => k.slice(6))
58
- for (const key of keys.filter(k => k.startsWith('claude|'))) {
59
- const file = await findAgentSessionFile('claude', key.slice(7), roots).catch(() => null)
60
- const read: SessionSubagents | null = file ? await readClaudeSubagents(file) : null
61
- if (read && read.total) out.set(key, read)
62
- }
63
- if (codex.length) {
64
- const details = await readCodexSubagentDetails(codex, roots.codexSessions, now)
70
+ const split = (k: string) => { const i = k.indexOf('|'); return { provider: k.slice(0, i) as AgentProvider, id: k.slice(i + 1) } }
71
+ const codexKeys = keys.filter(k => k.startsWith('codex|'))
72
+ const localKeys = keys.filter(k => k.startsWith('claude|') || k.startsWith('cursor|'))
73
+ const local = (async () => {
74
+ // One index for every Claude id in this ask, not one per call.
75
+ const claudeFiles = localKeys.some(k => k.startsWith('claude|')) ? await loadClaudeTranscriptIndex(roots).catch(() => undefined) : undefined
76
+ const aliases = claudeFiles ? await loadClaudeDesktopAliases(roots.claudeCodeSessions).catch(() => undefined) : undefined
77
+ for (const key of localKeys) {
78
+ const { provider, id } = split(key)
79
+ const file = await findAgentSessionFile(provider, id, roots, new Date(now), aliases, claudeFiles ? { claudeFiles } : undefined).catch(() => null)
80
+ if (!file) continue
81
+ if (provider === 'claude') {
82
+ const read: SessionSubagents | null = await readClaudeSubagents(file)
83
+ if (read && read.total) into.agents.set(key, read)
84
+ }
85
+ const recent = await readRecentActivity(provider, file, now)
86
+ if (recent) into.recent.set(key, recent)
87
+ }
88
+ })()
89
+ const codex = (async () => {
90
+ if (!codexKeys.length) return
91
+ const ids = codexKeys.map(k => k.slice(6))
92
+ const details = await readCodexSubagentDetails(ids, roots.codexSessions, now).catch(() => new Map())
65
93
  for (const [id, list] of details) {
66
- const rows = list.map(d => ({ name: d.name, task: d.task, status: d.state === 'active' ? 'running' as const : d.state === 'completed' ? 'done' as const : 'unknown' as const, at: d.at }))
67
- if (rows.length) out.set('codex|' + id, summarize(rows, false))
94
+ const rows = (list as Array<{ name: string; task: string; state: string; at: number }>).map(d => ({ name: d.name, task: d.task, status: d.state === 'active' ? 'running' as const : d.state === 'completed' ? 'done' as const : 'unknown' as const, at: d.at }))
95
+ // Every row, not six: the brief keeps the live and recent ones and counts the rest as history.
96
+ if (rows.length) into.agents.set('codex|' + id, summarize(rows, false, rows.length))
68
97
  }
69
- }
70
- return out
98
+ const lists = await Promise.all(codexSessionRootsOf(roots).map(root => listCodexJsonlFiles(root).catch(() => [] as string[])))
99
+ const codexFiles = lists.flat()
100
+ for (const key of codexKeys) {
101
+ const file = await findAgentSessionFile('codex', key.slice(6), roots, new Date(now), undefined, { codexFiles }).catch(() => null)
102
+ if (!file) continue
103
+ const recent = await readRecentActivity('codex', file, now)
104
+ if (recent) into.recent.set(key, recent)
105
+ }
106
+ })()
107
+ await Promise.all([local, codex])
108
+ return into
71
109
  }
72
110
 
73
111
  /** The server's own brief: the data folder, Sonnet (7.0.1) through the isolated text runner, one audit row per start. */
@@ -90,8 +128,12 @@ export function defaultTodayBrief(): TodayBrief {
90
128
  }
91
129
 
92
130
  const IN_PROGRESS = new Set(['draft', 'built', 'qa'])
93
- /** Work cards in progress, by title and stage: open, not delegated, drafting to QA, or with an agent on it. */
94
- export function workInProgress(rows: TaskBoardRow[] | null): BriefWork[] {
131
+ /**
132
+ * Work cards in progress, by title and stage: open, not delegated, drafting to QA, or with an agent on it. 2026-11-09:
133
+ * with when each last moved (the dated board read: the line's git change time, else the day it was created; null when
134
+ * neither is known), running first, then the most recently moved, so the twelve kept are the live ones.
135
+ */
136
+ export function workInProgress(rows: Array<TaskBoardRow & Partial<TaskDateFields>> | null, now = Date.now()): BriefWork[] {
95
137
  if (!rows) return []
96
138
  const out: BriefWork[] = []
97
139
  for (const row of rows) {
@@ -99,9 +141,11 @@ export function workInProgress(rows: TaskBoardRow[] | null): BriefWork[] {
99
141
  const stage = row.workStage ?? (row.stage === 'active' ? 'draft' : row.stage === 'review' ? 'qa' : 'planned')
100
142
  const running = row.agentState === 'running'
101
143
  if (!IN_PROGRESS.has(stage) && !running) continue
102
- out.push({ title: row.title || row.text || '', stage, running })
144
+ const at = Date.parse(row.lineChangedAt ?? '') || Date.parse(row.createdOn ?? '') || 0
145
+ out.push({ title: row.title || row.text || '', stage, running, movedMinutes: at > 0 ? Math.max(0, Math.round((now - at) / 60000)) : null })
103
146
  }
104
- return out.sort((a, b) => Number(b.running) - Number(a.running)).slice(0, 12)
147
+ const age = (w: BriefWork) => (w.movedMinutes == null ? Number.MAX_SAFE_INTEGER : w.movedMinutes)
148
+ return out.sort((a, b) => Number(b.running) - Number(a.running) || age(a) - age(b)).slice(0, 12)
105
149
  }
106
150
 
107
151
  /**
@@ -142,15 +186,26 @@ export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
142
186
  const pageInputs = normalizeTodayBriefInputs(body)
143
187
  const closed = deps.brief.gate({ force, reconnect })
144
188
  if (closed) return res.json(deps.brief.askGated(pageInputs, closed))
145
- const work = workInProgress(await deps.board().catch(() => null))
146
- let inputs = normalizeTodayBriefInputs(body, work)
147
- const keys = subagentLookups(inputs)
148
- if (keys.length && deps.subagents) {
149
- let timer: ReturnType<typeof setTimeout> | undefined
150
- const late = new Promise<Map<string, SubagentReading>>(resolve => { timer = setTimeout(() => resolve(new Map()), TODAY_BRIEF_SUBAGENT_WAIT_MS) })
151
- const readings = await Promise.race([deps.subagents(keys).catch(() => new Map<string, SubagentReading>()), late]).finally(() => clearTimeout(timer))
152
- inputs = withSubagents(inputs, readings, (deps.now ?? Date.now)())
153
- }
189
+ const now = (deps.now ?? Date.now)()
190
+ // The board and the session reads run together; the reads stop being waited for after 2.5 s and the ask goes on
191
+ // with whatever they wrote by then (a Codex first scan that runs on still fills its memo for the next ask).
192
+ const keys = sessionContextLookups(normalizeTodayBriefInputs(body))
193
+ const context = emptySessionContext()
194
+ // Today round 2: the calendar reads alongside the sessions, inside the same wait; whatever has not answered by
195
+ // then is left out (a slow bridge never holds the ask).
196
+ const cal: { value: CalendarReading | null } = { value: null }
197
+ const work: Array<Promise<unknown>> = []
198
+ if (keys.length && deps.context) work.push(Promise.resolve().then(() => deps.context!(keys, context)).catch(() => undefined))
199
+ if (deps.calendar) work.push(Promise.resolve().then(() => deps.calendar!()).then(c => { cal.value = c }, () => undefined))
200
+ let timer: ReturnType<typeof setTimeout> | undefined
201
+ const reads = work.length
202
+ ? Promise.race([Promise.all(work), new Promise<void>(resolve => { timer = setTimeout(resolve, TODAY_BRIEF_SUBAGENT_WAIT_MS) })]).finally(() => clearTimeout(timer))
203
+ : Promise.resolve()
204
+ const [rows] = await Promise.all([deps.board().catch(() => null), reads])
205
+ // A snapshot: a read still running after the wait cannot change the inputs under the call.
206
+ const agents = new Map(context.agents), recent = new Map(context.recent), calendar = cal.value
207
+ let inputs = normalizeTodayBriefInputs(body, workInProgress(rows, now))
208
+ inputs = withCalendar(withRecentActivity(withSubagents(inputs, agents, now), recent, now), calendar, now)
154
209
  res.json(await deps.brief.ask(inputs, todayBriefFingerprint(pageInputs), { force, reconnect }))
155
210
  } catch (error) {
156
211
  if (error instanceof TodayBriefInputError) return res.status(400).json({ error: { code: error.code, message: 'That is not a Today summary request.' } })
@@ -24,6 +24,10 @@ import {
24
24
  type WebLoginFailure,
25
25
  WebGlassesCodeProbeBudget,
26
26
  WEB_SESSION_TTL_MS,
27
+ WEB_SESSION_COOKIE_NAME,
28
+ WEB_SESSION_MAX_LIFE_MS,
29
+ readWebSessionCookie,
30
+ webSessionCookieFrom,
27
31
  WebHandoffStore,
28
32
  WebLoginLimiter,
29
33
  WebDeviceCodeStore,
@@ -199,6 +203,18 @@ export function webControlCsp(_requestPath: string): string {
199
203
  ].join('; ')
200
204
  }
201
205
 
206
+ /**
207
+ * The expiry (ISO) of the cos_web_session cookie on a request, read from the SAME value the verifier reads (Q1 S1:
208
+ * `parseCookies` keeps the last of several; another app on this host name can plant an earlier one). Read only after
209
+ * verification. Shapes: `<exp>.<epoch>.<sig>`, or a renewed `<exp>.<epoch>.<orig>.<sig>`.
210
+ */
211
+ export function webSessionCookieExpiry(cookieHeader: unknown): string | null {
212
+ const value = webSessionCookieFrom(cookieHeader)
213
+ const exp = typeof value === 'string' ? /^(\d{1,15})\./.exec(value) : null
214
+ const ms = exp ? Number(exp[1]) * 1000 : NaN
215
+ return Number.isSafeInteger(ms) && ms > 0 ? new Date(ms).toISOString() : null
216
+ }
217
+
202
218
  export function createWebControlRouter(deps: WebControlRouterDeps): Router {
203
219
  const router = Router()
204
220
  const handoffStore = deps.handoffStore ?? new WebHandoffStore()
@@ -287,11 +303,20 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
287
303
  ? { authenticated: false, mode: 'none' }
288
304
  : { authenticated: false, mode: 'none', network: 'tailscale_only' })
289
305
  }
306
+ // W1 (audit 02 F2): a signed-in browser learns when ITS OWN cookie ends, so the page can say so before it does.
307
+ // Only after the cookie verified (its signature covers the expiry), only for cookie mode, and nothing a caller without
308
+ // that cookie can read. The 7-day lifetime itself is unchanged.
309
+ const expiresAt = cookieOk && !headerOk ? webSessionCookieExpiry(req.headers.cookie) : null
310
+ // Renewal (Miles 2026-11-09): the latest this sign-in can last while in use, 30 days from its first sign-in.
311
+ const read = cookieOk && !headerOk ? readWebSessionCookie(webSessionCookieFrom(req.headers.cookie), token) : null
312
+ const renewUntil = read ? new Date((read.origUnixSec + Math.floor(WEB_SESSION_MAX_LIFE_MS / 1000)) * 1000).toISOString() : null
290
313
  res.json({
291
314
  authenticated: true,
292
315
  mode: headerOk ? 'header' : 'cookie',
293
316
  serverName: hostname().replace(/\.local$/i, ''),
294
317
  ownerName: getOwnerName(),
318
+ ...(expiresAt ? { expiresAt } : {}),
319
+ ...(renewUntil ? { renewUntil } : {}),
295
320
  })
296
321
  })
297
322
 
@@ -322,10 +347,13 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
322
347
  const token = deps.apiToken()
323
348
  const result = classifyWebLogin(body, token, handoffStore, deviceCodes, glassesCheck)
324
349
  if (!result.ok) {
325
- // Every failure counts, the glasses-code one included, so that check cannot be used to
326
- // probe codes faster than the limiter allows.
327
- loginLimiter.fail(ip, result.reason)
328
- log(`[web-login] fail kind=${result.reason} len=${result.length} ip=${ip}`)
350
+ // Every failure counts toward the per-IP limit, the glasses-code one included, so that check cannot be used to
351
+ // probe codes faster than the limiter allows. Except a failed sign-in LINK (Miles 2026-11-09 20:00, "Yes, stop
352
+ // counting stale links"): a ticket is 192 random bits, so guessing one is not a risk, and a person reopening an old
353
+ // QR or link should not lock themselves out of typing a code. It still fails with the same message.
354
+ if (result.reason !== 'handoff_expired') loginLimiter.fail(ip, result.reason)
355
+ // S1 item 7: why a handoff link failed (expired, used, evicted, unknown). The log only; the message is unchanged.
356
+ log(`[web-login] fail kind=${result.reason}${result.handoff ? ` sub=${result.handoff}` : ''} len=${result.length} ip=${ip}`)
329
357
  return res.status(401).json({
330
358
  ok: false,
331
359
  error: 'invalid_credentials',
@@ -340,6 +368,8 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
340
368
  return res.json({
341
369
  ok: true,
342
370
  expiresAt: new Date(minted.expiresAtMs).toISOString(),
371
+ // Renewal (Miles 2026-11-09): while in use this sign-in renews up to 30 days from now (lib/web-session-auth.ts).
372
+ renewUntil: new Date((Math.floor(minted.expiresAtMs / 1000) - Math.floor(WEB_SESSION_TTL_MS / 1000) + Math.floor(WEB_SESSION_MAX_LIFE_MS / 1000)) * 1000).toISOString(),
343
373
  serverName: hostname().replace(/\.local$/i, ''),
344
374
  ownerName: getOwnerName(),
345
375
  })
@@ -357,6 +387,9 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
357
387
  router.post('/web-session/revoke-all', (req: Request, res: Response) => {
358
388
  res.set('Cache-Control', 'private, no-store')
359
389
  const epoch = bumpWebSessionEpoch()
390
+ // Final review V7: a sign-in link or code made before the sign-out no longer signs a browser in after it.
391
+ handoffStore.revokeAll()
392
+ deviceCodes.revokeAll()
360
393
  res.setHeader('Set-Cookie', formatClearWebSessionCookie(req.secure))
361
394
  res.json({ ok: true, epoch })
362
395
  })