@gotcos/glasses-server 6.99.20 → 6.99.29

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 (65) hide show
  1. package/CHANGELOG.md +257 -0
  2. package/docs/work-orchestrator.md +20 -0
  3. package/package.json +1 -1
  4. package/server/index.ts +48 -5
  5. package/server/lib/agent-session-output-images.ts +280 -0
  6. package/server/lib/agent-session-store.ts +3 -1
  7. package/server/lib/agent-session-turns.ts +18 -7
  8. package/server/lib/client-liveness.ts +38 -3
  9. package/server/lib/codex-subagent-activity.ts +36 -0
  10. package/server/lib/g2-turn-provenance.ts +679 -0
  11. package/server/lib/media-store.ts +41 -2
  12. package/server/lib/permission-broker.ts +94 -17
  13. package/server/lib/queue-turn-evidence.ts +19 -2
  14. package/server/lib/session-halt.ts +15 -1
  15. package/server/lib/session-stream-events.ts +4 -0
  16. package/server/lib/session-subagents.ts +155 -0
  17. package/server/lib/session-turn-attachments.ts +245 -0
  18. package/server/lib/thread-turn-queue-deliver.ts +17 -2
  19. package/server/lib/thread-turn-queue.ts +10 -0
  20. package/server/lib/today-brief.ts +731 -0
  21. package/server/lib/turn-file-store.ts +202 -0
  22. package/server/lib/turn-footer.ts +81 -0
  23. package/server/lib/web-control-static.ts +226 -0
  24. package/server/lib/web-memories-bridge.ts +9 -8
  25. package/server/lib/web-pet-choice.ts +225 -0
  26. package/server/lib/web-pet.ts +226 -0
  27. package/server/lib/work-lens-rounds.ts +29 -4
  28. package/server/routes/agent-session-bindings.ts +164 -16
  29. package/server/routes/agent-sessions.ts +77 -8
  30. package/server/routes/client-instance.ts +12 -1
  31. package/server/routes/cursor-stop-followup.ts +30 -1
  32. package/server/routes/g2-turns.ts +46 -0
  33. package/server/routes/health.ts +9 -0
  34. package/server/routes/media.ts +32 -8
  35. package/server/routes/permission-broker.ts +47 -4
  36. package/server/routes/thread-turn-queue.ts +148 -8
  37. package/server/routes/today-brief.ts +148 -0
  38. package/server/routes/web-control.ts +110 -7
  39. package/server/routes/work-lens-rounds.ts +19 -5
  40. package/server/web-control/app.css +26 -5
  41. package/server/web-control/app.js +1351 -177
  42. package/server/web-control/control.css +1 -1
  43. package/server/web-control/index.html +31 -13
  44. package/server/web-control/memories/memory-workspace.js +1 -1
  45. package/server/web-control/panes/cos-chat.css +22 -0
  46. package/server/web-control/panes/cos-chat.js +264 -48
  47. package/server/web-control/panes/meetings.css +15 -2
  48. package/server/web-control/panes/meetings.js +20 -9
  49. package/server/web-control/panes/memories.css +291 -0
  50. package/server/web-control/panes/memories.js +2208 -0
  51. package/server/web-control/panes/questions.css +39 -1
  52. package/server/web-control/panes/questions.js +419 -64
  53. package/server/web-control/panes/reader-media.js +154 -0
  54. package/server/web-control/panes/sessions.css +2 -0
  55. package/server/web-control/panes/settings.js +136 -6
  56. package/server/web-control/panes/today-actions.css +167 -0
  57. package/server/web-control/panes/today-actions.js +1303 -0
  58. package/server/web-control/panes/work.css +54 -0
  59. package/server/web-control/panes/work.js +1037 -18
  60. package/server/web-control/shell.js +6 -2
  61. package/server/web-control/theme.js +0 -2
  62. package/server/web-control/memories/memories-app.js +0 -1561
  63. package/server/web-control/memories/memories-theme.css +0 -73
  64. package/server/web-control/memories/memories.html +0 -72
  65. package/server/web-control/memories/memory-stewardship.js +0 -63
@@ -0,0 +1,148 @@
1
+ /**
2
+ * 6.99.26 candidate: the Today summary for COS Control Web (lib/today-brief.ts says how it stops: switch, caps, breaker).
3
+ *
4
+ * - GET /api/today/brief the cached summary; never generates.
5
+ * - POST /api/today/brief an open Today's ask, with the rows it read (titles, states, how long, last lines,
6
+ * what needs you). Generates only when the switch is on, the inputs changed, 10 minutes
7
+ * passed and the day's 60 are not spent. The Work cards in progress come from the
8
+ * server's own shared board read, never the body.
9
+ * - GET /api/today/brief/settings the switch and what set it.
10
+ * - POST /api/today/brief/settings { enabled: boolean }, the Settings toggle. COS_TODAY_BRIEF_LLM, when set, still wins.
11
+ * Auth is the parent /api gate (token or web session cookie). Every answer is private, no-store.
12
+ */
13
+ import { Router } from 'express'
14
+ import type { Response } from 'express'
15
+ import { dataPath } from '../lib/data-dir.js'
16
+ import { runClaudeText } from '../lib/claude-text-runner.js'
17
+ import { logTokenAudit } from '../lib/token-audit.js'
18
+ import type { TaskBoardRow } from '../lib/task-store.js'
19
+ import { agentSessionRoots, findAgentSessionFile } from '../lib/agent-session-store.js'
20
+ import { readClaudeSubagents, summarize, type SessionSubagents } from '../lib/session-subagents.js'
21
+ import { readCodexSubagentDetails } from '../lib/codex-subagent-activity.js'
22
+ import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
23
+ import { isRecordingLive } from '../lib/stt-runtime.js'
24
+ import {
25
+ TODAY_BRIEF_BODY_MAX_BYTES, TODAY_BRIEF_EFFORT, TODAY_BRIEF_SETTINGS_FILE, TODAY_BRIEF_STATE_FILE, TODAY_BRIEF_TIMEOUT_MS,
26
+ TodayBriefInputError, createTodayBrief, normalizeTodayBriefInputs, subagentLookups, todayBriefFingerprint, withSubagents, type BriefWork, type SubagentReading, type TodayBrief,
27
+ } from '../lib/today-brief.js'
28
+
29
+ export interface TodayBriefRouterDeps {
30
+ brief: TodayBrief
31
+ /** The Work board as the server already reads it (a shared, bounded read), or null when it cannot be read. */
32
+ board: () => Promise<TaskBoardRow[] | null>
33
+ /** Sub-agents for up to 8 sessions, by "provider|id". Missing keys mean "not read"; the page's counts stay. */
34
+ subagents?: (keys: string[]) => Promise<Map<string, SubagentReading>>
35
+ now?: () => number
36
+ }
37
+
38
+ /** How long the sub-agent reads may hold the ask before it goes on without them. */
39
+ export const TODAY_BRIEF_SUBAGENT_WAIT_MS = 2_500
40
+
41
+ /**
42
+ * The server's own sub-agent reads: Claude from the session's transcript tail (lib/session-subagents.ts), Codex from
43
+ * the spawn-edge snapshot (readCodexSubagentDetails, server home only). Cursor has none. Sequential, bounded, read-only.
44
+ */
45
+ export async function readSubagentsFor(keys: string[], now = Date.now()): Promise<Map<string, SubagentReading>> {
46
+ const out = new Map<string, SubagentReading>()
47
+ const roots = agentSessionRoots()
48
+ const codex = keys.filter(k => k.startsWith('codex|')).map(k => k.slice(6))
49
+ for (const key of keys.filter(k => k.startsWith('claude|'))) {
50
+ const file = await findAgentSessionFile('claude', key.slice(7), roots).catch(() => null)
51
+ const read: SessionSubagents | null = file ? await readClaudeSubagents(file) : null
52
+ if (read && read.total) out.set(key, read)
53
+ }
54
+ if (codex.length) {
55
+ const details = await readCodexSubagentDetails(codex, roots.codexSessions, now)
56
+ for (const [id, list] of details) {
57
+ 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 }))
58
+ if (rows.length) out.set('codex|' + id, summarize(rows, false))
59
+ }
60
+ }
61
+ return out
62
+ }
63
+
64
+ /** The server's own brief: the data folder, Haiku through the isolated text runner, one audit row per start. */
65
+ export function defaultTodayBrief(): TodayBrief {
66
+ return createTodayBrief({
67
+ stateFile: dataPath(TODAY_BRIEF_STATE_FILE),
68
+ settingsFile: dataPath(TODAY_BRIEF_SETTINGS_FILE),
69
+ generate: (prompt, systemPrompt, model) => runClaudeText(prompt, {
70
+ model, effort: TODAY_BRIEF_EFFORT, timeoutMs: TODAY_BRIEF_TIMEOUT_MS, systemPrompt, label: 'Today brief',
71
+ }),
72
+ audit: row => logTokenAudit({
73
+ source: 'web-today-brief', model: row.model, inputChars: row.inputChars, outputChars: row.outputChars,
74
+ durationMs: row.durationMs, caller: row.outcome === 'ok' ? 'today_brief' : `today_brief_${row.guard ?? row.error ?? row.outcome}`,
75
+ }),
76
+ // No call while a meeting records (it keeps the Mac's CPU and the CLI for the meeting) or while the server drains.
77
+ blocked: () => (!maintenanceAdmissionsOpen() ? 'maintenance' : isRecordingLive() ? 'recording' : null),
78
+ // The call holds api_mutation, as the generic mutation lease would have; this route is lifecycle-owned in index.ts.
79
+ lease: () => acquireMaintenanceWork('api_mutation'),
80
+ })
81
+ }
82
+
83
+ const IN_PROGRESS = new Set(['draft', 'built', 'qa'])
84
+ /** Work cards in progress, by title and stage: open, not delegated, drafting to QA, or with an agent on it. */
85
+ export function workInProgress(rows: TaskBoardRow[] | null): BriefWork[] {
86
+ if (!rows) return []
87
+ const out: BriefWork[] = []
88
+ for (const row of rows) {
89
+ if (row.checked || row.delegated) continue
90
+ const stage = row.workStage ?? (row.stage === 'active' ? 'draft' : row.stage === 'review' ? 'qa' : 'planned')
91
+ const running = row.agentState === 'running'
92
+ if (!IN_PROGRESS.has(stage) && !running) continue
93
+ out.push({ title: row.title || row.text || '', stage, running })
94
+ }
95
+ return out.sort((a, b) => Number(b.running) - Number(a.running)).slice(0, 12)
96
+ }
97
+
98
+ export function createTodayBriefRouter(deps: TodayBriefRouterDeps): Router {
99
+ const router = Router()
100
+ router.use('/today/brief', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
101
+ const failed = (res: Response) => res.status(503).json({ error: { code: 'today_brief_unavailable', message: 'The Today summary is unavailable right now.' } })
102
+
103
+ router.get('/today/brief', (_req, res) => {
104
+ try { res.json(deps.brief.peek()) } catch { failed(res) }
105
+ })
106
+ router.post('/today/brief', async (req, res) => {
107
+ try {
108
+ if (Buffer.byteLength(JSON.stringify(req.body ?? null)) > TODAY_BRIEF_BODY_MAX_BYTES) {
109
+ return res.status(413).json({ error: { code: 'today_brief_too_large', message: 'Too much for the Today summary.' } })
110
+ }
111
+ // The switch first: off reads nothing, not even the board.
112
+ if (!deps.brief.settings().enabled) {
113
+ normalizeTodayBriefInputs(req.body)
114
+ return res.json(deps.brief.peek())
115
+ }
116
+ // Every gate that needs no reads goes first (switch, recording, drain, breaker, daily cap, interval): a gated ask
117
+ // answers from the page's rows alone and reads neither the board nor any transcript.
118
+ const pageInputs = normalizeTodayBriefInputs(req.body)
119
+ const closed = deps.brief.gate()
120
+ if (closed) return res.json(deps.brief.askGated(pageInputs, closed))
121
+ const work = workInProgress(await deps.board().catch(() => null))
122
+ let inputs = normalizeTodayBriefInputs(req.body, work)
123
+ const keys = subagentLookups(inputs)
124
+ if (keys.length && deps.subagents) {
125
+ let timer: ReturnType<typeof setTimeout> | undefined
126
+ const late = new Promise<Map<string, SubagentReading>>(resolve => { timer = setTimeout(() => resolve(new Map()), TODAY_BRIEF_SUBAGENT_WAIT_MS) })
127
+ const readings = await Promise.race([deps.subagents(keys).catch(() => new Map<string, SubagentReading>()), late]).finally(() => clearTimeout(timer))
128
+ inputs = withSubagents(inputs, readings, (deps.now ?? Date.now)())
129
+ }
130
+ res.json(await deps.brief.ask(inputs, todayBriefFingerprint(pageInputs)))
131
+ } catch (error) {
132
+ if (error instanceof TodayBriefInputError) return res.status(400).json({ error: { code: error.code, message: 'That is not a Today summary request.' } })
133
+ console.warn(`[today-brief] ask failed (${error instanceof Error ? error.name : 'error'})`)
134
+ failed(res)
135
+ }
136
+ })
137
+ router.get('/today/brief/settings', (_req, res) => {
138
+ try { res.json(deps.brief.settings()) } catch { failed(res) }
139
+ })
140
+ router.post('/today/brief/settings', (req, res) => {
141
+ const body = req.body
142
+ if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(k => k !== 'enabled') || typeof body.enabled !== 'boolean') {
143
+ return res.status(400).json({ error: { code: 'invalid_today_brief_settings', message: 'Send { enabled: true } or { enabled: false }.' } })
144
+ }
145
+ try { res.json(deps.brief.setEnabled(body.enabled)) } catch { failed(res) }
146
+ })
147
+ return router
148
+ }
@@ -1,4 +1,7 @@
1
1
  import express, { Router, type Request, type Response } from 'express'
2
+ import fs from 'node:fs'
3
+ import crypto from 'node:crypto'
4
+ import path from 'node:path'
2
5
  import { hostname, networkInterfaces } from 'node:os'
3
6
  import {
4
7
  hasProxyForwardedHeaders,
@@ -39,11 +42,20 @@ import {
39
42
  } from '../lib/web-memories-bridge.js'
40
43
  import { listLiveTranscriptSessions, readLiveTranscriptSession } from '../lib/web-live-chunks.js'
41
44
  import { getWebUsageSummary } from '../lib/web-usage-bridge.js'
45
+ import { WEB_PET_POSES, controlSupportDir, petChunk, petPreview, planPose, readPetSources, webPetManifest, type WebPetPose } from '../lib/web-pet.js'
46
+ import { controlResourcesDir, galleryPets, galleryThumb, parsePetChoice, petChoiceKey, resolvePet, shippedPets, type PetChoice, type PetFetcher } from '../lib/web-pet-choice.js'
47
+ import { dataPath } from '../lib/data-dir.js'
42
48
 
43
49
  export { isMemoryWebOpReadOnly, MUTATING_MEMORY_WEB_OPS, READ_ONLY_MEMORY_WEB_OPS }
44
50
 
45
51
  export interface WebControlRouterDeps {
46
52
  apiToken: () => string
53
+ /** Tests: where the pet's state map and strips live, the chunk cache, and a stand-in for sips. */
54
+ petSupportDir?: () => string
55
+ petCacheDir?: () => string
56
+ petRun?: (cmd: string, args: string[]) => Promise<void>
57
+ petResourcesDir?: () => string | null
58
+ petFetch?: PetFetcher
47
59
  port: () => number
48
60
  publicPort?: () => number
49
61
  httpsPort?: () => number | null
@@ -166,17 +178,15 @@ export function webControlEnabled(env: NodeJS.ProcessEnv = process.env): boolean
166
178
  }
167
179
 
168
180
  /**
169
- * CSP for `/control/*`. The shell (index.html, app.js, theme.js) has no inline script, so it
170
- * gets `script-src 'self'`: an injected `<img onerror>` in rendered session or meeting text
171
- * cannot run. The hosted Memories page is the native bundle as shipped and still uses inline
172
- * `onclick` handlers, so only `/control/memories/*` keeps `'unsafe-inline'`.
181
+ * CSP for `/control/*`. No page has inline script, so every path gets `script-src 'self'`: an injected
182
+ * `<img onerror>` in rendered session or meeting text cannot run. 6.99.25: Memories is a native pane
183
+ * (panes/memories.js); the hosted page and its `/control/memories/*` 'unsafe-inline' exception are gone.
173
184
  * `frame-ancestors 'self'` stops another site framing the signed-in page.
174
185
  */
175
- export function webControlCsp(requestPath: string): string {
176
- const inlineScripts = requestPath.startsWith('/memories/')
186
+ export function webControlCsp(_requestPath: string): string {
177
187
  return [
178
188
  "default-src 'self'",
179
- inlineScripts ? "script-src 'self' 'unsafe-inline'" : "script-src 'self'",
189
+ "script-src 'self'",
180
190
  "style-src 'self' 'unsafe-inline'",
181
191
  "font-src 'self' data:",
182
192
  "img-src 'self' data: blob:",
@@ -550,6 +560,99 @@ export function createWebControlRouter(deps: WebControlRouterDeps): Router {
550
560
  res.json(summary)
551
561
  })
552
562
 
563
+ // The session pet on Home (lib/web-pet.ts) and the pet picker (lib/web-pet-choice.ts). `?pet=` is the browser's
564
+ // choice: mac (default), shipped:<id> or gallery:<id>. Everything here reads COS Control's files; nothing writes them.
565
+ const petDir = (): string => deps.petSupportDir?.() ?? controlSupportDir()
566
+ const petResources = (): string | null => (deps.petResourcesDir ? deps.petResourcesDir() : controlResourcesDir())
567
+ const petCacheRoot = (): string => deps.petCacheDir?.() ?? dataPath('web-pet')
568
+ const petCacheFor = (name: string): string => (name ? path.join(petCacheRoot(), name) : petCacheRoot())
569
+ const petChoiceOf = (req: Request): PetChoice | null => parsePetChoice(req.query.pet ?? 'mac')
570
+ const resolveFor = (choice: PetChoice) => resolvePet(choice, { supportDir: petDir(), resources: petResources() })
571
+ const sendImage = async (res: Response, file: string, type: string, immutable: boolean): Promise<void> => {
572
+ res.set('Cache-Control', immutable ? 'private, max-age=31536000, immutable' : 'private, no-cache')
573
+ // Not sendFile: it refuses any path with a dot folder, and the cache lives under ~/.cos-glasses.
574
+ res.type(type).send(await fs.promises.readFile(file))
575
+ }
576
+ const fileTag = (file: string): string => { try { const st = fs.statSync(file); return `${st.size}-${Math.round(st.mtimeMs)}` } catch { return '0' } }
577
+
578
+ router.get('/web-pet', (req: Request, res: Response) => {
579
+ res.set('Cache-Control', 'private, no-store')
580
+ const choice = petChoiceOf(req)
581
+ if (!choice) return res.status(400).json({ error: 'pet_choice_invalid' })
582
+ const key = petChoiceKey(choice)
583
+ const found = resolveFor(choice)
584
+ if (!found) return res.json({ available: false, pet: key, poses: {} })
585
+ if (found.kind === 'strips') return res.json({ ...webPetManifest(found.dir, key), pet: key, kind: 'strips' })
586
+ const tag = found.kind === 'still' ? fileTag(found.file) : found.preview
587
+ return res.json({ available: true, pet: key, kind: 'still', poses: {}, still: `/api/web-pet/still?pet=${encodeURIComponent(key)}&v=${encodeURIComponent(crypto.createHash('sha256').update(tag).digest('hex').slice(0, 16))}` })
588
+ })
589
+
590
+ router.get('/web-pet/sheet/:pose/:chunk', async (req: Request, res: Response) => {
591
+ const choice = petChoiceOf(req)
592
+ const found = choice ? resolveFor(choice) : null
593
+ if (!found || found.kind !== 'strips') return res.status(404).json({ error: 'pet_unavailable' })
594
+ const pose = String(req.params.pose) as WebPetPose
595
+ const src = (WEB_PET_POSES as readonly string[]).includes(pose) ? readPetSources(found.dir)[pose] : undefined
596
+ if (!src) return res.status(404).json({ error: 'pet_pose_unavailable' })
597
+ const plan = planPose(src)
598
+ const index = Number(req.params.chunk)
599
+ if (!Number.isInteger(index) || index < 0 || index >= plan.chunks) return res.status(404).json({ error: 'pet_chunk_unavailable' })
600
+ try {
601
+ const file = await petChunk(plan, index, { cacheDir: petCacheFor(found.cacheName), run: deps.petRun })
602
+ await sendImage(res, file, 'png', req.query.v === plan.key)
603
+ } catch (err) {
604
+ log(`[web-pet] could not build ${pose} chunk ${index}: ${err instanceof Error ? err.message : String(err)}`)
605
+ res.status(503).json({ error: 'pet_build_failed' })
606
+ }
607
+ })
608
+
609
+ // A still pet: COS Control's single sprite, Cloud Puff, or a gallery picture (downloaded once, as the Mac does).
610
+ router.get('/web-pet/still', async (req: Request, res: Response) => {
611
+ const choice = petChoiceOf(req)
612
+ const found = choice ? resolveFor(choice) : null
613
+ if (!found || found.kind === 'strips') return res.status(404).json({ error: 'pet_unavailable' })
614
+ try {
615
+ const art = found.kind === 'still' ? found : await galleryThumb(found, petCacheFor('gallery'), deps.petFetch, petDir())
616
+ await sendImage(res, art.file, art.type, typeof req.query.v === 'string' && req.query.v.length > 0)
617
+ } catch (err) {
618
+ log(`[web-pet] gallery picture failed for ${found.kind === 'gallery' ? found.id : 'still'}: ${err instanceof Error ? err.message : String(err)}`)
619
+ res.status(502).json({ error: 'pet_picture_unavailable' })
620
+ }
621
+ })
622
+
623
+ // The picker's small picture: an animated pet's first frame, or the still itself.
624
+ router.get('/web-pet/preview', async (req: Request, res: Response) => {
625
+ const choice = petChoiceOf(req)
626
+ const found = choice ? resolveFor(choice) : null
627
+ if (!found) return res.status(404).json({ error: 'pet_unavailable' })
628
+ try {
629
+ if (found.kind === 'strips') return await sendImage(res, await petPreview(found.dir, petCacheFor(found.cacheName || 'mac-preview'), { run: deps.petRun }), 'png', typeof req.query.v === 'string')
630
+ const art = found.kind === 'still' ? found : await galleryThumb(found, petCacheFor('gallery'), deps.petFetch, petDir())
631
+ await sendImage(res, art.file, art.type, typeof req.query.v === 'string')
632
+ } catch (err) {
633
+ log(`[web-pet] preview failed: ${err instanceof Error ? err.message : String(err)}`)
634
+ res.status(502).json({ error: 'pet_picture_unavailable' })
635
+ }
636
+ })
637
+
638
+ // What the picker lists: the Mac's pet, the characters COS Control ships, and its OpenPets gallery.
639
+ router.get('/web-pet/catalog', (_req: Request, res: Response) => {
640
+ res.set('Cache-Control', 'private, no-store')
641
+ const preview = (key: string, tag: string) => `/api/web-pet/preview?pet=${encodeURIComponent(key)}&v=${encodeURIComponent(crypto.createHash('sha256').update(tag).digest('hex').slice(0, 16))}`
642
+ const mac = resolveFor({ kind: 'mac' })
643
+ const macTag = mac ? (mac.kind === 'still' ? fileTag(mac.file) : fileTag(path.join(petDir(), 'session-pet-states.json'))) : ''
644
+ res.json({
645
+ mac: { available: !!mac, preview: mac ? preview('mac', 'mac:' + macTag) : null },
646
+ // The tag follows the art on disk, so a COS Control update with new art gets a new preview URL.
647
+ shipped: shippedPets(petResources()).map((p) => {
648
+ const r = resolveFor({ kind: 'shipped', id: p.id })
649
+ const tag = r?.kind === 'strips' ? fileTag(path.join(r.dir, 'session-pet-states.json')) : r?.kind === 'still' ? fileTag(r.file) : ''
650
+ return { ...p, pet: `shipped:${p.id}`, preview: preview(`shipped:${p.id}`, `shipped:${p.id}:${tag}`) }
651
+ }),
652
+ gallery: galleryPets(petDir()).map((p) => ({ id: p.id, name: p.name, description: p.description, category: p.category, pet: `gallery:${p.id}`, preview: preview(`gallery:${p.id}`, p.preview) })),
653
+ })
654
+ })
655
+
553
656
  // Today / Work "Your Move" compatibility route
554
657
  router.get('/work-board/your-move', async (_req: Request, res: Response) => {
555
658
  res.set('Cache-Control', 'private, no-store')
@@ -1,11 +1,16 @@
1
1
  /**
2
2
  * 6.83.0: lens plans in Work chat history, and "Send this plan" (docs/work-orchestrator.md, "Lens plans").
3
3
  *
4
- * Lens (over the network, token auth from the parent /api mount):
5
- * POST /api/work/chat/lens-rounds record a plan the lens made (idempotent on clientRoundId)
4
+ * Lens, and COS Control Web (over the network, token or browser sign-in from the parent /api mount):
5
+ * POST /api/work/chat/lens-rounds record a plan the lens or the web made (idempotent on clientRoundId;
6
+ * source "lens" with a lens- id, or "web" with a web- id)
6
7
  * GET /api/work/chat/lens-rounds/:roundId the round's live state, never its prompts
7
8
  * POST /api/work/chat/lens-rounds/:roundId/send ask COS Control to send it (two-step confirm, or card Auto-send)
8
9
  * POST /api/work/chat/lens-rounds/:roundId/stop ask COS Control to Stop assignments
10
+ * COS Control Web:
11
+ * GET /api/work/chat/web-plans { protocolVersion: 1, webPlans: 1, sources }: this server records web plans
12
+ * (a web round is refused 409 round_id_conflict when its id is reused
13
+ * for a different plan; GET /api/work/chat is unchanged)
9
14
  * COS Control (this Mac only):
10
15
  * GET /api/work/chat/lens-inbox?since=<cursor>[&autoSend=1] rounds not yet in history, send and stop requests
11
16
  * POST /api/work/chat/lens-rounds/:roundId/history { state: "in_history", autoSendCard? }
@@ -17,8 +22,8 @@
17
22
  * (index.ts leaves these paths to this router so the error code is the contract's).
18
23
  */
19
24
  import { Router, type Request, type Response } from 'express'
20
- import { LensRoundError, LensRoundStore, LENS_ROUND_BODY_BYTES, ROUND_ID, SEND_ID, cardSessionsOf, parseLensRound, parseResultBody,
21
- parseSendBody, parseStopBody, roundView, defaultLensRoundStore } from '../lib/work-lens-rounds.js'
25
+ import { LensRoundError, LensRoundStore, LENS_ROUND_BODY_BYTES, ROUND_ID, ROUND_SOURCES, SEND_ID, cardSessionsOf, parseLensRound, parseResultBody,
26
+ parseSendBody, parseStopBody, roundView, sameRoundContent, defaultLensRoundStore } from '../lib/work-lens-rounds.js'
22
27
  import type { ControlSnapshot } from '../lib/work-orchestrator/snapshot.js'
23
28
  import { orchestratorSnapshots } from '../lib/work-orchestrator/runtime.js'
24
29
  import { acquireMaintenanceWork, maintenanceAdmissionsOpen } from '../lib/maintenance-lifecycle.js'
@@ -96,6 +101,9 @@ export function createWorkLensRoundsRouter(given: Partial<WorkLensRoundsDeps> =
96
101
  const input = parseLensRound(req.body)
97
102
  // A repeat is answered from the store, even when the board has since changed or is unreachable.
98
103
  const replay = deps.store.replay(input.clientRoundId)
104
+ if (replay && input.source === 'web' && !sameRoundContent(replay, input)) {
105
+ throw new LensRoundError('round_id_conflict', 409, 'This round id was used for a different plan. Plan again.')
106
+ }
99
107
  if (!replay) {
100
108
  let known: boolean
101
109
  try { known = await deps.knownWork(input.workID) }
@@ -103,10 +111,16 @@ export function createWorkLensRoundsRouter(given: Partial<WorkLensRoundsDeps> =
103
111
  if (!known) throw new LensRoundError('unknown_work', 404, 'This card is not on the Work board any more.')
104
112
  }
105
113
  const { round, created } = deps.store.record(input, cardSessionsOf(deps.snapshot(), input.workID))
106
- if (created) console.log(`[work-lens-rounds] recorded ${round.roundId} work=${round.workID} assignments=${round.assignments.length}`)
114
+ if (created) console.log(`[work-lens-rounds] recorded ${round.roundId} work=${round.workID} assignments=${round.assignments.length}${round.source === 'web' ? ' source=web' : ''}`)
107
115
  return res.status(created ? 201 : 200).json({ roundId: round.roundId, workID: round.workID, state: roundView(round).state, control: { connected: connected() } })
108
116
  }))
109
117
 
118
+ // COS Control Web asks this before it shows Work chat. A server without it answers 404, and the web says it needs an update.
119
+ router.get('/work/chat/web-plans', (_req, res) => {
120
+ res.set('Cache-Control', 'private, no-store')
121
+ return res.json({ protocolVersion: 1, webPlans: 1, sources: [...ROUND_SOURCES] })
122
+ })
123
+
110
124
  router.get('/work/chat/lens-rounds/:roundId', (req, res) => {
111
125
  try {
112
126
  const round = deps.store.find(roundIdOf(req))
@@ -200,6 +200,30 @@ select.field {
200
200
 
201
201
  /* ── Today ───────────────────────────────────────────────── */
202
202
  .today-date { color: var(--sub); font-size: 15px; margin: 6px 0 0; }
203
+ /* The session pet on Today: the pet COS Control shows, in the pose the Mac would pick. */
204
+ .today-pet { display: flex; flex-direction: column; align-items: center; margin: 6px 0 14px; min-height: 166px; }
205
+ .today-pet.hidden { display: none; }
206
+ .pet-btn { background: none; border: 0; padding: 0; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 4px; color: inherit; font: inherit; }
207
+ .pet-btn:focus-visible { outline: 2px solid var(--sub); outline-offset: 4px; border-radius: 8px; }
208
+ .pet-sprite { height: 140px; background-repeat: no-repeat; }
209
+ .pet-caption { color: var(--sub); font-size: 14px; }
210
+ /* A still pet (gallery pick, Cloud Puff, single image). Small pixel art scales without blur. */
211
+ .pet-still { height: 140px; width: auto; max-width: 220px; object-fit: contain; }
212
+ .pet-still.pixel { image-rendering: pixelated; }
213
+ .pet-change { background: none; border: 0; padding: 6px 10px; margin-top: 2px; color: var(--sub); font: inherit; font-size: 13px; cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
214
+ .pet-change:focus-visible { outline: 2px solid var(--sub); outline-offset: 2px; border-radius: 6px; }
215
+ /* Settings > Pet */
216
+ .pet-choice .pet-thumb { width: 44px; height: 44px; flex: 0 0 44px; object-fit: contain; image-rendering: pixelated; border-radius: 8px; }
217
+ .pet-search { width: 100%; box-sizing: border-box; margin: 4px 0 10px; padding: 10px 12px; border-radius: 10px; border: 1px solid var(--panel-border); background: var(--panel); color: inherit; font: inherit; }
218
+ .pet-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); gap: 8px; }
219
+ .pet-tile { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 10px 6px; border-radius: 12px; border: 1px solid var(--panel-border); background: var(--panel); color: inherit; font: inherit; font-size: 12px; cursor: pointer; min-width: 0; }
220
+ .pet-tile img { width: 56px; height: 56px; object-fit: contain; image-rendering: pixelated; }
221
+ .pet-tile span { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
222
+ .pet-tile[aria-pressed="true"] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
223
+ .pet-tile.hidden { display: none; }
224
+ .pet-tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
225
+ /* Unfolded: top of the right-hand column, above Needs you (app.js placePet). */
226
+ .today-side > .today-pet { margin: 0 0 14px; }
203
227
  .ask-bar {
204
228
  display: flex; align-items: center; gap: 10px; width: 100%;
205
229
  min-height: 52px; padding: 0 14px; margin-top: 16px;
@@ -222,6 +246,8 @@ select.field {
222
246
  @keyframes rec { 50% { opacity: 0.35; } }
223
247
 
224
248
  .story { font: 400 24px/1.28 var(--display); letter-spacing: -0.005em; margin: 26px 2px; text-wrap: pretty; }
249
+ /* The Today summary's quiet age line (app.js briefWhen). */
250
+ .story-when { display: block; margin-top: 10px; font: 400 14px/1.35 var(--body); letter-spacing: 0; color: var(--sub); }
225
251
  .story-skel { display: grid; gap: 12px; margin: 30px 0; }
226
252
  .story-skel .skeleton { height: 22px; }
227
253
 
@@ -365,9 +391,6 @@ select.field {
365
391
  .speaker-row audio { width: 100%; height: 40px; margin-top: 8px; grid-column: 1 / -1; }
366
392
  .speaker-row .capture-row { grid-column: 1 / -1; margin-top: 8px; }
367
393
 
368
- /* Memories: the native Memories page, hosted as-is. */
369
- .pane.flush { max-width: none; margin: -4px calc(-1 * (var(--gutter) + var(--safe-right))) 0 calc(-1 * (var(--gutter) + var(--safe-left))); }
370
- .memories-frame { display: block; width: 100%; height: calc(100dvh - var(--topbar-h) - var(--safe-top)); border: 0; background: var(--page); }
371
394
 
372
395
  /* Ask sheet (⌘K on desktop) */
373
396
  .sheet-backdrop { position: fixed; inset: 0; z-index: 50; background: rgba(23, 18, 14, 0.45); display: flex; align-items: flex-start; justify-content: center; animation: fade 160ms ease; }
@@ -445,8 +468,6 @@ select.field {
445
468
  .run-title, .run-meta { grid-column: 2; }
446
469
  .composer { position: sticky; bottom: calc(var(--safe-bottom) + 12px); }
447
470
  .toast { top: auto; bottom: 24px; }
448
- .pane.flush { margin: calc(-1 * (var(--safe-top) + 22px)) calc(-1 * (var(--gutter) + var(--safe-right))) calc(-1 * (var(--safe-bottom) + 40px)) calc(-1 * var(--gutter)); }
449
- .memories-frame { height: 100dvh; }
450
471
  .sheet-backdrop { padding-top: 8vh; }
451
472
  .sheet { max-width: 620px; border-radius: 18px; padding: 16px 18px 18px; max-height: 80dvh; box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25); }
452
473
  }