@gotcos/glasses-server 6.99.20 → 6.99.25

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 (33) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/docs/work-orchestrator.md +20 -0
  3. package/package.json +1 -1
  4. package/server/index.ts +2 -1
  5. package/server/lib/web-control-static.ts +226 -0
  6. package/server/lib/web-memories-bridge.ts +9 -8
  7. package/server/lib/web-pet-choice.ts +225 -0
  8. package/server/lib/web-pet.ts +226 -0
  9. package/server/lib/work-lens-rounds.ts +29 -4
  10. package/server/routes/web-control.ts +110 -7
  11. package/server/routes/work-lens-rounds.ts +19 -5
  12. package/server/web-control/app.css +24 -5
  13. package/server/web-control/app.js +646 -108
  14. package/server/web-control/control.css +1 -1
  15. package/server/web-control/index.html +27 -13
  16. package/server/web-control/memories/memory-workspace.js +1 -1
  17. package/server/web-control/panes/cos-chat.css +4 -0
  18. package/server/web-control/panes/cos-chat.js +26 -4
  19. package/server/web-control/panes/meetings.css +15 -2
  20. package/server/web-control/panes/memories.css +291 -0
  21. package/server/web-control/panes/memories.js +2208 -0
  22. package/server/web-control/panes/questions.js +59 -21
  23. package/server/web-control/panes/settings.js +102 -4
  24. package/server/web-control/panes/today-actions.css +109 -0
  25. package/server/web-control/panes/today-actions.js +972 -0
  26. package/server/web-control/panes/work.css +54 -0
  27. package/server/web-control/panes/work.js +1037 -18
  28. package/server/web-control/shell.js +6 -2
  29. package/server/web-control/theme.js +0 -2
  30. package/server/web-control/memories/memories-app.js +0 -1561
  31. package/server/web-control/memories/memories-theme.css +0 -73
  32. package/server/web-control/memories/memories.html +0 -72
  33. package/server/web-control/memories/memory-stewardship.js +0 -63
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The session pet on COS Control Web (Miles 11/06: "pets brought to the COS ... on home").
3
+ *
4
+ * COS Control keeps the active pet as horizontal PNG strips plus `session-pet-states.json`
5
+ * in `~/Library/Application Support/COS Control`. The web reads that same map, so the phone
6
+ * shows the pet the Mac shows, with no copy to keep in step.
7
+ *
8
+ * The Mac's 1x strips are 256 px tall and up to ~56,000 px wide (Windu's working cast is
9
+ * 184 frames, 3.2 MB). That is too much for a phone to decode at once, so each pose is
10
+ * resized to WEB_PET_HEIGHT with macOS `sips` and cut into chunks no wider than
11
+ * WEB_PET_MAX_CHUNK_WIDTH, each holding whole frames. The result is cached on disk, keyed by
12
+ * the source file's size and mtime, so a pet change on the Mac shows on the next load.
13
+ *
14
+ * `sips` quirk (measured 11/06): `--cropOffset 0 0` crops from the CENTER, while any other
15
+ * offset is measured from the top-left. The first chunk is therefore cut by flipping the
16
+ * strip, cropping from the far end, and flipping back. Pixel-identical to a direct crop.
17
+ */
18
+ import crypto from 'crypto'
19
+ import { execFile } from 'child_process'
20
+ import fs from 'fs'
21
+ import os from 'os'
22
+ import path from 'path'
23
+
24
+ /** The poses COS Control's live pet uses (PetSpritePose.liveCases). */
25
+ export const WEB_PET_POSES = ['idle', 'patrol', 'waiting', 'working', 'duel', 'trio', 'swarm', 'done', 'error', 'attention'] as const
26
+ export type WebPetPose = typeof WEB_PET_POSES[number]
27
+ /** Frame height sent to the phone, in pixels. */
28
+ export const WEB_PET_HEIGHT = 160
29
+ /** Widest chunk sent, in pixels. Keeps each decoded image near 5 MB. */
30
+ export const WEB_PET_MAX_CHUNK_WIDTH = 8192
31
+
32
+ export interface PetPoseSource { pose: WebPetPose; file: string; frames: number; interval: number; width: number; height: number; size: number; mtimeMs: number }
33
+ export interface PetPosePlan extends PetPoseSource { outHeight: number; frameWidth: number; perChunk: number; chunks: number; key: string }
34
+
35
+ export function controlSupportDir(env: NodeJS.ProcessEnv = process.env): string {
36
+ return env.COS_CONTROL_SUPPORT_DIR || path.join(os.homedir(), 'Library', 'Application Support', 'COS Control')
37
+ }
38
+
39
+ /** Width and height from a PNG's IHDR, or null for anything that is not a PNG. */
40
+ export function pngSize(file: string): { width: number; height: number } | null {
41
+ let fd: number | null = null
42
+ try {
43
+ // Non-blocking: a FIFO planted at this path would otherwise hang the whole server (hazard-invariants.test.ts).
44
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK)
45
+ if (!fs.fstatSync(fd).isFile()) return null
46
+ const head = Buffer.alloc(24)
47
+ if (fs.readSync(fd, head, 0, 24, 0) < 24) return null
48
+ if (head.readUInt32BE(0) !== 0x89504e47 || head.toString('ascii', 12, 16) !== 'IHDR') return null
49
+ return { width: head.readUInt32BE(16), height: head.readUInt32BE(20) }
50
+ } catch {
51
+ return null
52
+ } finally {
53
+ if (fd !== null) fs.closeSync(fd)
54
+ }
55
+ }
56
+
57
+ /** A file name from the state map, kept inside the support folder. */
58
+ function insideDir(dir: string, name: unknown): string | null {
59
+ if (typeof name !== 'string' || !name || name.includes('\0') || path.isAbsolute(name)) return null
60
+ const full = path.resolve(dir, name)
61
+ return full.startsWith(path.resolve(dir) + path.sep) ? full : null
62
+ }
63
+
64
+ /** Read the Mac's pet. Poses with a missing, unreadable or mismatched strip are left out. */
65
+ export function readPetSources(dir: string): Partial<Record<WebPetPose, PetPoseSource>> {
66
+ let map: { poses?: Record<string, { file?: unknown; frames?: unknown; interval?: unknown }> }
67
+ const mapFile = path.join(dir, 'session-pet-states.json')
68
+ try {
69
+ // Only a regular file is read: readFileSync on a FIFO blocks the event loop.
70
+ if (!fs.statSync(mapFile).isFile()) return {}
71
+ map = JSON.parse(fs.readFileSync(mapFile, 'utf8'))
72
+ } catch { return {} }
73
+ const out: Partial<Record<WebPetPose, PetPoseSource>> = {}
74
+ for (const pose of WEB_PET_POSES) {
75
+ const row = map?.poses?.[pose]
76
+ if (!row) continue
77
+ const file = insideDir(dir, row.file)
78
+ const frames = Number(row.frames)
79
+ if (!file || !Number.isInteger(frames) || frames < 1 || frames > 1000) continue
80
+ let stat: fs.Stats
81
+ try { stat = fs.statSync(file) } catch { continue }
82
+ if (!stat.isFile()) continue
83
+ const size = pngSize(file)
84
+ if (!size || size.height < 8 || size.width % frames !== 0) continue
85
+ const interval = Number(row.interval)
86
+ out[pose] = {
87
+ pose, file, frames, interval: Number.isFinite(interval) && interval >= 0.01 && interval <= 2 ? interval : 0.12,
88
+ width: size.width, height: size.height, size: stat.size, mtimeMs: stat.mtimeMs,
89
+ }
90
+ }
91
+ return out
92
+ }
93
+
94
+ /** How a pose is resized and chunked. Frame width is rounded once, so every chunk holds whole frames. */
95
+ export function planPose(src: PetPoseSource, height = WEB_PET_HEIGHT, maxChunk = WEB_PET_MAX_CHUNK_WIDTH): PetPosePlan {
96
+ const frameWidth = Math.max(1, Math.round((src.width / src.frames) * (height / src.height)))
97
+ const perChunk = Math.max(1, Math.floor(maxChunk / frameWidth))
98
+ const chunks = Math.ceil(src.frames / perChunk)
99
+ const key = crypto.createHash('sha256')
100
+ .update(`${src.file}\0${src.size}\0${src.mtimeMs}\0${src.frames}\0${height}\0${maxChunk}\0v1`)
101
+ .digest('hex').slice(0, 16)
102
+ return { ...src, outHeight: height, frameWidth, perChunk, chunks, key }
103
+ }
104
+
105
+ export function webPetManifest(dir: string = controlSupportDir(), pet = 'mac') {
106
+ const sources = readPetSources(dir)
107
+ const poses: Record<string, { frames: number; interval: number; frameWidth: number; height: number; perChunk: number; chunks: string[] }> = {}
108
+ for (const pose of WEB_PET_POSES) {
109
+ const src = sources[pose]
110
+ if (!src) continue
111
+ const p = planPose(src)
112
+ poses[pose] = {
113
+ frames: p.frames, interval: p.interval, frameWidth: p.frameWidth, height: WEB_PET_HEIGHT, perChunk: p.perChunk,
114
+ chunks: Array.from({ length: p.chunks }, (_, i) => `/api/web-pet/sheet/${pose}/${i}?pet=${encodeURIComponent(pet)}&v=${p.key}`),
115
+ }
116
+ }
117
+ return { available: Object.keys(poses).length > 0, poses }
118
+ }
119
+
120
+ type Run = (cmd: string, args: string[]) => Promise<void>
121
+ const sips: Run = (cmd, args) => new Promise((resolve, reject) => {
122
+ execFile(cmd, args, { timeout: 30_000 }, (err) => (err ? reject(err) : resolve()))
123
+ })
124
+
125
+ export interface PetChunkOptions { cacheDir: string; run?: Run; sipsPath?: string }
126
+ const inflight = new Map<string, Promise<string>>()
127
+
128
+ /**
129
+ * The PNG path for one chunk of one pose, building it on first request. Every chunk of the pose
130
+ * is built together, so a pose costs one resize. Concurrent requests share one build.
131
+ */
132
+ export function petChunk(plan: PetPosePlan, index: number, opts: PetChunkOptions): Promise<string> {
133
+ if (!Number.isInteger(index) || index < 0 || index >= plan.chunks) return Promise.reject(new Error('no_such_chunk'))
134
+ const base = path.join(opts.cacheDir, `${plan.pose}-${plan.key}`)
135
+ const target = `${base}-${index}.png`
136
+ if (fs.existsSync(target)) return Promise.resolve(target)
137
+ let job = inflight.get(base)
138
+ if (!job) {
139
+ job = buildPose(plan, base, opts).finally(() => inflight.delete(base))
140
+ inflight.set(base, job)
141
+ }
142
+ return job.then(() => target)
143
+ }
144
+
145
+ async function buildPose(plan: PetPosePlan, base: string, opts: PetChunkOptions): Promise<string> {
146
+ const run = opts.run ?? sips
147
+ const bin = opts.sipsPath ?? '/usr/bin/sips'
148
+ fs.mkdirSync(opts.cacheDir, { recursive: true })
149
+ const tmp = fs.mkdtempSync(path.join(opts.cacheDir, '.build-'))
150
+ try {
151
+ const H = plan.outHeight
152
+ const total = plan.frameWidth * plan.frames
153
+ const resized = path.join(tmp, 'resized.png')
154
+ await run(bin, ['-z', String(H), String(total), plan.file, '--out', resized])
155
+ let flipped: string | null = null
156
+ for (let i = 0; i < plan.chunks; i++) {
157
+ const x = i * plan.perChunk * plan.frameWidth
158
+ const w = Math.min(plan.perChunk, plan.frames - i * plan.perChunk) * plan.frameWidth
159
+ const out = path.join(tmp, `${i}.png`)
160
+ if (plan.chunks === 1) {
161
+ fs.copyFileSync(resized, out)
162
+ } else if (x === 0) {
163
+ // `--cropOffset 0 0` means centre to sips: cut the first chunk from the mirrored strip's far end.
164
+ if (!flipped) { flipped = path.join(tmp, 'flipped.png'); await run(bin, ['-f', 'horizontal', resized, '--out', flipped]) }
165
+ const far = path.join(tmp, `${i}-far.png`)
166
+ await run(bin, ['-c', String(H), String(w), '--cropOffset', '0', String(total - w), flipped, '--out', far])
167
+ await run(bin, ['-f', 'horizontal', far, '--out', out])
168
+ } else {
169
+ await run(bin, ['-c', String(H), String(w), '--cropOffset', '0', String(x), resized, '--out', out])
170
+ }
171
+ }
172
+ for (let i = 0; i < plan.chunks; i++) fs.renameSync(path.join(tmp, `${i}.png`), `${base}-${i}.png`)
173
+ // Drop this pose's older builds (a pet change on the Mac makes a new key).
174
+ const keep = path.basename(base) + '-'
175
+ for (const name of fs.readdirSync(opts.cacheDir)) {
176
+ if (name.startsWith(`${plan.pose}-`) && !name.startsWith(keep) && name.endsWith('.png')) fs.rmSync(path.join(opts.cacheDir, name), { force: true })
177
+ }
178
+ return base
179
+ } finally {
180
+ fs.rmSync(tmp, { recursive: true, force: true })
181
+ }
182
+ }
183
+
184
+ /** Height of the first-frame previews in the pet picker. */
185
+ export const WEB_PET_PREVIEW_HEIGHT = 96
186
+
187
+ /**
188
+ * One still frame (the first frame of idle, else of the first pose this pet has) for the picker, built once
189
+ * with sips and cached. Uses the same flip-and-crop as the first chunk, since sips reads offset 0 as centre.
190
+ */
191
+ export function petPreview(dir: string, cacheDir: string, opts: { run?: Run; sipsPath?: string } = {}): Promise<string> {
192
+ const sources = readPetSources(dir)
193
+ const src = sources.idle ?? WEB_PET_POSES.map((p) => sources[p]).find(Boolean)
194
+ if (!src) return Promise.reject(new Error('no_pet_art'))
195
+ const plan = planPose(src, WEB_PET_PREVIEW_HEIGHT)
196
+ const target = path.join(cacheDir, `preview-${plan.key}.png`)
197
+ if (fs.existsSync(target)) return Promise.resolve(target)
198
+ let job = inflight.get(target)
199
+ if (!job) {
200
+ job = (async () => {
201
+ const run = opts.run ?? sips
202
+ const bin = opts.sipsPath ?? '/usr/bin/sips'
203
+ fs.mkdirSync(cacheDir, { recursive: true })
204
+ const tmp = fs.mkdtempSync(path.join(cacheDir, '.preview-'))
205
+ try {
206
+ const H = plan.outHeight, total = plan.frameWidth * plan.frames, w = plan.frameWidth
207
+ const resized = path.join(tmp, 'resized.png'), out = path.join(tmp, 'out.png')
208
+ await run(bin, ['-z', String(H), String(total), plan.file, '--out', resized])
209
+ if (plan.frames === 1) {
210
+ fs.copyFileSync(resized, out)
211
+ } else {
212
+ const flipped = path.join(tmp, 'flipped.png'), far = path.join(tmp, 'far.png')
213
+ await run(bin, ['-f', 'horizontal', resized, '--out', flipped])
214
+ await run(bin, ['-c', String(H), String(w), '--cropOffset', '0', String(total - w), flipped, '--out', far])
215
+ await run(bin, ['-f', 'horizontal', far, '--out', out])
216
+ }
217
+ fs.renameSync(out, target)
218
+ return target
219
+ } finally {
220
+ fs.rmSync(tmp, { recursive: true, force: true })
221
+ }
222
+ })().finally(() => inflight.delete(target))
223
+ inflight.set(target, job)
224
+ }
225
+ return job
226
+ }
@@ -36,7 +36,14 @@ const MAX_SENDS_KEPT = 8
36
36
  const MAX_STOPS_KEPT = 8
37
37
 
38
38
  const WORK_ID = /^task:[a-z_]+:[a-f0-9]{12}$/
39
- const CLIENT_ROUND_ID = /^lens-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
39
+ const CLIENT_ROUND_ID = /^(lens|web)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
40
+ /**
41
+ * Who made the plan: the lens, or COS Control Web (Work chat on the phone or in a browser; GET /api/work/chat/web-plans
42
+ * says this server takes it). The client round id starts with its source (`lens-`, `web-`). A web round is the same contract as a
43
+ * lens round: recorded, sent only by COS Control through its own Send, never by this server.
44
+ */
45
+ export const ROUND_SOURCES = ['lens', 'web'] as const
46
+ export type RoundSource = typeof ROUND_SOURCES[number]
40
47
  const SID = /^(claude|codex|cursor):[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/
41
48
  const ITEM_ID = /^[a-z]{1,8}(:[a-f0-9]{12})?$/
42
49
  const RECEIPT_ID = /^[A-Za-z0-9_.:-]{1,80}$/
@@ -72,6 +79,8 @@ export interface LensRound {
72
79
  /** The card's sessions (Control snapshot links and receipts) when the plan was recorded; null when no snapshot. */
73
80
  cardSessions: string[] | null
74
81
  sends: LensSendRequest[]; stops: LensStopRequest[]; updatedAt: number
82
+ /** Set only on a round COS Control Web made. A lens round has no `source`, so its stored shape is unchanged. */
83
+ source?: 'web'
75
84
  /** COS Control's own Send of this round's assignments on the Mac (the lens was told to send from the Mac): when, and which. */
76
85
  mac?: { at: number; ids: string[] } | null
77
86
  }
@@ -89,7 +98,7 @@ const newId = (prefix: string) => `${prefix}_${randomBytes(8).toString('hex')}`
89
98
 
90
99
  export interface LensRoundInput {
91
100
  workID: string; clientRoundId: string; question: string; answer: string; planner: string | null; effort: string | null
92
- orchestrator: boolean; assignments: Omit<LensAssignment, 'state'>[]; createdAt: string
101
+ orchestrator: boolean; assignments: Omit<LensAssignment, 'state'>[]; createdAt: string; source: RoundSource
93
102
  }
94
103
 
95
104
  /** The record body. Unknown keys are ignored and never stored. */
@@ -103,7 +112,8 @@ export function parseLensRound(v: unknown): LensRoundInput {
103
112
  if (v.planner !== undefined && !(WORK_CHAT_PLANNERS as readonly unknown[]).includes(v.planner)) throw invalid('planner')
104
113
  if (v.effort !== undefined && !(WORK_CHAT_EFFORTS as readonly unknown[]).includes(v.effort)) throw invalid('effort')
105
114
  if (v.orchestrator !== undefined && typeof v.orchestrator !== 'boolean') throw invalid('orchestrator')
106
- if (v.source !== 'lens') throw invalid('source')
115
+ if (!(ROUND_SOURCES as readonly unknown[]).includes(v.source)) throw invalid('source')
116
+ if (!v.clientRoundId.startsWith(`${v.source}-`)) throw invalid('clientRoundId', 'The round id must start with its source.')
107
117
  if (!iso(v.createdAt)) throw invalid('createdAt')
108
118
  if (!Array.isArray(v.assignments) || v.assignments.length > WORK_CHAT_MAX_ASSIGNMENTS) throw invalid('assignments')
109
119
  const assignments: LensRoundInput['assignments'] = []
@@ -122,7 +132,19 @@ export function parseLensRound(v: unknown): LensRoundInput {
122
132
  ...(a.contextItemIDs ? { contextItemIDs: [...new Set<string>(a.contextItemIDs)] } : {}) })
123
133
  })
124
134
  return { workID: v.workID, clientRoundId: v.clientRoundId, question: v.question, answer: v.answer, planner: v.planner ?? null,
125
- effort: v.effort ?? null, orchestrator: v.orchestrator === true, assignments, createdAt: v.createdAt }
135
+ effort: v.effort ?? null, orchestrator: v.orchestrator === true, assignments, createdAt: v.createdAt, source: v.source }
136
+ }
137
+
138
+ /**
139
+ * A web round sent again under the same id must be the same plan. The lens keeps its replay rule (the stored round is
140
+ * answered, whatever the repeat carries); a web repeat with any other question, answer, planner, effort or assignment is
141
+ * refused, so one id can never stand for two plans. createdAt is when the plan was made and may be re-sent as it was.
142
+ */
143
+ export function sameRoundContent(round: LensRound, input: LensRoundInput): boolean {
144
+ const plan = (r: { workID: string; question: string; answer: string; planner: string | null; effort: string | null; orchestrator: boolean;
145
+ assignments: Omit<LensAssignment, 'state'>[] }) => JSON.stringify([r.workID, r.question, r.answer, r.planner, r.effort, r.orchestrator,
146
+ r.assignments.map(a => [a.id, a.mode, a.sessionID ?? null, a.provider ?? null, a.prompt, a.contextItemIDs ?? null])])
147
+ return plan(round) === plan(input)
126
148
  }
127
149
 
128
150
  /** The card's sessions as COS Control's snapshot records them: confirmed links, receipt targets and fork children. */
@@ -240,6 +262,7 @@ export class LensRoundStore {
240
262
  answer: input.answer, planner: input.planner, effort: input.effort, orchestrator: input.orchestrator,
241
263
  assignments: input.assignments.map(a => ({ ...a, state: 'pending' as const })), createdAt: input.createdAt, recordedAt: now,
242
264
  inHistory: false, historyAt: null, autoSendCard: false, cardSessions, sends: [], stops: [], updatedAt: now,
265
+ ...(input.source === 'web' ? { source: 'web' as const } : {}),
243
266
  }
244
267
  this.file.rounds.push(round)
245
268
  while (this.file.rounds.length > LENS_ROUNDS_MAX) {
@@ -320,6 +343,7 @@ export class LensRoundStore {
320
343
  if (changed) this.persist()
321
344
  const content = (r: LensRound) => ({ roundId: r.roundId, workID: r.workID, clientRoundId: r.clientRoundId, question: r.question, answer: r.answer,
322
345
  planner: r.planner, effort: r.effort, orchestrator: r.orchestrator, createdAt: r.createdAt, recordedAt: new Date(r.recordedAt).toISOString(),
346
+ ...(r.source === 'web' ? { source: 'web' as const } : {}),
323
347
  assignments: r.assignments.map(a => ({ id: a.id, mode: a.mode, ...(a.sessionID ? { sessionID: a.sessionID } : {}), ...(a.provider ? { provider: a.provider } : {}),
324
348
  prompt: a.prompt, ...(a.contextItemIDs ? { contextItemIDs: a.contextItemIDs } : {}) })) })
325
349
  return {
@@ -432,6 +456,7 @@ export function roundView(r: LensRound) {
432
456
  }
433
457
  return {
434
458
  roundId: r.roundId, workID: r.workID, state, inHistory: r.inHistory, autoSendCard: r.autoSendCard,
459
+ ...(r.source === 'web' ? { source: 'web' as const } : {}),
435
460
  assignments: r.assignments.map(a => ({ id: a.id, state: a.state, ...(a.reportedSessionID ?? a.sessionID ? { sessionID: a.reportedSessionID ?? a.sessionID } : {}),
436
461
  ...(a.receiptId ? { receiptId: a.receiptId } : {}), ...(a.detail ? { detail: a.detail } : {}) })),
437
462
  updatedAt: new Date(r.updatedAt).toISOString(),
@@ -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;
@@ -365,9 +389,6 @@ select.field {
365
389
  .speaker-row audio { width: 100%; height: 40px; margin-top: 8px; grid-column: 1 / -1; }
366
390
  .speaker-row .capture-row { grid-column: 1 / -1; margin-top: 8px; }
367
391
 
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
392
 
372
393
  /* Ask sheet (⌘K on desktop) */
373
394
  .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 +466,6 @@ select.field {
445
466
  .run-title, .run-meta { grid-column: 2; }
446
467
  .composer { position: sticky; bottom: calc(var(--safe-bottom) + 12px); }
447
468
  .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
469
  .sheet-backdrop { padding-top: 8vh; }
451
470
  .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
471
  }