@gotcos/glasses-server 7.0.7 → 7.0.27

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 (97) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +305 -0
  3. package/docs/web-key-verifier-contract.md +378 -0
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -3
  6. package/server/bootstrap.ts +10 -0
  7. package/server/index.ts +129 -23
  8. package/server/lib/agent-session-older.ts +218 -0
  9. package/server/lib/agent-session-recent.ts +321 -0
  10. package/server/lib/agent-session-store.ts +102 -20
  11. package/server/lib/api-auth.ts +37 -1
  12. package/server/lib/calendar-meetings.ts +70 -0
  13. package/server/lib/calendar-source.ts +54 -9
  14. package/server/lib/claude-hooks-installer.ts +111 -12
  15. package/server/lib/conversation.ts +56 -28
  16. package/server/lib/cors-reject-log.ts +51 -0
  17. package/server/lib/cos-started-sessions.ts +101 -0
  18. package/server/lib/disk-io-meter.ts +202 -0
  19. package/server/lib/drop-service.ts +47 -12
  20. package/server/lib/drop-store.ts +21 -1
  21. package/server/lib/event-loop-stall.ts +62 -0
  22. package/server/lib/full-hold-engine.ts +114 -0
  23. package/server/lib/full-hold.ts +124 -0
  24. package/server/lib/g2-turn-provenance.ts +128 -9
  25. package/server/lib/list-model-memo.ts +85 -0
  26. package/server/lib/listener-startup.ts +18 -0
  27. package/server/lib/meeting-batch-transcribe.ts +32 -5
  28. package/server/lib/model-efforts.ts +40 -0
  29. package/server/lib/permission-broker.ts +443 -11
  30. package/server/lib/secret-path-guard.ts +4 -1
  31. package/server/lib/session-cache-writer.ts +8 -1
  32. package/server/lib/session-hook-events.ts +23 -1
  33. package/server/lib/session-hooks-runtime.ts +34 -0
  34. package/server/lib/session-log.ts +313 -4
  35. package/server/lib/session-organizer.ts +305 -0
  36. package/server/lib/session-state-derive.ts +36 -0
  37. package/server/lib/session-stream-events.ts +39 -3
  38. package/server/lib/thread-turn-queue.ts +24 -0
  39. package/server/lib/today-brief.ts +522 -58
  40. package/server/lib/turn-end-ask.ts +320 -0
  41. package/server/lib/turn-footer.ts +3 -3
  42. package/server/lib/web-control-integrity.ts +160 -0
  43. package/server/lib/web-control-offnet-page.ts +58 -0
  44. package/server/lib/web-control-static.ts +53 -9
  45. package/server/lib/web-key-authority.ts +372 -0
  46. package/server/lib/web-key-envelope.ts +98 -0
  47. package/server/lib/web-key-handoff.ts +135 -0
  48. package/server/lib/web-key-origin.ts +21 -0
  49. package/server/lib/web-key-passkey.ts +155 -0
  50. package/server/lib/web-key-registry.ts +825 -0
  51. package/server/lib/web-key-signer-app.ts +152 -0
  52. package/server/lib/web-session-auth.ts +133 -15
  53. package/server/lib/whisper-local.ts +93 -7
  54. package/server/lib/whisper-preview.ts +5 -3
  55. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  56. package/server/lib/work-review-runtime.ts +17 -4
  57. package/server/routes/agent-session-bindings.ts +16 -1
  58. package/server/routes/agent-session-stream.ts +7 -0
  59. package/server/routes/agent-sessions.ts +106 -2
  60. package/server/routes/drops.ts +7 -2
  61. package/server/routes/g2-turns.ts +35 -0
  62. package/server/routes/health.ts +14 -1
  63. package/server/routes/live-events.ts +6 -1
  64. package/server/routes/permission-broker.ts +26 -0
  65. package/server/routes/query-jobs.ts +3 -0
  66. package/server/routes/session-organizer.ts +149 -0
  67. package/server/routes/sessions.ts +15 -24
  68. package/server/routes/thread-turn-queue.ts +50 -14
  69. package/server/routes/today-brief.ts +91 -36
  70. package/server/routes/web-control.ts +37 -4
  71. package/server/routes/web-key.ts +284 -0
  72. package/server/routes/welcome-context.ts +6 -2
  73. package/server/routes/work-orchestrator.ts +12 -7
  74. package/server/scripts/write-web-control-manifest.mjs +43 -0
  75. package/server/web-control/.manifest.json +44 -0
  76. package/server/web-control/app.css +45 -0
  77. package/server/web-control/app.js +1680 -108
  78. package/server/web-control/apple-touch-icon.png +0 -0
  79. package/server/web-control/index.html +54 -17
  80. package/server/web-control/panes/cos-chat.css +52 -6
  81. package/server/web-control/panes/cos-chat.js +364 -32
  82. package/server/web-control/panes/questions.js +135 -14
  83. package/server/web-control/panes/sessions.css +280 -0
  84. package/server/web-control/panes/settings.js +1 -1
  85. package/server/web-control/panes/today-actions.css +35 -0
  86. package/server/web-control/panes/today-actions.js +121 -6
  87. package/server/web-control/panes/work.css +3 -0
  88. package/server/web-control/panes/work.js +185 -38
  89. package/server/web-control/theme.js +33 -0
  90. package/server/web-signer/.manifest.json +11 -0
  91. package/server/web-signer/index.html +74 -0
  92. package/server/web-signer/signer-proof.js +348 -0
  93. package/server/web-signer/signer-store.js +39 -0
  94. package/server/web-signer/signer.css +23 -0
  95. package/server/web-signer/signer.js +341 -0
  96. package/shared/web-approval-packets.golden.json +124 -0
  97. 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
+ }
@@ -35,6 +35,16 @@ export interface AgentSessionListDropped {
35
35
  age: number
36
36
  limit: number
37
37
  oversized: number
38
+ /** Step 2a (Q1 FIX 1): kept rows (COS pins, folder members, COS-started sessions) past KEPT_LIST_MAX. Present only
39
+ * when something was dropped, so an older reader of `dropped` sees the shape it knows. */
40
+ kept?: number
41
+ }
42
+
43
+ /** Step 2a (Q1 FIX 1): the most kept rows one list answer carries, on top of `limit`. Native pins are not counted
44
+ * here; they stay inside `limit` as before. */
45
+ export const KEPT_LIST_MAX = 40
46
+ function countKeptOverflow(dropped: AgentSessionListDropped, n: number): void {
47
+ if (n > 0) dropped.kept = (dropped.kept ?? 0) + n
38
48
  }
39
49
 
40
50
  export function emptySessionListDropped(): AgentSessionListDropped {
@@ -250,7 +260,7 @@ const STRIPPABLE_TAGS = [
250
260
  'local-command-stdout', 'local-command-stderr', 'thinking',
251
261
  ]
252
262
 
253
- const STRIPPABLE_TAG_RE = new RegExp(`</?(?:${STRIPPABLE_TAGS.join('|')})(?:\\s[^>]*)?/?>`, 'gi')
263
+ export const STRIPPABLE_TAG_RE = new RegExp(`</?(?:${STRIPPABLE_TAGS.join('|')})(?:\\s[^>]*)?/?>`, 'gi')
254
264
 
255
265
  /**
256
266
  * Fenced code and known tags out. Shared so `proseSnippet` and `latestAssistantReply`
@@ -995,7 +1005,7 @@ export async function peekCodexMeta(path: string): Promise<{ id: string; cwd: st
995
1005
  return { id, cwd, title, subagent, created }
996
1006
  }
997
1007
 
998
- async function peekCursorDiscussion(path: string): Promise<{ lastUser: string | null; latestAssistant: string }> {
1008
+ export async function peekCursorDiscussion(path: string): Promise<{ lastUser: string | null; latestAssistant: string }> {
999
1009
  const text = await readWindow(path, true)
1000
1010
  let lastUser: string | null = null
1001
1011
  for (const line of text.split('\n')) {
@@ -1010,7 +1020,7 @@ async function peekCursorDiscussion(path: string): Promise<{ lastUser: string |
1010
1020
  return { lastUser, latestAssistant: latestAssistantFromWindow(text) }
1011
1021
  }
1012
1022
 
1013
- async function firstCursorUserTitle(path: string): Promise<string | null> {
1023
+ export async function firstCursorUserTitle(path: string): Promise<string | null> {
1014
1024
  const text = await readWindow(path, false)
1015
1025
  let fallback: string | null = null
1016
1026
  for (const line of text.split('\n')) {
@@ -1058,6 +1068,11 @@ export async function listClaudeSessions(
1058
1068
  desktopSessionsRoot = '',
1059
1069
  dropped: AgentSessionListDropped = emptySessionListDropped(),
1060
1070
  preloadedAliases?: ClaudeAliasMap,
1071
+ /** Step 2a: canonical ids the COS organizer keeps (COS pins, folder members). Listed at any age and outside `cap`,
1072
+ * like a native pin, but `pinned` stays the native star only. */
1073
+ keep: ReadonlySet<string> = new Set(),
1074
+ /** Which kept ids win when KEPT_LIST_MAX must choose: lower first (0 COS pins and folders, 1 COS-started). */
1075
+ keepRank: (id: string) => number = () => 0,
1061
1076
  ): Promise<AgentSessionRow[]> {
1062
1077
  const aliases = preloadedAliases ?? await loadClaudeDesktopAliases(desktopSessionsRoot)
1063
1078
  const canonical = (id: string) => { const cli = aliases.get(id)?.cliSessionId; return cli && CLAUDE_UUID_JSONL.test(cli + '.jsonl') ? cli : id }
@@ -1072,6 +1087,7 @@ export async function listClaudeSessions(
1072
1087
  const seen = new Set<string>()
1073
1088
  const pinnedCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
1074
1089
  const recentCandidates: Array<{ file: string; native: string; project: string; mtimeMs: number; birthtimeMs: number; desktop?: string }> = []
1090
+ const keptCandidates: typeof recentCandidates = []
1075
1091
  for (const folder of await dirents(projectsRoot)) {
1076
1092
  const dir = join(projectsRoot, folder)
1077
1093
  const dirSt = await fileStat(dir)
@@ -1084,7 +1100,9 @@ export async function listClaudeSessions(
1084
1100
  const file = join(dir, name)
1085
1101
  const st = await fileStat(file)
1086
1102
  if (!st?.isFile) continue
1087
- const pinned = canonicalStars.has(native.toLowerCase())
1103
+ const starred = canonicalStars.has(native.toLowerCase())
1104
+ const kept = !starred && keep.has(native.toLowerCase())
1105
+ const pinned = starred || kept
1088
1106
  const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
1089
1107
  if (!pinned && !fresh) {
1090
1108
  dropped.age += 1
@@ -1099,17 +1117,18 @@ export async function listClaudeSessions(
1099
1117
  desktop: desktopByCli.get(native)?.file,
1100
1118
  }
1101
1119
  seen.add(native.toLowerCase())
1102
- if (pinned) pinnedCandidates.push(candidate)
1120
+ if (starred) pinnedCandidates.push(candidate)
1121
+ else if (kept) keptCandidates.push(candidate)
1103
1122
  else recentCandidates.push(candidate)
1104
1123
  }
1105
1124
  }
1106
- for (const starred of canonicalStars) {
1125
+ for (const starred of new Set([...canonicalStars, ...keep])) {
1107
1126
  if (seen.has(starred) || canonicalLive.has(starred) || canonicalLive.has(starred.slice(0, 8))) continue
1108
1127
  const desktop = desktopByCli.get(starred)?.file
1109
1128
  if (!desktop) continue
1110
1129
  const st = await fileStat(desktop)
1111
1130
  if (!st?.isFile) continue
1112
- pinnedCandidates.push({
1131
+ ;(canonicalStars.has(starred) ? pinnedCandidates : keptCandidates).push({
1113
1132
  file: '',
1114
1133
  native: starred,
1115
1134
  project: '',
@@ -1120,6 +1139,7 @@ export async function listClaudeSessions(
1120
1139
  seen.add(starred)
1121
1140
  }
1122
1141
  pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
1142
+ keptCandidates.sort((a, b) => (keepRank(a.native.toLowerCase()) - keepRank(b.native.toLowerCase())) || b.mtimeMs - a.mtimeMs)
1123
1143
  recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
1124
1144
 
1125
1145
  const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
@@ -1166,8 +1186,13 @@ export async function listClaudeSessions(
1166
1186
  return rows
1167
1187
  }
1168
1188
 
1189
+ const keptLimitBefore = dropped.limit
1190
+ const keptRows = await toRows(keptCandidates, KEPT_LIST_MAX)
1191
+ countKeptOverflow(dropped, dropped.limit - keptLimitBefore)
1192
+ dropped.limit = keptLimitBefore
1169
1193
  return [
1170
1194
  ...await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0)),
1195
+ ...keptRows,
1171
1196
  ...await toRows(recentCandidates, Math.max(0, cap)),
1172
1197
  ]
1173
1198
  }
@@ -1177,27 +1202,36 @@ export async function listCodexSessions(
1177
1202
  now: Date,
1178
1203
  cap = AGENT_SESSION_PER_PROVIDER_LIMIT,
1179
1204
  dropped: AgentSessionListDropped = emptySessionListDropped(),
1205
+ /** Step 2a: thread ids the COS organizer keeps; see listClaudeSessions. */
1206
+ keep: ReadonlySet<string> = new Set(),
1207
+ keepRank: (id: string) => number = () => 0,
1180
1208
  ): Promise<AgentSessionRow[]> {
1181
1209
  const names = await loadCodexThreadNames(sessionsRoot)
1182
1210
  const pinnedIds = await loadCodexPinnedIds(sessionsRoot)
1183
1211
  const pinnedCandidates: Array<{ file: string; name: string; mtimeMs: number; birthtimeMs: number }> = []
1184
1212
  const recentCandidates: Array<{ file: string; name: string; mtimeMs: number; birthtimeMs: number }> = []
1213
+ const keptCandidates: typeof recentCandidates = []
1185
1214
  for (const file of await listCodexJsonlFiles(sessionsRoot)) {
1186
1215
  const st = await fileStat(file)
1187
1216
  if (!st?.isFile) continue
1188
1217
  const name = file.split('/').pop() || file
1189
1218
  const fileId = idFromCodexFilename(name)
1190
- const pinned = fileId ? pinnedIds.has(fileId) : false
1219
+ const nativePin = fileId ? pinnedIds.has(fileId) : false
1220
+ const kept = !nativePin && (fileId ? keep.has(fileId) : false)
1221
+ const pinned = nativePin || kept
1191
1222
  const fresh = now.getTime() - st.mtimeMs <= AGENT_SESSION_MAX_AGE_MS
1192
1223
  if (!pinned && !fresh) {
1193
1224
  dropped.age += 1
1194
1225
  continue
1195
1226
  }
1196
1227
  const candidate = { file, name, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs }
1197
- if (pinned) pinnedCandidates.push(candidate)
1228
+ if (nativePin) pinnedCandidates.push(candidate)
1229
+ else if (kept) keptCandidates.push(candidate)
1198
1230
  else recentCandidates.push(candidate)
1199
1231
  }
1200
1232
  pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
1233
+ const codexRank = (c: { name: string }) => keepRank(idFromCodexFilename(c.name) ?? '')
1234
+ keptCandidates.sort((a, b) => (codexRank(a) - codexRank(b)) || b.mtimeMs - a.mtimeMs)
1201
1235
  recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
1202
1236
 
1203
1237
  const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
@@ -1251,8 +1285,12 @@ export async function listCodexSessions(
1251
1285
  }
1252
1286
 
1253
1287
  const pinnedRows = await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0))
1288
+ const keptLimitBefore = dropped.limit
1289
+ const keptRows = await toRows(keptCandidates, KEPT_LIST_MAX)
1290
+ countKeptOverflow(dropped, dropped.limit - keptLimitBefore)
1291
+ dropped.limit = keptLimitBefore
1254
1292
  const recentRows = await toRows(recentCandidates, Math.max(0, cap))
1255
- return [...pinnedRows, ...recentRows]
1293
+ return [...pinnedRows, ...keptRows, ...recentRows]
1256
1294
  }
1257
1295
 
1258
1296
  export async function listCursorSessions(
@@ -1262,15 +1300,19 @@ export async function listCursorSessions(
1262
1300
  composerDb = '',
1263
1301
  pinnedIds: ReadonlySet<string> = new Set(),
1264
1302
  dropped: AgentSessionListDropped = emptySessionListDropped(),
1303
+ /** Step 2a: composer ids the COS organizer keeps; see listClaudeSessions. */
1304
+ keep: ReadonlySet<string> = new Set(),
1305
+ keepRank: (id: string) => number = () => 0,
1265
1306
  ): Promise<AgentSessionRow[]> {
1266
1307
  const composerNames = composerDb ? await loadCursorComposerNames(composerDb) : new Map<string, string>()
1308
+ const retained = (id: string) => pinnedIds.has(id.toLowerCase()) || keep.has(id.toLowerCase())
1267
1309
  const byId = new Map<string, { file: string; sessionDir: string; project: string; mtimeMs: number; birthtimeMs: number }>()
1268
1310
  for (const folder of await dirents(projectsRoot)) {
1269
1311
  if (folder.includes('var-folders') || folder.includes('private-var')) continue
1270
1312
  const transcripts = join(projectsRoot, folder, 'agent-transcripts')
1271
1313
  for (const sessionDir of await dirents(transcripts)) {
1272
1314
  if (sessionDir === 'subagents') continue
1273
- const pinned = pinnedIds.has(sessionDir.toLowerCase())
1315
+ const pinned = retained(sessionDir)
1274
1316
  if (folder === 'empty-window' && !pinned) continue
1275
1317
  const file = join(transcripts, sessionDir, `${sessionDir}.jsonl`)
1276
1318
  const st = await fileStat(file)
@@ -1295,9 +1337,12 @@ export async function listCursorSessions(
1295
1337
  byId.set(sessionDir, existing ? preferCursorCopy(next, existing) : next)
1296
1338
  }
1297
1339
  }
1298
- const pinnedCandidates = [...byId.values()].filter(c => pinnedIds.has(c.sessionDir.toLowerCase()))
1299
- const recentCandidates = [...byId.values()].filter(c => !pinnedIds.has(c.sessionDir.toLowerCase()))
1340
+ const nativePin = (id: string) => pinnedIds.has(id.toLowerCase())
1341
+ const pinnedCandidates = [...byId.values()].filter(c => nativePin(c.sessionDir))
1342
+ const keptCandidates = [...byId.values()].filter(c => !nativePin(c.sessionDir) && retained(c.sessionDir))
1343
+ const recentCandidates = [...byId.values()].filter(c => !retained(c.sessionDir))
1300
1344
  pinnedCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
1345
+ keptCandidates.sort((a, b) => (keepRank(a.sessionDir.toLowerCase()) - keepRank(b.sessionDir.toLowerCase())) || b.mtimeMs - a.mtimeMs)
1301
1346
  recentCandidates.sort((a, b) => b.mtimeMs - a.mtimeMs)
1302
1347
 
1303
1348
  const toRows = async (candidates: typeof recentCandidates, limit: number): Promise<AgentSessionRow[]> => {
@@ -1334,8 +1379,13 @@ export async function listCursorSessions(
1334
1379
  return rows
1335
1380
  }
1336
1381
 
1382
+ const keptLimitBefore = dropped.limit
1383
+ const keptRows = await toRows(keptCandidates, KEPT_LIST_MAX)
1384
+ countKeptOverflow(dropped, dropped.limit - keptLimitBefore)
1385
+ dropped.limit = keptLimitBefore
1337
1386
  return [
1338
1387
  ...await toRows(pinnedCandidates, Math.max(pinnedCandidates.length, 0)),
1388
+ ...keptRows,
1339
1389
  ...await toRows(recentCandidates, Math.max(0, cap)),
1340
1390
  ]
1341
1391
  }
@@ -1407,6 +1457,12 @@ export async function listAgentSessions(
1407
1457
  sort: AgentSessionSort = 'updated',
1408
1458
  dropped: AgentSessionListDropped = emptySessionListDropped(),
1409
1459
  preloadedAliases?: ClaudeAliasMap,
1460
+ /** Step 2a: canonical ids per provider the COS organizer keeps (COS pins, folder members). Kept rows are listed at
1461
+ * any age, outside the per-provider cap AND outside `limit`. Absent: exactly today's list. */
1462
+ keep: Partial<Record<AgentProvider, ReadonlySet<string>>> = {},
1463
+ /** Step 2a (Miles, 2026-11-10): sessions a COS job started in the last 7 days. Kept like `keep`, ranked after it when
1464
+ * the KEPT_LIST_MAX ceiling has to choose. */
1465
+ started: Partial<Record<AgentProvider, ReadonlySet<string>>> = {},
1410
1466
  ): Promise<AgentSessionRow[]> {
1411
1467
  const originalStars = await loadClaudeStarredIds(roots.claudeDesktopConfig)
1412
1468
  // ONE alias load for the whole walk. Before this the walk loaded it here AND
@@ -1429,15 +1485,26 @@ export async function listAgentSessions(
1429
1485
  })
1430
1486
  const liveIds = new Set(enrichedLive.flatMap(row => [row.session_id, row.session_id.slice(0, 8)]))
1431
1487
  const cap = AGENT_SESSION_PER_PROVIDER_LIMIT
1488
+ // Kept Claude ids are canonical already (the organizer route canonicalizes on write); map again in case a Desktop
1489
+ // id reached the store from an older path.
1490
+ const keptClaude = new Set([...(keep.claude ?? [])].map(id => canonical(id).toLowerCase()))
1491
+ const startedClaude = new Set([...(started.claude ?? [])].map(id => canonical(id).toLowerCase()))
1492
+ const inSet = (row: AgentSessionRow, claude: Set<string>, other: Partial<Record<AgentProvider, ReadonlySet<string>>>) =>
1493
+ row.provider === 'claude' ? claude.has(row.session_id.toLowerCase()) : (other[row.provider]?.has(row.session_id.toLowerCase()) ?? false)
1494
+ /** 0: a COS pin or folder member; 1: COS-started; null: not kept. */
1495
+ const keptTier = (row: AgentSessionRow): 0 | 1 | null => inSet(row, keptClaude, keep) ? 0 : inSet(row, startedClaude, started) ? 1 : null
1496
+ const isKept = (row: AgentSessionRow) => keptTier(row) !== null
1497
+ const union = (provider: Exclude<AgentProvider, 'claude'>) => new Set([...(keep[provider] ?? []), ...(started[provider] ?? [])])
1498
+ const keptForWalk = { claude: new Set([...keptClaude, ...startedClaude]), codex: union('codex'), cursor: union('cursor') }
1432
1499
  let rows = dedupeSessions([
1433
1500
  ...enrichedLive,
1434
- ...await listClaudeSessions(roots.claudeProjects, now, liveIds, cap, starredIds, roots.claudeCodeSessions, dropped, aliases),
1435
- ...(await Promise.all(codexSessionRootsOf(roots).map(root => listCodexSessions(root, now, cap, dropped)))).flat(),
1436
- ...await listCursorSessions(roots.cursorProjects, now, cap, roots.cursorComposerDb, cursorPinned, dropped),
1501
+ ...await listClaudeSessions(roots.claudeProjects, now, liveIds, cap, starredIds, roots.claudeCodeSessions, dropped, aliases, keptForWalk.claude, id => keptClaude.has(id) ? 0 : 1),
1502
+ ...(await Promise.all(codexSessionRootsOf(roots).map(root => listCodexSessions(root, now, cap, dropped, keptForWalk.codex, id => keep.codex?.has(id) ? 0 : 1)))).flat(),
1503
+ ...await listCursorSessions(roots.cursorProjects, now, cap, roots.cursorComposerDb, cursorPinned, dropped, keptForWalk.cursor, id => keep.cursor?.has(id) ? 0 : 1),
1437
1504
  ])
1438
1505
  if (sort === 'opened') {
1439
1506
  rows = rows.filter(entry => {
1440
- if (entry.alive) return true
1507
+ if (entry.alive || isKept(entry)) return true
1441
1508
  const created = Date.parse(entry.created)
1442
1509
  return Number.isFinite(created) && now.getTime() - created <= AGENT_SESSION_MAX_AGE_MS
1443
1510
  })
@@ -1450,9 +1517,24 @@ export async function listAgentSessions(
1450
1517
  // pin-boosting here made the lens show July stars instead of today.
1451
1518
  rows.sort((a, b) => (b.modified || '').localeCompare(a.modified || ''))
1452
1519
  }
1453
- const extras = Math.max(0, rows.length - Math.max(0, limit))
1454
- if (extras) dropped.limit += extras
1455
- return rows.slice(0, limit)
1520
+ // Step 2a: kept rows are not cut by `limit` but by their own ceiling, KEPT_LIST_MAX (Q1 FIX 1): COS pins and folder
1521
+ // members first, then COS-started sessions, newest first in each. They keep their place in the sort order.
1522
+ const max = Math.max(0, limit)
1523
+ const keptRanked = rows.filter(isKept)
1524
+ .sort((a, b) => (keptTier(a)! - keptTier(b)!) || (b.modified || '').localeCompare(a.modified || ''))
1525
+ const keptAllowed = new Set(keptRanked.slice(0, KEPT_LIST_MAX))
1526
+ countKeptOverflow(dropped, keptRanked.length - keptAllowed.size)
1527
+ let taken = 0
1528
+ let cut = 0
1529
+ const out = rows.filter(entry => {
1530
+ if (isKept(entry)) return keptAllowed.has(entry)
1531
+ taken += 1
1532
+ if (taken <= max) return true
1533
+ cut += 1
1534
+ return false
1535
+ })
1536
+ if (cut) dropped.limit += cut
1537
+ return out
1456
1538
  }
1457
1539
 
1458
1540
  /**