@gotcos/glasses-server 6.99.33 → 7.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
@@ -4,6 +4,8 @@
4
4
 
5
5
  import { Router } from 'express'
6
6
  import { callPython, pythonBridgeAvailable } from '../lib/python-bridge.js'
7
+ import { calendarToday, readCalendar } from '../lib/calendar-source.js'
8
+ import { isCalendarHold } from '../lib/calendar-meetings.js'
7
9
  import { parsePrepOccurrence } from '../lib/prep-store.js'
8
10
  import type { PrepOccurrence } from '../../shared/prep.js'
9
11
 
@@ -21,7 +23,8 @@ type WeatherPayload = { temp: string; desc: string; location: string }
21
23
  * provider, calendar id, event id or external id, and start. The bridge sends no event ids today, so it is absent.
22
24
  * It lets a client find a Prep draft for this exact event; it never binds one, and nothing is inferred from the title.
23
25
  */
24
- type NextEventPayload = { title: string; time: string; inMinutes?: number; occurrence?: PrepOccurrence }
26
+ // `hold` (11/09): present, true, only when the title is a hold or status block (calendar-meetings.ts isCalendarHold).
27
+ type NextEventPayload = { title: string; time: string; inMinutes?: number; occurrence?: PrepOccurrence; hold?: true }
25
28
 
26
29
  /** The bridge event's provider identity, or undefined. snake_case (calendar_context.py) or camelCase is accepted. */
27
30
  export function bridgeOccurrence(evt: Record<string, unknown> | null | undefined): PrepOccurrence | undefined {
@@ -34,7 +37,8 @@ export function bridgeOccurrence(evt: Record<string, unknown> | null | undefined
34
37
  calendarId: pick('calendar_id', 'calendarId'),
35
38
  eventId: pick('event_id', 'eventId') ?? null,
36
39
  externalId: pick('external_id', 'externalId') ?? null,
37
- startsAt: pick('start_time', 'start', 'startsAt'),
40
+ // occurrence_start (EventKit's original occurrence date) first, so a moved occurrence keeps its draft.
41
+ startsAt: pick('occurrence_start', 'start_time', 'start', 'startsAt'),
38
42
  endsAt: pick('end_time', 'end', 'endsAt') ?? null,
39
43
  }) ?? undefined
40
44
  } catch { return undefined }
@@ -327,7 +331,8 @@ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null
327
331
  }
328
332
 
329
333
  try {
330
- const cal = await callPython(['calendar']) as {
334
+ const calRead = await readCalendar()
335
+ const cal = calRead.data as {
331
336
  today_remaining?: Array<{
332
337
  title?: string
333
338
  start_time?: string
@@ -338,10 +343,14 @@ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null
338
343
  minutes_until_next?: number
339
344
  } | null
340
345
 
341
- const read = calendarAnswerWasRead(pythonBridgeAvailable(), cal)
346
+ // The EventKit snapshot is a real read without the bridge; 'none' (denied or expired) never is.
347
+ const sourceReady = calRead.source === 'bridge' ? pythonBridgeAvailable() : calRead.source !== 'none'
348
+ const read = calendarAnswerWasRead(sourceReady, cal)
342
349
  const remaining = cal?.today_remaining ?? []
343
350
  const meaningful = remaining.find((evt) => {
344
351
  if (evt.is_all_day) return false
352
+ // Snapshot rows say whether they may become next/prep (declined, shared-calendar travel, holidays).
353
+ if ((evt as { prep_worthy?: unknown }).prep_worthy === false) return false
345
354
  return !isGenericNoiseEvent(evt.title || '')
346
355
  })
347
356
 
@@ -357,6 +366,7 @@ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null
357
366
  time: meaningful.start_time || meaningful.start || '',
358
367
  ...(minsUntil != null ? { inMinutes: minsUntil } : {}),
359
368
  ...(occurrence ? { occurrence } : {}),
369
+ ...(isCalendarHold(meaningful.title) ? { hold: true as const } : {}),
360
370
  }
361
371
  } else if (cal?.next_event && !isGenericNoiseEvent(cal.next_event.title || '')) {
362
372
  const occurrence = bridgeOccurrence(cal.next_event as Record<string, unknown>)
@@ -365,6 +375,7 @@ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null
365
375
  time: cal.next_event.start_time || '',
366
376
  ...(cal.minutes_until_next != null ? { inMinutes: cal.minutes_until_next } : {}),
367
377
  ...(occurrence ? { occurrence } : {}),
378
+ ...(isCalendarHold(cal.next_event.title) ? { hold: true as const } : {}),
368
379
  }
369
380
  } else {
370
381
  cachedNextEvent = null
@@ -394,6 +405,19 @@ export function _resetWelcomeContextCachesForTests(): void {
394
405
  lastFailureLogAt.clear()
395
406
  }
396
407
 
408
+ /**
409
+ * PH plan P1: Today's calendar for Control Web and desktop. Behind the /api token gate. Selected calendars only, the
410
+ * next 5 events (Today round 2, was 3), plus `endedRecently` (ended within 2 h). `connected: false` means "Calendar not connected", never an empty day.
411
+ */
412
+ welcomeContextRouter.get('/calendar/today', async (_req, res) => {
413
+ try {
414
+ res.set('Cache-Control', 'no-store')
415
+ res.json(await calendarToday())
416
+ } catch {
417
+ res.status(503).json({ error: 'calendar_unavailable' })
418
+ }
419
+ })
420
+
397
421
  welcomeContextRouter.get('/welcome-context', async (req, res) => {
398
422
  const lat = parseCoord(req.query.lat, 'lat')
399
423
  const lon = parseCoord(req.query.lon, 'lon')
@@ -6,6 +6,7 @@ import { listBoard, listBoardWithDates, workBoardCapabilities, setTaskWorkStage,
6
6
  import { createWorkBoardReader, WORK_BOARD_READ_LIMITS, WorkBoardTimeoutError, type WorkBoardReader } from '../lib/work-board-reader.js'
7
7
  import type { MeetingDescriptor } from '../lib/work-review-store.js'
8
8
  import { isSafeDomainName } from '../lib/domains.js'
9
+ import { workSessionActivity, waitingSessionIds } from '../lib/work-session-activity.js'
9
10
  import { resolveSavedMeetingDetail } from './meetings.js'
10
11
  import type { MeetingDetail } from '../lib/meeting-store.js'
11
12
 
@@ -28,11 +29,13 @@ export interface WorkBoardDependencies {
28
29
  requests: () => Pick<WorkActivityCapabilities, 'requests' | 'requestsInbox' | 'requestsConsumerSeenAt'>
29
30
  /** 6.59.0: the bounded, shared board read (5 s). Defaults to one over `list`. */
30
31
  readBoard?: WorkBoardReader<TaskBoardRow>
32
+ /** 7.0.1: each linked session's newest activity (lib/work-session-activity.ts), for the open list's supersede rule. */
33
+ sessionActivity: (sessionIds: readonly string[]) => Promise<ReadonlyMap<string, number>>
31
34
  now: () => number
32
35
  }
33
36
  const defaults: WorkBoardDependencies = { journal: () => readWorkJournal(), list: listBoard, capabilities: workBoardCapabilities,
34
37
  stage: setTaskWorkStage, link: linkTaskMeeting, edit: editWorkTask, capture: captureWorkItem, captureCapabilities: workIntakeCapabilities, resolveMeeting: resolveSavedMeetingDetail,
35
- requests: () => ({ requests: 0, requestsInbox: 0, requestsConsumerSeenAt: null }), now: () => Date.now() }
38
+ requests: () => ({ requests: 0, requestsInbox: 0, requestsConsumerSeenAt: null }), sessionActivity: ids => workSessionActivity(ids), now: () => Date.now() }
36
39
  /** How old a board read the activity and open-work glances accept (the reader shares one read between them). */
37
40
  export const WORK_GLANCE_BOARD_AGE_MS = 10_000
38
41
 
@@ -82,7 +85,11 @@ export function createWorkBoardRouter(overrides: Partial<WorkBoardDependencies>
82
85
  if (!journal.available) return res.json({ version: 1, available: false, reason: journal.reason, capabilities, items: [], total: 0, truncated: false })
83
86
  const rows = await glanceBoard('open work')
84
87
  if (!rows) return res.json({ version: 1, available: false, reason: 'Work board is unavailable. Refresh before trying again.', capabilities, items: [], total: 0, truncated: false })
85
- return res.json({ version: 1, available: true, capabilities, ...projectOpenWork(journal, rows, deps.now()) })
88
+ // 7.0.1: a waiting item whose session moved on since its report is not waiting. Only those sessions are read; a
89
+ // read that fails keeps the list as it was.
90
+ const now = deps.now(), first = projectOpenWork(journal, rows, now), waiting = waitingSessionIds(first.items)
91
+ const sessionActivityMs = waiting.length ? await deps.sessionActivity(waiting).catch(() => undefined) : undefined
92
+ return res.json({ version: 1, available: true, capabilities, ...(sessionActivityMs?.size ? projectOpenWork(journal, rows, now, { sessionActivityMs }) : first) })
86
93
  })
87
94
  router.get('/work-board', async (_req, res) => {
88
95
  try {
@@ -13,7 +13,7 @@
13
13
  * 10 minutes (`refresh=1`: 2 minutes). No timer reads usage unless COS_WORK_USAGE_POLL=1.
14
14
  */
15
15
  import { Router } from 'express'
16
- import { parseSnapshot, type ControlSnapshot } from '../lib/work-orchestrator/snapshot.js'
16
+ import { parseSnapshotReport, type ControlSnapshot } from '../lib/work-orchestrator/snapshot.js'
17
17
  import type { OrchestratorInput } from '../lib/work-orchestrator/input.js'
18
18
  import type { UsageMonitor, UsageView } from '../lib/work-orchestrator/usage.js'
19
19
  import { orchestratorInputFor, orchestratorSnapshots, orchestratorUsage } from '../lib/work-orchestrator/runtime.js'
@@ -67,8 +67,8 @@ export function createWorkOrchestratorRouter(given: Partial<WorkOrchestratorDeps
67
67
  return res.status(400).json({ error: 'invalid_snapshot_binding' })
68
68
  }
69
69
  if (Buffer.byteLength(JSON.stringify(body)) > ORCHESTRATOR_BODY_BYTES) return res.status(413).json({ error: 'snapshot_too_large' })
70
- let snapshot: ControlSnapshot
71
- try { snapshot = parseSnapshot(body.snapshot, deps.now()) }
70
+ let snapshot: ControlSnapshot, droppedLiveRows: number
71
+ try { ({ snapshot, droppedLiveRows } = parseSnapshotReport(body.snapshot, deps.now())) }
72
72
  catch { return res.status(400).json({ error: 'invalid_snapshot' }) }
73
73
  const binding = () => {
74
74
  const id = deps.serverInstanceId()
@@ -90,7 +90,9 @@ export function createWorkOrchestratorRouter(given: Partial<WorkOrchestratorDeps
90
90
  // This is the same paired-client evidence cache as /input, not execution authority. parseSnapshot
91
91
  // retains only its known fields; arbitrary automatic/forkable/capability fields never enter the cache.
92
92
  deps.putSnapshot(snapshot)
93
- return res.json({ protocolVersion: 1, accepted: true, workID: body.workID, revision: body.revision, serverInstanceId })
93
+ if (droppedLiveRows) console.warn(`[work-orchestrator] snapshot work=${body.workID} droppedLiveRows=${droppedLiveRows}`)
94
+ return res.json({ protocolVersion: 1, accepted: true, workID: body.workID, revision: body.revision, serverInstanceId,
95
+ ...(droppedLiveRows ? { droppedLiveRows } : {}) })
94
96
  } catch (error) {
95
97
  if (res.destroyed) return
96
98
  const known = error instanceof AskWorkPlanError
@@ -105,16 +107,19 @@ export function createWorkOrchestratorRouter(given: Partial<WorkOrchestratorDeps
105
107
  || !Array.isArray(meetings) || meetings.length > 20 || !meetings.every(m => typeof m === 'string' && MEETING_ID.test(m))) {
106
108
  return res.status(400).json({ error: 'invalid_orchestrator_input' })
107
109
  }
110
+ // 7.0.13: live rows the snapshot left out (a short or malformed session id, or text over a bound). Reported in the
111
+ // answer and the log line only when a snapshot came with this request and something was left out.
112
+ let droppedLiveRows = 0
108
113
  if ('snapshot' in body) {
109
114
  if (Buffer.byteLength(JSON.stringify(body.snapshot)) > ORCHESTRATOR_BODY_BYTES) return res.status(413).json({ error: 'snapshot_too_large' })
110
115
  let snapshot: ControlSnapshot
111
- try { snapshot = parseSnapshot(body.snapshot, deps.now()) } catch (e) { return res.status(400).json({ error: 'invalid_snapshot', reason: (e as Error).message }) }
116
+ try { ({ snapshot, droppedLiveRows } = parseSnapshotReport(body.snapshot, deps.now())) } catch (e) { return res.status(400).json({ error: 'invalid_snapshot', reason: (e as Error).message }) }
112
117
  deps.putSnapshot(snapshot)
113
118
  }
114
119
  try {
115
120
  const input = await deps.input(body.workID, meetings as string[])
116
- console.log(`[work-orchestrator] input work=${body.workID} rows=${input.rows.length} discovered=${input.discovered} leftOut=${input.leftOut.length} snapshotAge=${input.snapshotAgeSec ?? '-'}s liveAge=${input.liveListAgeSec ?? '-'}s scan=${input.logScanComplete ? 'complete' : 'partial'}`)
117
- return res.json(input)
121
+ console.log(`[work-orchestrator] input work=${body.workID} rows=${input.rows.length} discovered=${input.discovered} leftOut=${input.leftOut.length} snapshotAge=${input.snapshotAgeSec ?? '-'}s liveAge=${input.liveListAgeSec ?? '-'}s scan=${input.logScanComplete ? 'complete' : 'partial'}${droppedLiveRows ? ` droppedLiveRows=${droppedLiveRows}` : ''}`)
122
+ return res.json(droppedLiveRows ? { ...input, droppedLiveRows } : input)
118
123
  } catch (e) {
119
124
  console.warn(`[work-orchestrator] input failed for ${body.workID}: ${(e as Error).message}`)
120
125
  return res.status(503).json({ error: 'orchestrator_input_unavailable' })
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ // 6.99.31 (Phase 6): the pack step. Writes server/web-control/.manifest.json, the sha256 of every
3
+ // file COS Control Web ships (every regular non-dot file except *.test.ts, which never ship). With
4
+ // COS_WEB_CONTROL_INTEGRITY on, the server serves /control only while the files match it
5
+ // (server/lib/web-control-integrity.ts, whose computeWebControlManifest must agree with this byte
6
+ // for byte; web-control-integrity.test.ts pins that). Run by `npm pack` / `npm publish` (prepack).
7
+ // Writes one file, prints its path and the file count. Never reads anything outside web-control/.
8
+ import { createHash } from 'node:crypto'
9
+ import { readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
10
+ import { dirname, join } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ export function manifestFor(dir) {
14
+ const files = {}
15
+ const walk = (d, base) => {
16
+ let names
17
+ try { names = readdirSync(d) } catch { return }
18
+ for (const name of names.sort()) {
19
+ if (name.startsWith('.')) continue
20
+ const file = join(d, name)
21
+ let st
22
+ try { st = statSync(file) } catch { continue }
23
+ const urlPath = `${base}/${name}`
24
+ if (st.isDirectory()) walk(file, urlPath)
25
+ else if (st.isFile() && !name.endsWith('.test.ts')) files[urlPath] = createHash('sha256').update(readFileSync(file)).digest('hex')
26
+ }
27
+ }
28
+ walk(dir, '')
29
+ return { v: 1, algorithm: 'sha256', files }
30
+ }
31
+
32
+ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]
33
+ if (isMain) {
34
+ const dir = process.argv[2] ?? join(dirname(fileURLToPath(import.meta.url)), '..', 'web-control')
35
+ const manifest = manifestFor(dir)
36
+ const out = join(dir, '.manifest.json')
37
+ const tmp = `${out}.tmp`
38
+ writeFileSync(tmp, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o644 })
39
+ renameSync(tmp, out)
40
+ // stderr, never stdout: it runs as `prepack`, and `npm pack --json` callers (the release packager, package-contents and
41
+ // launcher-contract tests) parse stdout as JSON (7.0.9 full suite).
42
+ console.error(`[web-control-manifest] ${out} (${Object.keys(manifest.files).length} files)`)
43
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "v": 1,
3
+ "algorithm": "sha256",
4
+ "files": {
5
+ "/app.css": "0d3ebbf2ca167bc9ef6ab8efef41618f514cc4f505ae588642d36935249697af",
6
+ "/app.js": "3c489b154588908178de5b70b18ee8184ec9256e99f774177b9e35ceb27eb362",
7
+ "/apple-touch-icon.png": "04ec7eca0bf6f53d89900d146ac32d4826759d6258bcdbd1ccdcc517f26f532c",
8
+ "/control.css": "e7b287aa4acc1b3895a9ce61512fe449c555b6335a7145684fd78108ae008c75",
9
+ "/fonts/DMSans.ttf": "8cd08d97e89c24d0aa92edd2f0f4c8ee6195eee9b7c9f154865a58b02f0c1c0d",
10
+ "/fonts/Fraunces.ttf": "177ff6c0f14e5550a3c624247cd1189611d4eb65d000b14944c63d967958abbb",
11
+ "/fonts/JetBrainsMono.ttf": "48715a42ec242c21e9f02692891e147d022299a52e48d5e413e1a942193ffeda",
12
+ "/index.html": "26ee9cd1f68829ae5f47dcc7a0c2e45a40d28c4ee65d0537f0677b13ea727267",
13
+ "/mark.svg": "8e380d33d393027df192eb1afa08f15a380c2070c72d68f27311b49d9e94ca18",
14
+ "/memories/d3.min.js": "f2094bbf6141b359722c4fe454eb6c4b0f0e42cc10cc7af921fc158fceb86539",
15
+ "/memories/graph-explorer.css": "ec128de59811f45c71b6bededd737989309205e903951eb8211ade7664f87486",
16
+ "/memories/graph-explorer.js": "98bde3df25f42022b098d047d2c8e7a22b972c1d817a18a24a0ab075d77871d3",
17
+ "/memories/memory-workspace.js": "013f6c8c72c3474768f8c374edccde0e3656fc243e56528e258c3c29acca64f5",
18
+ "/memory-review.js": "f27fe519f7ad7eccc3b44124fae724aa51e2eb3cb060d2f1795600e0e41b074f",
19
+ "/panes/cos-chat.css": "29a5d22d0f02d0707e65711d151ad7c8ac0ab4bfeb30ab04b1f9d9fcf7d457b8",
20
+ "/panes/cos-chat.js": "0005562525cab6353ef8fc002d7960b30a8bf9fc9a9a1059c0123c6cf343366e",
21
+ "/panes/dictation.js": "e9434b353a1a3ab3f21d4cef5b2d5f927a733f6af5f58285fda6a78472761f91",
22
+ "/panes/meetings.css": "3ad4d0c5d59bd9f21c5ac9e234437cc0c81b381bd98ca4634b147efba5105a53",
23
+ "/panes/meetings.js": "e3839cd7d52c98510ca232e41d3a336806d740b4a1381eab1a367d56a765f925",
24
+ "/panes/memories.css": "36731d6fd21ad1516dfeccf34fe5b2d3d267d21e40b47704055a0685334b579e",
25
+ "/panes/memories.js": "28461f953ad0bf9d13287039594f2ce7ea29a46366e249240fa50f4b0f3b8c31",
26
+ "/panes/messages.js": "be7a7b294f67a43fcb4cc518da987d2da2990484cb677ada810afa93af25a8a5",
27
+ "/panes/preview.js": "79f1aecb27f48d584ed4cf37c7f4f423f0f9b45dd6c2fb24a9d88c72625d8680",
28
+ "/panes/questions.css": "5c5a9b32ce32386b508ce326b17d40ecacbb0f2e049df7f4fe2a0009bd3b7908",
29
+ "/panes/questions.js": "228e8cd0a9651a290cbb380db487af677a2fc7e93a64b1e33c9411cd8b013478",
30
+ "/panes/reader-media.js": "4cf892a855aa2d0dce6978e69296cd49b8036d8fc62f12773b3fcadc210b2ef0",
31
+ "/panes/sessions.css": "fa2e714e7b03459e12407f7125ab0e1eb7d12a4cc9341a2442dc73900f5a3081",
32
+ "/panes/settings.js": "5c46c951fdcc2fc942486ec4b468b7e0bb0350a0a56a38d429948fc5147ab2f4",
33
+ "/panes/speakers.css": "49e70dae4266151b14b0057f2e43de169de225b8f3ac30343c81cf532735a487",
34
+ "/panes/speakers.js": "8132c9244fb3965de802680840d2985978571988b9b4462b347d079330251bc5",
35
+ "/panes/threads.js": "45fd7b56480be53225e91193cfa4c41a4ee7167890f10560e439bc7d2461747f",
36
+ "/panes/today-actions.css": "c82f95b2aaf09802b15d47b24eccdb5c974afcab38ff5f861b3a8d8630b60869",
37
+ "/panes/today-actions.js": "4fbc51dc624f9e4cdc85db89905f019a8a5961e4984e8733e0525c71cf0e8657",
38
+ "/panes/work.css": "da50355dac690f712d1f6331f70ee5eadbf5982d37508648ae7786afc99ac7b1",
39
+ "/panes/work.js": "0425f53fba98ca34496486d914c3b03f95de3c2a651dc0c88a2a20bc4f6407cd",
40
+ "/shell.js": "f1cc6a52c78e01ddba89ff27c6bd0e8f8bd8f29bdfcbb5150ee4ed66a8128d9d",
41
+ "/theme.js": "700453683539c9efcde014ef1eb088c39478dad54f1ef53fa5e2dee9b36f0a3f",
42
+ "/vendor/qrcode-encoder.js": "61d87cc7445cc66b862cff913de4571634746ea62bcd50d0f592be14f6d1ce46"
43
+ }
44
+ }
@@ -186,6 +186,11 @@ select.field {
186
186
  padding: 4px calc(var(--gutter) + var(--safe-right)) calc(var(--safe-bottom) + 96px) calc(var(--gutter) + var(--safe-left));
187
187
  }
188
188
  .pane { max-width: 880px; }
189
+ /* 7.0.2 (A2, the Duo audit): the COS chat route ends at its composer (panes/messages.js sets ws-chat). The 96 px above
190
+ (and the 40 px from 600 px up) held room for a bottom bar that is gone; under the sticky composer it was blank page
191
+ that a focused box scrolled to and the closed keyboard left on screen. Two columns from 860 px keep a small margin. */
192
+ .workspace.ws-chat { padding-bottom: 0; }
193
+ @media (min-width: 860px) { .workspace.ws-chat { padding-bottom: calc(var(--safe-bottom) + 16px); } }
189
194
  .pane-head { margin: 8px 0 18px; }
190
195
  .pane-title { font: 500 40px/1.02 var(--display); letter-spacing: -0.015em; margin: 0; }
191
196
  .pane-sub { color: var(--sub); margin: 6px 0 0; font-size: 15px; }
@@ -249,6 +254,8 @@ select.field {
249
254
  .story { font: 400 24px/1.28 var(--display); letter-spacing: -0.005em; margin: 26px 2px; text-wrap: pretty; }
250
255
  /* The Today summary's quiet age line (app.js briefWhen). */
251
256
  .story-when { display: block; margin-top: 10px; font: 400 14px/1.35 var(--body); letter-spacing: 0; color: var(--sub); }
257
+ /* Today round 2: "Ask about this" under the summary, the COS chat composer (panes/cos-chat.css) laid flat in the page. */
258
+ .today-ask.cc-composer { position: static; background: transparent; border-top: 0; padding: 0 0 14px; }
252
259
  .story-skel { display: grid; gap: 12px; margin: 30px 0; }
253
260
  .story-skel .skeleton { height: 22px; }
254
261
 
@@ -288,6 +295,12 @@ select.field {
288
295
  .next-up .when { font: 500 24px/1 var(--display); white-space: nowrap; }
289
296
  .next-up .what { font-weight: 600; }
290
297
  .next-up .sub { color: var(--sub); font-size: 14px; }
298
+ .next-later { padding: 0 16px 14px; font-size: 14px; }
299
+ .next-later .later-row { display: flex; gap: 12px; padding: 3px 0; min-width: 0; }
300
+ .next-later .later-when { color: var(--sub); font-variant-numeric: tabular-nums; white-space: nowrap; min-width: 4.5em; }
301
+ .next-later .later-what { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
302
+ .next-later .later-asof { color: var(--sub); margin-top: 4px; }
303
+ .next-later .later-more { margin-top: 4px; padding: 3px 0; }
291
304
 
292
305
  /* ── Lists (sessions, meetings, messages) ────────────────── */
293
306
  .list { background: var(--panel); border: 1px solid var(--panel-border); border-radius: var(--radius-lg); overflow: hidden; box-shadow: var(--shadow); }
@@ -405,6 +418,11 @@ select.field {
405
418
  .menu-host { display: flex; align-items: center; gap: 8px; color: var(--sub); font-size: 14px; margin: 6px 4px 12px; }
406
419
  .sheet-head { display: flex; gap: 10px; align-items: center; }
407
420
  .sheet-head .field { flex: 1; }
421
+ /* W1 (Miles 2026-11-09 21:24, closed Duo): the Ask field sat beside the camera, the clip and Cancel and its placeholder
422
+ was cut off. It needs at least 15rem; when the buttons do not fit beside that, they go to the row below, at its right. */
423
+ #ask-form { flex-wrap: wrap; }
424
+ #ask-form > .field { flex: 1 1 15rem; min-width: 0; }
425
+ #ask-form > .ask-clip { margin-left: auto; }
408
426
  @keyframes fade { from { opacity: 0; } }
409
427
  @keyframes drop { from { transform: translateY(-24px); opacity: 0.5; } }
410
428
 
@@ -507,6 +525,28 @@ select.field {
507
525
  .pane.reading .pane-head.with-ask { display: flex; }
508
526
  }
509
527
 
528
+ /* Duo tables: the divider between a list and its reader (app.js splitKit, W.splitKit), for Memories and Messages.
529
+ Hidden everywhere; each pane shows it only inside its own two-column breakpoint (panes/memories.css 760 px,
530
+ panes/cos-chat.css 860 px), so a phone never sees it. Sessions keeps its own copy of these rules (panes/sessions.css). */
531
+ .split-handle, .split-full-btn { display: none; }
532
+ .split-handle {
533
+ justify-content: center; align-items: center; position: sticky; top: calc(var(--safe-top) + 12px); align-self: start;
534
+ height: calc(100dvh - var(--safe-top) - 24px); cursor: col-resize; touch-action: none; user-select: none; -webkit-user-select: none;
535
+ -webkit-touch-callout: none; outline: none; z-index: 2;
536
+ }
537
+ /* The line is the split's own gap; the grip shows where to take it. */
538
+ .split-handle::after { content: ''; width: 4px; height: 36px; border-radius: 2px; background: var(--panel-border); transition: background 120ms ease, height 120ms ease; }
539
+ .split-handle:hover::after, .split-handle:focus-visible::after, .split-dragging > .split-handle::after { background: var(--accent); height: 52px; }
540
+ .split-handle:focus-visible { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--accent) 45%, transparent); border-radius: 6px; }
541
+ /* A finger gets at least a 44 px target, 13 px over each pane's edge; a mouse keeps the divider's own column. */
542
+ @media (pointer: coarse) { .split-handle::before { content: ''; position: absolute; top: 0; bottom: 0; left: -13px; right: -13px; } }
543
+ /* While dragging, nothing in the panes selects or takes the pointer. */
544
+ .split-dragging { user-select: none; -webkit-user-select: none; cursor: col-resize; }
545
+ .split-dragging > :not(.split-handle) { pointer-events: none; }
546
+ .split-full-btn { margin-left: auto; flex: none; }
547
+ @media (pointer: coarse) { .split-full-btn { min-height: 44px; } }
548
+ @media (prefers-reduced-motion: reduce) { .split-handle::after { transition: none; } }
549
+
510
550
  /* ── Desktop ─────────────────────────────────────────────── */
511
551
  @media (min-width: 1100px) and (min-height: 721px) {
512
552
  :root { --gutter: 40px; }
@@ -550,11 +590,20 @@ select.field {
550
590
  @media (any-pointer: coarse) {
551
591
  input:not([type='checkbox']):not([type='radio']):not([type='range']):not([type='color']):not([type='file']):not([type='button']):not([type='submit']):not([type='reset']):not([type='image']):not([type='hidden']),
552
592
  select, textarea { font-size: 16px !important; }
593
+ /* 7.0.17 (Miles 2026-11-10, Duo web app zooming): the same floor for any element that types without being a field.
594
+ None exists in Control Web today (work.js only names the selector); this keeps a future editor from zooming. */
595
+ [contenteditable]:not([contenteditable='false']) { font-size: 16px !important; }
553
596
  }
597
+ /* 7.0.17 (Miles 2026-11-10: "a lot of zooming in and out as I'm opening and closing keyboard"; the Duo's own breadcrumb
598
+ showed vvs=2.12 with the keyboard down, a double-tap zoom fitting a column). manipulation keeps pan and pinch and
599
+ turns off double-tap zoom. On the root only: touch-action is the intersection down the tree, so the splitter's
600
+ none and the charts' pan-y stay exactly as strict as they were. */
601
+ html { touch-action: manipulation; }
554
602
  /* Folded phones: every button and field is at least 44 px tall (design QA: .cx-btn came in six heights from 30 px). A
555
603
  minimum only, so it grows the compact ones and leaves the taller ones; the per-pane phone heights that fought it are
556
604
  gone. */
557
- @media (max-width: 599px) {
605
+ /* 7.0.2 (A8): a short touch screen at any width too (the unfolded Duo, 951x539 in Comet, is touch first). */
606
+ @media (max-width: 599px), (pointer: coarse) and (max-height: 720px) {
558
607
  .cx-btn, .field { min-height: 44px !important; }
559
608
  }
560
609
  /* Touch screens: the small controls the canary measured under 44 px (nav 30, switches 30, a chip's remove and Retry 36,
@@ -566,6 +615,32 @@ select.field {
566
615
  .cc-chip-x, .cc-attach, .cc-camera { min-width: 44px !important; min-height: 44px !important; }
567
616
  .cx-switch button::before { content: ''; position: absolute; inset: -7px -4px; }
568
617
  }
618
+ /* 7.0.2 (A8): a short touch screen 600 px wide or more (the unfolded Duo, 951x539) keeps the phone Ask sheet: full width
619
+ from the top, no centred desktop card with 8 % of a 539 px screen spent above it (app.js isPhone says phone too). */
620
+ @media (min-width: 600px) and (pointer: coarse) and (max-height: 720px) {
621
+ .sheet-backdrop { padding-top: 0; }
622
+ .sheet { max-width: none; border-radius: 0 0 20px 20px; padding: calc(var(--safe-top) + 10px) var(--gutter) 14px; max-height: calc(100dvh - 90px); box-shadow: none; }
623
+ }
569
624
  /* 6.99.30 (QA): an element with the hidden attribute is hidden, whatever display its classes give it ("Retry image"
570
625
  showed under loaded images: .link-btn's inline-flex beat [hidden]). */
571
626
  [hidden] { display: none !important; }
627
+
628
+ /* W1: the Mac not answering, and the sign-in ending soon (app.js "Mac reachability and sign-in lifetime"). */
629
+ .conn-notice { display: grid; gap: 8px; margin: 0 0 12px; }
630
+ .conn-notice[hidden] { display: none; }
631
+ .conn-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; padding: 12px 14px; border-radius: var(--radius-sm); border: 1px solid var(--panel-border); background: var(--panel); color: var(--ink); font-size: 15px; line-height: 1.4; }
632
+ .conn-line strong { font-weight: 600; }
633
+ .conn-line .conn-sub { color: var(--sub); flex: 1 1 220px; min-width: 0; }
634
+ .conn-line .btn { margin: 0; }
635
+ .auth-offline p { color: var(--sub); }
636
+ .meaning-consent .row-actions { padding: 0 16px 14px; }
637
+ /* W1 A5: a question waiting on the Mac's own prompt, at the top of the session reader. */
638
+ .sx-mac-q { margin: 8px 0 12px; padding: 12px 14px; border-radius: var(--radius-sm); border: 1px solid var(--panel-border); background: var(--panel); }
639
+ .sx-mac-q[hidden] { display: none; }
640
+ .sx-mac-q .sx-mac-q-title { margin: 0 0 6px; }
641
+ /* 7.0.18: a turn that ended asking him: the choices as buttons in a row that wraps under its own width (at most two,
642
+ each label at most 40 characters), so the closed Duo stacks them and the open Duo keeps them side by side. Labels wrap
643
+ inside a button rather than cut. */
644
+ .sx-te-choices, .ta-te-choices { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0 6px; }
645
+ .sx-te-choices .cx-btn, .ta-te-choices .cx-btn { flex: 1 1 12rem; min-height: 44px; white-space: normal; text-align: center; line-height: 1.25; }
646
+ .sx-mac-q .sx-note { margin: 6px 0 0; }