@gotcos/glasses-server 7.0.18 → 7.0.29

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,200 @@
1
+ ## 7.0.29 - Control Web: on the folded Duo, the panes sit in a side rail at the right edge, and Back is at thumb height
2
+
3
+ 2026-11-12. 7.0.28 plus Control Web changes: a side rail for the folded Duo only, and small Sessions list changes for every screen.
4
+
5
+ - **A side rail on the folded Duo.** Today, Messages, Speakers, Meetings, Memories, Threads, Sessions, Work, Dictation and Settings are one tap away in a slim rail at the right edge of the page, with the current one marked. Before, they sat behind the pane name's menu at the top left. The rail is the same list, in the same order, with the same icons and dots, as the rail on a wide screen.
6
+ - **Back at thumb height.** Where the top bar showed Back (a session or a message you are reading), Back now sits at the foot of the rail instead. If the panes do not fit, they scroll inside the rail and Back stays put.
7
+ - **One way to switch panes.** While the rail shows, the pane name at the top left stays as a label, without its arrow and its menu.
8
+ - **The page never reloads by itself over what you typed.** Since 7.0.28 the page reloads itself when part of it failed to load and your Mac answers again. Now, while a text box holds words, you are typing in one, or a message is on its way, it shows one line instead: "Part of this page did not load." with a Reload button, and adds "Send or copy what you have typed first. A reload clears it." when a box holds words. Only your tap reloads. If the browser keeps no record of earlier reloads (storage blocked), it shows the same line rather than reloading. This is for every screen size.
9
+ - **Words you typed are kept when you leave a session.** Back, the rail or another pane no longer empties the reply box: open the session again in the same tab and your unsent words are there. Sent words do not come back, and words from one session never land in another's box. This is for every screen size (it was lost before on every size too).
10
+ - **Folding or turning the phone updates it.** The page checks the screen again whenever its size or orientation changes, so opening or folding the Duo shows or hides the rail without a reload.
11
+ - **The reply box never cuts its words.** Where the rail leaves the reply box too narrow for "Message this session (not an answer)", it says "Message (not an answer)", and an empty box is always tall enough for its words.
12
+ - **Buttons are 44 points on your phone.** Under Safari's Page Zoom the page is laid out wider than the screen draws it, so the rail sizes its buttons from the zoom: 52 px at 85 percent, 44 px at 100.
13
+ - **Who gets it:** a touch screen, a page under 600 px wide, and a screen whose long side is less than 1.6 times its short side. The folded Duo is 1.46. Every iPhone is 1.78 or more, so none gets the rail, in the Home Screen app or a Safari tab, keyboard up or down. An iPhone SE (375 by 667) keeps today's layout. Nothing changes at 600 px and wider, so the open Duo is unchanged.
14
+ - **What it costs:** the rail takes 58 of 449 layout px at 85 percent zoom (49 points), 50 of 382 at 100. A long session title shows about one character fewer at 85 percent and seven fewer at 100. Wide tables in a session still scroll inside their own box, never the page.
15
+ - The page's existing layout report says whether the rail showed and its button size, so the phone tells the Mac what it saw.
16
+ - **Sessions: Pinned folds.** The Pinned heading folds and unfolds like a folder, with its count, and this browser remembers the choice. It starts open, so nothing changes until you fold it. A pinned session that is working still shows under Working now, and one that is waiting or failed under Needs you, folded or not; it goes back under Pinned when it is idle. Needs you and Working now never fold. This is for every screen size.
17
+ - **Sessions: sending into a session that just changed.** Before, a busy session stopped almost every message with "This thread changed on your Mac" and Refresh, Continue anyway and Fork, and Refresh did not help. Now, when you press Send and the session changed only in ways you had already seen, your message goes through by itself. When a reply arrived that you had not seen (one that landed below where you were reading counts as not seen), or the session is now waiting on a question, the reply is shown, your words stay in the box, and it says so: "A new reply arrived while you were writing. Your message is not sent yet." with "Send it anyway" and "Send to a copy". It sends by itself at most once. A message waiting to send, an answer to a question card, and Today's panel work as before. This is for every screen size.
18
+ - **Sessions: Remove from folder.** A session in a folder now has Remove from folder in its menu, right under Move to folder. Before, the only way out was Move to folder, then No folder at the bottom of that list, which still works. This is for every screen size.
19
+ - Tested in a browser (WebKit) at the Duo's folded sizes (449 by 797 at 85 percent zoom, 382 by 678, and a shorter Safari tab), an iPhone SE, 16 and Pro Max (the Pro Max also in a tab), and the open Duo, in light and dark, on the Sessions list, a session, Today, Messages and with the keyboard up. Where the rail does not show, every element sits where it did in 7.0.28. Not yet tested on a real device.
20
+
21
+ **Default-on for every install:** yes, with no switch: the rail only on a screen of that shape; the folding Pinned heading everywhere, open until folded; Remove from folder, the kept reply words, the changed-session send and no reload over typed words everywhere.
22
+
23
+ ### If you roll back
24
+
25
+ - To 7.0.28: the folded Duo goes back to the pane menu at the top left and Back in the top bar; Pinned no longer folds; Remove from folder goes (No folder still works); words in the reply box are lost on Back again; a changed session stops every send with the old box; the page can reload itself over what you typed again. The only thing stored is the Pinned fold choice, in the browser, which 7.0.28 does not read. Unsent words are kept in the tab's memory only.
26
+ - Further back: as in 7.0.28 below.
27
+
28
+ ## 7.0.28 - Control Web: the sign-in screen is styled even before the page can reach your Mac, and the page fixes itself when it can; page errors are reported; phone approvals get a trusted certificate (off by default)
29
+
30
+ 2026-11-11. 7.0.27 plus two Control Web changes, and server code for phone approvals that stays off unless you turn it on.
31
+
32
+ - **No more unstyled "Connect this browser" page.** Opening the web app before your phone could reach your Mac (Tailscale still connecting, or the Mac waking) could draw the page without its stylesheets: a plain black or white page with default text and buttons, and the COS mark drawn large twice. It stayed that way, because nothing asked for the stylesheets again. Now the sign-in and "not answering" screens carry their own styling inside the page, so they look right from the first moment. While a stylesheet or script is missing, the page checks after 1, 2, 4 and 8 seconds, then every 30 seconds, and as soon as your Mac answers it loads what was missing, reloading itself if a script was missing (never more than twice in 5 minutes). The "not answering" screen also checks again after 1, 2, 4 and 8 seconds before settling on every 30 seconds.
33
+ - **Page errors are reported.** A script error or an unhandled failure in Control Web now sends one short, numbers-only report to your Mac (what kind, the file, the line), at most 10 per page load. It never includes what you typed, a message or any text from the page. A page that had to bring back its styling reports that too, the same way.
34
+ - Tested in a browser with the page's files really blocked and then unblocked, at the open and folded phone sizes, in light and dark: 7.0.27 shows the unstyled page and stays broken, and this build shows the styled screen and recovers within seconds. The Sessions list, the reader and the reply box are unchanged.
35
+
36
+ **Phone approvals: the phone's signer page gets a publicly trusted certificate (off by default).** Every change sits behind the two phone-approval switches, `COS_WEB_KEY_AUTHORITY` and `COS_WEB_KEY_ANCHOR_BOOTSTRAP`, both off by default.
37
+
38
+ - **Both switches off (every install by default): nothing changes.** No Tailscale call, no new file, no new listener, no new status field. 3141 and 3143 serve exactly what they served before.
39
+ - **Only `COS_WEB_KEY_ANCHOR_BOOTSTRAP` on (the Mac-key setup):** if this Mac's tailnet name was missed at startup, the authenticated `/api/web-key/status` read looks it up again. That is one DNS lookup through Tailscale, at most once per 30 seconds, waiting at most 2 seconds. Before, it answered `origin: null` until a restart.
40
+ - **With `COS_WEB_KEY_AUTHORITY` on:**
41
+ - **The certificate.** The signer page on 3144 gets a certificate for this Mac's own tailnet name from Tailscale's HTTPS Certificates (Let's Encrypt). The server fetches it, keeps it in `web-key-tls/` in its data folder (key 0600, folder 0700), and shows it only to a connection that names exactly that host. It renews when fewer than 30 days remain. Any other name, no name, or an IP keeps the existing local certificate. 3143 never changes.
42
+ - **What `trusted` means.** `signerTls.trusted` means **publicly trusted**. The certificate chains, link by link, to one of Node's bundled public root certificates. It is inside its validity window, notBefore included. Its key matches the certificate (the public keys are compared, because a mismatched key type is not otherwise caught). A pair that fails any of these is never served and never stops the server from fetching the real one. Each refusal is named: `cert_untrusted`, `cert_not_yet_valid`, `cert_invalid`, `cert_expired`.
43
+ - **The fetch works on macOS.** Every Tailscale command the server ran used to answer "The Tailscale GUI failed to start", because a process started by the server's LaunchAgent has no SHLVL. The certificate commands now run with a small fixed env: HOME, PATH, TMPDIR, USER and LANG when set, plus SHLVL=1. They never see the server's own env. If that answer ever comes back, the status says `tailscale_cli_gui_unavailable`. The status call asks without peers, so a large tailnet cannot overflow it; an overflow says `tailscale_output_too_large`.
44
+ - **Bounded against Let's Encrypt's limit.** At most 3 `tailscale cert` calls per UTC day, so up to 21 in a week, and up to 6 within minutes across midnight UTC. Each call is counted on disk, in `web-key-tls/fetch-state.json`, before it runs, and the count survives a restart. If that file is missing or unreadable, the count starts again at 0. Over the cap the status says `cert_daily_cap`.
45
+ - **Turning on HTTPS Certificates is picked up within seconds.** An authenticated status read re-checks at once (at most every 15 s) when the last check said HTTPS Certificates was off or the name was unknown. It no longer waits out a backoff of up to an hour.
46
+ - **A certificate put in that folder by hand** serves at the next connection or status read, with no restart. It passes exactly the same checks. The folder and both files must be owned by this user and must not be writable by group or others; the folder must be a real folder and each file a regular file, not a link, each at most 64 KiB. Read permission for group or others is not checked. A file that breaks a rule is refused, and the status names why in `signerTls.storedRefused`.
47
+ - **After a tailnet rename,** 3144 no longer answers the old name with the old certificate.
48
+ - **`/api/web-key/status` reports `signerTls`:** trusted, reason, host, expiry, the last error, the last and next check, and `storedRefused`.
49
+ - **Needs before it can work:** Tailscale HTTPS Certificates turned on for the tailnet, in the Tailscale admin console. The Mac's name then appears in public certificate logs. Real issuance is limited to 5 per week for the same name.
50
+ - **Not changed:** Tailscale's link check (tailscale-nudge) has the same SHLVL gap and is left as it is. Fixing it changes behaviour for everyone and needs Miles's go.
51
+ - **Not yet tested on a real device:** a real `tailscale cert`, iPhone Safari against the Let's Encrypt certificate, and COS Control's signer check against it.
52
+
53
+ **Default-on for every install:** the two Control Web changes, yes, with no switch. The phone-approval code, no: both of its switches are off by default.
54
+
55
+ ### If you roll back
56
+
57
+ - To 7.0.27: an open that cannot reach your Mac can show the unstyled page again until you close and reopen it, and page errors are no longer reported. With the phone-approval switches off, nothing else changes and nothing is stored.
58
+ - Phone approvals, if you had turned them on:
59
+ - **Both switches off:** nothing to undo.
60
+ - **Bootstrap on:** a startup miss of the tailnet name sticks until a restart again.
61
+ - **Authority on:** 3144 goes back to the local certificate, so iPhone Safari refuses the signer page. `web-key-tls/` (the pair and `fetch-state.json`) stays on disk, unread.
62
+ - To 7.0.18: as in 7.0.27 below.
63
+
64
+ ## 7.0.27 - Control Web: the Sessions list fits its column at every width
65
+
66
+ 2026-11-11. 7.0.26 plus one Control Web layout fix.
67
+
68
+ - **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.
69
+ - 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.
70
+
71
+ **Default-on for every install:** yes, the layout fix; there is no switch.
72
+
73
+ ### If you roll back
74
+
75
+ - To 7.0.26: the narrow list scrolls sideways again. Nothing is stored by this change.
76
+ - To 7.0.18: as in 7.0.26 below.
77
+
78
+ ## 7.0.26 - 7.0.25, with kept sessions only where they were asked for: the Mac app and the glasses keep their list
79
+
80
+ 2026-11-11. 7.0.25 plus one server change and one Control Web line.
81
+
82
+ - **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.
83
+ - Nothing else changes: `/older` and the folder, pin and rename routes are as in 7.0.25.
84
+
85
+ **Default-on for every install:** everything in 7.0.25 below; the one difference is that kept sessions reach Control Web only.
86
+
87
+ ### If you roll back
88
+
89
+ - To 7.0.25: the Mac app and the glasses list kept sessions again. Nothing is stored by this change.
90
+ - To 7.0.18: as in 7.0.25 below.
91
+
92
+ ## 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
93
+
94
+ 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.
95
+
96
+ **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.
97
+
98
+ **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).
99
+
100
+ **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."
101
+
102
+ **What changes in the Sessions list, for everyone who opens it in Control Web:**
103
+ - **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.
104
+ - **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.
105
+ - **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."
106
+ - **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.
107
+ - **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.
108
+ - **The Sessions menu** (Sessions, Usage, COS cost, Refresh) and one sort menu (Updated or Opened; Best match while searching) head the list.
109
+
110
+ **What changes on the server, for every client:**
111
+ - **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.
112
+ - **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.
113
+ - `/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.
114
+ - **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.
115
+
116
+ **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.
117
+
118
+ **Tested** in a browser at phone and desktop sizes (the list, folders and frame checks pass) and by the full test suite.
119
+
120
+ **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.
121
+
122
+ ### If you roll back
123
+
124
+ - 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.
125
+ - 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.
126
+
127
+ ## 7.0.23 - Control Web: a visible height under 80 px is no reading, and Sessions is one screen (layout, not a proven fix)
128
+
129
+ 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.
130
+
131
+ - **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.
132
+ - **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).
133
+ - **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.
134
+ - A session opens at its newest turn and stays there as turns arrive, until you scroll away.
135
+ - 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.
136
+ - 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.
137
+ - Full width reads Hide list in Sessions. Usage and COS cost keep the page's own scroll.
138
+ - 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.
139
+ - **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.
140
+
141
+ **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.
142
+
143
+ ### If you roll back
144
+
145
+ - To 7.0.22: the guard and the frame go; Sessions scrolls as one page again. Nothing is stored by this release.
146
+
147
+ ## 7.0.22 - Control Web reports two more layout readings from the phone; nothing else changes
148
+
149
+ 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.
150
+
151
+ - **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.
152
+ - **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.
153
+ - **Numbers only,** plus the panel kind and the divider name. No text, titles or ids. Both go through the existing layout report limits.
154
+ - **Not proven off the device:** what his phone reports. On this Mac (WebKit, scale 1, 951 and 1119 wide) neither problem reproduces.
155
+
156
+ **Default-on for every install:** yes: both reports. There is no switch.
157
+
158
+ ### If you roll back
159
+
160
+ - To 7.0.21: the two reports stop. Nothing else changes and nothing is stored by this release.
161
+
162
+ ## 7.0.21 - Control Web shows the COS mark first on every reopen, and the phone reports where its open time goes
163
+
164
+ 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.
165
+
166
+ - **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.
167
+ - **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.
168
+ - **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.
169
+ - **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.
170
+ - **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.
171
+ - **Not proven off the device:** only the phone shows its own first paint, a first-ever open, and the Duo's widths.
172
+
173
+ **Default-on for every install:** yes: the script loading, and the extra numbers in the layout reports. There is no switch.
174
+
175
+ ### If you roll back
176
+
177
+ - 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.
178
+
179
+ ## 7.0.19 - Control Web shows the COS mark at once on open, instead of a blank page
180
+
181
+ 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.
182
+
183
+ - **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.
184
+ - **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.
185
+ - **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.
186
+ - **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.
187
+ - **With JavaScript off,** the page now says "COS Control Web needs JavaScript. Turn it on for this site, then reload." It was blank.
188
+ - **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.
189
+ - **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.
190
+ - **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.
191
+
192
+ **Default-on for every install:** yes: the first paint, the JavaScript-off line and the open timing report. There is no switch.
193
+
194
+ ### If you roll back
195
+
196
+ - To 7.0.18: the page is blank again until the Mac answers. Nothing is stored by this change.
197
+
1
198
  ## 7.0.18 - A question a session ends its turn with reaches the glasses, Control Web and the desktop
2
199
 
3
200
  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 +205,7 @@
8
205
  - **It clears itself** on any reply, a new turn, the session ending, or after 8 hours.
9
206
  - **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
207
  - **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.
208
+ - **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
209
  - **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
210
  - **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
211
  - **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.
@@ -47,7 +47,7 @@ canonical, so one message has exactly one signable form.
47
47
  `signer_origin_required` unless the request reached the 3144 listener (socket local port). A page on 3143
48
48
  can neither make a key nor fetch a handoff.
49
49
  - **rpId:** `selfDnsName`, the full lower-case MagicDNS host, no trailing dot (for example
50
- `ukaoma-mac-studio.tail38457f.ts.net`; `ts.net` is a public suffix). The verifier derives it as
50
+ `example-mac.tail00000a.ts.net`; `ts.net` is a public suffix). The verifier derives it as
51
51
  the host of `envelope.origin`.
52
52
  - **aud:** `hex(sha256(anchor SPKI DER || utf8(lower(rpId))))`. The verifier computes it from ITS
53
53
  pinned anchor, never from a key in the answer.
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.29",
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'
@@ -244,6 +245,7 @@ import { webControlOffnetResponse } from './lib/web-control-offnet-page.js'
244
245
  import { createWebControlStatic } from './lib/web-control-static.js'
245
246
  import { isWebControlSocketAllowed, isWebSessionAuthorized, setWebSessionEpochFile } from './lib/web-session-auth.js'
246
247
  import { getTailnetSelfName, installTailnetSelfName } from './lib/tailnet-self-name.js'
248
+ import { createSignerTlsForBoot, signerHttpsOptions, SIGNER_TLS_DIR, type SignerTls } from './lib/web-key-signer-tls.js'
247
249
  import { isManagedRuntime, managedServerVersion } from './lib/managed-runtime.js'
248
250
  import { reportClaudeExtraToolConfiguration } from './lib/claude-tool-access.js'
249
251
  import {
@@ -439,6 +441,10 @@ const webKeySignHandoffs = new WebKeySignHandoffs()
439
441
  const webKeySignerIntegrity: WebControlIntegrity | null = webKeyAuthorityEnabled()
440
442
  ? new WebControlIntegrity(path.join(path.dirname(fileURLToPath(import.meta.url)), 'web-signer'), { log: line => console.error(line.replace('[web-control-integrity]', '[web-signer-integrity]').replace('/control is', 'the 3144 signer is')) })
441
443
  : null
444
+ // B2 (QA 2026-11-09): the 3144 signer's trusted Tailscale certificate (lib/web-key-signer-tls.ts). Built in the
445
+ // listener loop below ONLY when the 3144 listener is (flag on at boot, HTTPS available); otherwise null, and no
446
+ // Tailscale call, file or listener change happens. 3143 is never given SNI selection.
447
+ let webKeySignerTls: SignerTls | null = null
442
448
  // Review W6: enrollment starts only from a device on COS_WEB_KEY_DEVICES (empty: none).
443
449
  const webKeyEnrollments = new WebKeyEnrollments(webKeyRegistry, { deviceAllowed: deviceAllowlistFrom() })
444
450
  // The ports a COS Control Web cookie is honoured on. Flag off: exactly 6.99.29's [3141, 3143].
@@ -489,8 +495,10 @@ const pairingState = new PairingState({
489
495
  // address as a paired phone. Notes only, never answers; noteRequest never throws.
490
496
  const tailscaleNudger = installTailscaleNudger({ activeMeetings: () => getActiveTranscriptionSessionCount() })
491
497
  // COS Control Web: this Mac's MagicDNS name, from the nudger when it has one and otherwise
492
- // from a reverse lookup at MagicDNS (the CLI does not answer under this LaunchAgent; see
493
- // lib/tailnet-self-name.ts). Learned at boot so the first phone sign-in is not refused.
498
+ // from a reverse lookup at MagicDNS (the nudger's Tailscale CLI calls fail under this LaunchAgent:
499
+ // node passes its child no SHLVL, so the macOS CLI answers "The Tailscale GUI failed to start";
500
+ // probe 2026-11-10, see lib/tailnet-self-name.ts and lib/web-key-signer-tls.ts signerTlsCliEnv).
501
+ // Learned at boot so the first phone sign-in is not refused.
494
502
  const tailnetSelf = installTailnetSelfName({ nudgerName: () => tailscaleNudger.currentSelfDnsName() })
495
503
  if (webControlEnabled()) void tailnetSelf.refresh()
496
504
  // Browser sessions sign this Mac's session epoch; "Sign out every browser" bumps it.
@@ -1001,8 +1009,12 @@ app.use('/api', createWebKeyRouter({
1001
1009
  isWebRequest: req => g2Provenance.originOf(req).origin.client === 'web',
1002
1010
  apiToken: () => API_TOKEN,
1003
1011
  selfDnsName: webKeySelfDnsName,
1012
+ // 7.0.14: a boot-time miss of the tailnet name is looked up again (bounded) instead of answering `origin: null`.
1013
+ refreshSelfDnsName: () => tailnetSelf.refreshIfMissing(),
1004
1014
  expectedOrigin: () => webKeyAuthority.deps.expectedOrigin(),
1005
1015
  integrityStatus: () => ({ ...(webControlIntegrity?.status() ?? { enabled: false }), signer: webKeySignerIntegrity?.status() ?? null }),
1016
+ // SR-04: an authenticated status read may start one bounded re-check (lib/web-key-signer-tls.ts statusRead).
1017
+ signerTls: () => webKeySignerTls?.statusRead() ?? null,
1006
1018
  // Phase 3: the phone's key steps and the handoff claim answer only on the 3144 signer listener.
1007
1019
  isSignerRequest: req => req.socket?.localPort === WEB_KEY_SIGNER_PORT,
1008
1020
  handoffs: webKeySignHandoffs,
@@ -1154,6 +1166,8 @@ app.use('/api', transcribeRouter)
1154
1166
  app.use('/api', sessionIndexRouter)
1155
1167
  // Claude + Codex + Cursor transcripts from this Mac. Same 7-day window as Control.
1156
1168
  app.use('/api', agentSessionsRouter)
1169
+ // Step 2a (Sessions chat-style layout): COS folders, COS pins and renamed titles. Additive; an older server 404s these.
1170
+ app.use('/api', createSessionOrganizerRouter())
1157
1171
  // The live view of one of those sessions. Four segments ending in a literal `stream`,
1158
1172
  // so it cannot shadow the three-segment transcript route above or the bindings
1159
1173
  // router's `/attachability` below. READ-ONLY, and deliberately NOT behind
@@ -1597,6 +1611,7 @@ async function gracefulShutdown(): Promise<void> {
1597
1611
  stopMorningBriefScheduler()
1598
1612
  stopRoutineScheduler()
1599
1613
  tailscaleNudger.stop()
1614
+ webKeySignerTls?.stop()
1600
1615
  await workReviewRuntime?.close()
1601
1616
  dropP2.stop()
1602
1617
  workIntakeStore?.close()
@@ -1739,16 +1754,27 @@ if (!httpsAvailable) console.log('[COS API] No certs found — HTTPS disabled (d
1739
1754
  for (const entry of listenerPlan({ httpsAvailable, webKeyAuthority: webKeyAuthorityEnabled(), httpPort: PORT, httpsPort: HTTPS_PORT, signerPort: WEB_KEY_SIGNER_PORT })) {
1740
1755
  // Phase 3: 3144 serves the signer page (verified bytes only) and an allowlist of /api routes,
1741
1756
  // never the big app (lib/web-key-signer-app.ts). It exists only with the flag on.
1742
- const handler = entry.label === 'HTTPS signer'
1743
- ? createWebKeySignerHandler({ integrity: webKeySignerIntegrity, api: app, origin: () => webKeySignerOrigin(webKeySelfDnsName()) })
1757
+ const signer = entry.label === 'HTTPS signer'
1758
+ const handler = signer
1759
+ ? createWebKeySignerHandler({ integrity: webKeySignerIntegrity, api: app, origin: () => webKeySignerOrigin(webKeySelfDnsName()), refreshOrigin: () => tailnetSelf.refreshIfMissing() })
1744
1760
  : app
1745
- const server = entry.tls
1746
- ? withRequestTimeout(createHttpsServer({ cert: readFileSync(path.join(certDir, 'cert.pem')), key: readFileSync(path.join(certDir, 'key.pem')) }, handler))
1761
+ // B2: on 3144 only, the trusted Tailscale pair for exactly this Mac's tailnet name (SNI), mkcert for anything else.
1762
+ if (signer) {
1763
+ webKeySignerTls = createSignerTlsForBoot({ authorityEnabled: webKeyAuthorityEnabled(), signerListener: true }, {
1764
+ dir: dataPath(SIGNER_TLS_DIR),
1765
+ selfDnsName: () => tailnetSelf.refresh(),
1766
+ })
1767
+ }
1768
+ const mkcertPair = entry.tls ? { cert: readFileSync(path.join(certDir, 'cert.pem')), key: readFileSync(path.join(certDir, 'key.pem')) } : null
1769
+ const server = mkcertPair
1770
+ ? withRequestTimeout(createHttpsServer(signer ? signerHttpsOptions(mkcertPair, webKeySignerTls) : mkcertPair, handler))
1747
1771
  : withRequestTimeout(createHttpServer(app))
1748
1772
  listeners.push({ server, port: entry.port, host: BIND_HOST, label: entry.label })
1749
1773
  }
1750
1774
 
1751
1775
  listenRequiredServers(listeners).then(() => {
1776
+ // B2: the boot check of the 3144 certificate, then daily (null unless the signer listener exists).
1777
+ webKeySignerTls?.start()
1752
1778
  const serverInstanceId = initializeServerInstanceId()
1753
1779
  const startupAdmissionsOpen = maintenanceAdmissionsOpen()
1754
1780
  if (listeners.some(listener => listener.label === 'HTTPS')) {
@@ -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
+ }