@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.
- package/.env.example +19 -0
- package/CHANGELOG.md +305 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +129 -23
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +102 -20
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/cos-started-sessions.ts +101 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/list-model-memo.ts +85 -0
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/model-efforts.ts +40 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-organizer.ts +305 -0
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +39 -3
- package/server/lib/thread-turn-queue.ts +24 -0
- package/server/lib/today-brief.ts +522 -58
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/agent-sessions.ts +106 -2
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +14 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-organizer.ts +149 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +50 -14
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +45 -0
- package/server/web-control/app.js +1680 -108
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +54 -17
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/sessions.css +280 -0
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +121 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +185 -38
- package/server/web-control/theme.js +33 -0
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- 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
|
|
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 (
|
|
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
|
|
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 (
|
|
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 =
|
|
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
|
|
1299
|
-
const
|
|
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
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
/**
|