@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,280 @@
1
+ // Native transcript output previews. A transcript reference is evidence of selection, not
2
+ // permission to read arbitrary files: only the session's exact recorded workspace is eligible.
3
+ // The public id is process-scoped (reload the session after a server restart). Raw sources
4
+ // stay in this module. Normalized media is a bounded, staged cache, never a fake COS message.
5
+ import { createHash, createHmac, randomBytes } from 'node:crypto'
6
+ import { constants, realpathSync, statSync } from 'node:fs'
7
+ import { open } from 'node:fs/promises'
8
+ import { homedir } from 'node:os'
9
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
10
+ import { agentSessionRoots, findAgentSessionFile, isWrapperPrompt, latestAssistantReply, LATEST_REPLY_MAX, type AgentProvider } from './agent-session-store.js'
11
+ import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './attached-workspace.js'
12
+ import { readComposerFacts } from './cursor-session-model.js'
13
+ import { MAX_OUTPUT_ARTIFACT_BYTES } from './image-safety.js'
14
+ import { getMediaStore, STAGED_TTL_MS, type MediaStore } from './media-store.js'
15
+ import { readRecentSessionTurns, SESSION_TURNS_MAX, type SessionTurnProjection } from './agent-session-turns.js'
16
+
17
+ export interface SessionOutputImage {
18
+ id: string
19
+ label: string
20
+ state: 'available' | 'unavailable'
21
+ /**
22
+ * `outside_workspace`: permanent (the path leaves the workspace, or the workspace is a broad
23
+ * root that is never eligible). `unavailable`: the workspace lookup failed this time; the
24
+ * content route re-resolves it, so a retry can succeed.
25
+ */
26
+ reason?: 'outside_workspace' | 'unavailable'
27
+ }
28
+ export const SESSION_OUTPUT_IMAGE_ID = /^ai_[a-f0-9]{64}$/
29
+ export const OUTPUT_IMAGES_PER_TURN = 5
30
+ export const OUTPUT_IMAGE_SCAN_CHARS = 128 * 1024
31
+ export const OUTPUT_IMAGE_CACHE_MAX = 128
32
+ export const OUTPUT_IMAGE_CONCURRENCY = 2
33
+ const CACHE_TTL = Math.min(30 * 60_000, STAGED_TTL_MS)
34
+ interface Candidate { source: string; label: string }
35
+ interface Source { candidate: Candidate; root: string | null; descriptor: SessionOutputImage }
36
+ interface PreviewContext { project: SessionTurnProjection; sources: Map<string, Source> }
37
+ interface MediaCache { id: string; at: number }
38
+ type PreviewStore = Pick<MediaStore, 'ingestOutputImage' | 'getContent'>
39
+ export interface OutputImageDeps {
40
+ findSession(provider: AgentProvider, id: string): Promise<string | null>
41
+ workspace(provider: AgentProvider, id: string, transcript: string): Promise<string | null>
42
+ store(): PreviewStore
43
+ secret: Buffer
44
+ now(): number
45
+ /** The home directory the broad-root denylist is built from (tests inject a scratch home). */
46
+ home?(): string
47
+ }
48
+
49
+ function assistantText(provider: AgentProvider, row: Record<string, unknown>): string | null {
50
+ if (row.isSidechain === true || row.isMeta === true || row.isCompactSummary === true) return null
51
+ let message: Record<string, unknown> | null = null
52
+ if (provider === 'claude') {
53
+ if (row.type !== 'assistant') return null
54
+ message = row.message as Record<string, unknown> | null
55
+ } else if (provider === 'codex') {
56
+ if (row.type !== 'response_item') return null
57
+ const p = row.payload as Record<string, unknown> | null
58
+ if (!p || p.type !== 'message' || p.role !== 'assistant') return null
59
+ message = p
60
+ } else {
61
+ if (row.role !== 'assistant') return null
62
+ message = row.message as Record<string, unknown> | null
63
+ }
64
+ if (!message || typeof message !== 'object') return null
65
+ // The generic prose extractor trims each block, which destroys Markdown indentation.
66
+ // Preserve it here: an indented first line must not become an image selection.
67
+ const content = message.content
68
+ const text = typeof content === 'string' ? content : Array.isArray(content)
69
+ ? content.flatMap(block => {
70
+ if (!block || typeof block !== 'object') return []
71
+ const b = block as Record<string, unknown>
72
+ return ['text', 'input_text', 'output_text'].includes(String(b.type)) && typeof b.text === 'string' ? [b.text] : []
73
+ }).join('\n\n') : ''
74
+ return text.trim() && !isWrapperPrompt(text) ? text : null
75
+ }
76
+ // Deliberately bounded Markdown subset: inline image destinations, including angle-wrapped
77
+ // paths with spaces and balanced parentheses. No HTML, reference definitions, code or URLs.
78
+ function imageTokens(raw: string): Array<Candidate & { start: number; end: number }> {
79
+ const text = raw.slice(0, OUTPUT_IMAGE_SCAN_CHARS)
80
+ const out: Array<Candidate & { start: number; end: number }> = []
81
+ let fence = '', inline = 0
82
+ for (let i = 0; i < text.length; i++) {
83
+ if (i === 0 || text[i - 1] === '\n') {
84
+ // An indented example is literal text, never an output selection.
85
+ if (!fence && /^(?: {4}| {0,3}\t)/.test(text.slice(i, i + 4))) {
86
+ i = text.indexOf('\n', i); if (i < 0) break; continue
87
+ }
88
+ const m = /^( {0,3})(`{3,}|~{3,})/.exec(text.slice(i, i + 80))
89
+ if (m) { if (!fence) fence = m[2]; else if (m[2][0] === fence[0] && m[2].length >= fence.length) fence = ''; i = text.indexOf('\n', i); if (i < 0) break; continue }
90
+ }
91
+ if (fence) continue
92
+ if (text[i] === '\\') { i++; continue }
93
+ if (text[i] === '`') { let n = 1; while (text[i + n] === '`') n++; if (!inline) inline = n; else if (inline === n) inline = 0; i += n - 1; continue }
94
+ if (inline) continue
95
+ // Comments may span lines; nothing inside them authorizes a file read.
96
+ if (text.startsWith('<!--', i)) {
97
+ const end = text.indexOf('-->', i + 4); if (end < 0) break
98
+ i = end + 2; continue
99
+ }
100
+ if (text.slice(i, i + 2) !== '![') continue
101
+ const endLabel = text.indexOf('](', i + 2)
102
+ if (endLabel < 0 || endLabel - i > 250 || text.slice(i, endLabel).includes('\n')) continue
103
+ let j = endLabel + 2, source = '', valid = false
104
+ if (text[j] === '<') {
105
+ const end = text.indexOf('>', j + 1)
106
+ if (end >= 0 && text[end + 1] === ')') { source = text.slice(j + 1, end); j = end + 2; valid = true }
107
+ } else {
108
+ const start = j; let depth = 1
109
+ while (j < text.length && j - start <= 4096) {
110
+ if (text[j] === '\n') break
111
+ if (text[j] === '(') depth++
112
+ if (text[j] === ')' && --depth === 0) { source = text.slice(start, j); j++; valid = true; break }
113
+ j++
114
+ }
115
+ }
116
+ if (!valid || !source || source.length > 4096 || /[\u0000-\u001f\u007f]/.test(source)) continue
117
+ // Only local paths. Scheme-like strings, network paths and URI escaping stay inert.
118
+ if (/^[a-z][a-z0-9+.-]*:/i.test(source) || source.startsWith('//') || source.includes('\\')) { i = j - 1; continue }
119
+ const alt = text.slice(i + 2, endLabel).trim()
120
+ const label = alt && !/[\\/\u0000-\u001f]/.test(alt) ? alt.slice(0, 120) : 'Output image'
121
+ out.push({ source, label, start: i, end: j }); i = j - 1
122
+ }
123
+ return out
124
+ }
125
+ export function assistantImageCandidates(provider: AgentProvider, row: Record<string, unknown>): Candidate[] {
126
+ const raw = assistantText(provider, row)
127
+ return raw ? imageTokens(raw).slice(0, OUTPUT_IMAGES_PER_TURN).map(({ source, label }) => ({ source, label })) : []
128
+ }
129
+ function inside(root: string, path: string): boolean {
130
+ const rel = relative(root, path)
131
+ return rel !== '' && rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel)
132
+ }
133
+ /**
134
+ * Exact roots that are never a session workspace, however a session recorded them. EXACT
135
+ * match only: `~/Documents/GitHub/x` stays eligible while `~/Documents` itself is not. Each
136
+ * entry is compared both as written and canonicalized, since the workspace is realpath'd.
137
+ */
138
+ export function broadWorkspaceRoots(home = homedir()): Set<string> {
139
+ const raw = ['/', '/Users', '/tmp', '/private/tmp', '/var', '/private/var', '/Volumes', home,
140
+ join(home, 'Documents'), join(home, 'Library'), join(home, '.cos-glasses')]
141
+ const out = new Set<string>()
142
+ for (const entry of raw) {
143
+ out.add(entry)
144
+ try { out.add(realpathSync(entry)) } catch { /* absent on this Mac: the literal still counts */ }
145
+ }
146
+ return out
147
+ }
148
+ /** A canonical workspace root, `'broad'` for a denied exact root, or null when the lookup failed. */
149
+ function canonicalRoot(path: string | null, home: string): string | 'broad' | null {
150
+ if (!path || !isAbsolute(path)) return null
151
+ try {
152
+ const root = realpathSync(path)
153
+ if (broadWorkspaceRoots(home).has(root)) return 'broad'
154
+ if (!statSync(root).isDirectory()) return null
155
+ return root
156
+ } catch { return null }
157
+ }
158
+ function digest(value: string): string { return createHash('sha256').update(value).digest('hex') }
159
+
160
+ export class AgentSessionOutputImages {
161
+ private cache = new Map<string, MediaCache>()
162
+ private pending = new Map<string, Promise<string>>()
163
+ private active = 0
164
+ private readers = 0
165
+ constructor(private readonly deps: OutputImageDeps) {}
166
+
167
+ async context(provider: AgentProvider, sessionId: string, transcript: string): Promise<PreviewContext> {
168
+ let workspace: string | null = null
169
+ try { workspace = await this.deps.workspace(provider, sessionId, transcript) } catch { /* unavailable, words still load */ }
170
+ const canonical = canonicalRoot(workspace, this.deps.home?.() ?? homedir())
171
+ const root = canonical === 'broad' ? null : canonical
172
+ // A failed lookup is temporary and the content route re-resolves it; a broad root is not.
173
+ const lookupFailed = canonical === null
174
+ const sources = new Map<string, Source>()
175
+ const transcriptKey = digest(resolve(transcript))
176
+ const project: SessionTurnProjection = (p, row, turn) => {
177
+ const raw = assistantText(p, row)
178
+ if (!raw) return turn
179
+ // Prose cleanup can collapse a huge whitespace/code prefix, bringing an unscanned
180
+ // suffix into the visible reply. Never project that suffix. Drop the boundary line
181
+ // too: it might end halfway through a Markdown destination (or an ordinary sentence).
182
+ const shortened = raw.length > OUTPUT_IMAGE_SCAN_CHARS
183
+ const scanned = shortened ? raw.slice(0, raw.lastIndexOf('\n', OUTPUT_IMAGE_SCAN_CHARS - 1) + 1) : raw
184
+ const candidates = imageTokens(scanned)
185
+ if (!candidates.length && !shortened) return turn
186
+ const recordKey = digest(JSON.stringify(row))
187
+ const refs = candidates.slice(0, OUTPUT_IMAGES_PER_TURN).map((c, ordinal) => {
188
+ const id = 'ai_' + createHmac('sha256', this.deps.secret).update(JSON.stringify([provider, sessionId, transcriptKey, recordKey, ordinal, c.source])).digest('hex')
189
+ const allowed = root !== null && inside(root, resolve(root, c.source))
190
+ const descriptor: SessionOutputImage = { id, label: c.label, state: allowed ? 'available' : 'unavailable', ...(!allowed ? { reason: lookupFailed ? 'unavailable' as const : 'outside_workspace' as const } : {}) }
191
+ sources.delete(id); sources.set(id, { candidate: c, root, descriptor })
192
+ while (sources.size > (SESSION_TURNS_MAX + 1) * OUTPUT_IMAGES_PER_TURN) sources.delete(sources.keys().next().value!)
193
+ return descriptor
194
+ })
195
+ // Never turn an explicit local image into a browser-visible Mac path. Existing callers
196
+ // without this opt-in projection retain their original text and contract.
197
+ // The preview cap limits DTOs, not redaction: the sixth image is still private.
198
+ const parts: string[] = []; let end = 0
199
+ for (const token of candidates) { parts.push(scanned.slice(end, token.start), '[Output image]'); end = token.end }
200
+ parts.push(scanned.slice(end))
201
+ const omission = '[Reply shortened at the image preview scan limit.]'
202
+ const text = shortened
203
+ ? [latestAssistantReply(parts.join(''), LATEST_REPLY_MAX - omission.length - 1), omission].filter(Boolean).join('\n')
204
+ : latestAssistantReply(parts.join(''))
205
+ const at = typeof row.timestamp === 'string' && Number.isFinite(Date.parse(row.timestamp)) ? row.timestamp : undefined
206
+ return { ...(turn ?? { role: 'assistant' as const, text: '', ...(at ? { at } : {}) }), text, outputImages: refs }
207
+ }
208
+ return { project, sources }
209
+ }
210
+
211
+ async content(provider: AgentProvider, sessionId: string, imageId: string, variant: 'thumb' | 'phone') {
212
+ if (!SESSION_OUTPUT_IMAGE_ID.test(imageId)) return { status: 'not_found' as const }
213
+ // Admission covers discovery, transcript parsing, authorization and image IO alike.
214
+ if (this.readers >= OUTPUT_IMAGE_CONCURRENCY) return { status: 'unavailable' as const }
215
+ this.readers++
216
+ try {
217
+ const transcript = await this.deps.findSession(provider, sessionId)
218
+ if (!transcript) return { status: 'not_found' as const }
219
+ const context = await this.context(provider, sessionId, transcript)
220
+ const recent = await readRecentSessionTurns(provider, transcript, SESSION_TURNS_MAX, undefined, context.project)
221
+ if (!recent.turns.some(t => t.outputImages?.some(i => i.id === imageId))) return { status: 'not_found' as const }
222
+ const source = context.sources.get(imageId)
223
+ if (!source?.root || source.descriptor.state !== 'available') return { status: 'unavailable' as const }
224
+ const path = resolve(source.root, source.candidate.source)
225
+ const canonical = realpathSync(path)
226
+ if (!inside(source.root, canonical)) return { status: 'unavailable' as const }
227
+ // Reject symlinks in any component and recheck path/fd identity after reading.
228
+ // A changed path never becomes a published preview.
229
+ if (canonical !== path) return { status: 'unavailable' as const }
230
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
231
+ let bytes: Buffer, key: string
232
+ try {
233
+ const before = await file.stat()
234
+ if (!before.isFile() || before.size <= 0 || before.size > MAX_OUTPUT_ARTIFACT_BYTES) return { status: 'unavailable' as const }
235
+ key = digest(JSON.stringify([imageId, before.dev, before.ino, before.size, before.mtimeMs, before.ctimeMs]))
236
+ // Even a cache hit rechecks that the authorized source still exists within the root.
237
+ const hit = this.cache.get(key)
238
+ if (hit && this.deps.now() - hit.at < CACHE_TTL) {
239
+ const current = statSync(path)
240
+ if (realpathSync(path) !== canonical || current.dev !== before.dev || current.ino !== before.ino) return { status: 'unavailable' as const }
241
+ const content = this.deps.store().getContent(hit.id, variant)
242
+ if (content.status === 'ok') return content
243
+ }
244
+ // Bounded positional read, not readFile(): an append during read cannot allocate past the cap.
245
+ bytes = Buffer.alloc(before.size)
246
+ let offset = 0
247
+ while (offset < bytes.length) { const got = await file.read(bytes, offset, bytes.length - offset, offset); if (!got.bytesRead) break; offset += got.bytesRead }
248
+ const after = await file.stat(), current = statSync(path)
249
+ if (offset !== bytes.length || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs || current.dev !== before.dev || current.ino !== before.ino || realpathSync(path) !== canonical) return { status: 'unavailable' as const }
250
+ } finally { await file.close() }
251
+ let job = this.pending.get(key!)
252
+ if (!job) {
253
+ if (this.active >= OUTPUT_IMAGE_CONCURRENCY) return { status: 'unavailable' as const }
254
+ this.active++
255
+ job = this.deps.store().ingestOutputImage({ bytes: bytes!, kind: 'generated_visual', label: source.candidate.label }).then(ref => {
256
+ this.cache.set(key!, { id: ref.id, at: this.deps.now() })
257
+ while (this.cache.size > OUTPUT_IMAGE_CACHE_MAX) this.cache.delete(this.cache.keys().next().value!)
258
+ return ref.id
259
+ }).finally(() => { this.active--; this.pending.delete(key!) })
260
+ this.pending.set(key!, job)
261
+ }
262
+ return this.deps.store().getContent(await job, variant)
263
+ } catch { return { status: 'unavailable' as const } }
264
+ finally { this.readers-- }
265
+ }
266
+ }
267
+
268
+ export const agentSessionOutputImages = new AgentSessionOutputImages({
269
+ findSession: (p, id) => findAgentSessionFile(p, id, agentSessionRoots()),
270
+ workspace: async (provider, id, transcript) => {
271
+ const deps = realAttachedWorkspaceDeps()
272
+ // Use the exact transcript already selected by the route, including keyed Codex roots.
273
+ const workspace = resolveAttachedWorkspace(provider, id, { ...deps, transcriptPath: () => transcript })?.path
274
+ if (workspace) return workspace
275
+ if (provider === 'cursor') return (await readComposerFacts([id], agentSessionRoots().cursorComposerDb)).get(id.toLowerCase())?.workspace ?? null
276
+ return null
277
+ },
278
+ store: getMediaStore,
279
+ secret: randomBytes(32), now: Date.now,
280
+ })
@@ -12,6 +12,7 @@ import { join } from 'node:path'
12
12
  import { createInterface } from 'node:readline'
13
13
  import { promisify } from 'node:util'
14
14
  import { codexUserAsk, unwrapPeerMessage } from './session-stream-events.js'
15
+ import { withoutTurnFooter } from './turn-footer.js'
15
16
  import { profileCodexHomeSubdirs } from './codex-homes.js'
16
17
 
17
18
  const execFileAsync = promisify(execFile)
@@ -1831,7 +1832,8 @@ export async function parseAgentSession(
1831
1832
  // frame. It IS the user's prompt (dictated on the glasses), so it is the
1832
1833
  // turn's query and never an injected row; the frame is stripped.
1833
1834
  const peer = (obj.origin as { kind?: unknown } | undefined)?.kind === 'peer' ? unwrapPeerMessage(text) : null
1834
- const query = peer ?? text
1835
+ // 6.99.29: the verify footer of a glasses, phone or web turn is never the turn's query or title.
1836
+ const query = withoutTurnFooter(peer ?? text)
1835
1837
  collectTurn(query, tail, peer === null && (obj.isMeta === true || obj.isCompactSummary === true))
1836
1838
  if (!firstPrompt) firstPrompt = firstLineTitle(query)
1837
1839
  if (!title) title = firstLineTitle(query)
@@ -33,15 +33,21 @@ import {
33
33
  payloadText,
34
34
  type AgentProvider,
35
35
  } from './agent-session-store.js'
36
+ import type { SessionOutputImage } from './agent-session-output-images.js'
36
37
  import { unwrapPeerMessage } from './session-stream-events.js'
38
+ import { withoutTurnFooter } from './turn-footer.js'
37
39
 
38
40
  export interface SessionTurn {
39
41
  role: 'user' | 'assistant'
40
42
  text: string
41
43
  /** The record's ISO timestamp, when the provider writes one (Cursor does not). */
42
44
  at?: string
45
+ /** Opt-in native-session output previews. No local paths or COS message identities. */
46
+ outputImages?: SessionOutputImage[]
43
47
  }
44
48
 
49
+ export type SessionTurnProjection = (provider: AgentProvider, record: Record<string, unknown>, turn: SessionTurn | null) => SessionTurn | null
50
+
45
51
  /** What a client may ask for in one read. Forty messages is ~10 lens pages of words. */
46
52
  export const SESSION_TURNS_MAX = 40
47
53
 
@@ -103,10 +109,12 @@ export function sessionTurnFromRecord(provider: AgentProvider, obj: Record<strin
103
109
  // (a teammate's report, an idle notification, another session's SendMessage),
104
110
  // written as a plain user row with no origin. QA found 20 of those in one session
105
111
  // rendering as "YOU".
106
- const peer = unwrapPeerMessage(text)
112
+ // 6.99.29: the verify footer a glasses, phone or web turn carries is for the agent, not the reader.
113
+ const wrapped = unwrapPeerMessage(text)
114
+ const peer = wrapped === null ? null : withoutTurnFooter(wrapped)
107
115
  if (peer !== null) return (obj.origin as { kind?: unknown } | undefined)?.kind === 'peer' ? shaped('user', peer, at) : null
108
116
  if (obj.isMeta === true || obj.isCompactSummary === true) return null
109
- return shaped('user', text, at)
117
+ return shaped('user', withoutTurnFooter(text), at)
110
118
  }
111
119
  if (provider === 'codex') {
112
120
  if (obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') return null
@@ -117,22 +125,24 @@ export function sessionTurnFromRecord(provider: AgentProvider, obj: Record<strin
117
125
  // and an attached-file message reads as the request that was typed.
118
126
  const text = payload.role === 'user' ? codexUserText(payload) : payloadText(payload)
119
127
  if (!text) return null
120
- return shaped(payload.role, text, iso(obj.timestamp))
128
+ return shaped(payload.role, payload.role === 'user' ? withoutTurnFooter(text) : text, iso(obj.timestamp))
121
129
  }
122
130
  if (obj.role !== 'user' && obj.role !== 'assistant') return null
123
131
  const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
124
132
  const text = message ? payloadText(message) : null
125
133
  if (!text) return null
126
- return shaped(obj.role, obj.role === 'user' ? cursorQuery(text) : text, iso(obj.timestamp))
134
+ // QA 6.99.29: a Cursor user row carries the verify footer inside its <user_query> block, like any other turn.
135
+ return shaped(obj.role, obj.role === 'user' ? withoutTurnFooter(cursorQuery(text)) : text, iso(obj.timestamp))
127
136
  }
128
137
 
129
138
  /** The newest `limit` messages among these lines (file order in, file order out). */
130
- export function recentSessionTurns(provider: AgentProvider, lines: readonly string[], limit: number): SessionTurn[] {
139
+ export function recentSessionTurns(provider: AgentProvider, lines: readonly string[], limit: number, project?: SessionTurnProjection): SessionTurn[] {
131
140
  const out: SessionTurn[] = []
132
141
  for (const line of lines) {
133
142
  const obj = parseJsonLine(line)
134
143
  if (!obj) continue
135
- const turn = sessionTurnFromRecord(provider, obj)
144
+ const parsed = sessionTurnFromRecord(provider, obj)
145
+ const turn = project ? project(provider, obj, parsed) : parsed
136
146
  if (turn) out.push(turn)
137
147
  }
138
148
  return limit > 0 ? out.slice(-limit) : []
@@ -155,6 +165,7 @@ export async function readRecentSessionTurns(
155
165
  path: string,
156
166
  limit: number,
157
167
  windows: readonly number[] = SESSION_TURNS_WINDOWS,
168
+ project?: SessionTurnProjection,
158
169
  ): Promise<RecentTurnsRead> {
159
170
  const want = Math.max(0, Math.min(SESSION_TURNS_MAX, Math.floor(limit)))
160
171
  const { size } = await stat(path)
@@ -177,7 +188,7 @@ export async function readRecentSessionTurns(
177
188
  const lines = buffer.toString('utf-8').split('\n')
178
189
  if (start > 0) lines.shift()
179
190
  // One more than wanted, so `more` can be answered from what was read.
180
- found = recentSessionTurns(provider, lines, want + 1)
191
+ found = recentSessionTurns(provider, lines, want + 1, project)
181
192
  if (found.length > want || start === 0) break
182
193
  }
183
194
  const more = found.length > want || start > 0
@@ -44,6 +44,14 @@
44
44
  // while the Mac sleeps, so on its own it would read a pre-sleep presence as younger
45
45
  // than it is; the wall clock does advance. A wall clock stepped forward reads older
46
46
  // (no hold), never younger.
47
+ //
48
+ // 6.99.26 (Miles 11/07, "Away rule, like phone"): COS Control Web's presence, kept apart from every
49
+ // stamp above. A browser poll `?client=web&hold=1&presenceAgeMs=<int>` (the ms since a touch or a
50
+ // key while the page was in front) records it, and the broker reads it for the away hold ONLY: it
51
+ // never moves the newest poll (/api/health), the 6.59.0 liveness, the `hold=1` poll stamp (which
52
+ // makes a stamped question's client connected without presence), or the wearer presence above. So
53
+ // an open tab nobody touches holds nothing, and at the desk the Mac's own pop-up still shows at once
54
+ // (6.99.13). Same contract and both clocks, forward-only, as the wearer presence.
47
55
 
48
56
  /** 6.60.0: the most a client may report as `presenceAgeMs`. Anything else is no presence. */
49
57
  export const PRESENCE_AGE_MAX_MS = 86_400_000
@@ -53,6 +61,8 @@ let lastLegacyPollAt: number | null = null
53
61
  let lastHoldPollAt: number | null = null
54
62
  let lastPresenceMono: number | null = null
55
63
  let lastPresenceWall: number | null = null
64
+ let lastWebPresenceMono: number | null = null
65
+ let lastWebPresenceWall: number | null = null
56
66
 
57
67
  /** 6.60.0: the monotonic clock. Milliseconds since this process started; paused while the Mac sleeps. */
58
68
  export function monotonicNowMs(): number {
@@ -109,12 +119,35 @@ export function noteQuestionsPoll(at: number, poll: QuestionsPollFacts = {}, mon
109
119
  * and the wall-clock age, so a Mac that slept never makes it read younger.
110
120
  */
111
121
  export function presenceAgeMs(mono: number = monotonicNowMs(), wall: number = Date.now()): number | null {
112
- if (lastPresenceMono === null || !Number.isFinite(mono)) return null
113
- const monoAge = Math.max(0, mono - lastPresenceMono)
114
- const wallAge = lastPresenceWall !== null && Number.isFinite(wall) ? wall - lastPresenceWall : 0
122
+ return ageOn(lastPresenceMono, lastPresenceWall, mono, wall)
123
+ }
124
+
125
+ /** The age of a presence stamped on both clocks: the larger of the two, so a Mac that slept never reads younger. */
126
+ function ageOn(stampMono: number | null, stampWall: number | null, mono: number, wall: number): number | null {
127
+ if (stampMono === null || !Number.isFinite(mono)) return null
128
+ const monoAge = Math.max(0, mono - stampMono)
129
+ const wallAge = stampWall !== null && Number.isFinite(wall) ? wall - stampWall : 0
115
130
  return Math.max(monoAge, wallAge)
116
131
  }
117
132
 
133
+ /**
134
+ * 6.99.26: a COS Control Web poll answered at `at` (server clock) that reported `presenceAgeMs`, the ms
135
+ * since a touch or a key while the page was in front. Anything outside the contract is no presence and
136
+ * records nothing. Forward-only on each clock, like the wearer presence.
137
+ */
138
+ export function noteWebPresence(at: number, presenceAgeMs: number, mono: number = monotonicNowMs()): void {
139
+ if (!Number.isFinite(at) || !validPresenceAgeMs(presenceAgeMs) || !Number.isFinite(mono)) return
140
+ const presenceMono = mono - presenceAgeMs
141
+ const presenceWall = at - presenceAgeMs
142
+ if (lastWebPresenceMono === null || presenceMono > lastWebPresenceMono) lastWebPresenceMono = presenceMono
143
+ if (lastWebPresenceWall === null || presenceWall > lastWebPresenceWall) lastWebPresenceWall = presenceWall
144
+ }
145
+
146
+ /** 6.99.26: how long ago COS Control Web's newest touch or key was, or null when none since this process started. */
147
+ export function webPresenceAgeMs(mono: number = monotonicNowMs(), wall: number = Date.now()): number | null {
148
+ return ageOn(lastWebPresenceMono, lastWebPresenceWall, mono, wall)
149
+ }
150
+
118
151
  /** When the newest authenticated questions poll of any kind arrived, or null since boot (/api/health). */
119
152
  export function lastQuestionsPollAt(): number | null {
120
153
  return lastPollAt
@@ -136,4 +169,6 @@ export function __resetClientLivenessForTests(): void {
136
169
  lastHoldPollAt = null
137
170
  lastPresenceMono = null
138
171
  lastPresenceWall = null
172
+ lastWebPresenceMono = null
173
+ lastWebPresenceWall = null
139
174
  }
@@ -139,3 +139,39 @@ export async function readCodexSubagents(
139
139
  }
140
140
  return out
141
141
  }
142
+
143
+ /**
144
+ * 6.99.26 candidate (the Today summary, lib/session-subagents.ts): each parent's direct children by name, most recently
145
+ * updated first, at most `perParent`. The same read-only snapshot and child lifecycle reader as readCodexSubagents.
146
+ * Name is the child's nickname, else its role; the task is its thread title. Text only goes to the summary's prompt,
147
+ * cleaned there; ids and paths never leave this function. A failed read is an empty map, never a made-up zero.
148
+ */
149
+ export interface CodexSubagentDetail { name: string; task: string; state: ChildState; at: number }
150
+ export async function readCodexSubagentDetails(
151
+ parents: readonly string[], sessionsRoot: string, now = Date.now(), perParent = 6,
152
+ ): Promise<Map<string, CodexSubagentDetail[]>> {
153
+ const ids = [...new Set(parents.filter(id => UUID.test(id)).map(id => id.toLowerCase()))].slice(0, 12)
154
+ const out = new Map<string, CodexSubagentDetail[]>()
155
+ if (!ids.length) return out
156
+ try {
157
+ const home = dirname(sessionsRoot)
158
+ const files = (await readdir(home)).filter(n => /^state_\d+\.sqlite$/.test(n)).sort((a,b) => Number(b.match(/\d+/)![0])-Number(a.match(/\d+/)![0]))
159
+ if (!files.length) return out
160
+ // UUID validation above is the SQL parameter boundary.
161
+ const values = ids.map(id => `'${id}'`).join(',')
162
+ const query = `SELECT e.parent_thread_id AS root, t.rollout_path, t.archived, t.agent_nickname AS nick, t.agent_role AS role, t.title, t.updated_at_ms AS at
163
+ FROM thread_spawn_edges e LEFT JOIN threads t ON t.id=e.child_thread_id
164
+ WHERE e.status='open' AND e.parent_thread_id IN (${values}) ORDER BY t.updated_at_ms DESC LIMIT ${ids.length * perParent * 4}`
165
+ const { stdout } = await exec('/usr/bin/sqlite3', ['-readonly', '-json', join(home,files[0]), query], { timeout: 2000, maxBuffer: 2*1024*1024 })
166
+ const rows = JSON.parse(stdout || '[]') as Array<{root:string;rollout_path?:string;archived?:number;nick?:string;role?:string;title?:string;at?:number}>
167
+ const budget = { bytes: MAX_SNAPSHOT_BYTES }
168
+ for (const row of rows) {
169
+ const list = out.get(row.root) ?? []
170
+ if (list.length >= perParent) continue
171
+ const state: ChildState = row.archived === 1 ? 'completed' : typeof row.rollout_path === 'string' ? await childState(row.rollout_path, sessionsRoot, now, budget) : 'unknown'
172
+ list.push({ name: String(row.nick || row.role || 'agent').slice(0, 60), task: String(row.title || '').slice(0, 160), state, at: Number(row.at) || 0 })
173
+ out.set(row.root, list)
174
+ }
175
+ } catch { return new Map() }
176
+ return out
177
+ }