@gotcos/glasses-server 6.98.1 → 6.99.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/package.json +2 -1
  3. package/server/index.ts +170 -11
  4. package/server/lib/api-auth.ts +31 -14
  5. package/server/lib/claude-bridge.ts +21 -2
  6. package/server/lib/codex-bridge.ts +21 -2
  7. package/server/lib/cos-thread.ts +582 -0
  8. package/server/lib/display-bus.ts +3 -0
  9. package/server/lib/drop-attachments.ts +55 -0
  10. package/server/lib/drop-destinations.ts +134 -0
  11. package/server/lib/drop-history-search.ts +97 -0
  12. package/server/lib/drop-p2-boot.ts +60 -0
  13. package/server/lib/drop-p2-runtime.ts +237 -0
  14. package/server/lib/drop-p2-settings.ts +174 -0
  15. package/server/lib/drop-resolution.ts +168 -0
  16. package/server/lib/drop-router.ts +325 -0
  17. package/server/lib/drop-service.ts +830 -0
  18. package/server/lib/drop-settings.ts +56 -0
  19. package/server/lib/drop-store.ts +450 -0
  20. package/server/lib/drop-when.ts +265 -0
  21. package/server/lib/glasses-pairing.ts +15 -0
  22. package/server/lib/learning-needs-you.ts +111 -0
  23. package/server/lib/live-cues-v2-settings.ts +2 -1
  24. package/server/lib/live-cues-v2.ts +6 -4
  25. package/server/lib/media-store.ts +80 -0
  26. package/server/lib/meeting-batch-transcribe.ts +15 -2
  27. package/server/lib/meeting-consolidation-run.ts +169 -0
  28. package/server/lib/meeting-store.ts +61 -0
  29. package/server/lib/meeting-title-steer.ts +200 -0
  30. package/server/lib/network-policy.ts +84 -6
  31. package/server/lib/permission-broker.ts +13 -0
  32. package/server/lib/phone-recording.ts +1 -1
  33. package/server/lib/ping-budget.ts +149 -0
  34. package/server/lib/ping-engine.ts +245 -0
  35. package/server/lib/ping-feed-sink.ts +33 -0
  36. package/server/lib/ping-sources.ts +192 -0
  37. package/server/lib/query-job-coordinator.ts +5 -2
  38. package/server/lib/query-job-runtime.ts +45 -4
  39. package/server/lib/query-job-types.ts +10 -1
  40. package/server/lib/reminder-scheduler.ts +153 -0
  41. package/server/lib/tailnet-self-name.ts +103 -0
  42. package/server/lib/tailscale-nudge.ts +40 -2
  43. package/server/lib/task-store.ts +32 -0
  44. package/server/lib/web-ask-admission.ts +56 -0
  45. package/server/lib/web-live-chunks.ts +273 -0
  46. package/server/lib/web-memories-bridge.ts +323 -0
  47. package/server/lib/web-session-auth.ts +492 -0
  48. package/server/lib/web-usage-bridge.ts +154 -0
  49. package/server/lib/work-search.ts +14 -1
  50. package/server/lib/your-move-count.ts +58 -0
  51. package/server/routes/cos-thread.ts +79 -0
  52. package/server/routes/display.ts +11 -1
  53. package/server/routes/drops.ts +262 -0
  54. package/server/routes/health.ts +16 -0
  55. package/server/routes/live-cues.ts +5 -4
  56. package/server/routes/meeting.ts +61 -5
  57. package/server/routes/permission-broker.ts +23 -3
  58. package/server/routes/transcribe-stream.ts +43 -4
  59. package/server/routes/web-control.ts +569 -0
  60. package/server/routes/welcome-context.ts +2 -1
  61. package/server/scripts/web-live-cls/cdp.mjs +57 -0
  62. package/server/scripts/web-live-cls/measure.mjs +80 -0
  63. package/server/scripts/web-live-cls/mock.mjs +97 -0
  64. package/server/scripts/web-live-cls/render.mjs +26 -0
  65. package/server/web-control/app.css +519 -0
  66. package/server/web-control/app.js +3643 -0
  67. package/server/web-control/control.css +325 -0
  68. package/server/web-control/fonts/DMSans.ttf +0 -0
  69. package/server/web-control/fonts/Fraunces.ttf +0 -0
  70. package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
  71. package/server/web-control/index.html +275 -0
  72. package/server/web-control/mark.svg +1 -0
  73. package/server/web-control/memories/d3.min.js +2 -0
  74. package/server/web-control/memories/graph-explorer.css +200 -0
  75. package/server/web-control/memories/graph-explorer.js +748 -0
  76. package/server/web-control/memories/memories-app.js +1561 -0
  77. package/server/web-control/memories/memories-theme.css +73 -0
  78. package/server/web-control/memories/memories.html +72 -0
  79. package/server/web-control/memories/memory-stewardship.js +63 -0
  80. package/server/web-control/memories/memory-workspace.js +216 -0
  81. package/server/web-control/panes/cos-chat.css +174 -0
  82. package/server/web-control/panes/cos-chat.js +1118 -0
  83. package/server/web-control/panes/dictation.js +173 -0
  84. package/server/web-control/panes/meetings.css +314 -0
  85. package/server/web-control/panes/meetings.js +2568 -0
  86. package/server/web-control/panes/messages.js +718 -0
  87. package/server/web-control/panes/questions.css +32 -0
  88. package/server/web-control/panes/questions.js +275 -0
  89. package/server/web-control/panes/sessions.css +452 -0
  90. package/server/web-control/panes/settings.js +292 -0
  91. package/server/web-control/panes/speakers.css +269 -0
  92. package/server/web-control/panes/speakers.js +2755 -0
  93. package/server/web-control/panes/threads.js +200 -0
  94. package/server/web-control/panes/work.css +261 -0
  95. package/server/web-control/panes/work.js +1667 -0
  96. package/server/web-control/shell.js +294 -0
  97. package/server/web-control/theme.js +25 -0
  98. package/server/web-control/vendor/qrcode-encoder.js +10 -0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,230 @@
1
+ ## 6.99.16 - The COS thread: one size-capped conversation behind the COS chat's Ask, stored to recover and search (private)
2
+
3
+ Merged in: claude/iphone-dictionary (94323de, from the COS Glasses QA session; its own full suite on 94323de: 13,041 passed, 0 failed).
4
+
5
+ - Default-on for iPhone microphone recordings: iPhone recordings now transcribe live with the same Whisper as the glasses, so your dictionary shows while you record. Saved transcripts are unchanged (strict Parakeet, as for G2 saves). If live Whisper fails, the chunk errors as retryable `local_asr_unavailable`; it never falls back to Parakeet live. The capability keeps `asrModel: 'parakeet-ultra'` (glasses clients 677 and older check it before offering the iPhone mic) and adds `liveAsrModel: 'server-whisper'`.
6
+ - iPhone saved transcripts can use the dictionary when Second opinion is on (`phoneHqPlan` in meeting-batch-transcribe.ts): Parakeet stays the strict primary and the dictionary-prompted Whisper Large-v3 fills in names it missed. Off stays off; Miles's Mac has it off, so this changes nothing there until he turns it on.
7
+
8
+ 2026-11-05. Private build for one Mac, on top of 6.99.15. Pairs with COS Control 0.5.374 (written against this contract), and works with any COS Control that pairs with 6.99.15. Nothing changes for the glasses app. Everything here exists only while Drop it is on.
9
+
10
+ Miles, 11/05 22:55: "As long as we're storing so we can easily recover and search for them, I like this plan let's build and deploy."
11
+
12
+ - **Why.** Every Ask from the COS chat started a brand-new session, and a fresh session already starts at about 100K tokens of context (CLAUDE.md, gotchas, memory, tool definitions): 101,720 and 103,116 on the first call, read from two session logs on 11/05. Continuing a session while its prompt cache is warm is cheaper than starting fresh; what makes a forever thread expensive is its size. So the chat keeps one thread and continues the same session until that session's context passes a cap (`COS_THREAD_CAP_TOKENS`, a whole number from 20,000 to 1,000,000, default 150,000: about 50K of conversation on top of the baseline). The next Ask then starts a fresh session carrying a short summary.
13
+ - **Routes** (`server/routes/cos-thread.ts`, logic in `server/lib/cos-thread.ts`), under the token-auth `/api` mount, `Cache-Control: private, no-store`, and gated like the Drop it routes: while Drop it is off, every one answers 404 `drop_it_off` and no file is created.
14
+ - `GET /api/cos-thread`: `{ protocol: 1, capTokens, thread: { id, startedAt, lastAskAt, asks, segments, sessionId, provider, contextTokens }, next: { mode, sessionId, reason, digest } }`. `reason` is `continue` (the session is known and its context is at or under the cap), `empty` (no session yet), `size` (over the cap), `missing_session` (the conversation is gone, as after the midnight archive, or its log cannot be found) or `manual` (right after New thread). Only `continue` continues; every other reason is `fresh` with no sessionId and the summary.
15
+ - `POST /api/cos-thread/new {}`: closes the thread (a `rollover` row with reason `manual` and the summary it carries, then a `close` row) and starts a new one with no session. Its first Ask still carries the summary, so it knows the last thread and the day's drops.
16
+ - `POST /api/cos-thread/turn { clientId, sessionId, query, answer?, messageNo?, provider? }`: records one Ask, idempotent on clientId. The first one adopts its session; a different session than the thread's is a rollover (a `rollover` row with the reason GET would have given and the summary) onto a new segment of the same thread. Anything out of contract is 400 `invalid_turn` and writes nothing. Answers the GET shape.
17
+ - `GET /api/cos-thread/search?q&limit` (q 2 to 200 characters, limit 1 to 50, default 20): `{ hits: [{ threadId, sessionId, at, query, answerHead, messageNo }], scanned }`, newest first, case-insensitive over the question and the answer's head.
18
+ - `GET /api/cos-thread/threads?limit`: `{ threads: [{ id, startedAt, endedAt, asks, segments, title }] }`, newest first; the title is the first question, cut to 80 characters. A thread with no asks is listed only while it is the current one.
19
+ - `/api/health` and `/api/models`: `capabilities.dropIt.thread: { protocol: 1 }`, only while Drop it is on. Every other dropIt key is unchanged.
20
+ - **The summary** is mechanical (no model call), at most 6,000 characters, headed "Earlier in this COS thread (a summary carried over; ask COS to search History or Messages for more):": the thread's last 8 asks, oldest first (question cut to 200 characters, the answer's head to 300, with the message number when known), then up to 12 drops created since the thread started or in the last 24 hours, whichever is earlier, with where each was filed. A turn recorded without its answer takes it from the live conversation when it is still there. Over 6,000 characters, the oldest drops go first, then the oldest asks.
21
+ - **Context size** is read from the session's Claude Code log, `~/.claude/projects/<any project>/<id>.jsonl` (`COS_CLAUDE_PROJECTS_DIR` overrides it, `CLAUDE_CONFIG_DIR` is honored), found through the CLI session the bridge holds for the COS conversation, or the one recorded with the last turn after a restart. Only the last 512 KB is read; the last non-sidechain assistant message's `input_tokens + cache_creation_input_tokens + cache_read_input_tokens` is the context. A log with no readable usage continues with `contextTokens` null. Checked on this Mac against a 412 MB live log: 4 ms to find, tail read only. A provider with no Claude log (Codex, Cursor) reads as `missing_session`, so it starts fresh with the summary each time rather than growing without a cap.
22
+ - **Stored to recover and search**: `DATA_DIR/cos-thread/ledger.jsonl`, append-only and fsync'd, keeps every row (`thread`, `turn`, `rollover`, `close`), and `state.json`, written atomically after each append, is a snapshot of the current thread. When the snapshot is missing, corrupt or behind the ledger, the state is rebuilt from the ledger by the same code that builds it live. A torn last row is skipped and never swallows the next. The last 5,000 asks stay in memory for search. Directory 0700, files 0600. Both the log read and the ledger append open with O_NOFOLLOW and O_NONBLOCK, so a symlink or a FIFO planted at either path can never be followed or stop the server.
23
+ - **COS Control Web** (`panes/cos-chat.js`), only when `/api/health` advertises `dropIt.thread`: an Ask from the chat's own composer reads `GET /api/cos-thread` first. `continue` sends that sessionId. `fresh` sends none and carries the summary as the Ask's `reference`, the existing query-jobs field for quoted context, so the question in Messages is only what was typed. While an Ask in the thread is still answering, the next one continues its conversation. When an Ask finishes, the page posts its turn once, with the answer and message number. A Reply in the message reader keeps its own turn's conversation, as before. The header gets "New thread" beside "Mark all read", and one quiet line under the title: "Continues your COS thread · N asks", or after a rollover or New thread, "New thread. It carries a short summary of the last one." Without the capability the per-day conversation stays, unchanged.
24
+ - `DropService.recentDrops(since, limit)` reads the drop store for the summary; it never touches Work.
25
+ - **Fix: Ask on COS Control Web read as off while durable query jobs were on.** The page looked for the switch in `capabilities.durableQueryJobs` (where `/api/models` carries it) or at the top of the body, but `/api/health` carries it at `features.durableQueryJobs`, so the chat said "Ask needs the durable query jobs switch on your Mac." on Miles's Mac with jobs on. It now reads `features.durableQueryJobs` too. The web Ask still sends `source: 'web'`, so the server fills in the message era and number as before (a bare POST without it answers 409 `message_era_mismatch`, as it always has).
26
+ - Tests: `server/lib/cos-thread.test.ts` (every reason, the cap edge: exactly the cap continues and one over is `size`, idempotent turns, rollover rows, the CLI session recorded across a restart, the summary's bounds and drop window, the ledger rebuild from a missing, corrupt or stale snapshot, a torn row, search, the threads list, validation, file modes), `server/routes/cos-thread.test.ts` (the shapes over HTTP, 400s, `drop_it_off` with no file created, 503 when the store cannot open, the health capability on and off), `server/web-control/cos-thread-web.test.ts` (continue sends the sessionId, fresh sends none and carries the summary, the turn is posted once, a second Ask while one runs, New thread posts `/new`, the old rule without the capability, and Ask on for a health body shaped like the live one), and a `recentDrops` case in `drop-service.test.ts`. Mutation-checked: the cap comparison, the idempotency check, the rollover on a different session, the `drop_it_off` gate, the capability only while on, the web continue, the once-only turn post, and the `features.durableQueryJobs` read.
27
+ - Default-on changes: none for anyone with Drop it off (the default). With Drop it on, the COS chat on the web continues one thread instead of one conversation per day, and `/api/health` gains `dropIt.thread`.
28
+
29
+ ## 6.99.15 - Messages cleanup on COS Control Web; the pinned COS line never says its title twice (private)
30
+
31
+ 2026-11-05. Private build for one Mac, on top of 6.99.14. Pairs with COS Control 0.5.374 (the same Messages cleanup on the Mac), and works with any COS Control that pairs with 6.99.14.
32
+
33
+ Miles, 11/05 21:15, with a screenshot of Messages: "clean up the UI/UX on this, make sure it's clean on the browser and the desktop version".
34
+
35
+ - The pinned COS row's line (`/api/inbox/summary` `lastLine`, `pinLine` in `server/lib/drop-service.ts`): a follow-up's line already names its card, so "Sprocket Rocket AI Unified Nav Approved (G2). 2 follow-ups from Sprocket Rocket AI Unified Nav Approved (G2)." now reads "2 follow-ups from Sprocket Rocket AI Unified Nav Approved (G2).". A title the line does not name still leads it, with one period. Both COS Control and the web show this line.
36
+ - COS Control Web, Messages (`panes/messages.js`, `control.css`): each row leads with the ask, one line cut by width (was 57 characters), with its time beside it; the answer's two-line preview under it; one quiet line of facts (`#442`, Routine or Task, the model by name, attachments with their icon and "1 image"). No per-row glyph, no "ROUTINE and TASK" legend, no visible "Message view" label. The header says "30 messages, newest first". Previews put a space between progress sentences that ran together ("now.Reading").
37
+ - COS Control Web, the COS chat (`panes/cos-chat.js`): a P2 follow-up that carries its own Dismiss no longer gets a second one. The pinned row's time is in the body font.
38
+ - Tests: `drop-service.test.ts` covers `pinLine` (mutation-checked: always prefixing the title fails it); `cos-chat-p2.test.ts` counts one Dismiss on a follow-up that carries one, and one on a question that does not (mutation-checked).
39
+
40
+ ## 6.99.14 - The live meeting view on COS Control Web stops jumping, and gets the recording bar and Add file (private)
41
+
42
+ 2026-11-05. Private build for one Mac, on top of 6.99.13. Web only: no server route, no store and no glasses change. Pairs with any COS Control that pairs with 6.99.13.
43
+
44
+ Miles, 11/05: "It currently keeps CLS jumping when the new transcription lines come in. We also don't have the ability to add rich text [files] or see the voice histogram coming in." The target is the Mac's live recording bar.
45
+
46
+ - **New lines no longer move the page.** The cause: the live transcript was part of the page's own scroll, so following the newest line meant scrolling the whole page on every update. The title, the status, the Stop button and, on a phone, the Ask panel moved with each line. Measured in headless Chromium on 150 real-sized chunks with 20 lines appended one per poll: the page moved 1,279 px at 390 px wide and 1,002 px at 1280 px wide. Now the transcript scrolls inside its own box, as Control's does: following keeps the box on its newest line and the page moves 0 px at both widths. Reading higher up, a new line lands below you and nothing moves (0 px); turns above you that the speaker pass rewrites longer are held by the pane's own anchor, on every browser (iOS Safari has no scroll anchoring; the box sets `overflow-anchor: none` so Chrome's never corrects twice). A scroll write happens only when a turn above you changed height, so a fling on the iPhone is never stopped. Jump to live floats over the box's bottom edge, so showing it moves nothing.
47
+ - The layout-shift API reported 0 for this before and after: a scroll is not a layout shift, which is why the jump never showed up as CLS. (The 0.0007 it reports at 1280 px on load, before and after, is the app rail, not this pane.) The numbers above are page movement in CSS pixels, the user's unit.
48
+ - The header is the same height whatever it says (the status and duration lines stay one line), and everything that changes height while it records sits under the transcript, never above it. Before, the Files row landing with the Ask history pushed the transcript down 52 px; now 0 px.
49
+ - **The recording bar, from the Mac's.** Under the transcript, one 50 px row: the red dot, the elapsed time on its own one-second clock (m:ss, then h:mm:ss; the row is not rebuilt each second), the level strip, Add file and Stop and save. Under 560 px of bar Add file is the paperclip alone; under 340 px the still glyph takes the strip's place. Copy transcript and Copy full context stay under it.
50
+ - The strip is Control's glasses level strip (`LiveChunkLevelStripView`): one bar per chunk from the live read's `levels`, the two numbers per chunk the quiet gate has kept on the active-session record since 6.87.0 (RMS on a dBFS scale, and Silero speech seconds). Newest at the right, a dot where a chunk held under 0.5 speech seconds (or level 0.27 and under without them), the oldest clipped and faded at the left. It trails the room by a chunk and says so to VoiceOver. Reduce Motion: one still bar for the newest chunk. It comes from the poll the page already makes every 1.5 s (`GET /api/meetings/live-chunks`), so nothing new is read and no route was added. A Mac recording's 10-a-second audiogram lives only in COS Control on that Mac; the web shows the same per-chunk strip for every recording.
51
+ - Stop and save is the glasses stop of 6.99.x, moved into the bar, and still asks first: "Stop and save this glasses meeting?" and nothing is sent until you confirm. A Mac recording says "Stop on your Mac" and an iPhone one "Stop on iPhone"; neither can be stopped from the web.
52
+ - **Add file, paste and drop while it records.** The same upload as the Ask composer (photos resized to a 1024 px JPEG to `POST /api/media`, other files raw to `POST /api/media/file`), then `POST /api/meeting-ask/files/media`, the route the phone uses, so Meeting Ask reads the file and Copy full context carries it. Photos are read for their text on the Mac, as the phone's are. Up to 10 at a time; video is refused with what to attach instead. A paste or drop that holds a file is always taken as the file: if the browser will not hand the file over, the page says "This browser did not hand over the file. Use Add file to choose it." and never lets its name or path land as text in the Ask box. A file drag outlines the transcript column and moves nothing. Refusals say what happened and what to do (a full meeting, an update in progress, an expired upload, a restarted server, which is read again once); Retry adds the kept upload without sending the bytes again. Files are removed in COS Control on the Mac, as before.
53
+ - The Files row is drawn from the start while it records ("No files yet. Add file, paste or drop one here."), one line tall whatever it says, so files arriving with the Ask history, an upload starting and one finishing never move what is under it. Open, an upload is a row of the same height as a file and becomes its file in place.
54
+ - Tests: `server/web-control/live-meeting-view.test.ts` runs the real `meetings.js` and `cos-chat.js` against a fake DOM that lays the transcript out (follow, reading, rewrite above the reader, the bar, the clock, the strip, Stop and save, Add file, paste, drop, refusals). `server/lib/web-live-files-canary.test.ts` runs Add file end to end through the real `/api` gate with the web session cookie, the real media routes and the real Meeting Ask routes, and checks that a write without `X-COS-Web`, from another origin or without the cookie is refused (401) and changes nothing. The page's requests are read out of the page's own source. The page-movement numbers come from `server/scripts/web-live-cls/` (a mock server, a CDP driver and the measurement).
55
+ - Default-on changes, for every COS Control Web user (no server behavior changes):
56
+ - The live meeting view puts the transcript in its own scrolling box with the recording bar under it; Stop and save moves from the header into the bar.
57
+ - Add file, paste and drag-drop add files to a live meeting from the web (before: "Add or remove them in COS Control on your Mac").
58
+ - A meeting file that came from the media store is labelled "Added from a phone or browser" (was "Added from the phone").
59
+ - The Ask composer's upload code is shared with the live view (`W.cosChat.uploadMedia`); its requests are unchanged byte for byte.
60
+
61
+ ## 6.99.13 - The Mac asks first; Drop takes files on COS Control Web (private)
62
+
63
+ 2026-11-05. Private build for one Mac, on top of 6.99.12. Pairs with COS Control 0.5.371 or 0.5.372. Nothing changes for the glasses app.
64
+
65
+ - **An open web tab never holds a session question from the Mac (option B).** Miles chose B on 11/05: the Mac shows its own dialog at once. A COS Control Web poll (`GET /api/session-questions?client=web`, browser session) no longer counts as a live answering client, so a tab left open on another computer never delays the Mac's pop-up. The web still lists and answers what the glasses or phone are holding, with the same cards and actions as 6.99.12. Token polls from the glasses and phone are unchanged.
66
+ - **Drop takes files (COS Control Web).** Miles, 11/05: "I don't see the file attachment in drop. Can we ensure that functionality is there." The clip, paste and drag-drop already added files to the composer; now Drop sends them with the words, as Ask does.
67
+ - `POST /api/drops` takes `attachmentIds`: up to 5 fresh uploads from `POST /api/media` or `/api/media/file`, held to the Ask's own rules (media ids, deduplicated, 5 at most). Refusals are plain and save nothing: a sixth file (400 `too_many_attachments`), an id this Mac never gave out (400 `invalid_attachment`, 404 `attachment_not_found`), an upload past its 4 hour wait (410 `attachment_expired`), a file already queued with an Ask or held by another drop (409 `attachment_in_use`). Each says what to do ("Add it again, then Drop").
68
+ - Words are still required: a drop is filed by its words, so files alone are refused with "Add a few words with the files so COS knows where they go." (400 `words_needed`). The password and key check on the words runs exactly as before.
69
+ - Where the files live: in the media store, the same place an Ask's files live (`data/media/assets/<id>/`). The drop pins them (`MediaStore.pinForDrop`): the upload becomes `associated` with the drop's id, which no upload TTL and no GC pass touches, so the files stay as long as the drop holds them, across restarts. Nothing is copied, nothing leaves the data dir, and the index is written atomically as always.
70
+ - The drop record keeps each file's id, name, type and size (`attachments`). Your words in the chat show what you sent, and every receipt lists each file with Open (`GET /api/media/:id/content`). A card, a thread note and a memory take words only, so the receipt says where the files are: "The 2 files stay here in the COS chat with your words." History finds a drop by a file's name. Move keeps the files.
71
+ - A drop read as a question sends its files with the Ask (`next.attachmentIds`), so "what is on this shelf?" with a photo asks about the photo.
72
+ - Undo removes the files with the drop: the bytes are deleted, the drop forgets them, and no chat line offers them any more. Undo removes only what that drop pinned; a file an Ask has since sent with a message stays. A refused Undo (the card was edited) keeps them.
73
+ - `capabilities.dropIt.attachments: 5` on `/api/health` and `/api/drops/settings`. The web sends files only to a server that says so; against an older server Drop keeps the words-only note and sends nothing.
74
+ - On the web: Option-Return is still Drop and Return is still Ask. With files attached, Drop waits for the uploads (as Ask does), says so when a file failed, and keeps the words and files in the box when the Mac refuses one.
75
+ - Default-on changes, for every user:
76
+ - The Mac's own session question dialog shows at once even while a COS Control Web tab is open (before, a visible tab could hold it up to the broker's deadline).
77
+ - `capabilities.dropIt.attachments` on `/api/health` and `/api/drops/settings` for every install (additive). Drop it itself stays off by default; files on a drop exist only where Drop it is on.
78
+
79
+ ## 6.99.12 - COS Control Web catches up with the Even app: Ask with files, Reply, session questions, wide Usage (private)
80
+
81
+ 2026-11-05. Private build for one Mac, on top of 6.99.11 (Drop it P2), which carries 6.99.10 (Drop it P1 and the web Ask sheet fix). Pairs with COS Control 0.5.371 or 0.5.372. Nothing changes for the glasses app.
82
+
83
+ Miles, 11/04 23:38: "On click it shows that it sent but it never shows up in messages. Also there isn't the ability to send a message from the web view." The four gaps the parity audit put first (`PARITY_even_app_vs_control_web_2026-11-04.md`), closed:
84
+
85
+ - **Ask COS in Messages, with or without Drop it.** Wherever durable query jobs are on (the default), Messages pins an Ask COS row first in Recent and opens a chat with a composer (`#/messages/cos`; beside the list from 860 px). Answers stream with their message number and land in Recent as numbered turns, here, on the Mac and on the lens. With Drop it on, the chat is the pinned COS chat of 6.99.10 with its Drop button; with it off, there is one Ask button and no inbox read. Return asks; Option-Return drops only where Drop shows; the buttons need no key on the phone. The Model menu now comes from `/api/models` (Claude Opus, Fable and Sonnet, Codex from the Mac's catalog, Cursor and Ollama when the Mac says they are ready), with an Effort menu (High, Extra high, Max, Ultracode) sent as `effort`.
86
+ - **The Ask sheet's Ask COS now shows its answer.** It hands the words to the same composer path and opens the chat, so the answer streams on screen instead of a status line that vanished.
87
+ - **Attachments on Ask, as in the Even app.** The clip opens Camera, Photo Library or Files on iOS (`<input type=file multiple>` for photos, PDF, TXT, MD, CSV, JSON, XLSX, MP4 and MOV); paste and drag-drop work too. Photos are drawn into a canvas at most 1024 px on the longest edge, JPEG quality 0.85, and posted to `POST /api/media` (`kind: user_photo`); documents and video go raw to `POST /api/media/file` with `X-COS-Filename`, `X-COS-Captured-At` and `X-COS-Web: 1`. The Ask sends up to five `attachmentIds`. Chips show a thumbnail, the name, the size, upload progress and Remove. The server caps are said plainly before anything uploads (5 per message, 2 MB per photo after resizing, 64 MB per document, 100 MB per video). HEIC is refused outside Safari with a line saying why. A 401 during an upload never opens the sign-in screen: the words and files stay, the chip says signed out, and Sign in again then Retry sends them. Drop takes words only; trying to drop with files attached says so. Uploads use XMLHttpRequest for progress (fetch has none); same origin, same cookie, same headers.
88
+ - **Reply from the message reader.** The reader ends with the same composer, sending that turn's `sessionId`, so the answer continues that conversation while it is live (else the server starts a new one, as before). The reader is not rebuilt under a half-written reply when Recent refreshes.
89
+ - **Session questions on the web.** `GET /api/session-questions` takes the COS Control Web session as well as `X-Cos-Token`, through the same cookie check the `/api` gate runs; the hook token still opens nothing. A browser poll counts as a live answering client only with `?client=web` (the page sends it only while a visible, signed-in tab shows the cards), in the phone's 30 s window, and never reports presence, so it never enables the away hold. A cookie poll naming `glasses` or `phone`, or no client, never counts; a token poll naming `web` never counts. `/api/models` `capabilities.sessionQuestions` gains `webClient: true`; the page never calls the route without it. Question and approval cards show over every pane, with the Even app's actions: pick (or Other), Send, Allow once, Deny (each tap then confirm), and Leave for the Mac, through the existing `POST .../answer`. A 401 on the poll stops it quietly. Plan approvals never reach the broker (ExitPlanMode is unbrokered), so there is no plan card.
90
+ - **Usage on the web uses the Mac's layout from 860 px wide, and custom date ranges** (merged from `claude/web-usage-wide` 5dfebf0). Below 860 px it is byte for byte the 6.99.10 page.
91
+ - Canary 12: with a message era active, a web Ask with a photo and a PDF through the real `/api/media`, `/api/media/file` and `/api/query-jobs` is admitted, the model receives both, the answer turn carries both refs, the counter moves by exactly one, and a Reply continues the same session.
92
+ - Default-on changes, for every web user:
93
+ - Messages shows the pinned Ask COS row and the two-column layout from 860 px wherever durable query jobs are on (the default); before, only with Drop it on.
94
+ - The message reader shows Reply.
95
+ - The Ask sheet's Ask COS opens the chat and streams the answer.
96
+ - Session question and approval cards show on the web wherever the permission broker is on (the default), and a visible Control Web tab now counts as a client that can answer, so the Mac holds a question for it (up to the broker's deadline) instead of showing its own dialog at once.
97
+ - Usage uses the Mac's layout from 860 px wide.
98
+ - `capabilities.sessionQuestions.webClient` on `/api/models` for every install (additive).
99
+
100
+ ## 6.99.11 - Drop it P2: pings, follow-ups, reminders and History search (private)
101
+
102
+ 2026-11-04. Private build for one Mac, on top of 6.99.10 (Drop it P1, with the web Ask sheet fix), which carries 6.99.9, 6.99.8 (resumable meeting consolidation) and the 6.99.7 dictation hotfix. Pairs with COS Control 0.5.371 now; COS Control 0.5.372 adds the Mac banners and the local reminder backup. Nothing changes for the glasses app yet.
103
+
104
+ Phase 2 of Drop it, the part that reaches out. Everything here runs only while Drop it is on (off for every user; on where `COS_DROP_IT=1` is set or Control turns it on), checked at boot and each minute. While off, nothing new opens and nothing is written.
105
+
106
+ - **Reminders.** "remind me Thursday to send Gina the JCK numbers" now reads its time: today, tonight, tomorrow, weekdays, next week, "at 3", "3:30 p.m.", "15:00", noon, morning, afternoon, evening, "in 20 minutes", "in 2 weeks", "Nov 12" and ISO dates. A day alone means 9:00, today's weekday means next week, and a bare hour means the next one. A time already past is refused, never moved. The receipt says "Reminder set: tomorrow 9:00 AM". With no time it asks once (Tomorrow 9:00, Monday 9:00) and waits on you until you pick (`when:` on `POST /api/inbox/action`).
107
+ - **Reminders fire.** A 30-second tick. After sleep the first tick fires what is overdue, once, and says how late past 10 minutes. `firedAt` is written before the notification, so nothing fires twice. Done, Snooze 1 hour and Tomorrow 9 on the ping. A reminder he set fires at any hour. `GET /api/drops/reminders` lists the pending ones for COS Control's local backup, which uses the same notification id (`reminder-<id>`), so the two never double.
108
+ - **Pings.** COS posts to the pinned chat, reading signals that already exist: your next meeting 15 minutes before it starts, follow-ups ready from a meeting, a thread gone quiet (at most once a week each), a red signal, an agent finishing work you sent it, and a Monday 9:00 check-in. Fixed wording over each source's own titles; no model is called, and a test fails if the ping code can reach any AI, mail, Slack or Telegram module. Work pings that arrive after 6 PM, before 6 AM or on a weekend wait and post as one "While you were off" item at 8:00, minus anything that moved on.
109
+ - **Follow-ups.** One line under the drop when what it became resolves: a card reaching QA or Complete, or archived; a handoff completed (with its report), failed or refused; a session you sent it to replied or stopped; a thread resolved. Exactly once per change, across restarts and a QA, Draft, QA bounce. Found by a 2-minute poll, sooner after a card write through this server, and at once when a session's turn ends.
110
+ - **Banners.** At most 6 a weekday, 2 per source, 45 minutes apart, none in quiet hours or before 8:00. Reminders are exempt. On by default only for reminders and follow-ups to your own drops; the rest land silently in the chat. `GET /api/inbox/banners` is the outbox COS Control reads. A reminder also goes to the lens as display event `cos_ping` (withheld from any stream without a ticket; shipped glasses builds ignore it).
111
+ - **Waiting on you.** Pings and follow-ups that need an answer (a card at QA, a failed or refused handoff, follow-ups ready, the check-in, a session that stopped, a reminder with no time) count toward `waiting` until answered, done or dismissed.
112
+ - **History search by meaning.** `POST /api/drops/search`: one Jev Choice over your drops' words and where they went, charged to the drop budget (`COS_DROP_DAILY_TOKENS`) and off with `COS_DROP_ROUTER=0`. A drop that looks like it holds a password or key is never offered. Over 254 drops it searches the newest 254 and says so; word search still covers everything. Read-only, so it is not held during an update drain.
113
+ - **COS Control Web.** The pinned chat's new buttons work: Done, Snooze, Tomorrow 9, the reminder time picks, Open result, Open, Review, Prep me, and Reply on the check-in.
114
+ - **Settings: tune and steer it.** `GET`/`PUT /api/drops/settings/p2` (its own file, `data/drop-p2-settings.json`; the P1 switch and its route are unchanged), and a COS reaching out section in COS Control Web Settings. Every value is honored by the code that does the thing:
115
+ - Pings: on or off; interruptions a day (6), from one kind a day (2), minutes between (45); quiet from and until (6 PM to 6 AM) and weekends; the first interruption (8:00); a switch for meeting prep, agents finishing, follow-ups ready, threads gone quiet and red signals.
116
+ - Follow-ups on your drops: on or off, and Work cards, handoffs, sessions and threads one by one.
117
+ - Weekly check-in: on or off, its day and time (Monday 9:00).
118
+ - Reminders: the default time (what a day alone means, and Tomorrow and Monday), the snooze length (the button says it), notifications, and the glasses push.
119
+ - Which kinds may interrupt; the rest land in the chat without a notification.
120
+ - Steer: Ping me about (a ping that mentions one of these words may interrupt, even when its kind stays in the chat; the limits still hold) and Leave alone (a ping that mentions one is not posted; reminders and follow-ups always are). Whole words, 300 characters each, no model.
121
+ - Numbers are clamped and a save changes only what it names. Readable and writable while Drop it is off. `capabilities.dropIt.p2Settings` lets older clients hide the controls.
122
+ - **Quiet during updates.** Every P2 loop skips its turn while the server drains; a reminder then fires on the next tick, marked late.
123
+ - **State.** `data/pings/state.json` (the banner ledger, held items, consumed keys): one writer, a 0700 folder and a 0600 file. Logs carry counts only, never words.
124
+ - Default-on changes: none for anyone with Drop it off, which is every install by default. With Drop it on: reminder times, reminder and follow-up banners, the Monday check-in, and silent pings in the chat, all adjustable in Settings. `capabilities.dropIt` gains `p2Settings: 1` for every install (additive).
125
+
126
+ ## 6.99.10 - Drop it P1, with Ask and Drop on the web (private)
127
+
128
+ 2026-11-04. Private build for one Mac, on top of 6.99.9 (web phone link and provider logos), which carries 6.99.8 (resumable meeting consolidation) and the 6.99.7 dictation hotfix. Pairs with COS Control 0.5.371. Nothing changes for the glasses app.
129
+
130
+ Server half of Drop it phase 1: a pinned COS chat that takes a drop (a thought, a task, a reminder, a question), files it, and answers with a receipt. Off for every user; on only where `COS_DROP_IT=1` is set or Control writes `PUT /api/drops/settings {"enabled":true}`. While off, every new route except the settings pair answers 404 `drop_it_off` and no drop or chat file is created. Messages, the lens message counter, `all-messages` and `live-chats` are unchanged either way.
131
+
132
+ - **Drops and the chat.** Two new stores beside the Messages store, never inside it: `data/drops/drops.json` (each drop, where it went, a status timeline) and `data/inbox/feed.json` (the chat: drop, receipt, question, answer, follow-up). One writer lock each, a 0700 folder and 0600 files, atomic writes, and a bad row set aside on its own. A drop is saved before it is filed, so a restart mid-filing keeps it as a note.
133
+ - **Where a drop goes.** "remind me" is a reminder, "remember" a memory, and a question goes to the existing Ask job, all by rule with no Jev call. Anything else is one Jev request: it files on its own at 0.80 with a 0.25 lead (a memory needs 0.90), otherwise it asks one question with the top two readings and Just a note. When Jev cannot run, the drop is kept as a plain note and the receipt says why.
134
+ - **Its own budget, Jev only.** Cap `COS_DROP_DAILY_TOKENS` (300,000 a day), its own ledger (`jev-drop-usage.json`) and breaker, and `COS_DROP_ROUTER=0` turns Jev routing off. Token audit caller `drop`, sizes only. Pinned to Jev for P1: the decision provider setting (SubQ) does not apply, and there is no `drop` decision use case, so the settings file stays readable by 6.99.4 and earlier. No claude -p and no new model caller.
135
+ - **Privacy.** A drop that looks like it holds a password or key is checked before any rule and kept as a plain note: it never reaches memory, the Ask job or Jev, and Move to a memory is refused. Jev reads cleaned words only. Where words do leave the Mac: Jev (api.typesafe.ai) for routing, the existing Ask job for a question, and, for a memory, the COS bridge embeds the words through the embedding provider it uses for every memory (OpenAI today). Logs carry ids, codes and lengths, never words.
136
+ - **Filing writes.** New card through the bridge verb `task-capture-drop` (answers the card id; an open card with the same words is linked, never copied), memory through `memory-capture` into To review (a new capture key per filing), thread notes through `thread-source`/`thread-unsource` by id. A bridge without a verb keeps a plain note and says so; a memory too short for the memory contract is kept as a note and says that. Existing card, session follow-up (held until Send), note and reminder live in the drop store only. Reminder times and firing come in P2.
137
+ - **Undo and Move.** 24 hours, refused with a reason when the card or thread note was edited, or the memory reviewed, since (`memory-uncapture`). Undo never archives or removes something the drop did not create. Move is Undo, then filing where you picked, with no Jev call, and a target is checked before anything is undone. Nothing is deleted.
138
+ - **Retention.** Drops a year; an undone drop 30 days; a drop's chat lines go when the drop goes. Neither store ever writes a file larger than it will read back (32 MB).
139
+ - **Routes.** `POST/GET /api/drops`, `GET /api/drops/:id`, `POST /api/drops/:id/{undo,move,ask,sent}`, `GET /api/inbox/summary`, `GET /api/inbox/feed`, `POST /api/inbox/{read,action}`, `GET/PUT /api/drops/settings`. History is word search with kind and status filters.
140
+ - **Capability.** `capabilities.dropIt` `{ protocol: 1, enabled, source }` on `/api/health` and `/api/models`. Additive.
141
+ - **Waiting on you.** `GET /api/inbox/summary` adds `waiting`, a whole number: questions COS asked that you have not answered, chat items marked `needsReply` (a follow-up or ping that needs a decision), and the Work items in Your move that want an answer or a look (needs input, attention, done; a run still going does not count). Unread clears when the chat is opened; waiting clears only when the item is answered, done or dismissed (`POST /api/inbox/action {action: "dismiss"}`, for a `needsReply` item). `POST /api/inbox/read` answers `waiting` too. The Your move count shares Work's own board read and is kept 30 seconds, and the summary waits at most 1.5 seconds for it. COS Control 0.5.371's Messages badge reads this field.
142
+ - **Ask from COS Control Web.** `POST /api/query-jobs` with `source: "web"` (the glasses' own route, behind the web sign-in and its write check): the server picks the conversation (the one the web is in today, or a new one) and gives the turn the next message number, from the same count `/api/message-counter` serves. The answer is a normal Messages turn: it shows in Recent on the Mac and the web, and in the glasses' Messages list. A retry of the same request reuses its number. Any other caller is unchanged.
143
+ - **COS Control Web, while Drop it is on.** Messages pins a COS row first in Recent (`#/messages/cos` opens the chat), and from 860 px the list and the chat sit side by side. The chat's composer has two visible buttons, Ask (filled) and Drop (outlined), so a phone with no keyboard needs no key combination or long press; with a keyboard, Return asks and Option-Return drops. Ask streams its answer with its message number, with Steer (where the Mac allows it) and Cancel; the Model menu lists what meeting Ask offers. Drop shows the receipt with Undo and Move. The rail and the section menu show a number on Messages for what waits on you, and a dot when there is only something unread; both are read with the page's existing status check, with no new timer, and never redraw the pane. With Drop it off the web looks exactly as in 6.99.9.
144
+ - **Fix: Ask COS in the web's Ask sheet did nothing.** Tapping it sent an older request (a non-standard id, no message era), so a Mac with a message era refused every one and only a small status line said so. It now sends the same request as the COS chat's Ask, and a refusal also shows a toast.
145
+ - Default-on changes: none. Drop it stays off until it is turned on. The `waiting` field and the web Ask route change nothing until a client uses them.
146
+
147
+ ## 6.99.9 - Open COS on the web from the glasses app, and real provider logos (private)
148
+
149
+ 2026-11-04. Private build for one Mac, on top of 6.99.8 (meeting consolidation, with the 6.99.7 dictation hotfix). Pairs with COS Glasses 6.11.672. Nothing changes for the COS Control Mac app.
150
+
151
+ - **A sign-in link for your phone's browser.** COS Glasses 6.11.672 adds Open COS on the web in Settings. It asks the Mac for a link, copies it, and you paste it into Safari or any browser to land on COS Activity already signed in, over Tailscale. The link works once and lasts 2 minutes; a short sign-in code is shown with it as a fallback. Links from the Even app open inside Even's own browser, never Safari, so copying is the way out (tested on an iPhone).
152
+ - New route `POST /api/web-session/phone-link`, for the paired glasses app only (its token; a signed-in browser cookie is refused). Callers must be on this Mac, Tailscale or the home network. At most 5 links a minute. The link and the code are never written to the log. `/api/health` advertises `capabilities.webPhoneLink`, so older glasses builds and older servers simply do not show the row.
153
+ - An expired link now says to get a new one from COS Control or the COS Glasses app.
154
+ - **Sessions on the web show the real Claude, OpenAI and Cursor logos**, the same marks COS Control draws, instead of plain shapes.
155
+ - Default-on changes: the web Sessions logos. The route answers only the paired glasses app.
156
+
157
+ ## 6.99.8 - Resumable meeting consolidation (private)
158
+
159
+ - Explicit meeting cleanup jobs continue across bounded 15-minute provider passes under one durable job, model selection and cancellation control. Ordinary chats retain their current limits.
160
+ - Each pass closes its provider process tree before continuation. Partial output at the pass limit is progress, never a completed cleanup.
161
+ - A saved inventory, verified evidence hashes and final audit report gate completion. Provider failures, cancellation, three unchanged checkpoints or the 12-hour/48-pass safeguard stop honestly with saved work intact.
162
+ - Exposes the maintenance workflow capability for compatible COS Control clients. Interrupted server boots retain the existing interruption/orphan recovery policy; this does not silently restart writes after a server crash.
163
+
164
+ ## 6.99.7 - Dictation no longer fails while meetings record (private hotfix)
165
+
166
+ 2026-11-04. Private hotfix for one Mac, on top of 6.99.5. 6.99.6 is a separate meeting consolidation build, not yet installed; it needs this fix merged before it ships. Nothing changes for the glasses, the phone companion or the COS Control Mac app.
167
+
168
+ - **Fix: Mac dictation said "Server stopped" while a meeting was recording.** Asking whether a meeting was saved read and parsed every saved meeting's chunk file (813 files, 1.8 GB on the test Mac) whenever the meeting was not saved yet, which is every live recording. COS Control asks that of each live recording every few seconds, so two recordings kept the server busy for 4 to 17 seconds at a time. Dictation's 5 second check timed out, and live chunks took up to 26 seconds. Now each saved file's session is remembered until the file changes, and its first 4 KB is enough for most files. On the test Mac one lookup went from about 4.7 seconds to 5 ms (575 ms the first time after a start). Results were identical for 37 saved meetings, 6 of them Mac recordings.
169
+ - Default-on changes: none. The lookup gives the same answers, only faster.
170
+
171
+ ## 6.99.5 - Control Web: questions asked on the Mac show on the phone (private)
172
+
173
+ 2026-11-04. Private build for one Mac, on top of 6.99.4. Fixes from the web and Mac meeting comparison. Nothing changes for the glasses, the phone companion or the COS Control Mac app.
174
+
175
+ - **Meeting Ask stays in step.** A question asked on the Mac now appears on the phone while the live meeting is open. The page re-reads the Ask history every 12 seconds, and at once when you come back to the page. The panel redraws only when something changed.
176
+ - **Files.** A meeting with files shows the Mac's "Files · N" row, in the live view and on the saved meeting. Open it for the names and any that could not be read. Files are still added and removed in COS Control on your Mac.
177
+ - **The meeting's name.** A name typed in COS Control while recording now shows on the web. Without one, the web uses the calendar event that started within 20 minutes of the recording, as the Mac does.
178
+ - **Opening a page right after signing in is fast again.** The side rail's story lines load two at a time instead of all at once, so the page you open is never stuck behind them. A meeting opened 4 seconds after sign-in waited 10 to 14 seconds for its first read; it now takes under 2.
179
+ - Default-on changes: the web's background Ask history read (one GET every 12 seconds while a live meeting's panel is open). No change to the Mac app or the glasses.
180
+
181
+ ## 6.99.4 - Control Web matches the Mac (private)
182
+
183
+ 2026-11-04. Private build for one Mac, on top of 6.99.3. COS Control Web, the page at `/control`, now looks and behaves like the Mac app's panes. Nothing changes for the glasses, the phone companion or the COS Control Mac app.
184
+
185
+ - **Work.** Sort leads with today's meetings. A `#/work/sort` link opens Sort directly. The board holds still while you read: background loads no longer repaint under your finger.
186
+ - **Meetings.** Two columns from 860 px wide, so an unfolded phone reads like the Mac. The live transcript no longer jumps when new lines arrive. A Model menu picks the model for meeting Ask, and a refused change says why in words. When a sign-in lapses, every Meetings call stops and the reason stays on screen. Months that hit the list cap say N of M.
187
+ - **Sessions and Speakers hold still and say when offline.** Nothing above the turns moves while you read, Speakers never rebuilds the whole pane under your finger, and a read that times out offers Retry.
188
+ - **The sign-in screen keeps its message.** A later sign-out never blanks the words that say why you were signed out.
189
+ - Default-on changes: none beyond the web page itself; the Model menu changes the one meeting-assistance model the Mac also uses.
190
+
191
+ ## 6.99.3 - Signing in on the phone says what went wrong (private)
192
+
193
+ 2026-11-04. Private build for one Mac, on top of 6.99.2. Pairs with COS Control 0.5.367, which adds Open COS on your phone. Nothing changes for the glasses or the phone companion.
194
+
195
+ - **Fix: the sign-in page had no way in for a first phone.** A code only came from a browser that was already signed in, and the only code the Mac showed was the glasses pairing code, which the page refused. COS Control 0.5.367 adds Open COS on your phone: scan its QR with the phone camera and Safari opens signed in, or tap Show a sign-in code and type it. The server now gives Control that phone link: the handoff answer adds `phoneUrl` (this Mac's tailnet name, or its tailnet address when the name is not known yet) and `tailnet.ipUrl` / `tailnet.nameUrl`. The link works once and lasts 2 minutes.
196
+ - **The page names what failed.** A wrong token, a wrong or expired code, an expired sign-in link, and the glasses pairing code each get their own message. Typing the glasses code says it only pairs the COS Glasses app, and the code is left untouched for the glasses. That answer is given at most 20 times per glasses code across every device, the same cap the glasses app has, so the page cannot be used to hunt for the code. After 8 failures in 10 minutes the lockout message says what the last try was. Every failure still counts toward that limit. If the phone cannot reach the Mac at all, the page now says so instead of showing the browser's "Load failed".
197
+ - **A pasted token survives the paste.** The token is checked as typed, then again with spaces, line breaks, zero-width characters and wrapping quotes removed. The page has a Show token button and says the copy clears after 60 seconds.
198
+ - **The server log says why a sign-in failed** (`[web-login] fail kind=wrong_code len=8 ip=...`): the kind, the length and the device address, never what was typed.
199
+
200
+ ## 6.99.2 - The phone address by name works (private)
201
+
202
+ 2026-11-04. Private build for one Mac, on top of 6.99.1. Nothing changes for the glasses, the phone companion or COS Control.
203
+
204
+ - **Fix: signing in at `http://<your-mac>.<your-tailnet>.ts.net:3141/control` was refused.** The server learned this Mac's Tailscale name from the Tailscale command line, which does not answer when the server runs in the background, so every sign-in from the name address was turned away. It now asks Tailscale's own DNS for the name of this Mac's Tailscale address, which answers in milliseconds. Only this Mac's exact name is accepted. The address by number (`http://100.x.y.z:3141/control`) always worked.
205
+
206
+ ## 6.99.1 - COS Control in a browser on your phone, over Tailscale (private)
207
+
208
+ 2026-11-04. Private build for one Mac, on top of 6.99.0. Nothing changes for the glasses, the phone companion or COS Control.
209
+
210
+ - **COS Control Web.** Open `http://<your-mac>.<your-tailnet>.ts.net:3141/control` on a phone or another computer on your tailnet: Today, Sessions, Meetings, Work, Messages, Memories, Speakers, Usage and Settings, against the same routes the Mac app uses. Built phone first for the iPhone Duo: on the closed screen one top bar that slides away while you scroll; on the open screen a labelled left rail and two columns. No bottom bar, because Safari owns that edge.
211
+ - **Talk to your sessions from the phone.** Open a Claude Code, Codex or Cursor session and send it a message, the way the glasses' Continue does. If the Mac is busy with it, the message waits in that session's line and goes when it is free; if COS is still answering, it goes right after. Stop a running reply, or Fork to send your message to a copy. The reply streams in as it is written.
212
+ - **Sign in once per browser.** On a signed-in browser choose Settings, Sign in another device, then type the 8-character code on the phone (single use, five minutes). The pairing token still works. The session is an HttpOnly cookie for 7 days; the page never holds the token. Settings, Sign out every browser ends every browser session at once; the glasses and the phone app stay paired.
213
+ - **This Mac and your tailnet only.** The page and its sign-in answer loopback and Tailscale addresses. Home Wi-Fi is off unless `COS_WEB_CONTROL_LAN=1`. `COS_WEB_CONTROL_ENABLED=0` turns the whole thing off.
214
+ - **Guards.** Changes from a browser need the page's own origin, matching the address it reached, plus an extra header, so another site or another machine cannot act for you. Eight wrong codes or tokens in ten minutes lock that device out for the rest of the window. The page runs only its own scripts. A browser session can do what the pairing token can, so sign out a browser you no longer use.
215
+ - New routes: `/api/web-session/{login,logout,status,device-code,handoff,revoke-all}`, `/api/tailscale/web-url`, `/api/web-bridge/memories`, `/api/meetings/live-chunks`, `/api/usage/summary`, `/api/work-board/your-move`. Not in this build: answering session questions and approvals from the browser.
216
+
217
+ ## 6.99.0 - Rename a live meeting, and up to 50 cues per meeting (private)
218
+
219
+ 2026-11-03. Private build for one Mac, on top of the public 6.98.1. COS Control needs a later build to show the rename field; nothing changes for older clients.
220
+
221
+ - A live meeting (a G2 meeting or a Mac recording) can be renamed while it records: `GET` and `PUT /api/meeting/title-steer`. The name is kept across a server restart, a second client editing at the same time is told the name changed, and the name text is never logged.
222
+ - When the meeting saves without a title of its own, the name you gave becomes its title and helps file it in the right domain. It is also saved as a title steer, so the meeting sync names the meeting from your words and sharpens them, instead of starting from "Mac recording 9:05 PM".
223
+ - A placeholder title such as "Mac recording 9:05 PM" never beats a name you gave; a real title sent with the save still wins. A rename sent while the meeting is saving is refused (409 `save_in_progress`) instead of being lost.
224
+ - The live meeting file Control reads carries the name as soon as you change it. `capabilities.titleSteer` says a server supports it.
225
+ - Live cues per meeting can be set up to 50 (was 25) in COS Control or the phone. The default stays 25. A meeting at 50 can use up to 100 of the 150 daily writer calls.
226
+ - `GET /live-cues/history` now reports the per-meeting cap you set, not a fixed 25.
227
+
1
228
  ## 6.98.1 - Tableau answers by lead source (private)
2
229
 
3
230
  2026-11-03. Private build for one Mac, on top of the public 6.98.0.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.98.1",
3
+ "version": "6.99.16",
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": {
@@ -41,6 +41,7 @@
41
41
  "shared",
42
42
  "!server/**/*.test.ts",
43
43
  "!server/scripts/engine-canary.ts",
44
+ "!server/scripts/start-web-candidate.mjs",
44
45
  "!server/scripts/hq-native-canary.ts",
45
46
  "!server/scripts/hq-contention-canary.ts",
46
47
  "!server/scripts/live-cos-ask-canary.ts",
package/server/index.ts CHANGED
@@ -15,7 +15,7 @@ import { randomBytes, randomUUID } from 'node:crypto'
15
15
  import { healthRouter } from './routes/health.js'
16
16
  import { diagRouter } from './routes/diag.js'
17
17
  import { createClientInstanceRouter, isMeetingChunkPost } from './routes/client-instance.js'
18
- import { installTailscaleNudger } from './lib/tailscale-nudge.js'
18
+ import { getTailscaleNudger, installTailscaleNudger } from './lib/tailscale-nudge.js'
19
19
  import { queryRouter } from './routes/query.js'
20
20
  import { providerProofRouter } from './routes/provider-proof.js'
21
21
  import { transcribeRouter } from './routes/transcribe.js'
@@ -123,7 +123,7 @@ import { meetingAskRouter } from './routes/meeting-ask.js'
123
123
  import { meetingAskRuntime } from './lib/meeting-ask-runtime.js'
124
124
  import { lensGistRouter } from './routes/lens-gist.js'
125
125
  import { bookmarksRouter } from './routes/bookmarks.js'
126
- import { setWeatherPersistence, welcomeContextRouter } from './routes/welcome-context.js'
126
+ import { fetchNextEvent, setWeatherPersistence, welcomeContextRouter } from './routes/welcome-context.js'
127
127
  import { liveCuesRouter } from './routes/live-cues.js'
128
128
  import { tasksRouter } from './routes/tasks.js'
129
129
  import { memoryRouter } from './routes/memory.js'
@@ -132,7 +132,7 @@ import { skillsRouter } from './routes/skills.js'
132
132
  import { shutdownLiveCues } from './lib/live-cues-engine.js'
133
133
  import { shutdownLiveCuesV2 } from './lib/live-cues-v2.js'
134
134
  import { prewarmContext } from './lib/context-builder.js'
135
- import { preWarmCLI } from './lib/claude-bridge.js'
135
+ import { getCliSessionId, preWarmCLI } from './lib/claude-bridge.js'
136
136
  import { sweepStaleTurnRequests } from './lib/turn-request.js'
137
137
  import { getCodexRunConfig } from './lib/codex-run-ledger.js'
138
138
  import {
@@ -147,7 +147,7 @@ import { initSessionCache } from './lib/session-cache-writer.js'
147
147
  import { initSpeakerEmbeddings } from './lib/speaker-embeddings.js'
148
148
  import { startNemotronWarmup } from './lib/nemotron-live.js'
149
149
  import { killNemotronChildren } from './lib/nemotron-cli.js'
150
- import { logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversation.js'
150
+ import { getSessionRaw, logActiveSessionsOnShutdown, startAutoSnapshot } from './lib/conversation.js'
151
151
  import { getMediaStore } from './lib/media-store.js'
152
152
  import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
153
153
  import { serverMetrics } from './lib/server-metrics.js'
@@ -169,6 +169,23 @@ import { createWorkOrchestratorRouter } from './routes/work-orchestrator.js'
169
169
  import { createWorkLensRoundsRouter } from './routes/work-lens-rounds.js'
170
170
  import { startWorkOrchestrator } from './lib/work-orchestrator/runtime.js'
171
171
  import { createWorkSearchRouter } from './routes/work-search.js'
172
+ import { createDropsRouter, lazyDropService, openDropService } from './routes/drops.js'
173
+ import { createCosThreadRouter } from './routes/cos-thread.js'
174
+ import { claudeContextReader, CosThread, describeDropFiling, lazyCosThread } from './lib/cos-thread.js'
175
+ import { mediaDropAttachments } from './lib/drop-attachments.js'
176
+ import { createDropRouter, sharedDropJevClient, dropDailyCap, dropRouterEnabled } from './lib/drop-router.js'
177
+ import { createBridgeDropWriters } from './lib/drop-destinations.js'
178
+ import { dropItEnabled } from './lib/drop-settings.js'
179
+ import { serverTimezone } from './lib/morning-brief-config.js'
180
+ import { createYourMoveCounter } from './lib/your-move-count.js'
181
+ import { readWorkJournal } from './lib/work-activity.js'
182
+ import { createDropP2Boot } from './lib/drop-p2-boot.js'
183
+ import { createP2SettingsReader } from './lib/drop-p2-settings.js'
184
+ import { createDropP2Runtime, journalReceiptsLite } from './lib/drop-p2-runtime.js'
185
+ import { sessionTurnOutcome } from './lib/drop-resolution.js'
186
+ import { DropHistorySearcher } from './lib/drop-history-search.js'
187
+ import { emitDisplay } from './lib/display-bus.js'
188
+ import { COS_SCRIPTS_DIR } from './lib/python-bridge.js'
172
189
  import { isReadOnlyApiPost } from './lib/work-search.js'
173
190
  import { isMacVoiceNamingBatchPost } from './lib/mac-voice-naming-batch.js'
174
191
  import { dataPath } from './lib/data-dir.js'
@@ -198,7 +215,10 @@ import {
198
215
  import { isPublicApiRequest, requireApiToken } from './lib/api-auth.js'
199
216
  import { PairingState, pairingHostsFrom } from './lib/glasses-pairing.js'
200
217
  import { createPairingRouter } from './routes/pairing.js'
201
- import { isManagedRuntime } from './lib/managed-runtime.js'
218
+ import { createWebControlRouter, isAllowedCorsOrigin, webControlCsp, webControlEnabled } from './routes/web-control.js'
219
+ import { isWebControlSocketAllowed, isWebSessionAuthorized, setWebSessionEpochFile } from './lib/web-session-auth.js'
220
+ import { getTailnetSelfName, installTailnetSelfName } from './lib/tailnet-self-name.js'
221
+ import { isManagedRuntime, managedServerVersion } from './lib/managed-runtime.js'
202
222
  import { reportClaudeExtraToolConfiguration } from './lib/claude-tool-access.js'
203
223
  import {
204
224
  acquireMaintenanceWork,
@@ -271,13 +291,17 @@ app.use((req, res, next) => {
271
291
  next()
272
292
  })
273
293
 
274
- // Allow localhost + LAN IPs (glasses WebView accesses server over LAN)
294
+ // Allow localhost + LAN IPs (glasses WebView accesses server over LAN) + this Mac's verified Tailscale MagicDNS
275
295
  app.use(cors({
276
296
  origin: (origin, cb) => {
277
297
  // Allow requests with no origin (same-origin, curl, SSE) or "null" origin (file:// WebViews like Even Hub)
278
298
  if (!origin || origin === 'null') return cb(null, true)
279
- if (isAllowedNetworkOrigin(origin)) return cb(null, true)
280
- cb(new Error('CORS blocked'))
299
+ // Async only for an unrecognised *.ts.net origin (see isAllowedCorsOrigin); every other
300
+ // origin is decided from cached state, as before.
301
+ isAllowedCorsOrigin(origin, getTailnetSelfName()).then(
302
+ ok => ok ? cb(null, true) : cb(new Error('CORS blocked')),
303
+ () => cb(new Error('CORS blocked')),
304
+ )
281
305
  },
282
306
  // Without this, a cross-origin reader sees only the CORS-safelisted response
283
307
  // headers. The companion's origin is `null` (a file:// WebView), so it is
@@ -367,16 +391,31 @@ const pairingState = new PairingState({
367
391
  // request that arrives proves the tunnel carries COS traffic. Authenticated ones also mark the
368
392
  // address as a paired phone. Notes only, never answers; noteRequest never throws.
369
393
  const tailscaleNudger = installTailscaleNudger({ activeMeetings: () => getActiveTranscriptionSessionCount() })
394
+ // COS Control Web: this Mac's MagicDNS name, from the nudger when it has one and otherwise
395
+ // from a reverse lookup at MagicDNS (the CLI does not answer under this LaunchAgent; see
396
+ // lib/tailnet-self-name.ts). Learned at boot so the first phone sign-in is not refused.
397
+ const tailnetSelf = installTailnetSelfName({ nudgerName: () => tailscaleNudger.currentSelfDnsName() })
398
+ if (webControlEnabled()) void tailnetSelf.refresh()
399
+ // Browser sessions sign this Mac's session epoch; "Sign out every browser" bumps it.
400
+ setWebSessionEpochFile(dataPath('web-session.json'))
370
401
  app.use('/api', (req, _res, next) => {
371
402
  tailscaleNudger.noteRequest(req.socket?.remoteAddress, { meetingChunk: isMeetingChunkPost(req.method, req.path) })
372
403
  next()
373
404
  })
405
+ const HTTPS_PORT = parseInt(process.env.HTTPS_PORT ?? '3143', 10)
374
406
  app.use('/api', requireApiToken(API_TOKEN, {
375
407
  onAuthenticated: req => pairingState.noteAuthenticated(req.socket?.remoteAddress),
408
+ selfDnsName: () => tailnetSelf.currentSelfDnsName(),
409
+ allowedPorts: () => [PORT, HTTPS_PORT],
376
410
  }))
377
411
  // Past the token gate, a request on a non-public path carried the token: mark a paired phone.
412
+ // Only the X-Cos-Token header counts: a browser signed in to COS Control Web passed the gate
413
+ // with its cookie, and treating that laptop or tab as a paired phone would point the nudger's
414
+ // phone checks at it (review note).
378
415
  app.use('/api', (req, _res, next) => {
379
- if (!isPublicApiRequest(req.method, req.path)) tailscaleNudger.noteRequest(req.socket?.remoteAddress, { authenticated: true })
416
+ if (!isPublicApiRequest(req.method, req.path) && typeof req.headers['x-cos-token'] === 'string') {
417
+ tailscaleNudger.noteRequest(req.socket?.remoteAddress, { authenticated: true })
418
+ }
380
419
  next()
381
420
  })
382
421
 
@@ -854,6 +893,12 @@ app.use('/api', createWorkReviewsRouter(workReviewRuntime))
854
893
  const workHandoffRequestStore = createOptionalWorkHandoffRequestStore(() => new WorkHandoffRequestStore(dataPath('work-handoff-requests')))
855
894
  // One bounded board read (5 s) shared by the glasses' Work glances and the request inbox; GET /api/work-board primes it.
856
895
  const workBoardReader = createWorkBoardReader(() => listBoard())
896
+ // Drop it P2: a card written through this server kicks the resolution poll (a few seconds, one poll per burst) instead
897
+ // of waiting up to 2 minutes. Mounted before the Work and tasks routers so it sees their answers; it never changes them.
898
+ app.use(['/api/work-board', '/api/tasks'], (req, res, next) => {
899
+ if (req.method !== 'GET') res.on('finish', () => { if (res.statusCode < 300) dropP2.runtime()?.kickPoll() })
900
+ next()
901
+ })
857
902
  app.use('/api', createWorkBoardRouter({ readBoard: workBoardReader, requests: () => handoffRequestCapabilities(workHandoffRequestStore, Date.now()) }))
858
903
  app.use('/api', createWorkHandoffRequestsRouter({ store: workHandoffRequestStore, readBoard: workBoardReader }))
859
904
  // Work intake (6.57.0): meeting-derived suggestions, asks and review items. Optional: a bad journal never blocks boot.
@@ -866,6 +911,85 @@ app.use('/api', createJevRouter(workReviewRuntime ? { review: async id => workRe
866
911
  app.use('/api', createDecisionProviderRouter())
867
912
  app.use('/api', createSubqKeyRouter())
868
913
  app.use('/api', createWorkSearchRouter())
914
+ // Drop it P1 (6.100 candidate): the pinned COS chat's drops, receipts and History. Off by default (COS_DROP_IT or
915
+ // PUT /api/drops/settings); its stores open on first use, so an install with it off writes nothing. The router is one
916
+ // Jev request per drop with its own cap (COS_DROP_DAILY_TOKENS), ledger, breaker and switch (COS_DROP_ROUTER).
917
+ // 6.99.11: P2 settings (data/drop-p2-settings.json), read through a 15 s cache; a PUT updates it at once.
918
+ const dropP2Settings = createP2SettingsReader()
919
+ const dropService = lazyDropService(() => openDropService(part => dataPath(part), {
920
+ p2Settings: () => dropP2Settings.read(),
921
+ router: createDropRouter(),
922
+ writers: createBridgeDropWriters(),
923
+ timezone: () => serverTimezone(),
924
+ // 6.99.13: a drop's files are pinned in the media store (lib/drop-attachments.ts); Undo removes them.
925
+ attachments: mediaDropAttachments(() => getMediaStore()),
926
+ askLookup: async (clientJobId, generation) => {
927
+ const job = await queryJobCoordinator.getByClientGeneration(clientJobId, generation)
928
+ return job ? { status: job.status, response: job.response, sessionId: job.sessionId } : undefined
929
+ },
930
+ }))
931
+ // Drop it P2 (6.99.11 candidate): pings, reminders and follow-ups, running only while Drop it is on (checked at boot and
932
+ // each minute). It reads signals that already exist (calendar, Work intake, the board, the Work journal, the thread and
933
+ // signal caches) and calls no model; History search by meaning shares the drop router's Jev client and cap.
934
+ const dropP2 = createDropP2Boot({
935
+ enabled: () => dropItEnabled(),
936
+ service: dropService,
937
+ pingRoot: dataPath('pings'),
938
+ make: (stores, pingState) => createDropP2Runtime({
939
+ ...stores, pingState,
940
+ timezone: () => serverTimezone(),
941
+ nextEvent: async () => { const r = await fetchNextEvent(); return r.read ? r.event : undefined },
942
+ intakeOpen: () => workIntakeStore?.open(),
943
+ scriptsDir: COS_SCRIPTS_DIR,
944
+ board: () => workBoardReader.read(60_000, 5_000),
945
+ journal: () => { const j = readWorkJournal(); return j.available ? journalReceiptsLite(j.receipts) : undefined },
946
+ emitLens: event => { emitDisplay(event) },
947
+ // Quiet while the server drains for an update or restart, as the morning brief is.
948
+ paused: () => gracefulShutdownStarted || !maintenanceAdmissionsOpen(),
949
+ settings: () => dropP2Settings.read(),
950
+ }),
951
+ })
952
+ // The turn after Send to session ended: its held drop gets one follow-up (replied, or stopped). Never throws.
953
+ sessionSignalStore.subscribe((signal, env, child) => {
954
+ try {
955
+ const outcome = sessionTurnOutcome(env.event, child)
956
+ if (outcome) dropP2.runtime()?.onSessionTurn(signal.sessionId, outcome, env.ts)
957
+ } catch { /* a follow-up is never worth breaking another subscriber */ }
958
+ })
959
+ const dropSearcher = new DropHistorySearcher(sharedDropJevClient())
960
+ app.use('/api', createDropsRouter({
961
+ enabled: () => dropItEnabled(),
962
+ service: dropService,
963
+ p2: () => dropP2.runtime(),
964
+ search: () => dropSearcher,
965
+ p2Settings: dropP2Settings,
966
+ routerStatus: () => {
967
+ const status = sharedDropJevClient().status()
968
+ return { on: dropRouterEnabled(), dailyCap: dropDailyCap(), usedToday: status.usedToday, breakerOpenUntil: status.breakerOpenUntil }
969
+ },
970
+ // 6.99.10: Work items in Your move count toward `waiting` (plan section 12). The board read is the one Work's own
971
+ // glances share (10 s old at most, 3 s bound), and the count is kept 30 s, so the summary poll adds no bridge load.
972
+ yourMove: createYourMoveCounter({
973
+ journal: () => readWorkJournal(),
974
+ rows: () => workBoardReader.read(10_000, 3_000).catch(() => null),
975
+ }),
976
+ }))
977
+ // 6.99.16: the COS thread (routes/cos-thread.ts): one size-capped conversation behind the COS chat's Ask, with a
978
+ // ledger to recover and search it. Gated with Drop it; its store opens on first use, so an install with it off writes
979
+ // nothing. It reads the Claude Code log tail of the thread's session and the drop store; it calls no model.
980
+ const cosThread = lazyCosThread(() => new CosThread({
981
+ dir: dataPath('cos-thread'),
982
+ context: claudeContextReader({ sessionExists: id => !!getSessionRaw(id), nativeFor: id => getCliSessionId(id) }),
983
+ nativeFor: id => getCliSessionId(id),
984
+ drops: (since, limit) => (dropService()?.recentDrops(since, limit) ?? []).map(d => ({ text: d.text, createdAt: d.createdAt, filed: describeDropFiling(d) })),
985
+ answerFor: (sessionId, clientId, messageNo) => {
986
+ const exchanges = getSessionRaw(sessionId)?.exchanges ?? []
987
+ const id = clientId.toLowerCase()
988
+ const hit = exchanges.find(e => e.role === 'assistant' && (e.clientJobId === id || (messageNo !== null && e.globalMsgNum === messageNo)))
989
+ return hit?.content
990
+ },
991
+ }))
992
+ app.use('/api', createCosThreadRouter({ enabled: () => dropItEnabled(), thread: cosThread }))
869
993
  // Find a session by meaning (Control 0.5.363): consent per provider, its own ledgers and breakers (lib/session-find.ts).
870
994
  app.use('/api', createSessionFindRouter())
871
995
  app.use('/api', createWorkChatRouter())
@@ -898,7 +1022,12 @@ app.use('/api', createSessionHooksRouter({ port: PORT }))
898
1022
  // 6.52.0: the permission broker's client API (the hook's door is mounted before the /api
899
1023
  // gate, above). Behind the API token; a poll counts as a live answerer only with
900
1024
  // `?client=glasses|phone`.
901
- app.use('/api', createSessionQuestionsRouter({ broker: permissionBroker, apiToken: () => API_TOKEN }))
1025
+ // 6.99.12: COS Control Web reads the questions with its browser session, checked exactly as the /api gate checks it.
1026
+ const webSessionCookieAuthorized = (req: express.Request): boolean => isWebSessionAuthorized(req, API_TOKEN, {
1027
+ selfDnsName: tailnetSelf.currentSelfDnsName(),
1028
+ allowedPorts: [PORT, HTTPS_PORT],
1029
+ })
1030
+ app.use('/api', createSessionQuestionsRouter({ broker: permissionBroker, apiToken: () => API_TOKEN, webSessionAuthorized: webSessionCookieAuthorized }))
902
1031
  // Phase 0 of Continue Original Agent Thread: can COS write into a desktop thread
903
1032
  // without colliding with a live writer? Read-only — it answers, it never attaches.
904
1033
  // Registered AFTER agentSessionsRouter deliberately: its paths are 2 and 4 segments
@@ -1305,6 +1434,7 @@ async function gracefulShutdown(): Promise<void> {
1305
1434
  stopRoutineScheduler()
1306
1435
  tailscaleNudger.stop()
1307
1436
  await workReviewRuntime?.close()
1437
+ dropP2.stop()
1308
1438
  workIntakeStore?.close()
1309
1439
  workHandoffRequestStore?.close()
1310
1440
  stopMeetingImportScheduler()
@@ -1361,6 +1491,34 @@ process.on('unhandledRejection', (reason: any) => {
1361
1491
  // Start HTTPS alongside HTTP — Even Hub WebView prefers HTTPS (iOS ATS).
1362
1492
  // Optional: drop cert.pem + key.pem in server/certs/ (e.g. via mkcert) to enable.
1363
1493
  const __dirname = path.dirname(fileURLToPath(import.meta.url))
1494
+ if (webControlEnabled()) {
1495
+ const webControlDir = path.join(__dirname, 'web-control')
1496
+ app.use('/control', (req, res, next) => {
1497
+ // Loopback and Tailscale only, unless COS_WEB_CONTROL_LAN=1 (review W2).
1498
+ if (!isWebControlSocketAllowed(req.socket?.remoteAddress, process.env, req.headers)) {
1499
+ res.status(403).type('text').send('COS Control Web is available on this Mac and over Tailscale.')
1500
+ return
1501
+ }
1502
+ res.setHeader('Content-Security-Policy', webControlCsp(req.path))
1503
+ res.setHeader('X-Content-Type-Options', 'nosniff')
1504
+ res.setHeader('Referrer-Policy', 'no-referrer')
1505
+ next()
1506
+ }, express.static(webControlDir))
1507
+ app.use('/api', createWebControlRouter({
1508
+ apiToken: () => API_TOKEN,
1509
+ serverVersion: () => managedServerVersion() ?? 'unknown',
1510
+ port: () => PORT,
1511
+ httpsPort: () => existsSync(path.join(__dirname, 'certs', 'cert.pem')) ? HTTPS_PORT : null,
1512
+ selfDnsName: () => tailnetSelf.currentSelfDnsName(),
1513
+ // A glasses pairing code typed on the web sign-in page gets its own message (read only).
1514
+ glassesCodeIsLive: code => pairingState.isLiveCode(code),
1515
+ liveGlassesCodeKey: () => pairingState.status().code?.expiresAt ?? null,
1516
+ queryTailscaleStatus: async () => {
1517
+ const name = await tailnetSelf.refresh()
1518
+ return name ? { backendState: 'Running', selfIps: [], selfDnsName: name, certDomains: [] } : null
1519
+ },
1520
+ }))
1521
+ }
1364
1522
  /**
1365
1523
  * Let a slow large upload finish instead of killing its socket mid-body.
1366
1524
  *
@@ -1389,7 +1547,6 @@ function withRequestTimeout<T extends { requestTimeout: number }>(server: T): T
1389
1547
  return server
1390
1548
  }
1391
1549
 
1392
- const HTTPS_PORT = parseInt(process.env.HTTPS_PORT ?? '3143', 10)
1393
1550
  const certDir = path.join(__dirname, 'certs')
1394
1551
  const listeners: RequiredListener[] = []
1395
1552
  if (existsSync(path.join(certDir, 'cert.pem'))) {
@@ -1604,6 +1761,8 @@ listenRequiredServers(listeners).then(() => {
1604
1761
  // still mounted, so the kill switch is an immediate rollback.
1605
1762
  console.error('[COS API] Durable query-job store unavailable:', error)
1606
1763
  })
1764
+ // Drop it P2: off unless Drop it is on; the boot re-checks the switch each minute.
1765
+ dropP2.start()
1607
1766
 
1608
1767
  if (COS_MODE) {
1609
1768
  initSessionCache()