@gotcos/glasses-server 6.99.33 → 7.0.18

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 (133) hide show
  1. package/.env.example +37 -0
  2. package/CHANGELOG.md +587 -0
  3. package/README.md +3 -1
  4. package/docs/web-key-verifier-contract.md +378 -0
  5. package/managed-runtime-contract.json +12 -1
  6. package/package.json +10 -3
  7. package/server/bootstrap.ts +10 -0
  8. package/server/index.ts +155 -30
  9. package/server/lib/agent-session-output-images.ts +627 -49
  10. package/server/lib/agent-session-recent.ts +321 -0
  11. package/server/lib/agent-session-store.ts +27 -14
  12. package/server/lib/agent-session-tool-images.ts +224 -0
  13. package/server/lib/agent-session-turns.ts +19 -2
  14. package/server/lib/api-auth.ts +43 -1
  15. package/server/lib/ask-work-plan.ts +3 -1
  16. package/server/lib/calendar-meetings.ts +70 -0
  17. package/server/lib/calendar-source.ts +536 -0
  18. package/server/lib/claude-hooks-installer.ts +111 -12
  19. package/server/lib/codex-bridge.ts +4 -1
  20. package/server/lib/codex-run-ledger.ts +29 -0
  21. package/server/lib/context-builder.ts +2 -1
  22. package/server/lib/conversation.ts +56 -28
  23. package/server/lib/cors-reject-log.ts +51 -0
  24. package/server/lib/disk-io-meter.ts +202 -0
  25. package/server/lib/drop-p2-runtime.ts +1 -1
  26. package/server/lib/drop-service.ts +47 -12
  27. package/server/lib/drop-store.ts +31 -5
  28. package/server/lib/event-loop-stall.ts +62 -0
  29. package/server/lib/full-hold-engine.ts +114 -0
  30. package/server/lib/full-hold.ts +124 -0
  31. package/server/lib/g2-turn-provenance.ts +128 -9
  32. package/server/lib/learning-needs-you.ts +24 -7
  33. package/server/lib/listener-startup.ts +18 -0
  34. package/server/lib/live-events.ts +533 -0
  35. package/server/lib/local-path-redact.ts +272 -0
  36. package/server/lib/meeting-ask-orientation.ts +40 -6
  37. package/server/lib/meeting-ask-runner.ts +30 -10
  38. package/server/lib/meeting-batch-transcribe.ts +32 -5
  39. package/server/lib/morning-brief-runtime.ts +4 -2
  40. package/server/lib/native-mac-caller.ts +29 -0
  41. package/server/lib/permission-broker.ts +443 -11
  42. package/server/lib/ping-engine.ts +1 -1
  43. package/server/lib/ping-sources.ts +11 -6
  44. package/server/lib/preview-content.ts +172 -0
  45. package/server/lib/preview-error-code.ts +10 -0
  46. package/server/lib/preview-folders-setting.ts +133 -0
  47. package/server/lib/preview-log-throttle.ts +18 -0
  48. package/server/lib/preview-thumbs.ts +139 -0
  49. package/server/lib/secret-path-guard.ts +83 -0
  50. package/server/lib/session-cache-writer.ts +8 -1
  51. package/server/lib/session-files-setting.ts +88 -0
  52. package/server/lib/session-hook-events.ts +23 -1
  53. package/server/lib/session-hooks-runtime.ts +34 -0
  54. package/server/lib/session-links.ts +280 -0
  55. package/server/lib/session-log.ts +313 -4
  56. package/server/lib/session-state-derive.ts +36 -0
  57. package/server/lib/session-stream-events.ts +25 -1
  58. package/server/lib/thread-turn-queue.ts +10 -0
  59. package/server/lib/today-brief.ts +640 -85
  60. package/server/lib/turn-end-ask.ts +320 -0
  61. package/server/lib/turn-footer.ts +3 -3
  62. package/server/lib/web-control-integrity.ts +160 -0
  63. package/server/lib/web-control-offnet-page.ts +58 -0
  64. package/server/lib/web-control-static.ts +53 -9
  65. package/server/lib/web-key-authority.ts +372 -0
  66. package/server/lib/web-key-envelope.ts +98 -0
  67. package/server/lib/web-key-handoff.ts +135 -0
  68. package/server/lib/web-key-origin.ts +21 -0
  69. package/server/lib/web-key-passkey.ts +155 -0
  70. package/server/lib/web-key-registry.ts +825 -0
  71. package/server/lib/web-key-signer-app.ts +152 -0
  72. package/server/lib/web-memories-bridge.ts +3 -1
  73. package/server/lib/web-session-auth.ts +133 -15
  74. package/server/lib/whisper-local.ts +93 -7
  75. package/server/lib/whisper-preview.ts +5 -3
  76. package/server/lib/work-activity.ts +56 -6
  77. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  78. package/server/lib/work-review-runtime.ts +17 -4
  79. package/server/lib/work-session-activity.ts +260 -0
  80. package/server/lib/your-move-count.ts +9 -3
  81. package/server/routes/agent-session-bindings.ts +29 -1
  82. package/server/routes/agent-session-stream.ts +23 -0
  83. package/server/routes/agent-sessions.ts +146 -11
  84. package/server/routes/ask-work-plan.ts +4 -1
  85. package/server/routes/claude-sessions.ts +5 -1
  86. package/server/routes/drops.ts +7 -2
  87. package/server/routes/g2-turns.ts +35 -0
  88. package/server/routes/health.ts +46 -1
  89. package/server/routes/live-events.ts +334 -0
  90. package/server/routes/permission-broker.ts +95 -5
  91. package/server/routes/query-jobs.ts +3 -0
  92. package/server/routes/session-preview.ts +200 -0
  93. package/server/routes/sessions.ts +15 -24
  94. package/server/routes/thread-turn-queue.ts +223 -12
  95. package/server/routes/today-brief.ts +134 -44
  96. package/server/routes/web-control.ts +37 -4
  97. package/server/routes/web-key.ts +284 -0
  98. package/server/routes/welcome-context.ts +28 -4
  99. package/server/routes/work-board.ts +9 -2
  100. package/server/routes/work-orchestrator.ts +12 -7
  101. package/server/scripts/write-web-control-manifest.mjs +43 -0
  102. package/server/web-control/.manifest.json +44 -0
  103. package/server/web-control/app.css +76 -1
  104. package/server/web-control/app.js +2247 -98
  105. package/server/web-control/apple-touch-icon.png +0 -0
  106. package/server/web-control/control.css +25 -0
  107. package/server/web-control/index.html +21 -3
  108. package/server/web-control/memory-review.js +100 -0
  109. package/server/web-control/panes/cos-chat.css +94 -9
  110. package/server/web-control/panes/cos-chat.js +628 -44
  111. package/server/web-control/panes/meetings.css +18 -2
  112. package/server/web-control/panes/meetings.js +24 -8
  113. package/server/web-control/panes/memories.css +17 -2
  114. package/server/web-control/panes/memories.js +127 -42
  115. package/server/web-control/panes/messages.js +21 -2
  116. package/server/web-control/panes/preview.js +318 -0
  117. package/server/web-control/panes/questions.js +162 -21
  118. package/server/web-control/panes/reader-media.js +409 -23
  119. package/server/web-control/panes/sessions.css +32 -3
  120. package/server/web-control/panes/settings.js +97 -6
  121. package/server/web-control/panes/today-actions.css +46 -0
  122. package/server/web-control/panes/today-actions.js +119 -17
  123. package/server/web-control/panes/work.css +13 -0
  124. package/server/web-control/panes/work.js +188 -37
  125. package/server/web-control/shell.js +17 -14
  126. package/server/web-signer/.manifest.json +11 -0
  127. package/server/web-signer/index.html +74 -0
  128. package/server/web-signer/signer-proof.js +348 -0
  129. package/server/web-signer/signer-store.js +39 -0
  130. package/server/web-signer/signer.css +23 -0
  131. package/server/web-signer/signer.js +341 -0
  132. package/shared/web-approval-packets.golden.json +124 -0
  133. package/shared/web-turn-envelope.ts +271 -0
@@ -0,0 +1,321 @@
1
+ /**
2
+ * "Recent activity first" for the Today summary (Miles, 2026-11-09): what was said INTO a session lately and where its
3
+ * latest reply left off, with when, so the summary describes what a session is doing now instead of its first prompt.
4
+ *
5
+ * Why: each session's list `line` (composeDiscussionSummary) is its FIRST prompt plus the latest reply, cut at 180
6
+ * characters. On 11/09 the Codex orchestrator's line led with an ask from 10/11, 29 days old, and the model wrote
7
+ * "following up a transcription bakeoff" about a session that had just confirmed a release was live.
8
+ *
9
+ * readRecentActivity(provider, file, now) answers the last few asks (each with when and how it arrived), the latest
10
+ * reply and when the session last moved. Read-only display evidence: never imported by an attach, queue or write gate.
11
+ *
12
+ * - Claude: the last 8 MB of the transcript, memoized on mtime and size like readClaudeSubagents. User turns follow
13
+ * parseAgentSession's rules: sidechains, `isMeta` and `isCompactSummary` rows are not asks (a peer row is, unwrapped
14
+ * by unwrapPeerMessage), the verify footer comes off (withoutTurnFooter), wrapper tags go (STRIPPABLE_TAG_RE) and a
15
+ * pasted JSON block becomes "[pasted data]". A peer row, or a row that carried a COS verify footer, arrived from
16
+ * another screen or session: `via: 'peer'`, never attributed to the person. A peer row that is agent-to-agent
17
+ * protocol (a cross-session or agent-message envelope) is not an ask at all.
18
+ * - Codex: rollouts reach 32 GB with single lines of 44 MB (measured 11/09). An incremental forward scan keeps its byte
19
+ * offset in memory; the first pass reads at most the last 256 MB; a line over 1 MB is skipped without being decoded
20
+ * or parsed; reads are async, 4 MB at a time, and the scan yields between chunks. One scan per file at a time: a
21
+ * second caller waits on the first, and a scan the Today route stopped waiting for still finishes and fills the memo.
22
+ * Codex rows carry no sender marker, so only a COS verify footer marks one as sent in from another screen.
23
+ * - Cursor: the existing tail peek (the last user query and reply); its transcript has no timestamps, so every time is
24
+ * the file's mtime.
25
+ *
26
+ * `startedAt` and `firstAsk` are set only for a session whose file was created within the last day, for the
27
+ * "started today as" note. Opens refuse symlinks and non-regular files. Never throws: null when unreadable.
28
+ */
29
+ import { constants } from 'node:fs'
30
+ import { open, stat } from 'node:fs/promises'
31
+ import {
32
+ STRIPPABLE_TAG_RE, createdFromCodexFilename, firstClaudeUserTitle, firstCursorUserTitle, isWrapperPrompt, parseJsonLine,
33
+ payloadText, codexUserText, peekCodexMeta, peekCursorDiscussion, proseSnippet, type AgentProvider,
34
+ } from './agent-session-store.js'
35
+ import { unwrapPeerMessage } from './session-stream-events.js'
36
+ import { stripG2TurnFooter } from './turn-footer.js'
37
+
38
+ export type RecentVia = 'typed' | 'peer'
39
+ export interface RecentAsk { at: number; text: string; via: RecentVia }
40
+ export interface RecentReply { at: number; text: string }
41
+ export interface RecentActivity {
42
+ /** The newest asks into the session, oldest first, at most RECENT_ASKS_KEPT. */
43
+ asks: RecentAsk[]
44
+ /** The newest assistant reply (Codex: its newest final answer, not commentary). */
45
+ reply: RecentReply | null
46
+ /** The newest record's time (ms), 0 when none was seen. */
47
+ movedAt: number
48
+ /** Only for a session created within the last day: when, and its first ask. */
49
+ startedAt?: number
50
+ firstAsk?: string
51
+ }
52
+
53
+ export const RECENT_ASKS_KEPT = 3
54
+ export const CLAUDE_RECENT_TAIL_BYTES = 8 * 1024 * 1024
55
+ export const CODEX_FIRST_PASS_BYTES = 256 * 1024 * 1024
56
+ /** A line longer than this is skipped unparsed (a 44 MB Codex line holds tool output, never an ask). */
57
+ export const RECENT_LINE_MAX_BYTES = 1024 * 1024
58
+ export const RECENT_CHUNK_BYTES = 4 * 1024 * 1024
59
+ const TEXT_KEEP = 1000
60
+ const MEMO_MAX = 64
61
+ const DAY_MS = 24 * 60 * 60 * 1000
62
+
63
+ // ── text ───────────────────────────────────────────────────────────────────
64
+
65
+ const PASTED = /<pasted_content\b[^>]*>([\s\S]*?)<\/pasted_content\b[^>]*>/gi
66
+ const WRAPPER_BLOCKS = /<(task-notification|system-reminder|relevant-memories|cache-health|daily-bulletin|cos-alarms|device-handoff|now|memory-stored|local-command-stdout|local-command-stderr|command-name|command-message|command-args|user_query)\b[^>]*>[\s\S]*?<\/\1>/gi
67
+ /** A peer row that is agent-to-agent protocol, not words for the session. */
68
+ const AGENT_ENVELOPE = /^\s*(?:<(?:cross-session-message|agent-message)\b|Message Type:|Subagent hand-?back\b)/i
69
+ /** A paragraph that is pasted data: it opens with { or [ and holds a "key": pair. */
70
+ const JSON_PARAGRAPH = /^\s*[[{][\s\S]*"\s*:/
71
+
72
+ /** One ask's words: pasted data replaced, wrapper blocks and tags out, one line, bounded. '' when nothing is left. */
73
+ export function cleanAskText(raw: string): string {
74
+ // Whole wrapper blocks go first (a background task's notification, a reminder, a slash command's parts). What is left
75
+ // is then judged as parseAgentSession's collectTurn judges a turn: still a wrapper (it opens with a tag the block list
76
+ // does not know), it is not an ask, and no tag is taken off so that its insides could read as words.
77
+ let s = raw.replace(WRAPPER_BLOCKS, ' ').replace(PASTED, (_m, inner: string) => {
78
+ const t = String(inner).trim()
79
+ return t.startsWith('{') || t.startsWith('[') ? '\n\n[pasted data]\n\n' : ` ${t} `
80
+ })
81
+ if (!s.trim() || isWrapperPrompt(s)) return ''
82
+ s = s.split(/\n\s*\n/).map(p => (JSON_PARAGRAPH.test(p) ? '[pasted data]' : p)).join('\n\n')
83
+ s = s.replace(STRIPPABLE_TAG_RE, ' ').replace(/\s+/g, ' ').trim()
84
+ if (!s || isWrapperPrompt(s) || s.startsWith('[Request interrupted') || s.startsWith('[image: source')) return ''
85
+ return s.slice(0, TEXT_KEEP)
86
+ }
87
+
88
+ /** An ask's words and how it arrived: a footer means another screen (glasses, phone or web) sent it. */
89
+ function footerVia(text: string, peer: boolean): { text: string; via: RecentVia } {
90
+ const f = stripG2TurnFooter(text)
91
+ return { text: f.text, via: peer || f.turnId ? 'peer' : 'typed' }
92
+ }
93
+
94
+ const tsOf = (v: unknown): number => (typeof v === 'string' ? Date.parse(v) || 0 : 0)
95
+
96
+ interface Collector { asks: RecentAsk[]; reply: RecentReply | null; movedAt: number }
97
+ const emptyCollector = (): Collector => ({ asks: [], reply: null, movedAt: 0 })
98
+ function addAsk(c: Collector, ask: RecentAsk) {
99
+ c.asks.push(ask)
100
+ if (c.asks.length > RECENT_ASKS_KEPT) c.asks.shift()
101
+ }
102
+ const moved = (c: Collector, at: number) => { if (at > c.movedAt) c.movedAt = at }
103
+
104
+ /** One Claude record into the collector (parseAgentSession's user-turn rules). */
105
+ export function collectClaudeRecord(c: Collector, obj: Record<string, unknown>): void {
106
+ if (obj.isSidechain === true) return
107
+ const at = tsOf(obj.timestamp)
108
+ if (at && (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'system')) moved(c, at)
109
+ if (obj.type !== 'user' && obj.type !== 'assistant') return
110
+ if (obj.type === 'user' && obj.toolUseResult) return
111
+ const message = obj.message && typeof obj.message === 'object' ? obj.message as Record<string, unknown> : null
112
+ const text = message ? payloadText(message) : null
113
+ if (!text) return
114
+ if (obj.type === 'assistant') {
115
+ const prose = proseSnippet(text, TEXT_KEEP)
116
+ if (prose) c.reply = { at, text: prose }
117
+ return
118
+ }
119
+ const isPeer = (obj.origin as { kind?: unknown } | undefined)?.kind === 'peer'
120
+ const peerWords = isPeer ? unwrapPeerMessage(text) : null
121
+ // Injected rows (a slash-command body, a compaction preamble) are not asks; a peer row is, once unwrapped.
122
+ if (peerWords === null && (obj.isMeta === true || obj.isCompactSummary === true)) return
123
+ if (peerWords !== null && AGENT_ENVELOPE.test(peerWords)) return
124
+ const { text: words, via } = footerVia(peerWords ?? text, peerWords !== null)
125
+ const clean = cleanAskText(words)
126
+ if (clean) addAsk(c, { at, text: clean, via })
127
+ }
128
+
129
+ /** One Codex rollout line into the collector. The caller has already checked its size. */
130
+ export function collectCodexLine(c: Collector, line: Buffer): void {
131
+ const head = line.subarray(0, 512).toString('utf8')
132
+ const stamp = /"timestamp":"([^"]{10,40})"/.exec(head)
133
+ const at = stamp ? Date.parse(stamp[1]!) || 0 : 0
134
+ if (at) moved(c, at)
135
+ // Role and type sit before the content in a rollout line, so the head decides without decoding the rest.
136
+ if (!head.includes('"type":"message"') || !(head.includes('"role":"user"') || head.includes('"role":"assistant"'))) return
137
+ const obj = parseJsonLine(line.toString('utf8'))
138
+ if (!obj || obj.type !== 'response_item' || !obj.payload || typeof obj.payload !== 'object') return
139
+ const p = obj.payload as Record<string, unknown>
140
+ if (p.type !== 'message') return
141
+ if (p.role === 'assistant') {
142
+ if (p.phase === 'commentary') return
143
+ const text = payloadText(p)
144
+ const prose = text ? proseSnippet(text, TEXT_KEEP) : ''
145
+ if (prose) c.reply = { at, text: prose }
146
+ return
147
+ }
148
+ if (p.role !== 'user') return
149
+ const text = codexUserText(p)
150
+ if (!text || isWrapperPrompt(text)) return
151
+ const { text: words, via } = footerVia(text, false)
152
+ const clean = cleanAskText(words)
153
+ if (clean) addAsk(c, { at, text: clean, via })
154
+ }
155
+
156
+ // ── reads ──────────────────────────────────────────────────────────────────
157
+
158
+ type Handle = Awaited<ReturnType<typeof open>>
159
+ async function openRegular(file: string): Promise<{ handle: Handle; size: number; mtimeMs: number; birthtimeMs: number; ino: number } | null> {
160
+ let handle: Handle | null = null
161
+ try {
162
+ handle = await open(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0))
163
+ const st = await handle.stat()
164
+ if (!st.isFile()) { await handle.close().catch(() => {}); return null }
165
+ return { handle, size: st.size, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, ino: st.ino }
166
+ } catch {
167
+ await handle?.close().catch(() => {})
168
+ return null
169
+ }
170
+ }
171
+ const yieldLoop = () => new Promise<void>(resolve => setImmediate(resolve))
172
+
173
+ const claudeMemo = new Map<string, { mtimeMs: number; size: number; value: Collector }>()
174
+ const remember = <V>(memo: Map<string, V>, key: string, value: V) => {
175
+ if (!memo.has(key) && memo.size >= MEMO_MAX) memo.delete(memo.keys().next().value!)
176
+ memo.set(key, value)
177
+ }
178
+
179
+ /** A Claude transcript's tail (8 MB), memoized on mtime and size. */
180
+ async function readClaude(file: string, tailBytes = CLAUDE_RECENT_TAIL_BYTES): Promise<{ c: Collector; birthtimeMs: number } | null> {
181
+ const opened = await openRegular(file)
182
+ if (!opened) return null
183
+ const { handle, size, mtimeMs, birthtimeMs } = opened
184
+ try {
185
+ const hit = claudeMemo.get(file)
186
+ if (hit && hit.mtimeMs === mtimeMs && hit.size === size) return { c: hit.value, birthtimeMs }
187
+ const length = Math.min(size, tailBytes), start = size - length
188
+ const buffer = Buffer.alloc(length)
189
+ const read = await handle.read(buffer, 0, length, start)
190
+ const data = buffer.subarray(0, read.bytesRead)
191
+ const c = emptyCollector()
192
+ let pos = 0, n = 0
193
+ // A tail that starts mid-file has a cut first line: it fails to parse and is skipped like any bad line.
194
+ while (pos < data.length) {
195
+ let end = data.indexOf(0x0a, pos)
196
+ if (end < 0) end = data.length
197
+ if (end - pos > 0 && end - pos <= RECENT_LINE_MAX_BYTES) {
198
+ const obj = parseJsonLine(data.subarray(pos, end).toString('utf8'))
199
+ if (obj) collectClaudeRecord(c, obj)
200
+ }
201
+ pos = end + 1
202
+ if (++n % 2000 === 0) await yieldLoop()
203
+ }
204
+ remember(claudeMemo, file, { mtimeMs, size, value: c })
205
+ return { c, birthtimeMs }
206
+ } catch {
207
+ return null
208
+ } finally {
209
+ await handle.close().catch(() => {})
210
+ }
211
+ }
212
+
213
+ interface CodexScan { offset: number; size: number; partial: Buffer | null; skipping: boolean; c: Collector; running: Promise<void> | null; ino: number }
214
+ const codexMemo = new Map<string, CodexScan>()
215
+
216
+ /** Test seam: forget every memo (a new server process starts with none). */
217
+ export function resetRecentActivityMemo(): void { claudeMemo.clear(); codexMemo.clear() }
218
+ /** Test seam: the bytes of an unfinished line a Codex scan holds for its next read (never more than the line bound). */
219
+ export function codexScanHeldBytes(file: string): number { return codexMemo.get(file)?.partial?.length ?? 0 }
220
+
221
+ /**
222
+ * Scan a Codex rollout forward from where the last scan stopped. A first pass, or a gap past `firstPassBytes`, starts
223
+ * that far from the end and drops the line it lands inside.
224
+ */
225
+ async function scanCodex(file: string, firstPassBytes: number, chunkBytes: number): Promise<CodexScan | null> {
226
+ const opened = await openRegular(file)
227
+ if (!opened) return null
228
+ const { handle, size, ino } = opened
229
+ try {
230
+ let scan = codexMemo.get(file)
231
+ if (scan && (scan.ino !== ino || size < scan.offset)) scan = undefined // replaced or truncated: start over
232
+ if (!scan) {
233
+ scan = { offset: 0, size: 0, partial: null, skipping: false, c: emptyCollector(), running: null, ino }
234
+ remember(codexMemo, file, scan)
235
+ }
236
+ if (scan.running) { await scan.running; return scan }
237
+ const s = scan
238
+ const run = (async () => {
239
+ if (size - s.offset > firstPassBytes) {
240
+ s.offset = size - firstPassBytes
241
+ s.partial = null
242
+ s.skipping = s.offset > 0 // landed mid-line: that line is not whole
243
+ }
244
+ while (s.offset < size) {
245
+ const length = Math.min(chunkBytes, size - s.offset)
246
+ const chunk = Buffer.alloc(length)
247
+ const { bytesRead } = await handle.read(chunk, 0, length, s.offset)
248
+ if (bytesRead <= 0) break
249
+ s.offset += bytesRead
250
+ let buf = chunk.subarray(0, bytesRead)
251
+ if (s.partial) { buf = Buffer.concat([s.partial, buf]); s.partial = null }
252
+ let pos = 0
253
+ for (let nl = buf.indexOf(0x0a, pos); nl >= 0; nl = buf.indexOf(0x0a, pos)) {
254
+ if (s.skipping) s.skipping = false
255
+ else if (nl - pos > 0 && nl - pos <= RECENT_LINE_MAX_BYTES) collectCodexLine(s.c, buf.subarray(pos, nl))
256
+ pos = nl + 1
257
+ }
258
+ const rest = buf.subarray(pos)
259
+ // An unfinished line past the bound is never kept: the rest of it is skipped up to its newline. While skipping
260
+ // (no newline in this chunk at all), nothing is kept either.
261
+ if (!s.skipping) {
262
+ if (rest.length > RECENT_LINE_MAX_BYTES) s.skipping = true
263
+ else if (rest.length) s.partial = Buffer.from(rest)
264
+ }
265
+ await yieldLoop()
266
+ }
267
+ s.size = size
268
+ })()
269
+ s.running = run
270
+ try { await run } finally { s.running = null }
271
+ return s
272
+ } catch {
273
+ return null
274
+ } finally {
275
+ await handle.close().catch(() => {})
276
+ }
277
+ }
278
+
279
+ export interface RecentReadOptions { claudeTailBytes?: number; codexFirstPassBytes?: number; chunkBytes?: number }
280
+
281
+ /** What was said into one session lately, its latest reply and when it last moved, or null when it cannot be read. */
282
+ export async function readRecentActivity(provider: AgentProvider, file: string, now = Date.now(), opts: RecentReadOptions = {}): Promise<RecentActivity | null> {
283
+ try {
284
+ let c: Collector, createdAt = 0, firstAsk = ''
285
+ if (provider === 'claude') {
286
+ const r = await readClaude(file, opts.claudeTailBytes)
287
+ if (!r) return null
288
+ c = r.c
289
+ createdAt = r.birthtimeMs
290
+ if (createdAt && now - createdAt < DAY_MS) firstAsk = (await firstClaudeUserTitle(file)) ?? ''
291
+ } else if (provider === 'codex') {
292
+ const s = await scanCodex(file, opts.codexFirstPassBytes ?? CODEX_FIRST_PASS_BYTES, opts.chunkBytes ?? RECENT_CHUNK_BYTES)
293
+ if (!s) return null
294
+ c = s.c
295
+ createdAt = Date.parse(createdFromCodexFilename(file.split('/').pop() ?? '') ?? '') || 0
296
+ if (createdAt && now - createdAt < DAY_MS) firstAsk = (await peekCodexMeta(file))?.title ?? ''
297
+ } else {
298
+ const st = await stat(file).catch(() => null)
299
+ if (!st?.isFile()) return null
300
+ const peek = await peekCursorDiscussion(file)
301
+ const at = st.mtimeMs
302
+ c = { asks: [], reply: null, movedAt: at }
303
+ const ask = peek.lastUser ? cleanAskText(peek.lastUser) : ''
304
+ if (ask) c.asks.push({ at, text: ask, via: 'typed' })
305
+ const reply = peek.latestAssistant ? proseSnippet(peek.latestAssistant, TEXT_KEEP) : ''
306
+ if (reply) c.reply = { at, text: reply }
307
+ createdAt = st.birthtimeMs
308
+ if (createdAt && now - createdAt < DAY_MS) firstAsk = (await firstCursorUserTitle(file)) ?? ''
309
+ }
310
+ // Copies, so a caller can never edit the memo.
311
+ const out: RecentActivity = {
312
+ asks: c.asks.filter(a => a.at <= now).map(a => ({ ...a })),
313
+ reply: c.reply && c.reply.at <= now ? { ...c.reply } : null,
314
+ movedAt: c.movedAt,
315
+ }
316
+ if (firstAsk && createdAt) { out.startedAt = createdAt; out.firstAsk = firstAsk }
317
+ return out
318
+ } catch {
319
+ return null
320
+ }
321
+ }
@@ -250,7 +250,7 @@ const STRIPPABLE_TAGS = [
250
250
  'local-command-stdout', 'local-command-stderr', 'thinking',
251
251
  ]
252
252
 
253
- const STRIPPABLE_TAG_RE = new RegExp(`</?(?:${STRIPPABLE_TAGS.join('|')})(?:\\s[^>]*)?/?>`, 'gi')
253
+ export const STRIPPABLE_TAG_RE = new RegExp(`</?(?:${STRIPPABLE_TAGS.join('|')})(?:\\s[^>]*)?/?>`, 'gi')
254
254
 
255
255
  /**
256
256
  * Fenced code and known tags out. Shared so `proseSnippet` and `latestAssistantReply`
@@ -995,7 +995,7 @@ export async function peekCodexMeta(path: string): Promise<{ id: string; cwd: st
995
995
  return { id, cwd, title, subagent, created }
996
996
  }
997
997
 
998
- async function peekCursorDiscussion(path: string): Promise<{ lastUser: string | null; latestAssistant: string }> {
998
+ export async function peekCursorDiscussion(path: string): Promise<{ lastUser: string | null; latestAssistant: string }> {
999
999
  const text = await readWindow(path, true)
1000
1000
  let lastUser: string | null = null
1001
1001
  for (const line of text.split('\n')) {
@@ -1010,7 +1010,7 @@ async function peekCursorDiscussion(path: string): Promise<{ lastUser: string |
1010
1010
  return { lastUser, latestAssistant: latestAssistantFromWindow(text) }
1011
1011
  }
1012
1012
 
1013
- async function firstCursorUserTitle(path: string): Promise<string | null> {
1013
+ export async function firstCursorUserTitle(path: string): Promise<string | null> {
1014
1014
  const text = await readWindow(path, false)
1015
1015
  let fallback: string | null = null
1016
1016
  for (const line of text.split('\n')) {
@@ -1455,12 +1455,31 @@ export async function listAgentSessions(
1455
1455
  return rows.slice(0, limit)
1456
1456
  }
1457
1457
 
1458
+ /**
1459
+ * Every Claude transcript by its lowercase id (names only, no stat). `shouldStop` lets a bounded caller end the walk
1460
+ * between folders; a stopped walk answers what it has.
1461
+ */
1462
+ export async function loadClaudeTranscriptIndex(roots: AgentSessionRoots, shouldStop: () => boolean = () => false): Promise<Map<string, string[]>> {
1463
+ const files = new Map<string, string[]>()
1464
+ for (const folder of await dirents(roots.claudeProjects)) {
1465
+ if (shouldStop()) break
1466
+ const dir = join(roots.claudeProjects, folder)
1467
+ for (const name of await dirents(dir)) {
1468
+ if (!CLAUDE_UUID_JSONL.test(name)) continue
1469
+ const id = name.slice(0, -6).toLowerCase()
1470
+ files.set(id, [...(files.get(id) || []), join(dir, name)])
1471
+ }
1472
+ }
1473
+ return files
1474
+ }
1475
+
1458
1476
  export async function findAgentSessionFile(
1459
1477
  provider: AgentProvider,
1460
1478
  sessionId: string,
1461
1479
  roots: AgentSessionRoots,
1462
1480
  now = new Date(),
1463
1481
  preloadedAliases?: ClaudeAliasMap,
1482
+ preloaded?: { claudeFiles?: ReadonlyMap<string, string[]>; codexFiles?: readonly string[] },
1464
1483
  ): Promise<string | null> {
1465
1484
  if (!isSafeSessionId(sessionId)) return null
1466
1485
  const needle = sessionId.trim().toLowerCase()
@@ -1473,15 +1492,8 @@ export async function findAgentSessionFile(
1473
1492
  // Names only. This used to lstat every transcript on this Mac (1,697 of
1474
1493
  // them) to keep directories out of the candidate set, on every call. The
1475
1494
  // isFile check now runs on the one id that matched, below.
1476
- const files = new Map<string, string[]>()
1477
- for (const folder of await dirents(roots.claudeProjects)) {
1478
- const dir = join(roots.claudeProjects, folder)
1479
- for (const name of await dirents(dir)) {
1480
- if (!CLAUDE_UUID_JSONL.test(name)) continue
1481
- const id = name.slice(0, -6).toLowerCase()
1482
- files.set(id, [...(files.get(id) || []), join(dir, name)])
1483
- }
1484
- }
1495
+ // 7.0.1: a caller resolving several ids at once (work-session-activity.ts) builds this index once and hands it in.
1496
+ const files = preloaded?.claudeFiles ?? await loadClaudeTranscriptIndex(roots)
1485
1497
  const exact = aliases.has(needle) || files.has(needle)
1486
1498
  const ids = new Set<string>()
1487
1499
  for (const [id, head] of aliases) {
@@ -1498,8 +1510,9 @@ export async function findAgentSessionFile(
1498
1510
  return matches.length === 1 ? matches[0] : null
1499
1511
  }
1500
1512
  if (provider === 'codex') {
1501
- for (const root of codexSessionRootsOf(roots)) {
1502
- for (const file of await listCodexJsonlFiles(root)) {
1513
+ const lists = preloaded?.codexFiles ? [preloaded.codexFiles] : await Promise.all(codexSessionRootsOf(roots).map(root => listCodexJsonlFiles(root)))
1514
+ for (const list of lists) {
1515
+ for (const file of list) {
1503
1516
  const name = file.split('/').pop()?.toLowerCase() ?? ''
1504
1517
  if (name.includes(needle)) return file
1505
1518
  }
@@ -0,0 +1,224 @@
1
+ // Images a session LOOKED AT: the pictures that came back inside tool results (a Read of a PNG,
2
+ // a browser or computer-use screenshot) and the pictures a person attached to a prompt.
3
+ //
4
+ // WHY. Miles, 11/08: the Claude app shows a thumbnail under a step like "Ran 2 commands, read 2
5
+ // files", labelled "Read" and "1 of 2". Control Web showed only words for that turn. 6.99.28
6
+ // covered images written into reply text and 6.99.35 covered SendUserFile; neither covered tool
7
+ // results or prompt attachments.
8
+ //
9
+ // RECORD SHAPES (read from real transcripts on this Mac, 11/08, shapes only):
10
+ // - Claude, a Read of an image: a `user` row with `toolUseResult: { type: 'image', file: { base64,
11
+ // type, originalSize, dimensions } }` and `message.content: [{ type: 'tool_result', tool_use_id,
12
+ // content: [{ type: 'image', source: { type: 'base64', media_type, data } }] }]`.
13
+ // - Claude, a screenshot (Claude_Browser / claude-in-chrome / computer-use / Claude_Preview): the
14
+ // same tool_result block, its `content` a mix of text blocks and image blocks of base64 source.
15
+ // - Claude, a prompt attachment: a plain `user` row (no toolUseResult) whose `message.content`
16
+ // holds `{ type: 'image', source: { type: 'base64', ... } }` beside the text block. A few rows
17
+ // (sent through the SDK) carry the image and no words at all.
18
+ // - Codex: `response_item` rows. A tool output is `function_call_output` or
19
+ // `custom_tool_call_output` with `output: [{ type: 'input_text' }, { type: 'input_image',
20
+ // image_url: 'data:image/png;base64,...', detail }]`; a prompt attachment is a user `message`
21
+ // whose `content` holds `input_image` blocks. The calls were `exec`, `wait` and `js`.
22
+ // - Every image seen was embedded base64 (png, jpeg, webp). None referenced a path only. The
23
+ // path-only Read is still handled, under the workspace rules output images use.
24
+ //
25
+ // This module is pure: it finds the images and names them. Ids, the workspace check and the
26
+ // bytes live in agent-session-output-images.ts, beside the other previews.
27
+ import type { AgentProvider } from './agent-session-store.js'
28
+
29
+ export type ToolImageLabel = 'Read' | 'Screenshot' | 'Image' | 'Attached'
30
+ export interface SessionToolImage {
31
+ /** Opaque, process-scoped: `ti_` and 64 hex characters. */
32
+ id: string
33
+ /** What the step was, as the Claude app labels it: "Read", "Screenshot", or "Attached" for a prompt image. */
34
+ tool: ToolImageLabel
35
+ /** Its place in the step, 1-based: "1 of 2". */
36
+ index: number
37
+ total: number
38
+ state: 'available' | 'unavailable'
39
+ /**
40
+ * `too_large`: the embedded image is over MAX_TOOL_IMAGE_BYTES. `outside_workspace` and
41
+ * `unavailable`: a path-only Read, under the output-image rules. `missing`: no bytes and no path.
42
+ * `secret_file` (7.0.4): a Read (path-only, or with its bytes embedded) of a file whose name says it may hold secrets
43
+ * (lib/secret-path-guard.ts). Embedded bytes with no path (a screenshot, a prompt attachment) have no name to judge.
44
+ */
45
+ reason?: 'outside_workspace' | 'unavailable' | 'too_large' | 'missing' | 'secret_file'
46
+ /** 7.0.4: the words the card shows instead, set only with `secret_file`. */
47
+ notice?: string
48
+ /** The step came before the turn's words (a reply that opened with tool calls). */
49
+ before?: true
50
+ }
51
+
52
+ export const SESSION_TOOL_IMAGE_ID = /^ti_[a-f0-9]{64}$/
53
+ /** Thumbnails one turn may show. The rest are counted ("+N more") and never get an id. */
54
+ export const TOOL_IMAGES_PER_TURN = 6
55
+ /** The largest decoded image served. The biggest seen on this Mac was about 510 KB. */
56
+ export const MAX_TOOL_IMAGE_BYTES = 8 * 1024 * 1024
57
+ export const TOOL_IMAGE_TYPES: readonly string[] = ['image/png', 'image/jpeg', 'image/webp', 'image/gif']
58
+ const IMAGE_PATH = /\.(png|jpe?g|webp|gif)$/i
59
+
60
+ export type ToolImageRef =
61
+ /** `path`: the file a Read named, when its bytes are embedded (7.0.4 QA: the secret guard judges that name). */
62
+ | { kind: 'embedded'; mediaType: string; data: string; path?: string }
63
+ | { kind: 'path'; path: string }
64
+ | { kind: 'missing' }
65
+ export interface ToolUseMeta { name: string; path?: string }
66
+ export interface FoundToolImage { tool: ToolImageLabel; ref: ToolImageRef }
67
+
68
+ /** The Claude app's label for a tool. Pure. */
69
+ export function toolLabel(name: string | undefined): ToolImageLabel {
70
+ if (name === 'Read') return 'Read'
71
+ if (name && /screenshot|computer|zoom|browser_batch|app_batch|snapshot/i.test(name)) return 'Screenshot'
72
+ return 'Image'
73
+ }
74
+
75
+ /** Decoded size of a base64 string, without decoding it. */
76
+ export function estimateBytes(b64: string): number {
77
+ let pad = 0
78
+ if (b64.endsWith('==')) pad = 2; else if (b64.endsWith('=')) pad = 1
79
+ return Math.max(0, Math.floor(b64.length * 3 / 4) - pad)
80
+ }
81
+
82
+ /** The image type the bytes ARE, from their magic numbers, or null. Never the declared type. */
83
+ export function sniffImageType(bytes: Buffer): string | null {
84
+ if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
85
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
86
+ if (bytes.length >= 6 && /^GIF8[79]a$/.test(bytes.subarray(0, 6).toString('latin1'))) return 'image/gif'
87
+ if (bytes.length >= 12 && bytes.subarray(0, 4).toString('latin1') === 'RIFF' && bytes.subarray(8, 12).toString('latin1') === 'WEBP') return 'image/webp'
88
+ return null
89
+ }
90
+
91
+ /** Decode an embedded image: refused over the cap (checked before decoding) or when the bytes are not a safe image type. */
92
+ export function decodeToolImage(ref: { mediaType: string; data: string }): { status: 'ok'; bytes: Buffer; type: string } | { status: 'too_large' | 'unavailable' } {
93
+ if (estimateBytes(ref.data) > MAX_TOOL_IMAGE_BYTES) return { status: 'too_large' }
94
+ const bytes = Buffer.from(ref.data, 'base64')
95
+ if (!bytes.length || bytes.length > MAX_TOOL_IMAGE_BYTES) return { status: bytes.length ? 'too_large' : 'unavailable' }
96
+ const type = sniffImageType(bytes)
97
+ return type ? { status: 'ok', bytes, type } : { status: 'unavailable' }
98
+ }
99
+
100
+ function obj(v: unknown): Record<string, unknown> | null { return v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : null }
101
+
102
+ /** An Anthropic image block of base64 source with a safe declared type, or null. */
103
+ function base64Block(block: unknown): ToolImageRef | null {
104
+ const b = obj(block); if (!b || b.type !== 'image') return null
105
+ const s = obj(b.source)
106
+ if (!s || s.type !== 'base64' || typeof s.data !== 'string' || !s.data || typeof s.media_type !== 'string') return null
107
+ if (!TOOL_IMAGE_TYPES.includes(s.media_type)) return null
108
+ return { kind: 'embedded', mediaType: s.media_type, data: s.data }
109
+ }
110
+ /** A Codex/OpenAI `input_image` block carrying a data URL of a safe type, or null. Remote URLs stay inert. */
111
+ function dataUrlBlock(block: unknown): ToolImageRef | null {
112
+ const b = obj(block); if (!b || b.type !== 'input_image' || typeof b.image_url !== 'string') return null
113
+ const m = /^data:(image\/(?:png|jpeg|webp|gif));base64,/.exec(b.image_url.slice(0, 64))
114
+ if (!m) return null
115
+ return { kind: 'embedded', mediaType: m[1], data: b.image_url.slice(m[0].length) }
116
+ }
117
+
118
+ /** Keep the source of a Codex view_image so embedded bytes pass the same filename guard as Read. */
119
+ function codexImagePath(args: unknown): string | undefined {
120
+ if (typeof args === 'string') {
121
+ if (args.length > 16_384) return undefined
122
+ try { args = JSON.parse(args) } catch { return undefined }
123
+ }
124
+ const path = obj(args)?.path
125
+ return typeof path === 'string' && path.trim().length > 0 && path.length <= 4096 && !path.includes('\0') ? path : undefined
126
+ }
127
+
128
+ /** The tool calls a row makes, by call id: their names, and a Read's file path. Pure. */
129
+ export function toolUsesOf(provider: AgentProvider, row: Record<string, unknown>): Array<[string, ToolUseMeta]> {
130
+ if (row.isSidechain === true) return []
131
+ if (provider === 'claude') {
132
+ if (row.type !== 'assistant') return []
133
+ const content = obj(row.message)?.content
134
+ if (!Array.isArray(content)) return []
135
+ const out: Array<[string, ToolUseMeta]> = []
136
+ for (const block of content) {
137
+ const b = obj(block)
138
+ if (!b || b.type !== 'tool_use' || typeof b.id !== 'string' || typeof b.name !== 'string') continue
139
+ const input = obj(b.input)
140
+ const path = typeof input?.file_path === 'string' ? input.file_path : undefined
141
+ out.push([b.id, path ? { name: b.name, path } : { name: b.name }])
142
+ }
143
+ return out
144
+ }
145
+ if (provider === 'codex') {
146
+ if (row.type !== 'response_item') return []
147
+ const p = obj(row.payload)
148
+ if (!p || (p.type !== 'function_call' && p.type !== 'custom_tool_call') || typeof p.call_id !== 'string' || typeof p.name !== 'string') return []
149
+ const path = p.name === 'view_image' ? codexImagePath(p.type === 'function_call' ? p.arguments : p.input) : undefined
150
+ return [[p.call_id, path ? { name: p.name, path } : { name: p.name }]]
151
+ }
152
+ return []
153
+ }
154
+
155
+ /**
156
+ * The images inside a row's tool results, in order, labelled by the tool that produced them.
157
+ * A sub-agent's results went to its parent, not to the person, and stay out. Pure.
158
+ */
159
+ export function toolResultImages(provider: AgentProvider, row: Record<string, unknown>, uses: ReadonlyMap<string, ToolUseMeta>): FoundToolImage[] {
160
+ if (row.isSidechain === true) return []
161
+ const out: FoundToolImage[] = []
162
+ if (provider === 'claude') {
163
+ if (row.type !== 'user') return []
164
+ const content = obj(row.message)?.content
165
+ if (!Array.isArray(content)) return []
166
+ const results = content.filter(b => obj(b)?.type === 'tool_result')
167
+ for (const block of results) {
168
+ const b = obj(block)!
169
+ const meta = typeof b.tool_use_id === 'string' ? uses.get(b.tool_use_id) : undefined
170
+ const tool = toolLabel(meta?.name)
171
+ // 7.0.4 QA: a Read's embedded bytes keep the path it read, so `~/Desktop/credentials.png` is refused by name.
172
+ const readPath = meta?.name === 'Read' && meta.path ? meta.path : undefined
173
+ const found = Array.isArray(b.content) ? b.content.map(base64Block).filter((x): x is ToolImageRef => x !== null) : []
174
+ if (found.length) { for (const ref of found) out.push({ tool, ref: readPath && ref.kind === 'embedded' ? { ...ref, path: readPath } : ref }); continue }
175
+ // A Read of an image whose message block was not written: the row's own record of the
176
+ // result may still carry the bytes; else only the path the Read named; else nothing to show.
177
+ const result = obj(row.toolUseResult)
178
+ if (results.length !== 1 || b.is_error === true || result?.type !== 'image') continue
179
+ const file = obj(result.file)
180
+ if (file && typeof file.base64 === 'string' && file.base64 && typeof file.type === 'string' && TOOL_IMAGE_TYPES.includes(file.type)) {
181
+ out.push({ tool, ref: { kind: 'embedded', mediaType: file.type, data: file.base64, ...(readPath ? { path: readPath } : {}) } }); continue
182
+ }
183
+ if (meta?.name === 'Read' && meta.path && IMAGE_PATH.test(meta.path)) out.push({ tool, ref: { kind: 'path', path: meta.path } })
184
+ else out.push({ tool, ref: { kind: 'missing' } })
185
+ }
186
+ return out
187
+ }
188
+ if (provider === 'codex') {
189
+ if (row.type !== 'response_item') return []
190
+ const p = obj(row.payload)
191
+ if (!p || (p.type !== 'function_call_output' && p.type !== 'custom_tool_call_output') || !Array.isArray(p.output)) return []
192
+ const meta = typeof p.call_id === 'string' ? uses.get(p.call_id) : undefined
193
+ const tool = meta?.name === 'view_image' ? 'Read' : toolLabel(meta?.name)
194
+ for (const block of p.output) {
195
+ const ref = dataUrlBlock(block)
196
+ if (!ref || ref.kind !== 'embedded') continue
197
+ // A known file-read without a trustworthy source cannot bypass secret filename checks.
198
+ // Other image tools (for example exec screenshots) do not name a source file.
199
+ out.push({ tool, ref: meta?.name === 'view_image' ? meta.path ? { ...ref, path: meta.path } : { kind: 'missing' } : ref })
200
+ }
201
+ }
202
+ return out
203
+ }
204
+
205
+ /**
206
+ * The images a person attached to a prompt row, or [] (a tool result, a sub-agent, an injected
207
+ * row, an assistant row). Whether the row is a turn at all is the turn reader's call. Pure.
208
+ */
209
+ export function promptImages(provider: AgentProvider, row: Record<string, unknown>): ToolImageRef[] {
210
+ if (row.isSidechain === true || row.isMeta === true || row.isCompactSummary === true) return []
211
+ if (provider === 'claude') {
212
+ if (row.type !== 'user' || row.toolUseResult) return []
213
+ const content = obj(row.message)?.content
214
+ if (!Array.isArray(content) || content.some(b => obj(b)?.type === 'tool_result')) return []
215
+ return content.map(base64Block).filter((x): x is ToolImageRef => x !== null)
216
+ }
217
+ if (provider === 'codex') {
218
+ if (row.type !== 'response_item') return []
219
+ const p = obj(row.payload)
220
+ if (!p || p.type !== 'message' || p.role !== 'user' || !Array.isArray(p.content)) return []
221
+ return p.content.map(dataUrlBlock).filter((x): x is ToolImageRef => x !== null)
222
+ }
223
+ return []
224
+ }