@gotcos/glasses-server 6.99.25 → 6.99.33
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 +224 -0
- package/package.json +1 -1
- package/server/index.ts +46 -4
- 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/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/web-control/app.css +42 -9
- package/server/web-control/app.js +975 -114
- package/server/web-control/control.css +12 -6
- package/server/web-control/index.html +6 -1
- package/server/web-control/panes/cos-chat.css +27 -3
- package/server/web-control/panes/cos-chat.js +240 -46
- package/server/web-control/panes/meetings.css +5 -2
- package/server/web-control/panes/meetings.js +21 -10
- package/server/web-control/panes/memories.css +1 -1
- package/server/web-control/panes/memories.js +3 -8
- package/server/web-control/panes/questions.css +56 -2
- package/server/web-control/panes/questions.js +454 -56
- package/server/web-control/panes/reader-media.js +154 -0
- package/server/web-control/panes/sessions.css +20 -7
- package/server/web-control/panes/settings.js +34 -2
- package/server/web-control/panes/speakers.css +3 -5
- package/server/web-control/panes/speakers.js +7 -4
- package/server/web-control/panes/threads.js +3 -2
- package/server/web-control/panes/today-actions.css +81 -3
- package/server/web-control/panes/today-actions.js +527 -62
- package/server/web-control/panes/work.css +1 -1
- package/server/web-control/shell.js +3 -0
|
@@ -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) !== '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
}
|