@gotcos/glasses-server 6.99.16 → 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 (52) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/docs/work-orchestrator.md +20 -0
  3. package/package.json +1 -1
  4. package/server/index.ts +23 -1
  5. package/server/lib/ask-work-plan.ts +116 -0
  6. package/server/lib/batch-transcript-quality.ts +2 -0
  7. package/server/lib/drop-store.ts +16 -2
  8. package/server/lib/meeting-ask-runtime.ts +1 -1
  9. package/server/lib/meeting-batch-transcribe.ts +40 -1
  10. package/server/lib/ping-sources.ts +6 -2
  11. package/server/lib/prep-runtime.ts +915 -0
  12. package/server/lib/prep-store.ts +226 -0
  13. package/server/lib/stt-resolver.ts +26 -2
  14. package/server/lib/transcript-cleanup.ts +11 -2
  15. package/server/lib/web-control-static.ts +226 -0
  16. package/server/lib/web-memories-bridge.ts +9 -8
  17. package/server/lib/web-pet-choice.ts +225 -0
  18. package/server/lib/web-pet.ts +226 -0
  19. package/server/lib/work-lens-rounds.ts +29 -4
  20. package/server/lib/work-orchestrator/runtime.ts +8 -2
  21. package/server/routes/ask-work-plan.ts +114 -0
  22. package/server/routes/health.ts +8 -0
  23. package/server/routes/prep.ts +81 -0
  24. package/server/routes/web-control.ts +110 -7
  25. package/server/routes/welcome-context.ts +29 -1
  26. package/server/routes/work-lens-rounds.ts +19 -5
  27. package/server/routes/work-orchestrator.ts +57 -0
  28. package/server/web-control/app.css +24 -5
  29. package/server/web-control/app.js +646 -108
  30. package/server/web-control/control.css +1 -1
  31. package/server/web-control/index.html +27 -13
  32. package/server/web-control/memories/memory-workspace.js +1 -1
  33. package/server/web-control/panes/cos-chat.css +26 -0
  34. package/server/web-control/panes/cos-chat.js +641 -6
  35. package/server/web-control/panes/meetings.css +45 -2
  36. package/server/web-control/panes/meetings.js +230 -7
  37. package/server/web-control/panes/memories.css +291 -0
  38. package/server/web-control/panes/memories.js +2208 -0
  39. package/server/web-control/panes/questions.js +59 -21
  40. package/server/web-control/panes/settings.js +102 -4
  41. package/server/web-control/panes/today-actions.css +109 -0
  42. package/server/web-control/panes/today-actions.js +972 -0
  43. package/server/web-control/panes/work.css +54 -0
  44. package/server/web-control/panes/work.js +1037 -18
  45. package/server/web-control/shell.js +6 -2
  46. package/server/web-control/theme.js +0 -2
  47. package/shared/meeting-ask.ts +6 -2
  48. package/shared/prep.ts +317 -0
  49. package/server/web-control/memories/memories-app.js +0 -1561
  50. package/server/web-control/memories/memories-theme.css +0 -73
  51. package/server/web-control/memories/memories.html +0 -72
  52. package/server/web-control/memories/memory-stewardship.js +0 -63
@@ -0,0 +1,114 @@
1
+ /** Mounted under authenticated /api. Plan-only: never records a round, sends a handoff or writes a task. */
2
+ import { Router } from 'express'
3
+ import { ASK_WORK_PLAN_PROTOCOL, ASK_WORK_PLAN_BODY_BYTES, ASK_WORK_PLAN_DEADLINE_MS, ASK_WORK_PLAN_RULES,
4
+ AskWorkPlanError, parseAskWorkPlanRequest, resolveAskWorkTask, askWorkMeetingIDs, prepareAskWorkPlan, askWorkProposalID } from '../lib/ask-work-plan.js'
5
+ import { listBoard, type TaskBoardRow } from '../lib/task-store.js'
6
+ import { createWorkBoardReader } from '../lib/work-board-reader.js'
7
+ import { orchestratorInputFor, type OrchestratorParts } from '../lib/work-orchestrator/runtime.js'
8
+ import { runMeetingAskModel, AskProcessError } from '../lib/meeting-ask-runner.js'
9
+ import { maintenanceAdmissionsOpen, acquireMaintenanceWork } from '../lib/maintenance-lifecycle.js'
10
+ import { parseWorkChatAnswer, WORK_CHAT_SYSTEM, WORK_CHAT_ORCHESTRATOR_RULES, WORK_CHAT_PLANNERS, WORK_CHAT_EFFORTS,
11
+ WORK_CHAT_DEFAULT_EFFORT, WORK_CHAT_ORCHESTRATOR_OUTPUT_TOKENS, WORK_CHAT_THINKING_TOKENS,
12
+ WORK_CHAT_DEADLINE_MS, WORK_CHAT_MAX_ASSIGNMENTS } from '../lib/work-chat.js'
13
+
14
+ export interface AskWorkPlanDeps {
15
+ board: () => Promise<TaskBoardRow[]>
16
+ input: typeof orchestratorInputFor
17
+ run: typeof runMeetingAskModel
18
+ admissions: () => boolean
19
+ lease: () => { release(): void }
20
+ now: () => number
21
+ }
22
+ /** Ask never launches usage probes; stale or unknown cached headroom disables targets in prepareAskWorkPlan. */
23
+ export function readAskWorkPlanInput(workID: string, meetingWorkIDs: string[], parts?: OrchestratorParts) {
24
+ return orchestratorInputFor(workID, meetingWorkIDs, parts, { usageMode: 'cached' })
25
+ }
26
+ const defaults: AskWorkPlanDeps = {
27
+ // A new reader guarantees the post-plan revision check starts a new bounded board read.
28
+ board: () => createWorkBoardReader(() => listBoard()).read(0), input: readAskWorkPlanInput, run: runMeetingAskModel,
29
+ admissions: maintenanceAdmissionsOpen, lease: () => acquireMaintenanceWork('work_chat'), now: Date.now,
30
+ }
31
+
32
+ export function createAskWorkPlanRouter(given: Partial<AskWorkPlanDeps> = {}) {
33
+ const deps = { ...defaults, ...given }, router = Router()
34
+ let running = false
35
+ router.use('/ask/work-plan', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
36
+ router.get('/ask/work-plan', (_req, res) => res.json({ protocolVersion: ASK_WORK_PLAN_PROTOCOL, planOnly: true,
37
+ requiresExistingWork: true, dispatch: false, controlSnapshot: 1, planner: 'sonnet', planners: WORK_CHAT_PLANNERS,
38
+ effort: WORK_CHAT_DEFAULT_EFFORT, efforts: WORK_CHAT_EFFORTS, maxAssignments: WORK_CHAT_MAX_ASSIGNMENTS,
39
+ maxQuestionChars: 2000, maxBodyBytes: ASK_WORK_PLAN_BODY_BYTES }))
40
+ router.post('/ask/work-plan', async (req, res) => {
41
+ const fail = (error: unknown) => {
42
+ const known = error instanceof AskWorkPlanError
43
+ if (!res.destroyed) res.status(known ? error.status : 503).json({ error: known ? error.code : 'ask_work_plan_unavailable',
44
+ message: known && error.code === 'question_contains_sensitive_text'
45
+ ? 'Remove credentials from the question and try again.'
46
+ : 'No assignment was dispatched. Refresh the task and retry the plan.' })
47
+ }
48
+ let request
49
+ try { request = parseAskWorkPlanRequest(req.body) } catch (error) { fail(error); return }
50
+ if (!deps.admissions()) { fail(new AskWorkPlanError('maintenance', 503)); return }
51
+ if (running) { fail(new AskWorkPlanError('ask_work_plan_busy', 409)); return }
52
+ running = true
53
+ const controller = new AbortController(), abort = () => controller.abort()
54
+ const timer = setTimeout(abort, ASK_WORK_PLAN_DEADLINE_MS)
55
+ res.on('close', abort)
56
+ let lease: ReturnType<AskWorkPlanDeps['lease']> | undefined
57
+ let release = true
58
+ try {
59
+ lease = deps.lease()
60
+ let rows: TaskBoardRow[]
61
+ try { rows = await deps.board() }
62
+ catch { throw new AskWorkPlanError('work_board_unavailable', 503) }
63
+ if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
64
+ const task = resolveAskWorkTask(request, rows)
65
+ let evidence
66
+ // The default reads cached usage only. Still await the bounded evidence dependency before releasing admission.
67
+ try { evidence = await deps.input(request.workID, askWorkMeetingIDs(task)) }
68
+ catch { throw new AskWorkPlanError('orchestrator_input_unavailable', 503) }
69
+ if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
70
+ const prepared = prepareAskWorkPlan(request, task, evidence, deps.now())
71
+ if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
72
+ if (!deps.admissions()) throw new AskWorkPlanError('maintenance', 503)
73
+ const { planner = 'sonnet', effort = WORK_CHAT_DEFAULT_EFFORT, ...prompt } = prepared.input
74
+ // The runner settles only after termination proof (or explicit unconfirmed exit). Racing its
75
+ // promise with abort would release the lease and admit another provider during the kill wait.
76
+ const output = await deps.run({ engine: 'claude', model: planner }, JSON.stringify(prompt),
77
+ WORK_CHAT_SYSTEM + '\n\n' + WORK_CHAT_ORCHESTRATOR_RULES + '\n\n' + ASK_WORK_PLAN_RULES,
78
+ controller.signal, undefined, { caller: 'ask_work_plan' }, { effort, maxOutputTokens: WORK_CHAT_ORCHESTRATOR_OUTPUT_TOKENS,
79
+ maxThinkingTokens: WORK_CHAT_THINKING_TOKENS[effort], deadlineMs: WORK_CHAT_DEADLINE_MS })
80
+ if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
81
+ let answer
82
+ try { answer = parseWorkChatAnswer(output.text, prepared.input) }
83
+ catch { throw new AskWorkPlanError('invalid_plan_answer', 503) }
84
+ // A model may take minutes; never return an apparently current proposal for a changed/finished task.
85
+ try { rows = await deps.board() }
86
+ catch { throw new AskWorkPlanError('work_board_unavailable', 503) }
87
+ if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
88
+ const currentTask = resolveAskWorkTask(request, rows)
89
+ try { evidence = await deps.input(request.workID, askWorkMeetingIDs(currentTask)) }
90
+ catch { throw new AskWorkPlanError('orchestrator_input_unavailable', 503) }
91
+ if (controller.signal.aborted) throw new AskWorkPlanError('ask_work_plan_canceled', 408)
92
+ const current = prepareAskWorkPlan(request, currentTask, evidence, deps.now())
93
+ // Revalidate every proposed target against current discovery, live state and plan headroom.
94
+ try { answer = parseWorkChatAnswer(output.text, current.input) }
95
+ catch { throw new AskWorkPlanError('plan_targets_changed', 409) }
96
+ if (!res.destroyed) res.json({ protocolVersion: ASK_WORK_PLAN_PROTOCOL, planOnly: true, workID: request.workID,
97
+ revision: request.revision, title: prepared.title, ...answer, evidence: prepared.evidence, revalidatedEvidence: current.evidence,
98
+ proposalID: askWorkProposalID(prepared.input, answer), question: prepared.input.question, createdAt: new Date(deps.now()).toISOString(),
99
+ model: output.model ?? `claude:${planner} (requested)`, effort })
100
+ } catch (error) {
101
+ if (error instanceof AskProcessError && error.terminationUnconfirmed) {
102
+ // Match Meeting Ask's orphan policy: no second process or maintenance drain until recovery.
103
+ release = false
104
+ console.warn('[ask-work-plan] provider termination unconfirmed; admission and maintenance lease retained')
105
+ fail(new AskWorkPlanError('provider_termination_unconfirmed', 503))
106
+ } else fail(error)
107
+ }
108
+ finally {
109
+ clearTimeout(timer); res.off('close', abort)
110
+ if (release) { lease?.release(); running = false }
111
+ }
112
+ })
113
+ return router
114
+ }
@@ -1,4 +1,5 @@
1
1
  import { dropItCapability } from '../lib/drop-settings.js'
2
+ import { PREP_CAPABILITY } from '../../shared/prep.js'
2
3
  import { phoneMicrophoneMeetingsCapability } from '../lib/phone-recording.js'
3
4
  import { macLiveVoicesSnapshot } from '../lib/mac-window-replay.js'
4
5
  import { DICTATION_CLEAN_MODELS } from '../lib/dictation-clean-models.js'
@@ -465,6 +466,9 @@ healthRouter.get('/health', async (_req, res) => {
465
466
  providerObserveFields({ state: cachedHookStatus().state, ready: hookHaltReady(cachedHookStatus()) }),
466
467
  ),
467
468
  server_instance_id: getServerInstanceId(),
469
+ // 6.99.20: the same id in camelCase, the name COS Control 0.5.376's Prep discovery reads from /api/health
470
+ // (it refused Prep with "did not answer Prep discovery" when only server_instance_id was here). Additive.
471
+ serverInstanceId: getServerInstanceId(),
468
472
  boot_id: serverMetrics.bootId,
469
473
  generation_id: getServerGenerationId(),
470
474
  mediaLimits,
@@ -538,6 +542,8 @@ healthRouter.get('/health', async (_req, res) => {
538
542
  titleSteer: TITLE_STEER_CAPABILITY,
539
543
  // Drop it P1: the pinned COS chat (routes/drops.ts). Codes only; `enabled` is false unless turned on. Absent on an older server.
540
544
  dropIt: dropItCapability(),
545
+ // 6.99.19 Prep: notes and files before a call, kept in the meeting by an explicit bind (routes/prep.ts). Absent on an older server.
546
+ prep: { ...PREP_CAPABILITY, serverInstanceId: getServerInstanceId() },
541
547
  // 6.77.0: codes only (this route is public): the protocol and whether the switch is on.
542
548
  transcriptCleanup: transcriptCleanupAdvertised(),
543
549
  richMedia: {
@@ -752,6 +758,8 @@ healthRouter.get('/models', async (req, res) => {
752
758
  titleSteer: TITLE_STEER_CAPABILITY,
753
759
  // Drop it P1: the pinned COS chat (routes/drops.ts). Codes only; `enabled` is false unless turned on. Absent on an older server.
754
760
  dropIt: dropItCapability(),
761
+ // 6.99.19 Prep: notes and files before a call, kept in the meeting by an explicit bind (routes/prep.ts). Absent on an older server.
762
+ prep: { ...PREP_CAPABILITY, serverInstanceId: getServerInstanceId() },
755
763
  richMedia: {
756
764
  video: richMedia.video,
757
765
  maxPromptVisuals: MAX_MODEL_IMAGE_INPUTS,
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Prep v1.1 routes (6.99.19). Mounted under /api, so the X-Cos-Token check in index.ts runs first. Every answer is
3
+ * Cache-Control: no-store and carries the top-level `serverInstanceId` (errors included; the original-bytes route
4
+ * sends it as the X-COS-Server-Instance header). Errors are `{ error: '<code>' }` like Meeting Ask; a revision conflict
5
+ * also carries `draft`, run_in_progress carries `run`, and file_context_limit carries needBytes and haveBytes.
6
+ * Every mutation requires X-COS-Expected-Server-Instance (v1.1 #19), checked before the runtime is called. The
7
+ * behaviour lives in lib/prep-runtime.ts; shared/prep.ts lists the routes and DTOs.
8
+ */
9
+ import { Router, type Request, type Response } from 'express'
10
+ import { PrepError } from '../lib/prep-store.js'
11
+ import { wellFormed, type PrepRuntime } from '../lib/prep-runtime.js'
12
+
13
+ export const EXPECTED_SERVER_HEADER = 'x-cos-expected-server-instance'
14
+
15
+ /** An ASCII fallback name plus the RFC 5987 UTF-8 name: no quotes, slashes or control characters reach the header. */
16
+ export function attachmentDisposition(name: string): string {
17
+ // By code point, without lone surrogates: encodeURIComponent throws URIError on half an emoji.
18
+ const whole = Array.from(wellFormed(name)).slice(0, 240).join('')
19
+ const ascii = whole.replace(/[^A-Za-z0-9._ -]/g, '_').replace(/^[ .]+/, '').slice(0, 120) || 'file'
20
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(whole)}`
21
+ }
22
+
23
+ export function createPrepRouter(runtime: PrepRuntime): Router {
24
+ const router = Router()
25
+ router.use('/prep', (_q, res, next) => { res.set('Cache-Control', 'no-store'); next() })
26
+
27
+ const serverInstanceId = () => { try { return runtime.serverInstanceId() } catch { return null } }
28
+ const send = (res: Response, status: number, body: Record<string, unknown>) => res.status(status).json({ ...body, serverInstanceId: serverInstanceId() })
29
+ const failure = (res: Response, error: unknown) => {
30
+ if (error instanceof PrepError) {
31
+ send(res, error.status, { error: error.code, ...error.extra, ...(error.draft ? { draft: error.draft } : {}) })
32
+ return
33
+ }
34
+ console.warn('[prep] request failed:', error instanceof Error ? error.message : 'unknown')
35
+ send(res, 503, { error: 'prep_unavailable' })
36
+ }
37
+ /** A mutation: the expected server is checked before the runtime runs anything. */
38
+ const write = (status: number, fn: (req: Request) => Promise<Record<string, unknown>>) => async (req: Request, res: Response) => {
39
+ try {
40
+ runtime.authorize(req.get(EXPECTED_SERVER_HEADER))
41
+ send(res, status, await fn(req))
42
+ } catch (e) { failure(res, e) }
43
+ }
44
+ const read = (fn: (req: Request) => Promise<Record<string, unknown>> | Record<string, unknown>) => async (req: Request, res: Response) => {
45
+ try { send(res, 200, await fn(req)) } catch (e) { failure(res, e) }
46
+ }
47
+
48
+ router.post('/prep/drafts', async (req, res) => {
49
+ try {
50
+ runtime.authorize(req.get(EXPECTED_SERVER_HEADER))
51
+ const { draft, created } = await runtime.create(req.body)
52
+ send(res, created ? 201 : 200, { draft })
53
+ } catch (e) { failure(res, e) }
54
+ })
55
+ router.get('/prep/drafts', read(req => ({ drafts: runtime.list({ occurrence: req.query.occurrence, state: req.query.state, limit: req.query.limit }) })))
56
+ router.get('/prep/drafts/:id', read(async req => ({ draft: await runtime.get(String(req.params.id)) })))
57
+ router.delete('/prep/drafts/:id', write(200, async req => ({ draft: await runtime.archive(String(req.params.id)) })))
58
+ router.patch('/prep/drafts/:id', write(200, async req => ({ draft: await runtime.patch(String(req.params.id), req.body) })))
59
+ router.post('/prep/drafts/:id/files', write(200, async req => ({ draft: await runtime.addFiles(String(req.params.id), req.body) })))
60
+ router.delete('/prep/drafts/:id/files/:fileId', write(200, async req => ({ draft: await runtime.removeFile(String(req.params.id), String(req.params.fileId), req.query.revision, req.query.serverInstanceId) })))
61
+ router.get('/prep/drafts/:id/files/:fileId', (req, res) => {
62
+ try {
63
+ const file = runtime.readOriginal(String(req.params.id), String(req.params.fileId))
64
+ res.set({ 'Content-Type': file.mime, 'Content-Disposition': attachmentDisposition(file.name), 'X-Content-Type-Options': 'nosniff', 'X-COS-Server-Instance': serverInstanceId() ?? '' })
65
+ res.send(file.bytes)
66
+ } catch (e) { failure(res, e) }
67
+ })
68
+ router.get('/prep/drafts/:id/files/:fileId/text', read(req => ({ text: runtime.readText(String(req.params.id), String(req.params.fileId)) })))
69
+ router.post('/prep/drafts/:id/run', write(202, async req => ({ ...await runtime.run(String(req.params.id), req.body) })))
70
+ router.get('/prep/drafts/:id/runs/:clientRequestId', async (req, res) => {
71
+ try {
72
+ const { status, run, job } = await runtime.getRun(String(req.params.id), String(req.params.clientRequestId))
73
+ send(res, status, status === 202 ? { run } : { run, job })
74
+ } catch (e) { failure(res, e) }
75
+ })
76
+ router.post('/prep/drafts/:id/bind', write(200, async req => ({ ...await runtime.bind(String(req.params.id), req.body) })))
77
+ router.delete('/prep/drafts/:id/bind/:sessionId', write(200, async req => ({ draft: await runtime.unbind(String(req.params.id), String(req.params.sessionId), req.query.serverInstanceId) })))
78
+ router.get('/prep/by-session/:sessionId', read(req => ({ snapshots: runtime.bySession(String(req.params.sessionId)) })))
79
+ router.get('/prep/suggest', read(req => ({ drafts: runtime.suggest({ sessionId: req.query.sessionId, startedAt: req.query.startedAt }) })))
80
+ return router
81
+ }
@@ -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')
@@ -4,6 +4,8 @@
4
4
 
5
5
  import { Router } from 'express'
6
6
  import { callPython, pythonBridgeAvailable } from '../lib/python-bridge.js'
7
+ import { parsePrepOccurrence } from '../lib/prep-store.js'
8
+ import type { PrepOccurrence } from '../../shared/prep.js'
7
9
 
8
10
  export const welcomeContextRouter = Router()
9
11
 
@@ -14,7 +16,29 @@ const CALENDAR_TTL_MS = 2 * 60_000
14
16
  const COORD_MOVE_DEG = 0.05 // ~5km
15
17
 
16
18
  type WeatherPayload = { temp: string; desc: string; location: string }
17
- type NextEventPayload = { title: string; time: string; inMinutes?: number }
19
+ /**
20
+ * 6.99.19: `occurrence` (shared/prep.ts PrepOccurrence) is present ONLY when the bridge answers the event with a
21
+ * provider, calendar id, event id or external id, and start. The bridge sends no event ids today, so it is absent.
22
+ * It lets a client find a Prep draft for this exact event; it never binds one, and nothing is inferred from the title.
23
+ */
24
+ type NextEventPayload = { title: string; time: string; inMinutes?: number; occurrence?: PrepOccurrence }
25
+
26
+ /** The bridge event's provider identity, or undefined. snake_case (calendar_context.py) or camelCase is accepted. */
27
+ export function bridgeOccurrence(evt: Record<string, unknown> | null | undefined): PrepOccurrence | undefined {
28
+ if (!evt || typeof evt !== 'object') return undefined
29
+ const pick = (...keys: string[]) => keys.map(k => evt[k]).find(v => v !== undefined && v !== null)
30
+ try {
31
+ return parsePrepOccurrence({
32
+ provider: pick('provider'),
33
+ account: pick('account', 'account_id', 'accountId') ?? null,
34
+ calendarId: pick('calendar_id', 'calendarId'),
35
+ eventId: pick('event_id', 'eventId') ?? null,
36
+ externalId: pick('external_id', 'externalId') ?? null,
37
+ startsAt: pick('start_time', 'start', 'startsAt'),
38
+ endsAt: pick('end_time', 'end', 'endsAt') ?? null,
39
+ }) ?? undefined
40
+ } catch { return undefined }
41
+ }
18
42
 
19
43
  let cachedWeather: WeatherPayload | null = null
20
44
  let weatherFetchedAt = 0
@@ -327,16 +351,20 @@ export async function fetchNextEvent(): Promise<{ event: NextEventPayload | null
327
351
  const minsUntil = Number.isFinite(eventTime)
328
352
  ? Math.max(0, Math.round((eventTime - now) / 60_000))
329
353
  : undefined
354
+ const occurrence = bridgeOccurrence(meaningful as Record<string, unknown>)
330
355
  cachedNextEvent = {
331
356
  title: meaningful.title || 'Meeting',
332
357
  time: meaningful.start_time || meaningful.start || '',
333
358
  ...(minsUntil != null ? { inMinutes: minsUntil } : {}),
359
+ ...(occurrence ? { occurrence } : {}),
334
360
  }
335
361
  } else if (cal?.next_event && !isGenericNoiseEvent(cal.next_event.title || '')) {
362
+ const occurrence = bridgeOccurrence(cal.next_event as Record<string, unknown>)
336
363
  cachedNextEvent = {
337
364
  title: cal.next_event.title || 'Meeting',
338
365
  time: cal.next_event.start_time || '',
339
366
  ...(cal.minutes_until_next != null ? { inMinutes: cal.minutes_until_next } : {}),
367
+ ...(occurrence ? { occurrence } : {}),
340
368
  }
341
369
  } else {
342
370
  cachedNextEvent = null
@@ -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))
@@ -5,6 +5,9 @@
5
5
  * POST /api/work/orchestrator/input {workID, meetingWorkIDs?, snapshot?}
6
6
  * Stores Control's snapshot when one is sent (Phase A: Control pushes, the server keeps the copy), then answers
7
7
  * the card's planner rows, what was left out and why, and the usage snapshot.
8
+ * POST /api/work/orchestrator/snapshot {workID, revision, serverInstanceId, snapshot}
9
+ * Bound to this server and a current canonical card revision. Stores validated Control evidence only;
10
+ * no discovery, usage refresh, model, link/receipt write or dispatch.
8
11
  * GET /api/work/orchestrator/usage[?refresh=1]
9
12
  * The usage snapshot per provider, read on demand: Claude and Codex are re-read when their reading is older than
10
13
  * 10 minutes (`refresh=1`: 2 minutes). No timer reads usage unless COS_WORK_USAGE_POLL=1.
@@ -14,6 +17,11 @@ import { parseSnapshot, type ControlSnapshot } from '../lib/work-orchestrator/sn
14
17
  import type { OrchestratorInput } from '../lib/work-orchestrator/input.js'
15
18
  import type { UsageMonitor, UsageView } from '../lib/work-orchestrator/usage.js'
16
19
  import { orchestratorInputFor, orchestratorSnapshots, orchestratorUsage } from '../lib/work-orchestrator/runtime.js'
20
+ import { listBoard, type TaskBoardRow } from '../lib/task-store.js'
21
+ import { createWorkBoardReader } from '../lib/work-board-reader.js'
22
+ import { getServerInstanceId } from '../lib/server-instance-id.js'
23
+ import { maintenanceAdmissionsOpen, acquireMaintenanceWork } from '../lib/maintenance-lifecycle.js'
24
+ import { AskWorkPlanError, resolveAskWorkTask } from '../lib/ask-work-plan.js'
17
25
 
18
26
  const WORK_ID = /^task:[a-z_]+:[a-f0-9]{12}$/
19
27
  const MEETING_ID = /^meeting:[^\u0000-\u001f]{1,250}$/
@@ -24,6 +32,10 @@ export interface WorkOrchestratorDeps {
24
32
  input: (workID: string, meetingWorkIDs: string[]) => Promise<OrchestratorInput>
25
33
  usage: (refresh: boolean) => Promise<UsageView[]>
26
34
  now: () => number
35
+ board: () => Promise<TaskBoardRow[]>
36
+ serverInstanceId: () => string | null
37
+ admissions: () => boolean
38
+ lease: () => { release(): void }
27
39
  }
28
40
 
29
41
  /** GET /usage reads on demand: a reading older than 10 minutes is re-read (`refresh=1`: 2 minutes). */
@@ -34,12 +46,57 @@ const DEFAULTS: WorkOrchestratorDeps = {
34
46
  input: orchestratorInputFor,
35
47
  usage: refresh => readUsage(refresh),
36
48
  now: () => Date.now(),
49
+ board: () => createWorkBoardReader(() => listBoard()).read(0),
50
+ serverInstanceId: getServerInstanceId,
51
+ admissions: maintenanceAdmissionsOpen,
52
+ lease: () => acquireMaintenanceWork('api_mutation'),
37
53
  }
38
54
 
39
55
  export function createWorkOrchestratorRouter(given: Partial<WorkOrchestratorDeps> = {}) {
40
56
  const deps = { ...DEFAULTS, ...given }
41
57
  const router = Router()
42
58
  router.use('/work/orchestrator', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
59
+ router.post('/work/orchestrator/snapshot', async (req, res) => {
60
+ const body = req.body
61
+ if (!body || typeof body !== 'object' || Array.isArray(body)
62
+ || Object.keys(body).some(k => !['workID', 'revision', 'serverInstanceId', 'snapshot'].includes(k))
63
+ || typeof body.workID !== 'string' || body.workID.length > 256 || !WORK_ID.test(body.workID)
64
+ || typeof body.revision !== 'string' || !/^[a-f0-9]{64}$/.test(body.revision)
65
+ || typeof body.serverInstanceId !== 'string'
66
+ || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(body.serverInstanceId)) {
67
+ return res.status(400).json({ error: 'invalid_snapshot_binding' })
68
+ }
69
+ if (Buffer.byteLength(JSON.stringify(body)) > ORCHESTRATOR_BODY_BYTES) return res.status(413).json({ error: 'snapshot_too_large' })
70
+ let snapshot: ControlSnapshot
71
+ try { snapshot = parseSnapshot(body.snapshot, deps.now()) }
72
+ catch { return res.status(400).json({ error: 'invalid_snapshot' }) }
73
+ const binding = () => {
74
+ const id = deps.serverInstanceId()
75
+ if (!id) throw new AskWorkPlanError('server_instance_unavailable', 503)
76
+ if (id !== body.serverInstanceId) throw new AskWorkPlanError('server_instance_changed', 409)
77
+ if (!deps.admissions()) throw new AskWorkPlanError('maintenance', 503)
78
+ return id
79
+ }
80
+ let lease: ReturnType<WorkOrchestratorDeps['lease']> | undefined
81
+ try {
82
+ binding()
83
+ lease = deps.lease()
84
+ let rows: TaskBoardRow[]
85
+ try { rows = await deps.board() }
86
+ catch { throw new AskWorkPlanError('work_board_unavailable', 503) }
87
+ resolveAskWorkTask(body, rows)
88
+ const serverInstanceId = binding()
89
+ if (res.destroyed) return
90
+ // This is the same paired-client evidence cache as /input, not execution authority. parseSnapshot
91
+ // retains only its known fields; arbitrary automatic/forkable/capability fields never enter the cache.
92
+ deps.putSnapshot(snapshot)
93
+ return res.json({ protocolVersion: 1, accepted: true, workID: body.workID, revision: body.revision, serverInstanceId })
94
+ } catch (error) {
95
+ if (res.destroyed) return
96
+ const known = error instanceof AskWorkPlanError
97
+ return res.status(known ? error.status : 503).json({ error: known ? error.code : 'snapshot_unavailable' })
98
+ } finally { lease?.release() }
99
+ })
43
100
  router.post('/work/orchestrator/input', async (req, res) => {
44
101
  const body = req.body
45
102
  const meetings: unknown = body?.meetingWorkIDs ?? []