@gotcos/glasses-server 7.0.18 → 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/CHANGELOG.md CHANGED
@@ -1,3 +1,137 @@
1
+ ## 7.0.27 - Control Web: the Sessions list fits its column at every width
2
+
3
+ 2026-11-11. 7.0.26 plus one Control Web layout fix.
4
+
5
+ - **The list no longer scrolls sideways when its column is narrow.** With the divider dragged to its narrowest (240 px), the list was 32 px wider than its column, so the column panned sideways and cut its left edge ("ssions", "earch titles and transcripts", half the row icons). The reach line ("Last 30 days. 582 older through search.") shared a row with New folder and the sort menu and was squeezed to one word a line, and with the keyboard up the sort menu and search field were cut at the right. Now the reach line has its own full-width row, New folder and the sort menu share the next one and wrap under each other if they must, and a long folder name ends in an ellipsis like a title. Nothing is hidden to make it fit: the content fits the column.
6
+ - Tested in a browser by dragging the divider from its narrowest to its widest at both open-phone sizes, keyboard up and down, with and without a session open: 7.0.26 fails, this build passes. The reply area and the reader are unchanged.
7
+
8
+ **Default-on for every install:** yes, the layout fix; there is no switch.
9
+
10
+ ### If you roll back
11
+
12
+ - To 7.0.26: the narrow list scrolls sideways again. Nothing is stored by this change.
13
+ - To 7.0.18: as in 7.0.26 below.
14
+
15
+ ## 7.0.26 - 7.0.25, with kept sessions only where they were asked for: the Mac app and the glasses keep their list
16
+
17
+ 2026-11-11. 7.0.25 plus one server change and one Control Web line.
18
+
19
+ - **Kept sessions only for a client that asks.** In 7.0.25 every client received the kept sessions (COS pins, folders, sessions a person started through COS) on top of its usual list, so after a server update the Mac app and the glasses would have shown up to 40 more rows with no app update. Now the server adds them only when the request asks (`kept=1`). The Mac app 0.5.389 and the glasses 6.11.681 do not ask, so they get the same list as 7.0.18. Control Web asks, so Sessions in Control Web shows them as in 7.0.25.
20
+ - Nothing else changes: `/older` and the folder, pin and rename routes are as in 7.0.25.
21
+
22
+ **Default-on for every install:** everything in 7.0.25 below; the one difference is that kept sessions reach Control Web only.
23
+
24
+ ### If you roll back
25
+
26
+ - To 7.0.25: the Mac app and the glasses list kept sessions again. Nothing is stored by this change.
27
+ - To 7.0.18: as in 7.0.25 below.
28
+
29
+ ## 7.0.25 - Control Web: the Sessions list in groups, 30 days, folders, COS pins and rename; the server keeps them; two 7.0.23 faults fixed; a queued note is no longer stuck
30
+
31
+ 2026-11-11. 7.0.23 plus fixes to it (below), the server's session store, the queue fixes and the grouped list. 7.0.24 was never released.
32
+
33
+ **A fault in 7.0.23, fixed here: Send could sit below the screen once files were attached.** In the new Sessions frame the reply area could grow (the chips of attached files wrap to more rows) until Send was past the bottom of the screen with nothing to scroll: 2 files on the open Duo, 1 with the keyboard up on the folded phone. 7.0.22 did not do this (its page scrolls). Now the reply area follows one rule: it never grows past the visible height, its inside scrolls, and the reply box with Send stays pinned at its bottom, always in view. The chip row and a long draft scroll inside their own capped rows; the busy note, the queue and Not sent sit above the reply box and scroll. Checked in WebKit at five sizes with and without the keyboard, with 0 to 5 files, 0 to 3 queued notes, Not sent, the busy and "another app" notes and a long draft. Also: on the open Duo, Copy session and the rail's Settings reach 44 px for touch, and on a short screen with the keyboard up the reply area drops its extra spacing.
34
+
35
+ **A second fault in 7.0.23, fixed here:** when the web sign-in lapsed while Sessions was open, the sign-in screen came up but kept the Sessions frame (the page fixed at one screen, no scrolling). With the keyboard up the Connect button sat below the screen and could not be reached; only quitting and reopening cleared it. 7.0.22 did not do this. Now the frame comes off whenever the sign-in screen shows, and comes back with the app. Checked in WebKit with a real scroll and a real tap on Connect at four sizes (7.0.23 fails, this build passes).
36
+
37
+ **A queued note is no longer stuck (server):** a follow-up sent while a session was busy could wait long after the session's turn had really ended: COS's reading of newer Claude Code transcripts took a copied tool request for an open turn, and while the session kept writing, the wait kept renewing. One note waited over two hours this way. Now that reading ignores records that are not the user's or the assistant's own turns, so a copied tool request no longer reopens a turn. Each waiting note also carries why it waits, and the queued-note line in Control Web says it in words, for example "It has files, so it waits for the current turn to end."
38
+
39
+ **What changes in the Sessions list, for everyone who opens it in Control Web:**
40
+ - **Groups, top to bottom:** Needs you, Working now, Pinned, your folders, then Today, Yesterday, Earlier this week, Last week, Earlier this month. A session shows once. Needs you uses the same rule as Today, so the two agree.
41
+ - **Rows move only when the list is at rest.** A session that starts waiting or running moves to its group 2 s after the list was last pressed, scrolled or focused, never under your finger. That includes a change the live stream sends. The list keeps its place when rows are added above.
42
+ - **30 days instead of 7:** Last week and Earlier this month are folded groups of compact rows, with Show older for more. One quiet line says how far it reaches, for example "Last 30 days. 271 older through search."
43
+ - **Folders:** New folder, rename, delete, and Move to folder from a row's menu. Deleting a folder releases its sessions; it never deletes a session.
44
+ - **COS pins and rename:** Pin and Unpin from a row's menu, and Rename, which search matches. An agent's own pin shows as "Pinned in Claude" and only the agent can undo it; a COS pin under it shows Unpin in COS.
45
+ - **The Sessions menu** (Sessions, Usage, COS cost, Refresh) and one sort menu (Updated or Opened; Best match while searching) head the list.
46
+
47
+ **What changes on the server, for every client:**
48
+ - **Kept sessions (changed in 7.0.26: Control Web only).** In 7.0.26 the server adds them only when a client asks (`kept=1`); Control Web asks, and the Mac app 0.5.389 and the glasses 6.11.681 get the same list as 7.0.18. What a kept session is: a session pinned in COS or put in a folder stays in the list at any age and past the per-agent cap of 20; a session a person started through COS stays past that cap for 7 days. At most 40 kept rows per response, across agents; the rest are counted (`dropped.kept`) and come back as short rows through `/older`, so none is lost. About +150 ms and +27 KB per list read when all 40 are in use.
49
+ - **Sessions a person started through COS (Ask, Work, Continue, tasks) stay in the list for 7 days past the cap of 20, with no action from anyone** (from 7.0.26, in Control Web). COS routines and maintenance jobs are left out. A job counts only when a person started it: no origin or a task origin, and no maintenance workflow, so a new kind of background job is left out by default.
50
+ - `/api/models` gains each agent's efforts. List rows gain `folder_id`, `cos_pinned`, `title_override` and the reported model where known. Older clients ignore unknown keys.
51
+ - **New disk reads:** a model warm after each list read (Claude and Codex, at most 4 at a time, each file at most once a minute), and the query-job journal for session ids and times only (each day file at most every 30 s). No prompts or answers are read.
52
+
53
+ **What is stored and where:** one new file, `session-organizer.json` in the data folder (`~/.cos-glasses/data/` on a default install; it follows `COS_DATA_DIR`). It is created on the first change and holds folder names, COS pins, renamed titles and the session ids they belong to, as plain text.
54
+
55
+ **Tested** in a browser at phone and desktop sizes (the list, folders and frame checks pass) and by the full test suite.
56
+
57
+ **Default-on for every install:** yes: the grouped list, the 30 days, the kept sessions (Control Web only from 7.0.26), the store, the reply area rule, the sign-in screen fix, the queue fix and the reason line. There is no switch.
58
+
59
+ ### If you roll back
60
+
61
+ - To 7.0.23: Control Web shows the frame with the old flat 7-day list, both 7.0.23 faults above come back, and a queued note can wait after a turn has ended again. The other clients drop back to today's list (on 7.0.26 they never received kept sessions). `session-organizer.json` stays on disk untouched and is ignored; reinstalling 7.0.25 picks it up again. Nothing else is stored by this release.
62
+ - To 7.0.18 (the public build before this one): everything from 7.0.19 to 7.0.25 in Control Web goes back to the 7.0.18 page, and a queued note can wait after a turn has ended again. `session-organizer.json` stays on disk and 7.0.18 ignores it (it reads no such file); the queued-note fields this release adds (the hold reason and its time) are ignored too. Nothing is rewritten.
63
+
64
+ ## 7.0.23 - Control Web: a visible height under 80 px is no reading, and Sessions is one screen (layout, not a proven fix)
65
+
66
+ 2026-11-11. 7.0.22 (dca6a1a) plus two changes to Control Web, on the lead's go (00:17). Candidate only: nothing is installed or published by this commit. Neither change is claimed to fix his screenshots until his phone shows it.
67
+
68
+ - **What his phone reported:** on the unfolded Duo (the open, 1119 wide layout), after a keyboard closed, the visible height read 0 for minutes at a time (19:55 to 20:01, 20:13 to 20:27 on 11/10), while the page height was back within 0.3 s. Never on the folded phone.
69
+ - **What the code now does with that:** a visible height under 80 px, or none, counts as no reading. The page then uses the whole page height from the top, so the reply box, Today's panel, the question card and the image viewer are laid out on the real screen, not on a height of 0. Before, only exactly 0 fell back. Work's chat box reads the same corrected height. Each run of bad readings is counted, and every layout report carries the counts (vz, vzr).
70
+ - **Sessions is one screen (the frame):** while Sessions shows its list and a session, the page never scrolls as a whole. The rail, the list and the conversation each scroll by themselves, and the reply area sits at the bottom of the session.
71
+ - A session opens at its newest turn and stays there as turns arrive, until you scroll away.
72
+ - The reply area takes at most 60 percent of the session (three quarters with the keyboard up). What it is doing now gives way first; the reply box never shrinks.
73
+ - The session's question cards end the conversation. Scrolled away from them, a Go to the question button sits over the end of the conversation and takes you back.
74
+ - Full width reads Hide list in Sessions. Usage and COS cost keep the page's own scroll.
75
+ - One position report on open and after a keyboard closes (at most 8 a page load): the visible box, and whether the rail, the list and the reply box are inside it. Numbers and yes or no only.
76
+ - **Checked on this Mac** (WebKit, touch on, scale 1, six sizes from 449 to 1440 wide): 227 checks pass, with real wheel events, and two keyboard stand-ins whose limits are stated with the checks. Only his phone shows a real keyboard, real touch scrolling and the 0.85 scale.
77
+
78
+ **Default-on for every install:** yes: both. The frame is a layout change for everyone who opens Sessions in Control Web. There is no switch.
79
+
80
+ ### If you roll back
81
+
82
+ - To 7.0.22: the guard and the frame go; Sessions scrolls as one page again. Nothing is stored by this release.
83
+
84
+ ## 7.0.22 - Control Web reports two more layout readings from the phone; nothing else changes
85
+
86
+ 2026-11-10. 7.0.21 (be6b117) plus two layout reports in Control Web, on the lead's go (20:52). It fixes nothing. Miles saw the Today panel move with the page under it, and the rail and list leave the screen when he squeezed a divider on his Duo. Neither happens on this Mac, so these reports let his phone say what the page did. Private on Miles's Mac first.
87
+
88
+ - **The Today panel report:** while the panel is open, the first time the page scrolls or the visible area moves (up, down or sideways), one report with those positions at open and now, and where the panel and the rail sit. Once per open.
89
+ - **The divider report:** when a drag on any divider ends, one report with the column widths, the page width and scroll width, how far the visible area has moved, and where the rail, the list and the reader sit. At most six a page load.
90
+ - **Numbers only,** plus the panel kind and the divider name. No text, titles or ids. Both go through the existing layout report limits.
91
+ - **Not proven off the device:** what his phone reports. On this Mac (WebKit, scale 1, 951 and 1119 wide) neither problem reproduces.
92
+
93
+ **Default-on for every install:** yes: both reports. There is no switch.
94
+
95
+ ### If you roll back
96
+
97
+ - To 7.0.21: the two reports stop. Nothing else changes and nothing is stored by this release.
98
+
99
+ ## 7.0.21 - Control Web shows the COS mark first on every reopen, and the phone reports where its open time goes
100
+
101
+ 2026-11-10. 7.0.19 (1a7c322) plus one change to Control Web, on the lead's go (14:55) after Miles's first 7.0.19 open on his phone: the mark showed only 72 ms before the app (first paint 520 ms, the app 592 ms). Not 7.0.20: that number stays with the phone approvals work, which is on hold. Private on Miles's Mac first.
102
+
103
+ - **Why the mark came late:** the page's six scripts (about 735 KB) sat at the end of the page, and the browser often drew nothing until all of them had run. On the phone that is most of the wait.
104
+ - **Now the scripts run one frame after the page is drawn,** in the same order as before, and the app starts only once every one of them has run, as before. They still start downloading while the page arrives, so a first open does not wait longer for them.
105
+ - **Measured in WebKit on this Mac,** with each script slowed to stand in for the phone and 150 ms added to every request: on a reopen the mark showed at about 185 ms every time (before: 194 ms on one run, 476 ms on another), and the app showed at the same time as before. A first-ever open still varies from run to run, before and after; the app is no slower.
106
+ - **The open timing report gains three numbers:** when the page finished arriving, when the scripts had run, and how long the sign-in check took. So the next slow open says where its time went.
107
+ - **The phone's layout reports gain the page width:** the layout width, the width the screen shows at 100%, the scroll width, and the element that sticks out past the edge (none when nothing does). This is to settle why the Duo shows Control Web at 85%: our page, or a phone or browser setting. Numbers and a short element name only, no text.
108
+ - **Not proven off the device:** only the phone shows its own first paint, a first-ever open, and the Duo's widths.
109
+
110
+ **Default-on for every install:** yes: the script loading, and the extra numbers in the layout reports. There is no switch.
111
+
112
+ ### If you roll back
113
+
114
+ - To 7.0.19: the scripts run as part of the page again, so the mark can come late; the reports lose the new numbers. Nothing is stored by this change.
115
+
116
+ ## 7.0.19 - Control Web shows the COS mark at once on open, instead of a blank page
117
+
118
+ 2026-11-10. 7.0.18 (a7e0317) plus one change to Control Web, on Miles's go (12:07, "Let's build that for the next publish for today. Ensure no regressions."). Private on Miles's Mac first.
119
+
120
+ - **The problem (Miles, 11:56):** opening the web app for the first time was a little slow. Measured: the page was blank until the Mac had answered twice, once to re-check the page and once to say whether this browser is signed in. Caching and compression were already right.
121
+ - **Now the COS mark shows at once,** on the page's own background in your theme, right after the small theme script and before the app's scripts run or the Mac answers. It goes the moment the sign-in card or the app shows. It shows neither one's content, so it is true whichever comes next.
122
+ - **Nothing else moves:** the mark sits above the page, so the app and the sign-in card appear exactly where they did. It takes no taps, asks the Mac for nothing and loads no new file.
123
+ - **If the page's scripts never run,** the mark hides itself after 25 seconds, longer than the 20 second sign-in check. A Mac that does not answer still shows "COS on your Mac is not answering" as before.
124
+ - **With JavaScript off,** the page now says "COS Control Web needs JavaScript. Turn it on for this site, then reload." It was blank.
125
+ - **A timing report on open:** one layout report gives the milliseconds from opening to the first paint and to the app or sign-in card showing, inside the existing report limits, with no text. So the next slow open is measured on the phone itself.
126
+ - **Measured in WebKit on this Mac** with 150 ms added to every request: on a reopen the first paint came at 189 ms instead of 358 ms, and the app at the same time as before. Only the phone shows its own numbers, and the Home Screen app.
127
+ - **Notes:** the 7.0.18 entry below now also says what the glasses show for a turn-end question and how to answer by voice. That line was left out of the 7.0.18 package.
128
+
129
+ **Default-on for every install:** yes: the first paint, the JavaScript-off line and the open timing report. There is no switch.
130
+
131
+ ### If you roll back
132
+
133
+ - To 7.0.18: the page is blank again until the Mac answers. Nothing is stored by this change.
134
+
1
135
  ## 7.0.18 - A question a session ends its turn with reaches the glasses, Control Web and the desktop
2
136
 
3
137
  2026-11-10. 7.0.17 (7313e50) plus X1's server half (claude/server-7018-turn-end-ask) and W1's Control Web card (claude/server-7018-web-turn-end), on Miles's go (07:14, "fold this in to the latest update so I can respond from glasses, web app and desktop"). Pairs with COS Control 0.5.389. Private on Miles's Mac first.
@@ -8,6 +142,7 @@
8
142
  - **It clears itself** on any reply, a new turn, the session ending, or after 8 hours.
9
143
  - **The words** follow the existing switch for question words (`COS_WAITING_QUESTION_WORDS`). With it off, the row still says a question waits, with no words and no choices.
10
144
  - **Control Web:** Today's row reads "<session> asked: <question>". The session reader and Today's sheet show the question, a button per choice, and one line: "A choice or a reply here reaches the session as a message from COS, not typed in the session, and the session may ask you to confirm." The reply box stays for your own answer.
145
+ - **On the glasses (public app 6.11.681, no new glasses build):** within about a minute while Home or the Sessions list is on the lens, the header shows `◎Mac` and Home reads "Mac waiting on you since" the time it started waiting. Swipe down to the Menu and choose `Mac waiting · 1` to see the session and the question. That page says "Answer on the Mac.", which is wrong for this case until the next glasses build: you can answer by voice from the glasses with Sessions, the session, then Continue. Your words arrive as a verified G2 turn. The choices are not offered on the glasses yet.
11
146
  - **What an answer sends:** a tap, or a typed reply, goes through the session's existing Continue as "Answer sent from COS Control Web through COS, not typed in this session: <your choice>". It never reads as typed at the desk, so the session may ask you to confirm. If the question was answered on another screen first, nothing is sent and it says so.
12
147
  - **Logs and counts:** the session ledger adds only the question, its labels and the blocked mark on that one tool's rows; never the session's recap. The authenticated question status adds counts (armed, shown, cleared, aged out), never words.
13
148
  - **Not proven off the device:** tests and WebKit show each rule; only the glasses, the phone and the desktop app can show the full loop.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "7.0.18",
3
+ "version": "7.0.27",
4
4
  "description": "COS Glasses: self-hosted AI heads-up-display server for Even G2 smart glasses, powered by Claude Code, Codex, Cursor Agent CLI, or local Ollama",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -31,6 +31,7 @@ import { providerProofRouter } from './routes/provider-proof.js'
31
31
  import { transcribeRouter } from './routes/transcribe.js'
32
32
  import { sessionIndexRouter } from './routes/session-index.js'
33
33
  import { agentSessionsRouter } from './routes/agent-sessions.js'
34
+ import { createSessionOrganizerRouter } from './routes/session-organizer.js'
34
35
  import { agentSessionStreamRouter } from './routes/agent-session-stream.js'
35
36
  import { createLiveEventsRouter, liveEventsEnabled, realLiveSources } from './routes/live-events.js'
36
37
  import { LiveEventsHub } from './lib/live-events.js'
@@ -1154,6 +1155,8 @@ app.use('/api', transcribeRouter)
1154
1155
  app.use('/api', sessionIndexRouter)
1155
1156
  // Claude + Codex + Cursor transcripts from this Mac. Same 7-day window as Control.
1156
1157
  app.use('/api', agentSessionsRouter)
1158
+ // Step 2a (Sessions chat-style layout): COS folders, COS pins and renamed titles. Additive; an older server 404s these.
1159
+ app.use('/api', createSessionOrganizerRouter())
1157
1160
  // The live view of one of those sessions. Four segments ending in a literal `stream`,
1158
1161
  // so it cannot shadow the three-segment transcript route above or the bindings
1159
1162
  // router's `/attachability` below. READ-ONLY, and deliberately NOT behind
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Older sessions in a short form (Sessions chat-style layout, step 2a; S1_CONTRACT_step2a.md, B2).
3
+ *
4
+ * The main list (listAgentSessions) keeps its 7-day window and its per-agent cap of 20, so its 5-second poll stays the
5
+ * size it is. On Miles's Mac that cap shows only the newest ~4 hours of Claude sessions (333 transcripts in 7 days,
6
+ * measured 2026-11-10). This walk answers the rest of the last `days` days as SHORT rows: id, agent, title, project,
7
+ * dates. No state, activity or model: those come from the detail route when a session is opened.
8
+ *
9
+ * - Paged newest first across all agents by `before` (exclusive) and `limit`; `nextBefore` is null on the last page.
10
+ * - Leaves out the ids in `exclude` (live rows, passed by the route) and each agent's own pins. A row may still also be
11
+ * in the main list; the client keeps the main row.
12
+ * - Includes the ids in `keep` (COS pins, folder members, COS-started sessions) AT ANY AGE (Q1 FIX 1, lead ruling
13
+ * 2026-11-10): the main list holds at most KEPT_LIST_MAX of them, so a kept row past that ceiling comes back here as
14
+ * a short row and is never silently dropped. The ones the main list does have are repeats the client drops.
15
+ * - Leaves out keep-warm sessions and Codex subagent rollouts, as the main list does.
16
+ * - Titles are read with the main list's own rules and memoized on file mtime and size. These files are days old and
17
+ * rarely change, so a repeat call re-reads almost nothing.
18
+ * - `olderThanWindow` counts sessions past `days` that are not kept or pinned: the "N older through search" line.
19
+ */
20
+ import { join } from 'node:path'
21
+ import {
22
+ AGENT_SESSION_MAX_FILE_BYTES,
23
+ CLAUDE_UUID_JSONL,
24
+ codexSessionRootsOf,
25
+ createdFromCodexFilename,
26
+ dirents,
27
+ fileStat,
28
+ firstClaudeUserTitle,
29
+ firstCursorUserTitle,
30
+ idFromCodexFilename,
31
+ isKeepWarmSessionTitle,
32
+ isoFromMtime,
33
+ listCodexJsonlFiles,
34
+ loadClaudeDesktopAliases,
35
+ loadClaudeStarredIds,
36
+ loadCodexPinnedIdsAll,
37
+ loadCodexThreadNamesAll,
38
+ loadCursorComposerNames,
39
+ loadCursorPinnedIds,
40
+ peekClaudeDiscussion,
41
+ peekCodexMeta,
42
+ peekCursorDiscussion,
43
+ workspaceLabel,
44
+ type AgentProvider,
45
+ type AgentSessionRoots,
46
+ type ClaudeAliasMap,
47
+ } from './agent-session-store.js'
48
+
49
+ export const OLDER_DEFAULT_DAYS = 30
50
+ export const OLDER_MIN_DAYS = 2
51
+ export const OLDER_MAX_DAYS = 30
52
+ export const OLDER_DEFAULT_LIMIT = 100
53
+ export const OLDER_MAX_LIMIT = 200
54
+
55
+ export interface OlderSessionRow {
56
+ session_id: string
57
+ provider: AgentProvider
58
+ display_label: string
59
+ project: string
60
+ modified: string
61
+ created: string
62
+ form: 'short'
63
+ }
64
+
65
+ interface Candidate { provider: AgentProvider; id: string; file: string; mtimeMs: number; birthtimeMs: number; size: number; folder: string }
66
+ interface Resolved { title: string; project: string; created: string; id: string; skip: boolean }
67
+
68
+ /** file -> what was read for this exact mtime and size. Bounded; cleared oldest-first. */
69
+ const titleMemo = new Map<string, { mtimeMs: number; size: number; resolved: Resolved }>()
70
+ const TITLE_MEMO_MAX = 5_000
71
+ export const olderTitleStats = { reads: 0, memoHits: 0 }
72
+ export function __resetOlderTitleMemoForTests(): void { titleMemo.clear(); olderTitleStats.reads = 0; olderTitleStats.memoHits = 0 }
73
+
74
+ async function memoized(candidate: Candidate, read: () => Promise<Resolved>): Promise<Resolved> {
75
+ const hit = titleMemo.get(candidate.file)
76
+ if (hit && hit.mtimeMs === candidate.mtimeMs && hit.size === candidate.size) { olderTitleStats.memoHits++; return hit.resolved }
77
+ olderTitleStats.reads++
78
+ const resolved = await read()
79
+ titleMemo.delete(candidate.file)
80
+ titleMemo.set(candidate.file, { mtimeMs: candidate.mtimeMs, size: candidate.size, resolved })
81
+ while (titleMemo.size > TITLE_MEMO_MAX) titleMemo.delete(titleMemo.keys().next().value!)
82
+ return resolved
83
+ }
84
+
85
+ export async function listOlderSessions(roots: AgentSessionRoots, now: Date, options: {
86
+ days?: number
87
+ before?: number
88
+ limit?: number
89
+ exclude?: Partial<Record<AgentProvider, ReadonlySet<string>>>
90
+ /** Listed at any age, outside `days` (see the header). */
91
+ keep?: Partial<Record<AgentProvider, ReadonlySet<string>>>
92
+ aliases?: ClaudeAliasMap
93
+ } = {}): Promise<{ rows: OlderSessionRow[]; nextBefore: string | null; olderThanWindow: number }> {
94
+ const days = Math.min(OLDER_MAX_DAYS, Math.max(OLDER_MIN_DAYS, Math.floor(options.days ?? OLDER_DEFAULT_DAYS)))
95
+ const limit = Math.min(OLDER_MAX_LIMIT, Math.max(1, Math.floor(options.limit ?? OLDER_DEFAULT_LIMIT)))
96
+ const cutoff = now.getTime() - days * 24 * 3600 * 1000
97
+ const before = Number.isFinite(options.before) ? options.before! : Number.POSITIVE_INFINITY
98
+ const excluded = (provider: AgentProvider, id: string) => options.exclude?.[provider]?.has(id.toLowerCase()) ?? false
99
+ const kept = (provider: AgentProvider, id: string) => options.keep?.[provider]?.has(id.toLowerCase()) ?? false
100
+
101
+ const aliases = options.aliases ?? await loadClaudeDesktopAliases(roots.claudeCodeSessions)
102
+ const canonical = (id: string) => { const cli = aliases.get(id)?.cliSessionId; return cli && CLAUDE_UUID_JSONL.test(cli + '.jsonl') ? cli : id }
103
+ const claudeStars = new Set([...await loadClaudeStarredIds(roots.claudeDesktopConfig)].map(id => canonical(id).toLowerCase()))
104
+ const codexPinned = await loadCodexPinnedIdsAll(roots)
105
+ const cursorPinned = await loadCursorPinnedIds(roots.cursorWorkspaceStorage)
106
+
107
+ let olderThanWindow = 0
108
+ const candidates: Candidate[] = []
109
+ const consider = (c: Candidate, pinned: boolean) => {
110
+ if (pinned || excluded(c.provider, c.id)) return
111
+ if (c.mtimeMs < cutoff && !kept(c.provider, c.id)) { olderThanWindow++; return }
112
+ candidates.push(c)
113
+ }
114
+
115
+ for (const folder of await dirents(roots.claudeProjects)) {
116
+ const dir = join(roots.claudeProjects, folder)
117
+ for (const name of await dirents(dir)) {
118
+ if (!CLAUDE_UUID_JSONL.test(name)) continue
119
+ const id = name.slice(0, -6).toLowerCase()
120
+ const st = await fileStat(join(dir, name))
121
+ if (!st?.isFile) continue
122
+ consider({ provider: 'claude', id, file: join(dir, name), mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, size: st.size, folder }, claudeStars.has(id))
123
+ }
124
+ }
125
+ for (const root of codexSessionRootsOf(roots)) {
126
+ for (const file of await listCodexJsonlFiles(root)) {
127
+ const id = idFromCodexFilename(file.split('/').pop() || '')
128
+ if (!id) continue
129
+ const st = await fileStat(file)
130
+ if (!st?.isFile) continue
131
+ consider({ provider: 'codex', id, file, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, size: st.size, folder: '' }, codexPinned.has(id))
132
+ }
133
+ }
134
+ const cursorById = new Map<string, Candidate>()
135
+ for (const folder of await dirents(roots.cursorProjects)) {
136
+ if (folder.includes('var-folders') || folder.includes('private-var') || folder === 'empty-window') continue
137
+ const transcripts = join(roots.cursorProjects, folder, 'agent-transcripts')
138
+ for (const sessionDir of await dirents(transcripts)) {
139
+ if (sessionDir === 'subagents') continue
140
+ const file = join(transcripts, sessionDir, `${sessionDir}.jsonl`)
141
+ const st = await fileStat(file)
142
+ if (!st?.isFile || st.size > AGENT_SESSION_MAX_FILE_BYTES) continue
143
+ const id = sessionDir.toLowerCase()
144
+ const next: Candidate = { provider: 'cursor', id, file, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, size: st.size, folder }
145
+ const prev = cursorById.get(id)
146
+ if (!prev || next.mtimeMs > prev.mtimeMs) cursorById.set(id, next)
147
+ }
148
+ }
149
+ for (const c of cursorById.values()) consider(c, cursorPinned.has(c.id))
150
+
151
+ const page = candidates.filter(c => c.mtimeMs < before).sort((a, b) => b.mtimeMs - a.mtimeMs)
152
+ const codexNames = page.some(c => c.provider === 'codex') ? await loadCodexThreadNamesAll(roots) : new Map<string, string>()
153
+ const cursorNames = page.some(c => c.provider === 'cursor') && roots.cursorComposerDb
154
+ ? await loadCursorComposerNames(roots.cursorComposerDb) : new Map<string, string>()
155
+ const desktopTitles = new Map<string, string>()
156
+ for (const [id, head] of aliases) if (head?.title) desktopTitles.set(canonical(id).toLowerCase(), head.title)
157
+
158
+ const rows: OlderSessionRow[] = []
159
+ let consumed = 0
160
+ for (const c of page) {
161
+ if (rows.length >= limit) break
162
+ consumed++
163
+ const r = await memoized(c, () => resolve(c, codexNames, cursorNames, desktopTitles))
164
+ if (r.skip) continue
165
+ rows.push({ session_id: r.id, provider: c.provider, display_label: r.title, project: r.project, modified: isoFromMtime(c.mtimeMs), created: r.created, form: 'short' })
166
+ }
167
+ const more = consumed < page.length
168
+ return {
169
+ rows,
170
+ nextBefore: more && consumed > 0 ? isoFromMtime(page[consumed - 1].mtimeMs) : null,
171
+ olderThanWindow,
172
+ }
173
+ }
174
+
175
+ async function resolve(c: Candidate, codexNames: Map<string, string>, cursorNames: Map<string, string>, desktopTitles: Map<string, string>): Promise<Resolved> {
176
+ const created = isoFromMtime(c.birthtimeMs)
177
+ if (c.provider === 'claude') {
178
+ const peek = await peekClaudeDiscussion(c.file)
179
+ const title = peek.customTitle ?? await firstClaudeUserTitle(c.file) ?? desktopTitles.get(c.id) ?? 'Claude session'
180
+ return { id: c.id, title, project: workspaceLabel(c.folder), created, skip: isKeepWarmSessionTitle(title) }
181
+ }
182
+ if (c.provider === 'codex') {
183
+ const meta = await peekCodexMeta(c.file)
184
+ if (!meta || meta.subagent) return { id: c.id, title: '', project: '', created, skip: true }
185
+ // The main list's rule: the filename wins when the meta id disagrees (a cloned rollout copies its parent's meta).
186
+ const metaId = (meta.id || '').trim().toLowerCase()
187
+ const id = metaId && metaId !== c.id ? c.id : (metaId || c.id)
188
+ const title = codexNames.get(id) || meta.title || 'Codex session'
189
+ const name = c.file.split('/').pop() || ''
190
+ return { id, title, project: workspaceLabel(meta.cwd), created: meta.created || createdFromCodexFilename(name) || created, skip: isKeepWarmSessionTitle(title) }
191
+ }
192
+ const peek = await peekCursorDiscussion(c.file)
193
+ const title = cursorNames.get(c.id) || await firstCursorUserTitle(c.file) || peek.lastUser || 'Cursor session'
194
+ return { id: c.id, title, project: workspaceLabel(c.folder), created, skip: isKeepWarmSessionTitle(title) }
195
+ }
196
+
197
+
198
+ /** One session's short row from its transcript file (renamed-title search hits). Null when it cannot be read or is a
199
+ * keep-warm or subagent session. Uses the same title rules and memo as the older walk. */
200
+ export async function shortRowFor(provider: AgentProvider, file: string, aliases?: ClaudeAliasMap, roots?: AgentSessionRoots): Promise<OlderSessionRow | null> {
201
+ const st = await fileStat(file)
202
+ if (!st?.isFile) return null
203
+ const parts = file.split('/')
204
+ const name = parts.pop() || ''
205
+ const id = provider === 'claude' ? name.replace(/\.jsonl$/, '').toLowerCase()
206
+ : provider === 'codex' ? (idFromCodexFilename(name) ?? '')
207
+ : (parts[parts.length - 1] || '').toLowerCase()
208
+ if (!id) return null
209
+ const folder = provider === 'claude' ? parts[parts.length - 1] || '' : provider === 'cursor' ? parts[parts.length - 3] || '' : ''
210
+ const c: Candidate = { provider, id, file, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs, size: st.size, folder }
211
+ const codexNames = provider === 'codex' && roots ? await loadCodexThreadNamesAll(roots) : new Map<string, string>()
212
+ const cursorNames = provider === 'cursor' && roots?.cursorComposerDb ? await loadCursorComposerNames(roots.cursorComposerDb) : new Map<string, string>()
213
+ const desktopTitles = new Map<string, string>()
214
+ for (const head of (aliases ?? new Map()).values()) if (head?.title && head.cliSessionId?.toLowerCase() === id) desktopTitles.set(id, head.title)
215
+ const r = await memoized(c, () => resolve(c, codexNames, cursorNames, desktopTitles))
216
+ if (r.skip) return null
217
+ return { session_id: r.id, provider, display_label: r.title, project: r.project, modified: isoFromMtime(c.mtimeMs), created: r.created, form: 'short' }
218
+ }