@gotcos/glasses-server 6.99.29 → 7.0.7

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 (80) hide show
  1. package/.env.example +18 -0
  2. package/CHANGELOG.md +475 -0
  3. package/README.md +3 -1
  4. package/managed-runtime-contract.json +3 -0
  5. package/package.json +1 -1
  6. package/server/index.ts +29 -7
  7. package/server/lib/agent-session-output-images.ts +627 -49
  8. package/server/lib/agent-session-store.ts +24 -11
  9. package/server/lib/agent-session-tool-images.ts +224 -0
  10. package/server/lib/agent-session-turns.ts +19 -2
  11. package/server/lib/api-auth.ts +6 -0
  12. package/server/lib/ask-work-plan.ts +3 -1
  13. package/server/lib/calendar-source.ts +491 -0
  14. package/server/lib/codex-bridge.ts +4 -1
  15. package/server/lib/codex-run-ledger.ts +29 -0
  16. package/server/lib/context-builder.ts +2 -1
  17. package/server/lib/drop-p2-runtime.ts +1 -1
  18. package/server/lib/drop-store.ts +10 -4
  19. package/server/lib/learning-needs-you.ts +24 -7
  20. package/server/lib/live-events.ts +533 -0
  21. package/server/lib/local-path-redact.ts +272 -0
  22. package/server/lib/meeting-ask-orientation.ts +40 -6
  23. package/server/lib/meeting-ask-runner.ts +30 -10
  24. package/server/lib/morning-brief-runtime.ts +4 -2
  25. package/server/lib/native-mac-caller.ts +29 -0
  26. package/server/lib/ping-engine.ts +1 -1
  27. package/server/lib/ping-sources.ts +11 -6
  28. package/server/lib/preview-content.ts +172 -0
  29. package/server/lib/preview-error-code.ts +10 -0
  30. package/server/lib/preview-folders-setting.ts +133 -0
  31. package/server/lib/preview-log-throttle.ts +18 -0
  32. package/server/lib/preview-thumbs.ts +139 -0
  33. package/server/lib/secret-path-guard.ts +80 -0
  34. package/server/lib/session-files-setting.ts +88 -0
  35. package/server/lib/session-links.ts +280 -0
  36. package/server/lib/thread-turn-queue.ts +10 -0
  37. package/server/lib/today-brief.ts +119 -28
  38. package/server/lib/web-memories-bridge.ts +3 -1
  39. package/server/lib/work-activity.ts +56 -6
  40. package/server/lib/work-session-activity.ts +260 -0
  41. package/server/lib/your-move-count.ts +9 -3
  42. package/server/routes/agent-session-bindings.ts +13 -0
  43. package/server/routes/agent-session-stream.ts +16 -0
  44. package/server/routes/agent-sessions.ts +146 -11
  45. package/server/routes/ask-work-plan.ts +4 -1
  46. package/server/routes/claude-sessions.ts +5 -1
  47. package/server/routes/health.ts +35 -0
  48. package/server/routes/live-events.ts +329 -0
  49. package/server/routes/permission-broker.ts +69 -5
  50. package/server/routes/session-preview.ts +200 -0
  51. package/server/routes/thread-turn-queue.ts +207 -3
  52. package/server/routes/today-brief.ts +44 -9
  53. package/server/routes/welcome-context.ts +23 -3
  54. package/server/routes/work-board.ts +9 -2
  55. package/server/web-control/app.css +79 -9
  56. package/server/web-control/app.js +1543 -65
  57. package/server/web-control/control.css +37 -6
  58. package/server/web-control/index.html +8 -2
  59. package/server/web-control/memory-review.js +100 -0
  60. package/server/web-control/panes/cos-chat.css +51 -6
  61. package/server/web-control/panes/cos-chat.js +268 -16
  62. package/server/web-control/panes/meetings.css +23 -4
  63. package/server/web-control/panes/meetings.js +25 -9
  64. package/server/web-control/panes/memories.css +18 -3
  65. package/server/web-control/panes/memories.js +130 -50
  66. package/server/web-control/panes/messages.js +21 -2
  67. package/server/web-control/panes/preview.js +318 -0
  68. package/server/web-control/panes/questions.css +20 -4
  69. package/server/web-control/panes/questions.js +127 -26
  70. package/server/web-control/panes/reader-media.js +409 -23
  71. package/server/web-control/panes/sessions.css +50 -10
  72. package/server/web-control/panes/settings.js +96 -5
  73. package/server/web-control/panes/speakers.css +3 -5
  74. package/server/web-control/panes/speakers.js +7 -4
  75. package/server/web-control/panes/threads.js +3 -2
  76. package/server/web-control/panes/today-actions.css +49 -18
  77. package/server/web-control/panes/today-actions.js +217 -50
  78. package/server/web-control/panes/work.css +11 -1
  79. package/server/web-control/panes/work.js +10 -2
  80. package/server/web-control/shell.js +20 -14
@@ -12,7 +12,18 @@ import { realAttachedWorkspaceDeps, resolveAttachedWorkspace } from './attached-
12
12
  import { readComposerFacts } from './cursor-session-model.js'
13
13
  import { MAX_OUTPUT_ARTIFACT_BYTES } from './image-safety.js'
14
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'
15
+ import { previewFoldersSetting } from './preview-folders-setting.js'
16
+ import { isDeniedSecretPath, SECRET_FILE_NOTICE } from './secret-path-guard.js'
17
+ import { previewErrorCode } from './preview-error-code.js'
18
+ import { readRecentSessionTurns, SESSION_TURNS_MAX, type SessionTurn, type SessionTurnProjection } from './agent-session-turns.js'
19
+ import {
20
+ authorizedRoot, insideRoot, isLocalTarget, LINK_CARDS_PER_TURN, linkCardWords, linkLabel, linkTokens, previewKind, resolveLinkTarget, SESSION_OUTPUT_LINK_ID,
21
+ linkOverflowSentence, splitLineAnchor, type LinkRoots, type PreviewKind, type SessionOutputLink,
22
+ } from './session-links.js'
23
+ import {
24
+ decodeToolImage, estimateBytes, MAX_TOOL_IMAGE_BYTES, promptImages, SESSION_TOOL_IMAGE_ID, TOOL_IMAGES_PER_TURN, toolResultImages, toolUsesOf,
25
+ type FoundToolImage, type SessionToolImage, type ToolUseMeta,
26
+ } from './agent-session-tool-images.js'
16
27
 
17
28
  export interface SessionOutputImage {
18
29
  id: string
@@ -21,19 +32,63 @@ export interface SessionOutputImage {
21
32
  /**
22
33
  * `outside_workspace`: permanent (the path leaves the workspace, or the workspace is a broad
23
34
  * root that is never eligible). `unavailable`: the workspace lookup failed this time; the
24
- * content route re-resolves it, so a retry can succeed.
35
+ * content route re-resolves it, so a retry can succeed. `secret_file` (7.0.4): the file's name (or
36
+ * the name it canonically resolves to) says it may hold secrets; never served (403 secret_file_denied).
25
37
  */
26
- reason?: 'outside_workspace' | 'unavailable'
38
+ reason?: 'outside_workspace' | 'unavailable' | 'missing' | 'secret_file'
39
+ /** 7.0.4: the words the card shows instead, set only with `secret_file`. */
40
+ notice?: string
27
41
  }
42
+ /**
43
+ * A file a Claude session sent to the person with its SendUserFile tool (6.99.35), when it is not
44
+ * an image the preview pipeline normalizes. Only the base name travels; never a Mac path.
45
+ * `missing`: the path is inside the workspace but nothing is there now.
46
+ */
47
+ export interface SessionOutputFile {
48
+ id: string
49
+ name: string
50
+ /** How the web may open it: `pdf` inline, `text` as words, `html` sandboxed or downloaded, `image` (GIF) as a picture, `file` downloaded. */
51
+ kind: SharedFileKind
52
+ /** A short type for people: "PDF", "HTML page", "Markdown", "Word document". */
53
+ type: string
54
+ size?: number
55
+ state: 'available' | 'unavailable'
56
+ reason?: 'outside_workspace' | 'unavailable' | 'missing' | 'secret_file'
57
+ /** 7.0.4: the words the card shows instead, set only with `secret_file`. */
58
+ notice?: string
59
+ }
60
+ export type SharedFileKind = 'pdf' | 'text' | 'html' | 'image' | 'file'
61
+ export const SESSION_OUTPUT_FILE_ID = /^af_[a-f0-9]{64}$/
62
+ /** Files one SendUserFile row may show (images and other files together). The rest are counted in words. */
63
+ export const SHARED_FILES_PER_TURN = 10
64
+ /** The largest sent file the web may fetch. Bigger files stay listed as "Too large to open here". */
65
+ export const MAX_SHARED_FILE_BYTES = 32 * 1024 * 1024
28
66
  export const SESSION_OUTPUT_IMAGE_ID = /^ai_[a-f0-9]{64}$/
29
67
  export const OUTPUT_IMAGES_PER_TURN = 5
30
68
  export const OUTPUT_IMAGE_SCAN_CHARS = 128 * 1024
31
69
  export const OUTPUT_IMAGE_CACHE_MAX = 128
32
70
  export const OUTPUT_IMAGE_CONCURRENCY = 2
33
71
  const CACHE_TTL = Math.min(30 * 60_000, STAGED_TTL_MS)
72
+ /** Sources kept per read: every preview the newest turns can name, a sent-files turn being the widest. */
73
+ const SOURCES_MAX = (SESSION_TURNS_MAX + 1) * Math.max(OUTPUT_IMAGES_PER_TURN, SHARED_FILES_PER_TURN)
34
74
  interface Candidate { source: string; label: string }
35
75
  interface Source { candidate: Candidate; root: string | null; descriptor: SessionOutputImage }
36
- interface PreviewContext { project: SessionTurnProjection; sources: Map<string, Source> }
76
+ interface FileSource { path: string | null; root: string | null; file: SessionOutputFile }
77
+ /** A link card's authorized source (preview engine phase 1). Kept only for an available card. */
78
+ interface LinkSource { path: string; root: string; link: SessionOutputLink }
79
+ /** Where a tool-result image's bytes are. Embedded bytes are kept only for the one id a content request names. */
80
+ type ToolSource = { kind: 'embedded'; mediaType: string; data: string } | { kind: 'path'; source: string; root: string; label: string }
81
+ interface PreviewContext { project: SessionTurnProjection; sources: Map<string, Source>; files: Map<string, FileSource>; toolSources: Map<string, ToolSource>; links: Map<string, LinkSource>; workspace: string | null }
82
+ /**
83
+ * PLAN 7 load: a reader with 20 cards asks for 20 thumbnails. The preview routes keep one projection of a transcript for
84
+ * 30 s while its size and mtime stand, so that is one transcript parse, not 20. Only ids, names and canonical sources are
85
+ * kept, and every content call still re-checks the file (containment, links, the deny list, identity) and the root.
86
+ */
87
+ export const PREVIEW_PROJECTION_TTL_MS = 30_000
88
+ const PREVIEW_PROJECTIONS_MAX = 8
89
+ interface PreviewProjection { at: number; size: number; mtimeMs: number; read: Promise<{ context: PreviewContext; turns: SessionTurn[] }> }
90
+ /** Tool calls remembered per read, so a result row can be labelled by the call that asked for it. */
91
+ const TOOL_USES_MAX = 4096
37
92
  interface MediaCache { id: string; at: number }
38
93
  type PreviewStore = Pick<MediaStore, 'ingestOutputImage' | 'getContent'>
39
94
  export interface OutputImageDeps {
@@ -44,6 +99,8 @@ export interface OutputImageDeps {
44
99
  now(): number
45
100
  /** The home directory the broad-root denylist is built from (tests inject a scratch home). */
46
101
  home?(): string
102
+ /** Settings extra folders COS may preview from (lib/preview-folders-setting.ts), canonical. Read on every context. */
103
+ extraRoots?(): readonly string[]
47
104
  }
48
105
 
49
106
  function assistantText(provider: AgentProvider, row: Record<string, unknown>): string | null {
@@ -156,15 +213,126 @@ function canonicalRoot(path: string | null, home: string): string | 'broad' | nu
156
213
  } catch { return null }
157
214
  }
158
215
  function digest(value: string): string { return createHash('sha256').update(value).digest('hex') }
216
+ /**
217
+ * 7.0.4 S2: whether a path a session named may hold secrets. Resolved against the workspace when there is one,
218
+ * otherwise judged by its own segments only (anchored at '/', never the server's directory). The guard checks the
219
+ * path as written AND its canonical file, so an innocently named link to a secret is refused too.
220
+ */
221
+ function secretSource(root: string | null, written: string): boolean {
222
+ return isDeniedSecretPath(root !== null ? resolve(root, written) : resolve('/', written))
223
+ }
224
+ const SECRET = { state: 'unavailable' as const, reason: 'secret_file' as const, notice: SECRET_FILE_NOTICE }
225
+
226
+ // ── Files a Claude session sent with SendUserFile (6.99.35) ──────────────────────────────
227
+ // Its input is { files: [paths], caption?, status, display? }; paths are absolute or relative to the
228
+ // session's working directory at that moment (the row's `cwd`). The row is evidence of what was
229
+ // shared, not permission to read: a file is served only when its canonical path is inside the
230
+ // session's exact workspace, as for images. Codex and Cursor transcripts have no such record.
231
+ const NORMALIZED_IMAGE = /\.(png|jpe?g|webp|hei[cf]|avif)$/i
232
+ const SHARED_KINDS: Array<[RegExp, SharedFileKind, string]> = [
233
+ [/\.pdf$/i, 'pdf', 'PDF'],
234
+ [/\.html?$/i, 'html', 'HTML page'],
235
+ [/\.(md|markdown)$/i, 'text', 'Markdown'],
236
+ [/\.csv$/i, 'text', 'CSV'],
237
+ [/\.tsv$/i, 'text', 'TSV'],
238
+ [/\.json$/i, 'text', 'JSON'],
239
+ [/\.ya?ml$/i, 'text', 'YAML'],
240
+ [/\.(txt|log)$/i, 'text', 'Text'],
241
+ // An SVG can carry script, so it is shown as its source, never as a picture from a same-origin URL.
242
+ [/\.svg$/i, 'text', 'SVG source'],
243
+ [/\.xml$/i, 'text', 'XML'],
244
+ [/\.gif$/i, 'image', 'GIF image'],
245
+ [/\.docx?$/i, 'file', 'Word document'],
246
+ [/\.xlsx?$/i, 'file', 'Excel workbook'],
247
+ [/\.pptx?$/i, 'file', 'PowerPoint deck'],
248
+ [/\.(wav|mp3|m4a|aac|flac)$/i, 'file', 'Audio'],
249
+ [/\.(mp4|mov|webm)$/i, 'file', 'Video'],
250
+ [/\.zip$/i, 'file', 'ZIP archive'],
251
+ [/\.ehpk$/i, 'file', 'EHPK package'],
252
+ ]
253
+ export function sharedFileKind(name: string): { kind: SharedFileKind; type: string } {
254
+ for (const [re, kind, type] of SHARED_KINDS) if (re.test(name)) return { kind, type }
255
+ const ext = /\.([a-z0-9]{1,8})$/i.exec(name)
256
+ return { kind: 'file', type: ext ? ext[1].toUpperCase() + ' file' : 'File' }
257
+ }
258
+ /** The base name a person sees: no directories, no control characters, at most 120 characters. */
259
+ function displayName(source: string): string {
260
+ const base = source.split(/[\\/]/).filter(Boolean).pop() || ''
261
+ const clean = base.replace(/[\u0000-\u001f\u007f]/g, '').trim()
262
+ return (clean || 'File').slice(0, 120)
263
+ }
264
+ interface SentFiles { caption: string; files: string[] }
265
+ /** The SendUserFile tool uses in one Claude assistant row, or null. Pure. */
266
+ export function sentUserFiles(provider: AgentProvider, row: Record<string, unknown>): SentFiles | null {
267
+ if (provider !== 'claude' || row.type !== 'assistant') return null
268
+ // A sub-agent's share went to its parent, not to the person; an injected row is no one's.
269
+ if (row.isSidechain === true || row.isMeta === true || row.isCompactSummary === true) return null
270
+ const message = row.message as Record<string, unknown> | null
271
+ const content = message && typeof message === 'object' ? message.content : null
272
+ if (!Array.isArray(content)) return null
273
+ const files: string[] = [], captions: string[] = []
274
+ for (const block of content) {
275
+ if (!block || typeof block !== 'object') continue
276
+ const b = block as Record<string, unknown>
277
+ if (b.type !== 'tool_use' || b.name !== 'SendUserFile' || !b.input || typeof b.input !== 'object') continue
278
+ const input = b.input as Record<string, unknown>
279
+ // Preview engine phase 1: 11 of 1,112 real calls sent `files` as a JSON string ("[\"a.png\"]"). Parse it, then the same checks.
280
+ let list: unknown = input.files
281
+ if (typeof list === 'string' && list.length <= 64 * 1024) { try { list = JSON.parse(list) } catch { list = null } }
282
+ if (!Array.isArray(list)) continue
283
+ let any = false
284
+ for (const f of list) {
285
+ if (typeof f !== 'string' || !f || f.length > 4096 || /[\u0000-\u001f\u007f]/.test(f)) continue
286
+ files.push(f); any = true
287
+ }
288
+ if (any && typeof input.caption === 'string' && input.caption.trim()) captions.push(input.caption.trim().slice(0, 4000))
289
+ }
290
+ return files.length ? { caption: captions.join('\n\n'), files } : null
291
+ }
292
+
293
+ /** A Claude row's content has typed words (a text block that is not blank). */
294
+ function hasWords(row: Record<string, unknown>): boolean {
295
+ const content = (row.message as Record<string, unknown> | null | undefined)?.content
296
+ if (typeof content === 'string') return content.trim().length > 0
297
+ return Array.isArray(content) && content.some(b => !!b && typeof b === 'object' && (b as Record<string, unknown>).type === 'text' && typeof (b as Record<string, unknown>).text === 'string' && ((b as Record<string, unknown>).text as string).trim().length > 0)
298
+ }
159
299
 
160
300
  export class AgentSessionOutputImages {
161
301
  private cache = new Map<string, MediaCache>()
162
302
  private pending = new Map<string, Promise<string>>()
163
303
  private active = 0
164
304
  private readers = 0
305
+ private projections = new Map<string, PreviewProjection>()
306
+ /** Transcript parses the preview routes made (the load gate counts them). */
307
+ previewParses = 0
165
308
  constructor(private readonly deps: OutputImageDeps) {}
166
309
 
167
- async context(provider: AgentProvider, sessionId: string, transcript: string): Promise<PreviewContext> {
310
+ /** The preview routes' projection of a transcript, reused for PREVIEW_PROJECTION_TTL_MS while the file is unchanged. */
311
+ private previewProjection(provider: AgentProvider, sessionId: string, transcript: string) {
312
+ const st = statSync(transcript)
313
+ const key = JSON.stringify([provider, sessionId, transcript])
314
+ const now = this.deps.now()
315
+ const hit = this.projections.get(key)
316
+ if (hit && now - hit.at < PREVIEW_PROJECTION_TTL_MS && hit.size === st.size && hit.mtimeMs === st.mtimeMs) return hit.read
317
+ this.previewParses++
318
+ const read = (async () => {
319
+ const context = await this.context(provider, sessionId, transcript)
320
+ const recent = await readRecentSessionTurns(provider, transcript, SESSION_TURNS_MAX, undefined, context.project)
321
+ return { context, turns: recent.turns }
322
+ })()
323
+ this.projections.delete(key)
324
+ this.projections.set(key, { at: now, size: st.size, mtimeMs: st.mtimeMs, read })
325
+ while (this.projections.size > PREVIEW_PROJECTIONS_MAX) this.projections.delete(this.projections.keys().next().value!)
326
+ // A failed parse is never reused.
327
+ read.catch(() => { if (this.projections.get(key)?.read === read) this.projections.delete(key) })
328
+ return read
329
+ }
330
+
331
+ /**
332
+ * `want`: the one tool-image id a content request is for. Only its embedded bytes are kept; a detail read
333
+ * (no `want`) keeps none, so a long session never holds 50 base64 images past the line that parsed them.
334
+ */
335
+ async context(provider: AgentProvider, sessionId: string, transcript: string, want?: string): Promise<PreviewContext> {
168
336
  let workspace: string | null = null
169
337
  try { workspace = await this.deps.workspace(provider, sessionId, transcript) } catch { /* unavailable, words still load */ }
170
338
  const canonical = canonicalRoot(workspace, this.deps.home?.() ?? homedir())
@@ -173,7 +341,19 @@ export class AgentSessionOutputImages {
173
341
  const lookupFailed = canonical === null
174
342
  const sources = new Map<string, Source>()
175
343
  const transcriptKey = digest(resolve(transcript))
176
- const project: SessionTurnProjection = (p, row, turn) => {
344
+ const files = new Map<string, FileSource>()
345
+ const home = this.deps.home?.() ?? homedir()
346
+ let extraRoots: readonly string[] = []
347
+ try { extraRoots = this.deps.extraRoots?.() ?? [] } catch { extraRoots = [] }
348
+ const roots: LinkRoots = { workspace: root, extra: extraRoots, home }
349
+ const links = new Map<string, LinkSource>()
350
+ // A Codex rollout names its working folder in session_meta and each turn_context, not on the message rows.
351
+ let codexCwd: string | null = null
352
+ const markdown: SessionTurnProjection = (p, row, turn) => {
353
+ if (p === 'codex' && (row.type === 'turn_context' || row.type === 'session_meta')) {
354
+ const cwd = (row.payload as Record<string, unknown> | null)?.cwd
355
+ if (typeof cwd === 'string' && isAbsolute(cwd)) codexCwd = cwd
356
+ }
177
357
  const raw = assistantText(p, row)
178
358
  if (!raw) return turn
179
359
  // Prose cleanup can collapse a huge whitespace/code prefix, bringing an unscanned
@@ -182,36 +362,333 @@ export class AgentSessionOutputImages {
182
362
  const shortened = raw.length > OUTPUT_IMAGE_SCAN_CHARS
183
363
  const scanned = shortened ? raw.slice(0, raw.lastIndexOf('\n', OUTPUT_IMAGE_SCAN_CHARS - 1) + 1) : raw
184
364
  const candidates = imageTokens(scanned)
185
- if (!candidates.length && !shortened) return turn
365
+ // Link cards: local links only, never one inside an image token.
366
+ const linked = linkTokens(scanned).filter(l => isLocalTarget(l.target) && !candidates.some(c => l.start < c.end && c.start < l.end))
367
+ if (!candidates.length && !linked.length && !shortened) return turn
186
368
  const recordKey = digest(JSON.stringify(row))
187
369
  const refs = candidates.slice(0, OUTPUT_IMAGES_PER_TURN).map((c, ordinal) => {
188
370
  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 } : {}) }
371
+ // 7.0.4 S2: a secret by name is refused before containment, and its card carries the base name only.
372
+ const descriptor: SessionOutputImage = secretSource(root, c.source)
373
+ ? { id, label: displayName(c.source), ...SECRET }
374
+ : root !== null && inside(root, resolve(root, c.source))
375
+ ? { id, label: c.label, state: 'available' }
376
+ : { id, label: c.label, state: 'unavailable', reason: lookupFailed ? 'unavailable' : 'outside_workspace' }
191
377
  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!)
378
+ while (sources.size > SOURCES_MAX) sources.delete(sources.keys().next().value!)
193
379
  return descriptor
194
380
  })
195
381
  // Never turn an explicit local image into a browser-visible Mac path. Existing callers
196
382
  // without this opt-in projection retain their original text and contract.
197
383
  // The preview cap limits DTOs, not redaction: the sixth image is still private.
384
+ // Link cards (preview engine phase 1): each local link becomes `[words](cos-file:N)`, N its card. The Mac path
385
+ // never stays in the text, with previews on or off, past the card cap or not. The same target twice is one card.
386
+ const rowCwd = p === 'claude' ? (typeof row.cwd === 'string' && isAbsolute(row.cwd) ? row.cwd : null) : p === 'codex' ? codexCwd : null
387
+ const linkRefs: SessionOutputLink[] = [], byTarget = new Map<string, number>()
388
+ let linkExtra = 0
389
+ const linkWords = (token: { label: string; target: string }): string => {
390
+ let n = byTarget.get(token.target)
391
+ if (n === undefined) {
392
+ const resolved = resolveLinkTarget(token.target, [rowCwd, root], roots)
393
+ if (linkRefs.length >= LINK_CARDS_PER_TURN) { linkExtra++; return linkCardWords(token.label, resolved.name, token.target) }
394
+ n = linkRefs.length; byTarget.set(token.target, n)
395
+ linkRefs.push(this.describeLink(provider, sessionId, transcriptKey, recordKey, n, token, resolved, lookupFailed, links))
396
+ }
397
+ // Each link keeps its own words; the same target twice shares one card.
398
+ return '[' + linkCardWords(token.label, linkRefs[n].name, token.target) + '](cos-file:' + n + ')'
399
+ }
400
+ const edits = [
401
+ ...candidates.map(c => ({ start: c.start, end: c.end, words: () => '[Output image]' })),
402
+ ...linked.map(l => ({ start: l.start, end: l.end, words: () => linkWords(l) })),
403
+ ].sort((a, b) => a.start - b.start)
198
404
  const parts: string[] = []; let end = 0
199
- for (const token of candidates) { parts.push(scanned.slice(end, token.start), '[Output image]'); end = token.end }
405
+ for (const edit of edits) { parts.push(scanned.slice(end, edit.start), edit.words()); end = edit.end }
200
406
  parts.push(scanned.slice(end))
407
+ if (linkExtra > 0) parts.push('\n\n' + linkOverflowSentence(linkExtra))
201
408
  const omission = '[Reply shortened at the image preview scan limit.]'
202
- const text = shortened
409
+ let text = shortened
203
410
  ? [latestAssistantReply(parts.join(''), LATEST_REPLY_MAX - omission.length - 1), omission].filter(Boolean).join('\n')
204
411
  : latestAssistantReply(parts.join(''))
412
+ // The reply cap can cut a card marker in half; keep its words, never a broken marker.
413
+ text = text.replace(/\[([^\]\n]*)\]\(cos-[^)\s]*…/g, '$1…')
205
414
  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 }
415
+ return {
416
+ ...(turn ?? { role: 'assistant' as const, text: '', ...(at ? { at } : {}) }), text,
417
+ ...(candidates.length || shortened ? { outputImages: refs } : {}),
418
+ ...(linkRefs.length ? { outputLinks: linkRefs } : {}),
419
+ }
420
+ }
421
+ const project: SessionTurnProjection = (p, row, turn) => {
422
+ const shaped = markdown(p, row, turn)
423
+ const sent = sentUserFiles(p, row)
424
+ if (!sent) return shaped
425
+ const recordKey = digest(JSON.stringify(row))
426
+ // Relative paths are relative to where the session stood when it sent them, as the tool reads them.
427
+ const cwd = typeof row.cwd === 'string' && isAbsolute(row.cwd) ? row.cwd : root
428
+ const images: SessionOutputImage[] = [], out: SessionOutputFile[] = []
429
+ sent.files.slice(0, SHARED_FILES_PER_TURN).forEach((written, ordinal) => {
430
+ const name = displayName(written)
431
+ // Never a URL, a network path or a home shorthand: only a local path is ever opened.
432
+ const remote = /^[a-z][a-z0-9+.-]*:/i.test(written) || written.startsWith('//') || written.startsWith('~')
433
+ const lexical = remote || !cwd ? null : resolve(cwd, written)
434
+ let canonical: string | null = null
435
+ // Preview engine phase 1: a Settings extra folder is an allowed root too (Miles decision 2). Nothing else is.
436
+ if (lexical && (root !== null || extraRoots.length)) { try { canonical = realpathSync(lexical) } catch { canonical = null } }
437
+ const allowRoot = canonical !== null ? authorizedRoot(canonical, roots) : null
438
+ const outside = root === null && lookupFailed ? 'unavailable' as const : 'outside_workspace' as const
439
+ let reason: SessionOutputFile['reason'] | undefined
440
+ let size: number | undefined
441
+ // 7.0.4 S2: refused by name (as written or as it canonically resolves) before anything else is asked.
442
+ if (secretSource(cwd, written)) reason = 'secret_file'
443
+ // Preview engine phase 1: with no workspace, a Settings extra folder can still authorize the file.
444
+ else if (root === null && !extraRoots.length) reason = lookupFailed ? 'unavailable' : 'outside_workspace'
445
+ else if (!lexical) reason = 'outside_workspace'
446
+ // The canonical file decides: a link that leaves the workspace is outside it.
447
+ else if (canonical !== null && !allowRoot) reason = outside
448
+ else if (canonical === null) reason = authorizedRoot(lexical, roots) ? 'missing' : outside
449
+ else {
450
+ try {
451
+ const st = statSync(canonical)
452
+ if (!st.isFile()) reason = 'missing'
453
+ // 7.0.4 QA: a hard link is the same file under another name, and realpath cannot see it. A sent file can be
454
+ // any text, so one with more than one link is refused as a secret. Images go through the JPEG normaliser.
455
+ else if (st.nlink > 1 && !NORMALIZED_IMAGE.test(name)) reason = 'secret_file'
456
+ else size = st.size
457
+ } catch { reason = 'missing' }
458
+ }
459
+ const allowed = reason === undefined
460
+ if (NORMALIZED_IMAGE.test(name)) {
461
+ const id = 'ai_' + createHmac('sha256', this.deps.secret).update(JSON.stringify([provider, sessionId, transcriptKey, recordKey, 'sent', ordinal, written])).digest('hex')
462
+ const descriptor: SessionOutputImage = reason === 'secret_file' ? { id, label: name, ...SECRET } : { id, label: name, state: allowed ? 'available' : 'unavailable', ...(reason ? { reason } : {}) }
463
+ // The canonical path is the source, so the content route's no-symlink check holds for it.
464
+ sources.delete(id); sources.set(id, { candidate: { source: allowed ? canonical! : written, label: name }, root: allowRoot ?? root, descriptor })
465
+ while (sources.size > SOURCES_MAX) sources.delete(sources.keys().next().value!)
466
+ images.push(descriptor)
467
+ return
468
+ }
469
+ const id = 'af_' + createHmac('sha256', this.deps.secret).update(JSON.stringify([provider, sessionId, transcriptKey, recordKey, 'file', ordinal, written])).digest('hex')
470
+ const { kind, type } = sharedFileKind(name)
471
+ const file: SessionOutputFile = reason === 'secret_file' ? { id, name, kind, type, ...SECRET } : { id, name, kind, type, ...(size !== undefined ? { size } : {}), state: allowed ? 'available' : 'unavailable', ...(reason ? { reason } : {}) }
472
+ files.delete(id); files.set(id, { path: allowed ? canonical : null, root: allowRoot ?? root, file })
473
+ while (files.size > SOURCES_MAX) files.delete(files.keys().next().value!)
474
+ out.push(file)
475
+ })
476
+ // The caption is the session's own words; an inline image in it is never a way to show a path.
477
+ const tokens = imageTokens(sent.caption)
478
+ let caption = '', end = 0
479
+ for (const token of tokens) { caption += sent.caption.slice(end, token.start) + '[Output image]'; end = token.end }
480
+ caption += sent.caption.slice(end)
481
+ const extra = sent.files.length - SHARED_FILES_PER_TURN
482
+ const words = [caption && latestAssistantReply(caption), extra > 0 ? extra + (extra === 1 ? ' more file was sent and is not listed here.' : ' more files were sent and are not listed here.') : ''].filter(Boolean).join('\n\n')
483
+ const at = typeof row.timestamp === 'string' && Number.isFinite(Date.parse(row.timestamp)) ? row.timestamp : undefined
484
+ const base = shaped ?? { role: 'assistant' as const, text: '', ...(at ? { at } : {}) }
485
+ return {
486
+ ...base,
487
+ text: [base.text, words].filter(Boolean).join('\n\n'),
488
+ ...(images.length || base.outputImages?.length ? { outputImages: [...(base.outputImages ?? []), ...images] } : {}),
489
+ ...(out.length ? { outputFiles: out } : {}),
490
+ }
491
+ }
492
+ // ── Images the session looked at: tool results, and images attached to prompts ──
493
+ // The reader shows messages, not tool calls, so a step's images ride on the assistant turn the step followed
494
+ // (the words before it, as the Claude app shows the step under them). A step that came before any words in this
495
+ // window waits for the next assistant turn and is marked `before`. A user turn ends a step that never got words.
496
+ const toolSources = new Map<string, ToolSource>()
497
+ const uses = new Map<string, ToolUseMeta>()
498
+ let groups = new WeakMap<SessionTurn, { before: number; after: number }>()
499
+ let last: SessionTurn | null = null
500
+ let pending: SessionToolImage[] = [], pendingExtra = 0
501
+ const recordKeyOf = (row: Record<string, unknown>) => typeof row.uuid === 'string' && row.uuid ? 'u:' + row.uuid : digest(JSON.stringify(row))
502
+ const describe = (recordKey: string, scope: 'tool' | 'prompt', ordinal: number, found: FoundToolImage): SessionToolImage => {
503
+ const id = 'ti_' + createHmac('sha256', this.deps.secret).update(JSON.stringify([provider, sessionId, transcriptKey, recordKey, scope, ordinal])).digest('hex')
504
+ const ref = found.ref
505
+ let reason: SessionToolImage['reason'] | undefined
506
+ if (ref.kind === 'embedded') {
507
+ // 7.0.4 QA: a Read's embedded bytes are judged by the file it read; bytes with no path (a screenshot, a prompt
508
+ // attachment) have no name to judge.
509
+ if (ref.path && secretSource(root, ref.path)) reason = 'secret_file'
510
+ else if (estimateBytes(ref.data) > MAX_TOOL_IMAGE_BYTES) reason = 'too_large'
511
+ else if (id === want) toolSources.set(id, { kind: 'embedded', mediaType: ref.mediaType, data: ref.data })
512
+ } else if (ref.kind === 'path') {
513
+ // A Read that names a path and carries no bytes: the output-image rule, the session's exact workspace only.
514
+ // 7.0.4 S2: a secret by name is refused first (embedded bytes from a Read are judged by the same name above).
515
+ let canonical: string | null = null
516
+ if (secretSource(root, ref.path)) reason = 'secret_file'
517
+ else if (root === null) reason = lookupFailed ? 'unavailable' : 'outside_workspace'
518
+ else if (!inside(root, resolve(root, ref.path))) reason = 'outside_workspace'
519
+ else {
520
+ // The canonical file decides, as for sent files: a link that leaves the workspace is outside it. Resolved
521
+ // once: a second realpath could throw if the file went away in between (7.0.4 QA), which lost the history.
522
+ try { canonical = realpathSync(resolve(root, ref.path)) } catch { canonical = null }
523
+ if (canonical === null) reason = 'missing'
524
+ else if (!inside(root, canonical)) reason = 'outside_workspace'
525
+ }
526
+ if (root !== null && !reason && canonical !== null) {
527
+ // The canonical path is the source, as for sent images, so the content read's no-link check holds for it.
528
+ toolSources.delete(id); toolSources.set(id, { kind: 'path', source: canonical, root, label: found.tool })
529
+ while (toolSources.size > SOURCES_MAX) toolSources.delete(toolSources.keys().next().value!)
530
+ }
531
+ } else reason = 'missing'
532
+ return { id, tool: found.tool, index: 0, total: 0, state: reason ? 'unavailable' : 'available', ...(reason ? { reason } : {}), ...(reason === 'secret_file' ? { notice: SECRET_FILE_NOTICE } : {}) }
533
+ }
534
+ // The turn is already in the reader's list, so it is extended in place. Past the cap an image is counted, never listed.
535
+ const attach = (turn: SessionTurn, images: SessionToolImage[], extra: number, before: boolean) => {
536
+ const g = groups.get(turn) ?? { before: 0, after: 0 }; groups.set(turn, g)
537
+ const key = before ? 'before' : 'after'
538
+ const list = turn.toolImages ?? []
539
+ for (const d of images) {
540
+ g[key]++
541
+ if (list.length < TOOL_IMAGES_PER_TURN) list.push({ ...d, index: g[key], ...(before ? { before: true as const } : {}) })
542
+ else turn.toolImagesMore = (turn.toolImagesMore ?? 0) + 1
543
+ }
544
+ if (extra > 0) { g[key] += extra; turn.toolImagesMore = (turn.toolImagesMore ?? 0) + extra }
545
+ for (const d of list) if ((d.before === true) === before) d.total = g[key]
546
+ if (list.length) turn.toolImages = list
207
547
  }
208
- return { project, sources }
548
+ const withTools: SessionTurnProjection = (p, row, turn) => {
549
+ for (const [id, meta] of toolUsesOf(p, row)) {
550
+ uses.delete(id); uses.set(id, meta)
551
+ while (uses.size > TOOL_USES_MAX) uses.delete(uses.keys().next().value!)
552
+ }
553
+ let result = project(p, row, turn)
554
+ const found = toolResultImages(p, row, uses)
555
+ if (found.length) {
556
+ const key = recordKeyOf(row)
557
+ const described = found.map((f, i) => describe(key, 'tool', i, f))
558
+ if (last && last.role === 'assistant') attach(last, described, 0, false)
559
+ else for (const d of described) { if (pending.length < TOOL_IMAGES_PER_TURN) pending.push(d); else pendingExtra++ }
560
+ }
561
+ const attached = promptImages(p, row)
562
+ if (attached.length) {
563
+ // A Claude prompt that is only an image (sent through the SDK) is still something the person said.
564
+ if (!result && p === 'claude' && !hasWords(row)) {
565
+ const at = typeof row.timestamp === 'string' && Number.isFinite(Date.parse(row.timestamp)) ? row.timestamp : undefined
566
+ result = { role: 'user', text: attached.length === 1 ? '[Image]' : `[${attached.length} images]`, ...(at ? { at } : {}) }
567
+ }
568
+ if (result && result.role === 'user') {
569
+ const key = recordKeyOf(row)
570
+ attach(result, attached.map((ref, i) => describe(key, 'prompt', i, { tool: 'Attached', ref })), 0, false)
571
+ }
572
+ }
573
+ if (result) {
574
+ if (result.role === 'assistant' && (pending.length || pendingExtra)) attach(result, pending, pendingExtra, true)
575
+ pending = []; pendingExtra = 0
576
+ last = result
577
+ }
578
+ return result
579
+ }
580
+ withTools.reset = () => {
581
+ // readRecentSessionTurns may grow from 2 to 8 to 24 MiB. Each pass must project
582
+ // only its own records, not attach early images to a turn discarded with the smaller window.
583
+ sources.clear(); files.clear(); toolSources.clear(); uses.clear(); links.clear()
584
+ codexCwd = null
585
+ groups = new WeakMap()
586
+ last = null; pending = []; pendingExtra = 0
587
+ }
588
+ return { project: withTools, sources, files, toolSources, links, workspace: root }
589
+ }
590
+
591
+ /** One link card's descriptor; an available card's canonical source is remembered for the preview route. */
592
+ private describeLink(provider: AgentProvider, sessionId: string, transcriptKey: string, recordKey: string, ordinal: number,
593
+ token: { label: string; target: string }, resolved: ReturnType<typeof resolveLinkTarget>, lookupFailed: boolean, links: Map<string, LinkSource>): SessionOutputLink {
594
+ const id = 'al_' + createHmac('sha256', this.deps.secret).update(JSON.stringify([provider, sessionId, transcriptKey, recordKey, 'link', ordinal, token.target])).digest('hex')
595
+ const { line } = splitLineAnchor(token.target)
596
+ const name = resolved.name
597
+ const typed = resolved.state === 'available' && resolved.isDir ? { kind: 'folder' as PreviewKind, type: 'Folder' } : previewKind(name)
598
+ const link: SessionOutputLink = {
599
+ id, label: linkLabel(token.label, name), name, kind: typed.kind, type: typed.type,
600
+ ...(resolved.state === 'available' && resolved.size !== undefined ? { size: resolved.size } : {}),
601
+ ...(line ? { line } : {}),
602
+ state: resolved.state,
603
+ ...(resolved.state === 'unavailable' ? { reason: resolved.reason === 'outside_workspace' && lookupFailed ? 'unavailable' as const : resolved.reason } : {}),
604
+ ...(resolved.state === 'unavailable' && resolved.reason === 'secret_file' ? { notice: SECRET_FILE_NOTICE } : {}),
605
+ }
606
+ if (resolved.state === 'available') {
607
+ links.delete(id); links.set(id, { path: resolved.path, root: resolved.root, link })
608
+ while (links.size > SOURCES_MAX) links.delete(links.keys().next().value!)
609
+ }
610
+ return link
611
+ }
612
+
613
+ /**
614
+ * A link card (`al_`) or a sent file (`af_`), re-authorized for the preview route: the id must name a card or file in
615
+ * the session's newest turns, its canonical path must still be inside the root it was authorized under (the workspace
616
+ * or a Settings extra folder, read fresh), never a denied secret name, no component a link, and the same file before
617
+ * and after the read. `peek` reads no bytes (the meta part and image parts).
618
+ */
619
+ async previewFile(provider: AgentProvider, sessionId: string, id: string, peek = false): Promise<
620
+ | { status: 'ok'; bytes: Buffer; name: string; kind: PreviewKind; type: string; line?: number; identity: { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number }; path: string; root: string; descriptor: SessionOutputLink | SessionOutputFile }
621
+ | { status: 'not_found' | 'unavailable' | 'too_large' | 'secret' | 'folder' | 'busy'; reason?: string }> {
622
+ const isLink = SESSION_OUTPUT_LINK_ID.test(id)
623
+ if (!isLink && !SESSION_OUTPUT_FILE_ID.test(id)) return { status: 'not_found' }
624
+ // 7.0.4 QA: at the limit the answer is "busy" (503 previews_busy, retried by the web), never "unavailable".
625
+ if (this.readers >= OUTPUT_IMAGE_CONCURRENCY) return { status: 'busy' }
626
+ this.readers++
627
+ try {
628
+ const transcript = await this.deps.findSession(provider, sessionId)
629
+ if (!transcript) return { status: 'not_found' }
630
+ const { context, turns } = await this.previewProjection(provider, sessionId, transcript)
631
+ const descriptor = isLink
632
+ ? turns.flatMap(t => t.outputLinks ?? []).find(l => l.id === id)
633
+ : turns.flatMap(t => t.outputFiles ?? []).find(f => f.id === id)
634
+ if (!descriptor) return { status: 'not_found' }
635
+ // 7.0.4 S2: an id that names a secret answers 403 secret_file_denied, never bytes.
636
+ if (descriptor.reason === 'secret_file') return { status: 'secret' }
637
+ let path: string | null = null, root: string | null = null
638
+ if (isLink) { const s = context.links.get(id); path = s?.path ?? null; root = s?.root ?? null }
639
+ else { const s = context.files.get(id); path = s?.path ?? null; root = s?.root ?? null }
640
+ if (descriptor.state !== 'available' || !path || !root) return { status: 'unavailable' }
641
+ // The root must still be allowed NOW: a Settings extra folder removed since the projection no longer counts.
642
+ if (root !== context.workspace) {
643
+ let extra: readonly string[] = []
644
+ try { extra = this.deps.extraRoots?.() ?? [] } catch { extra = [] }
645
+ if (!extra.includes(root)) return { status: 'unavailable' }
646
+ }
647
+ // The shared deny list, before any byte is read (and for a file sent before the list existed).
648
+ if (isDeniedSecretPath(path)) return { status: 'secret' }
649
+ if ((descriptor as SessionOutputLink).kind === 'folder') return { status: 'folder' }
650
+ if (!insideRoot(root, path) || realpathSync(path) !== path) return { status: 'unavailable' }
651
+ const { kind, type } = previewKind(descriptor.name)
652
+ const line = isLink ? (descriptor as SessionOutputLink).line : undefined
653
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
654
+ try {
655
+ const before = await file.stat()
656
+ if (!before.isFile()) return { status: 'unavailable' }
657
+ // 7.0.4 QA (the sent-file rule, for link cards too): a file with another hard link may be a secret under an
658
+ // innocent name, which realpath cannot see. Pictures go through the JPEG normaliser and are exempt.
659
+ if (before.nlink > 1 && !NORMALIZED_IMAGE.test(descriptor.name)) return { status: 'secret' }
660
+ if (before.size > MAX_SHARED_FILE_BYTES) return { status: 'too_large' }
661
+ const identity = { dev: before.dev, ino: before.ino, size: before.size, mtimeMs: before.mtimeMs, ctimeMs: before.ctimeMs }
662
+ const base = { status: 'ok' as const, name: descriptor.name, kind, type, ...(line ? { line } : {}), identity, path, root, descriptor }
663
+ if (peek) return { ...base, bytes: Buffer.alloc(0) }
664
+ const bytes = Buffer.alloc(before.size)
665
+ let offset = 0
666
+ while (offset < bytes.length) { const got = await file.read(bytes, offset, bytes.length - offset, offset); if (!got.bytesRead) break; offset += got.bytesRead }
667
+ const after = await file.stat(), current = statSync(path)
668
+ if (offset !== bytes.length || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs
669
+ || current.dev !== before.dev || current.ino !== before.ino || realpathSync(path) !== path) return { status: 'unavailable' }
670
+ return { ...base, bytes }
671
+ } finally { await file.close() }
672
+ } catch (error) {
673
+ // QA 7.0.5: the route logs this once, as reason=error_<code> (never the message, which carries the path).
674
+ return { status: 'unavailable', reason: 'error_' + previewErrorCode(error) }
675
+ }
676
+ finally { this.readers-- }
677
+ }
678
+
679
+ /** A link card or sent file that is a picture, through the same normalizer as output images (a JPEG). */
680
+ async previewImage(provider: AgentProvider, sessionId: string, id: string, variant: 'thumb' | 'phone') {
681
+ const found = await this.previewFile(provider, sessionId, id, true)
682
+ if (found.status !== 'ok') return found
683
+ if (found.kind !== 'image') return { status: 'unavailable' as const }
684
+ try { return await this.workspaceImage(id, found.root, found.path, found.name, variant) } catch (error) { return { status: 'unavailable' as const, reason: 'error_' + previewErrorCode(error) } }
209
685
  }
210
686
 
211
687
  async content(provider: AgentProvider, sessionId: string, imageId: string, variant: 'thumb' | 'phone') {
212
688
  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 }
689
+ // Admission covers discovery, transcript parsing, authorization and image IO alike. Full is `busy` (7.0.4 QA): the
690
+ // Mac is working on other previews, which is not the same as unreachable.
691
+ if (this.readers >= OUTPUT_IMAGE_CONCURRENCY) return { status: 'busy' as const }
215
692
  this.readers++
216
693
  try {
217
694
  const transcript = await this.deps.findSession(provider, sessionId)
@@ -220,51 +697,151 @@ export class AgentSessionOutputImages {
220
697
  const recent = await readRecentSessionTurns(provider, transcript, SESSION_TURNS_MAX, undefined, context.project)
221
698
  if (!recent.turns.some(t => t.outputImages?.some(i => i.id === imageId))) return { status: 'not_found' as const }
222
699
  const source = context.sources.get(imageId)
700
+ // 7.0.4 S2: after the id is authorized from the transcript, a secret by name answers 403, never bytes.
701
+ if (source?.descriptor.reason === 'secret_file') return { status: 'secret' as const }
223
702
  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 }
703
+ return await this.workspaceImage(imageId, source.root, source.candidate.source, source.candidate.label, variant)
704
+ } catch (error) { logPreviewFailure('output_image', error); return { status: 'unavailable' as const } }
705
+ finally { this.readers-- }
706
+ }
707
+
708
+ /**
709
+ * A tool-result or prompt image (`ti_` id), re-authorized from the transcript: the id must name an image in the
710
+ * session's newest turns. Embedded bytes are decoded here and typed by what they are; a path-only Read goes
711
+ * through the same workspace read and normalizer as an output image.
712
+ */
713
+ async toolImageContent(provider: AgentProvider, sessionId: string, imageId: string): Promise<
714
+ { status: 'ok'; bytes: Buffer; type: string } | { status: 'ok'; path: string; mime: string; bytes: number } | { status: 'not_found' | 'unavailable' | 'too_large' | 'secret' | 'busy' }> {
715
+ if (!SESSION_TOOL_IMAGE_ID.test(imageId)) return { status: 'not_found' }
716
+ if (this.readers >= OUTPUT_IMAGE_CONCURRENCY) return { status: 'busy' }
717
+ this.readers++
718
+ try {
719
+ const transcript = await this.deps.findSession(provider, sessionId)
720
+ if (!transcript) return { status: 'not_found' }
721
+ const context = await this.context(provider, sessionId, transcript, imageId)
722
+ const recent = await readRecentSessionTurns(provider, transcript, SESSION_TURNS_MAX, undefined, context.project)
723
+ const descriptor = recent.turns.flatMap(t => t.toolImages ?? []).find(i => i.id === imageId)
724
+ if (!descriptor) return { status: 'not_found' }
725
+ if (descriptor.reason === 'too_large') return { status: 'too_large' }
726
+ if (descriptor.reason === 'secret_file') return { status: 'secret' }
727
+ const source = context.toolSources.get(imageId)
728
+ if (descriptor.state !== 'available' || !source) return { status: 'unavailable' }
729
+ if (source.kind === 'embedded') return decodeToolImage(source)
730
+ const normalized = await this.workspaceImage(imageId, source.root, source.source, source.label, 'phone')
731
+ return normalized.status === 'ok' ? normalized : normalized.status === 'busy' ? { status: 'busy' } : { status: 'unavailable' }
732
+ } catch (error) { logPreviewFailure('tool_image', error); return { status: 'unavailable' } }
733
+ finally { this.readers-- }
734
+ }
735
+
736
+ /** The workspace read every path-based preview shares: exact containment, no links, the same file before and after. */
737
+ private async workspaceImage(imageId: string, root: string, written: string, label: string, variant: 'thumb' | 'phone') {
738
+ const source = { root, candidate: { source: written, label } }
739
+ const path = resolve(source.root, source.candidate.source)
740
+ const canonical = realpathSync(path)
741
+ if (!inside(source.root, canonical)) return { status: 'unavailable' as const }
742
+ // Reject symlinks in any component and recheck path/fd identity after reading.
743
+ // A changed path never becomes a published preview.
744
+ if (canonical !== path) return { status: 'unavailable' as const }
745
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
746
+ let bytes: Buffer, key: string
747
+ try {
748
+ const before = await file.stat()
749
+ if (!before.isFile() || before.size <= 0 || before.size > MAX_OUTPUT_ARTIFACT_BYTES) return { status: 'unavailable' as const }
750
+ key = digest(JSON.stringify([imageId, before.dev, before.ino, before.size, before.mtimeMs, before.ctimeMs]))
751
+ // Even a cache hit rechecks that the authorized source still exists within the root.
752
+ const hit = this.cache.get(key)
753
+ if (hit && this.deps.now() - hit.at < CACHE_TTL) {
754
+ const current = statSync(path)
755
+ if (realpathSync(path) !== canonical || current.dev !== before.dev || current.ino !== before.ino) return { status: 'unavailable' as const }
756
+ const content = this.deps.store().getContent(hit.id, variant)
757
+ if (content.status === 'ok') return content
758
+ }
759
+ // Bounded positional read, not readFile(): an append during read cannot allocate past the cap.
760
+ bytes = Buffer.alloc(before.size)
761
+ let offset = 0
762
+ while (offset < bytes.length) { const got = await file.read(bytes, offset, bytes.length - offset, offset); if (!got.bytesRead) break; offset += got.bytesRead }
763
+ const after = await file.stat(), current = statSync(path)
764
+ 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 }
765
+ } finally { await file.close() }
766
+ let job = this.pending.get(key!)
767
+ if (!job) {
768
+ if (this.active >= OUTPUT_IMAGE_CONCURRENCY) return { status: 'busy' as const }
769
+ this.active++
770
+ job = this.deps.store().ingestOutputImage({ bytes: bytes!, kind: 'generated_visual', label: source.candidate.label }).then(ref => {
771
+ this.cache.set(key!, { id: ref.id, at: this.deps.now() })
772
+ while (this.cache.size > OUTPUT_IMAGE_CACHE_MAX) this.cache.delete(this.cache.keys().next().value!)
773
+ return ref.id
774
+ }).finally(() => { this.active--; this.pending.delete(key!) })
775
+ this.pending.set(key!, job)
776
+ }
777
+ return this.deps.store().getContent(await job, variant)
778
+ }
779
+
780
+ /**
781
+ * The bytes of a file a session sent (6.99.35), re-authorized like an image: the id must name a
782
+ * file in the session's newest turns, its canonical path must still be inside the exact workspace,
783
+ * no component may be a link, and the file must be the same one before and after the read.
784
+ */
785
+ async fileContent(provider: AgentProvider, sessionId: string, fileId: string): Promise<
786
+ { status: 'ok'; bytes: Buffer; name: string; kind: SharedFileKind } | { status: 'not_found' | 'unavailable' | 'too_large' | 'secret' | 'busy' }> {
787
+ if (!SESSION_OUTPUT_FILE_ID.test(fileId)) return { status: 'not_found' }
788
+ if (this.readers >= OUTPUT_IMAGE_CONCURRENCY) return { status: 'busy' }
789
+ this.readers++
790
+ try {
791
+ const transcript = await this.deps.findSession(provider, sessionId)
792
+ if (!transcript) return { status: 'not_found' }
793
+ const context = await this.context(provider, sessionId, transcript)
794
+ const recent = await readRecentSessionTurns(provider, transcript, SESSION_TURNS_MAX, undefined, context.project)
795
+ if (!recent.turns.some(t => t.outputFiles?.some(f => f.id === fileId))) return { status: 'not_found' }
796
+ const source = context.files.get(fileId)
797
+ if (source?.file.reason === 'secret_file') return { status: 'secret' }
798
+ if (!source?.root || !source.path || source.file.state !== 'available') return { status: 'unavailable' }
799
+ const path = source.path
800
+ if (!inside(source.root, path) || realpathSync(path) !== path) return { status: 'unavailable' }
230
801
  const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
231
- let bytes: Buffer, key: string
232
802
  try {
233
803
  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)
804
+ if (!before.isFile()) return { status: 'unavailable' }
805
+ // 7.0.4 QA: the hard-link rule again on the open file, so a link made after the listing is refused too.
806
+ if (before.nlink > 1) return { status: 'secret' }
807
+ if (before.size > MAX_SHARED_FILE_BYTES) return { status: 'too_large' }
808
+ const bytes = Buffer.alloc(before.size)
246
809
  let offset = 0
247
810
  while (offset < bytes.length) { const got = await file.read(bytes, offset, bytes.length - offset, offset); if (!got.bytesRead) break; offset += got.bytesRead }
248
811
  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 }
812
+ if (offset !== bytes.length || after.size !== before.size || after.mtimeMs !== before.mtimeMs || current.dev !== before.dev || current.ino !== before.ino || realpathSync(path) !== path) return { status: 'unavailable' }
813
+ return { status: 'ok', bytes, name: source.file.name, kind: source.file.kind }
250
814
  } 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 } }
815
+ } catch (error) { logPreviewFailure('sent_file', error); return { status: 'unavailable' } }
264
816
  finally { this.readers-- }
265
817
  }
266
818
  }
267
819
 
820
+ /** 7.0.4 QA: a preview read that failed is logged with its kind and error code only (lib/preview-error-code.ts). */
821
+ function logPreviewFailure(kind: 'output_image' | 'tool_image' | 'sent_file', error: unknown): void {
822
+ console.warn(`[agent-session-previews] ${kind} read failed error=${previewErrorCode(error)}`)
823
+ }
824
+
825
+ /** Content-Type, Content-Disposition and CSP for a sent file. HTML is never served as HTML. */
826
+ export function sharedFileHeaders(name: string, kind: SharedFileKind): Record<string, string> {
827
+ const ascii = name.replace(/[^A-Za-z0-9._ -]/g, '_').replace(/^[ .]+/, '') || 'file'
828
+ const inline = kind === 'pdf'
829
+ const type = kind === 'pdf' ? 'application/pdf'
830
+ : kind === 'text' ? 'text/plain; charset=utf-8'
831
+ : kind === 'image' && /\.gif$/i.test(name) ? 'image/gif'
832
+ : 'application/octet-stream'
833
+ const headers: Record<string, string> = {
834
+ 'Content-Type': type,
835
+ 'Content-Disposition': `${inline ? 'inline' : 'attachment'}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`,
836
+ 'X-Content-Type-Options': 'nosniff',
837
+ 'Cache-Control': 'private, no-store',
838
+ 'Cross-Origin-Resource-Policy': 'same-origin',
839
+ }
840
+ // Opened directly, nothing in it runs and it cannot reach this origin. A PDF keeps the browser's viewer.
841
+ if (!inline) headers['Content-Security-Policy'] = "sandbox; default-src 'none'"
842
+ return headers
843
+ }
844
+
268
845
  export const agentSessionOutputImages = new AgentSessionOutputImages({
269
846
  findSession: (p, id) => findAgentSessionFile(p, id, agentSessionRoots()),
270
847
  workspace: async (provider, id, transcript) => {
@@ -277,4 +854,5 @@ export const agentSessionOutputImages = new AgentSessionOutputImages({
277
854
  },
278
855
  store: getMediaStore,
279
856
  secret: randomBytes(32), now: Date.now,
857
+ extraRoots: () => previewFoldersSetting.read().folders,
280
858
  })