@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,225 @@
1
+ /**
2
+ * Which pet COS Control Web shows (Miles 11/06 23:06: "build the change pet picker ... Just use the
3
+ * existing 300 pack that we have on the COS control").
4
+ *
5
+ * A choice is one of:
6
+ * mac the pet COS Control shows (its own session-pet-states.json, or its single sprite)
7
+ * shipped:<id> a character COS Control ships in its app bundle (BundledCharacters/<id>, animated),
8
+ * or shipped:cloud-puff, the StarterPet still
9
+ * gallery:<id> one of the OpenPets gallery pets COS Control lists (openpets-catalog.json, about 300).
10
+ * As on the Mac (installOpenPetsThumb), a gallery pick is the gallery picture as a still.
11
+ *
12
+ * The choice is kept per browser. Changing the Mac's own pet stays in COS Control on the Mac: the app does
13
+ * not watch its pet folder, so writing it from here would leave the Mac showing the old pet.
14
+ * Everything here reads COS Control's files and never writes them.
15
+ */
16
+ import crypto from 'crypto'
17
+ import fs from 'fs'
18
+ import path from 'path'
19
+ import { controlSupportDir, readPetSources } from './web-pet.js'
20
+
21
+ export type PetChoice =
22
+ | { kind: 'mac' }
23
+ | { kind: 'shipped'; id: string }
24
+ | { kind: 'gallery'; id: string }
25
+
26
+ const ID = /^[a-z0-9][a-z0-9-]{0,80}$/
27
+
28
+ export function parsePetChoice(raw: unknown): PetChoice | null {
29
+ if (raw === undefined) return { kind: 'mac' }
30
+ // A repeated ?pet= arrives as an array: refuse it rather than guess.
31
+ if (typeof raw !== 'string') return null
32
+ const s = raw
33
+ if (s === '' || s === 'mac') return { kind: 'mac' }
34
+ const m = s.match(/^(shipped|gallery):(.+)$/)
35
+ if (!m || !ID.test(m[2])) return null
36
+ return { kind: m[1] as 'shipped' | 'gallery', id: m[2] }
37
+ }
38
+
39
+ export function petChoiceKey(c: PetChoice): string {
40
+ return c.kind === 'mac' ? 'mac' : `${c.kind}:${c.id}`
41
+ }
42
+
43
+ /** COS Control's app resources. Most installs live in /Applications; ~/Applications is checked too. */
44
+ export function controlResourcesDir(env: NodeJS.ProcessEnv = process.env): string | null {
45
+ if (env.COS_CONTROL_RESOURCES_DIR) return env.COS_CONTROL_RESOURCES_DIR
46
+ for (const base of ['/Applications', path.join(env.HOME || '', 'Applications')]) {
47
+ const dir = path.join(base, 'COS Control.app', 'Contents', 'Resources')
48
+ try { if (fs.statSync(dir).isDirectory()) return dir } catch { /* next */ }
49
+ }
50
+ return null
51
+ }
52
+
53
+ /** Names and summaries word for word from COS Control (Models.swift bundledCharacters). Cloud Puff has no
54
+ * summary there (defaultCharacterName only), so its line is ours. Keep in step when Control adds a character. */
55
+ const SHIPPED_NAMES: Record<string, { name: string; summary: string }> = {
56
+ 'jedi-miles-windu': { name: 'Jedi Miles Windu', summary: 'Ten animated states. Purple saber and escalating droid fights.' },
57
+ 'jedi-nia-solari': { name: 'Jedi Nia Solari', summary: 'Eight-frame idle and four combat loops. Purple saber, braided silhouette, and Force counters.' },
58
+ 'jedi-elara-vale': { name: 'Jedi Elara Vale', summary: 'Eight-frame idle and four combat loops. Green saber, auburn braid, and agile redirects.' },
59
+ 'jedi-rowan-vale': { name: 'Jedi Rowan Vale', summary: 'Eight-frame idle and four combat loops. Blue saber, two-handed guard, and grounded strikes.' },
60
+ 'cloud-puff': { name: 'Cloud Puff', summary: 'A simple, calm figure. The default for new installs.' },
61
+ }
62
+
63
+ function isFile(p: string): boolean {
64
+ try { return fs.statSync(p).isFile() } catch { return false }
65
+ }
66
+
67
+ export interface ShippedPet { id: string; name: string; summary: string; animated: boolean }
68
+
69
+ /** Characters the installed COS Control carries. Only folders with a valid state map count. */
70
+ export function shippedPets(resources: string | null = controlResourcesDir()): ShippedPet[] {
71
+ if (!resources) return []
72
+ const out: ShippedPet[] = []
73
+ let names: string[] = []
74
+ try { names = fs.readdirSync(path.join(resources, 'BundledCharacters')).filter((n) => ID.test(n)).sort() } catch { /* none */ }
75
+ for (const id of names) {
76
+ if (Object.keys(readPetSources(path.join(resources, 'BundledCharacters', id))).length === 0) continue
77
+ const label = SHIPPED_NAMES[id] ?? { name: id.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()), summary: '' }
78
+ out.push({ id, name: label.name, summary: label.summary, animated: true })
79
+ }
80
+ if (isFile(path.join(resources, 'StarterPet', 'cloud-puff.webp'))) out.push({ id: 'cloud-puff', ...SHIPPED_NAMES['cloud-puff'], animated: false })
81
+ return out
82
+ }
83
+
84
+ export interface GalleryPet { id: string; name: string; description: string; category: string; preview: string }
85
+
86
+ /** The OpenPets gallery COS Control fetched (openpets-catalog.json). Only https previews on openpets.dev. */
87
+ export function galleryPets(dir: string = controlSupportDir()): GalleryPet[] {
88
+ const file = path.join(dir, 'openpets-catalog.json')
89
+ let raw: { catalog?: { pets?: unknown[] } }
90
+ try {
91
+ if (!isFile(file)) return []
92
+ raw = JSON.parse(fs.readFileSync(file, 'utf8'))
93
+ } catch { return [] }
94
+ const pets = Array.isArray(raw?.catalog?.pets) ? raw.catalog!.pets! : []
95
+ const out: GalleryPet[] = []
96
+ const seen = new Set<string>()
97
+ for (const p of pets as Array<Record<string, unknown>>) {
98
+ const id = typeof p?.id === 'string' ? p.id : ''
99
+ const preview = typeof p?.preview === 'string' ? p.preview : ''
100
+ if (!ID.test(id) || seen.has(id) || !isOpenPetsUrl(preview)) continue
101
+ seen.add(id)
102
+ out.push({
103
+ id,
104
+ name: typeof p.displayName === 'string' && p.displayName ? p.displayName : id,
105
+ description: typeof p.description === 'string' ? p.description : '',
106
+ category: [p.category, p.subcategory].filter((x) => typeof x === 'string' && x).join(' '),
107
+ preview,
108
+ })
109
+ }
110
+ return out
111
+ }
112
+
113
+ /** The Mac helper's rule, exactly (HelperSources/main.swift isAllowedThumbURL): https, host openpets.dev, a path
114
+ * ending in /thumb.webp, no user or password. The catalog is untrusted; nothing else is ever fetched. */
115
+ export function isOpenPetsUrl(url: string): boolean {
116
+ try {
117
+ const u = new URL(url)
118
+ return u.protocol === 'https:' && u.hostname === 'openpets.dev' && u.port === '' && !u.username && !u.password && u.pathname.endsWith('/thumb.webp')
119
+ } catch { return false }
120
+ }
121
+
122
+ export type ResolvedPet =
123
+ | { kind: 'strips'; dir: string; cacheName: string }
124
+ | { kind: 'still'; file: string; type: string }
125
+ | { kind: 'gallery'; id: string; preview: string }
126
+
127
+ const STILL_TYPES: Record<string, string> = { '.png': 'image/png', '.webp': 'image/webp', '.gif': 'image/gif', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg' }
128
+
129
+ /** Where the chosen pet's art lives, or null when that pet is not available on this Mac. */
130
+ export function resolvePet(choice: PetChoice, opts: { supportDir?: string; resources?: string | null } = {}): ResolvedPet | null {
131
+ const support = opts.supportDir ?? controlSupportDir()
132
+ const resources = opts.resources === undefined ? controlResourcesDir() : opts.resources
133
+ if (choice.kind === 'mac') {
134
+ if (Object.keys(readPetSources(support)).length > 0) return { kind: 'strips', dir: support, cacheName: '' }
135
+ // A gallery pick or a single custom image on the Mac: COS Control's plain sprite (PetSpriteStore.fileStem).
136
+ for (const ext of Object.keys(STILL_TYPES)) {
137
+ const file = path.join(support, `session-pet-sprite${ext}`)
138
+ if (isFile(file)) return { kind: 'still', file, type: STILL_TYPES[ext] }
139
+ }
140
+ return null
141
+ }
142
+ if (choice.kind === 'shipped') {
143
+ if (!resources) return null
144
+ if (choice.id === 'cloud-puff') {
145
+ const file = path.join(resources, 'StarterPet', 'cloud-puff.webp')
146
+ return isFile(file) ? { kind: 'still', file, type: 'image/webp' } : null
147
+ }
148
+ const dir = path.join(resources, 'BundledCharacters', choice.id)
149
+ return Object.keys(readPetSources(dir)).length > 0 ? { kind: 'strips', dir, cacheName: `shipped-${choice.id}` } : null
150
+ }
151
+ const pet = galleryPets(support).find((p) => p.id === choice.id)
152
+ return pet ? { kind: 'gallery', id: pet.id, preview: pet.preview } : null
153
+ }
154
+
155
+ /** The Mac helper's limits (openPetsThumbMaxBytes, timeout 6): 512 KB, enforced while reading, and 6 seconds. */
156
+ export const GALLERY_MAX_BYTES = 512 * 1024
157
+ export const GALLERY_TIMEOUT_MS = 6_000
158
+ /** COS Control keeps the thumbnails it already fetched here (main.swift openPetsThumbsDir, <id>.webp). */
159
+ export const MAC_THUMBS_DIR = 'openpets-thumbs'
160
+
161
+ function isWebP(b: Buffer): boolean {
162
+ return b.length >= 12 && b.toString('ascii', 0, 4) === 'RIFF' && b.toString('ascii', 8, 12) === 'WEBP'
163
+ }
164
+
165
+ /** Read at most max bytes from a response body, cancelling the stream past the limit. */
166
+ async function readCapped(res: Response, max: number): Promise<Buffer> {
167
+ const reader = res.body?.getReader()
168
+ if (!reader) return Buffer.from(await res.arrayBuffer())
169
+ const parts: Buffer[] = []
170
+ let total = 0
171
+ for (;;) {
172
+ const { done, value } = await reader.read()
173
+ if (done) break
174
+ total += value.byteLength
175
+ if (total > max) { await reader.cancel().catch(() => {}); throw new Error('gallery_too_large') }
176
+ parts.push(Buffer.from(value))
177
+ }
178
+ return Buffer.concat(parts)
179
+ }
180
+
181
+ export type PetFetcher = (url: string, init: { signal: AbortSignal; redirect: 'error' }) => Promise<Response>
182
+ const galleryInflight = new Map<string, Promise<{ file: string; type: string }>>()
183
+ /** A picture that just failed is not asked for again for this long, so a broken gallery is not hammered. */
184
+ export const GALLERY_RETRY_MS = 10 * 60_000
185
+ const galleryFailedAt = new Map<string, number>()
186
+
187
+ /**
188
+ * The gallery picture for one pet, downloaded once from openpets.dev (as COS Control does) and kept under
189
+ * the data folder. Refuses anything the Mac helper would (not WebP, over 512 KB, redirected, slower than 6 s).
190
+ */
191
+ export function galleryThumb(pet: { id: string; preview: string }, cacheDir: string, fetcher: PetFetcher = fetch as unknown as PetFetcher, macSupportDir: string | null = null): Promise<{ file: string; type: string }> {
192
+ if (!ID.test(pet.id) || !isOpenPetsUrl(pet.preview)) return Promise.reject(new Error('gallery_pet_invalid'))
193
+ // The thumbnail COS Control already fetched, when it is a real WebP: no second download (read only).
194
+ if (macSupportDir) {
195
+ const mac = path.join(macSupportDir, MAC_THUMBS_DIR, `${pet.id}.webp`)
196
+ try {
197
+ if (fs.statSync(mac).isFile() && fs.statSync(mac).size <= GALLERY_MAX_BYTES && isWebP(fs.readFileSync(mac))) return Promise.resolve({ file: mac, type: 'image/webp' })
198
+ } catch { /* not there: fetch below */ }
199
+ }
200
+ const tag = crypto.createHash('sha256').update(pet.preview).digest('hex').slice(0, 12)
201
+ const base = path.join(cacheDir, `${pet.id}-${tag}`)
202
+ if (isFile(base + '.webp')) return Promise.resolve({ file: base + '.webp', type: 'image/webp' })
203
+ const failed = galleryFailedAt.get(base)
204
+ if (failed && Date.now() - failed < GALLERY_RETRY_MS) return Promise.reject(new Error('gallery_recently_failed'))
205
+ let job = galleryInflight.get(base)
206
+ if (!job) {
207
+ job = (async () => {
208
+ const res = await fetcher(pet.preview, { signal: AbortSignal.timeout(GALLERY_TIMEOUT_MS), redirect: 'error' })
209
+ if (res.status !== 200) throw new Error(`gallery_http_${res.status}`)
210
+ const declared = Number(res.headers.get('content-length') || 0)
211
+ if (declared > GALLERY_MAX_BYTES) throw new Error('gallery_too_large')
212
+ const body = await readCapped(res, GALLERY_MAX_BYTES)
213
+ // WebP only, as the Mac (isOpenPetsWebP).
214
+ if (!isWebP(body)) throw new Error('gallery_not_image')
215
+ fs.mkdirSync(cacheDir, { recursive: true })
216
+ const tmp = `${base}.${process.pid}.${Date.now()}.tmp`
217
+ fs.writeFileSync(tmp, body)
218
+ fs.renameSync(tmp, base + '.webp')
219
+ return { file: base + '.webp', type: 'image/webp' }
220
+ })().then((ok) => { galleryFailedAt.delete(base); return ok }, (err) => { galleryFailedAt.set(base, Date.now()); throw err })
221
+ .finally(() => galleryInflight.delete(base))
222
+ galleryInflight.set(base, job)
223
+ }
224
+ return job
225
+ }
@@ -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(),