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