@gotcos/glasses-server 6.79.1 → 6.80.0

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 (74) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +98 -0
  3. package/README.md +20 -1
  4. package/managed-runtime-contract.json +6 -1
  5. package/package.json +9 -1
  6. package/server/index.ts +11 -0
  7. package/server/lib/agent-session-search.ts +8 -5
  8. package/server/lib/agent-session-store.ts +45 -4
  9. package/server/lib/archive.ts +2 -1
  10. package/server/lib/attached-provider-adapter.ts +11 -0
  11. package/server/lib/claude-bridge.ts +112 -8
  12. package/server/lib/claude-settings-guard.ts +94 -0
  13. package/server/lib/claude-steer.ts +27 -0
  14. package/server/lib/codex-bridge.ts +205 -32
  15. package/server/lib/codex-engine-sessions.ts +27 -6
  16. package/server/lib/codex-homes.ts +134 -0
  17. package/server/lib/codex-live-queue.ts +14 -0
  18. package/server/lib/codex-profile-home.ts +452 -0
  19. package/server/lib/codex-queue-control.ts +17 -1
  20. package/server/lib/codex-subagent-activity.ts +22 -0
  21. package/server/lib/conversation.ts +92 -15
  22. package/server/lib/dictation-clean.ts +13 -6
  23. package/server/lib/dictation-fillers.ts +17 -10
  24. package/server/lib/dictation-polish-need.ts +48 -0
  25. package/server/lib/dictation-prewarm.ts +209 -0
  26. package/server/lib/direct-bridge.ts +853 -0
  27. package/server/lib/direct-sse.ts +262 -0
  28. package/server/lib/fork-thread.ts +11 -0
  29. package/server/lib/key-answer-fields.ts +49 -0
  30. package/server/lib/key-catalog.ts +301 -0
  31. package/server/lib/key-lanes-pricing.ts +12 -0
  32. package/server/lib/key-lanes-wiring.ts +346 -0
  33. package/server/lib/key-lanes.ts +145 -0
  34. package/server/lib/key-ledger.ts +123 -0
  35. package/server/lib/key-notices.ts +486 -0
  36. package/server/lib/key-profile-rejects.ts +25 -0
  37. package/server/lib/key-profile-routing.ts +173 -0
  38. package/server/lib/key-profiles.ts +182 -0
  39. package/server/lib/key-relay-contract.ts +262 -0
  40. package/server/lib/key-relay-errors.ts +211 -0
  41. package/server/lib/key-relay-meter.ts +303 -0
  42. package/server/lib/key-relay.ts +1018 -0
  43. package/server/lib/key-secrets-guard.ts +52 -0
  44. package/server/lib/key-store.ts +228 -0
  45. package/server/lib/keyed-sessions.ts +154 -0
  46. package/server/lib/lane-b-env.ts +169 -0
  47. package/server/lib/live-cues-v2-settings.ts +51 -13
  48. package/server/lib/live-cues-v2.ts +147 -19
  49. package/server/lib/model-router.ts +96 -7
  50. package/server/lib/morning-brief-config.ts +3 -0
  51. package/server/lib/native-head.ts +13 -1
  52. package/server/lib/occupancy-probes.ts +4 -0
  53. package/server/lib/ollama-run-ledger.ts +96 -13
  54. package/server/lib/prompt-edit.ts +2 -0
  55. package/server/lib/query-job-coordinator.ts +2 -1
  56. package/server/lib/query-job-runtime.ts +67 -15
  57. package/server/lib/query-job-store.ts +7 -1
  58. package/server/lib/query-job-types.ts +15 -1
  59. package/server/lib/session-hook-events.ts +28 -0
  60. package/server/lib/session-hook-ledger.ts +10 -0
  61. package/server/lib/thread-occupancy.ts +22 -1
  62. package/server/lib/token-audit.ts +18 -4
  63. package/server/lib/work-handoff-requests.ts +3 -0
  64. package/server/routes/agent-session-bindings.ts +15 -0
  65. package/server/routes/agent-sessions.ts +17 -5
  66. package/server/routes/handoffs.ts +22 -1
  67. package/server/routes/health.ts +5 -0
  68. package/server/routes/key-lanes.ts +442 -0
  69. package/server/routes/live-cues.ts +39 -3
  70. package/server/routes/openai-compat.ts +28 -3
  71. package/server/routes/prompt-drafts.ts +75 -8
  72. package/server/routes/query.ts +20 -6
  73. package/shared/key-preference.ts +57 -0
  74. package/shared/model-preference.ts +20 -2
package/.env.example CHANGED
@@ -417,3 +417,12 @@ COS_CONTINUE_FULL_PERMISSIONS=1
417
417
  # COS_PIN_BOOT_REAP=1 # free a thread a crashed run held, 31 min after the restart and once its child is gone (default on; 0 = the 24 h reap)
418
418
  # COS_QUEUE_RESEND=1 # Send again on a refused or expired queued turn (default on)
419
419
  # COS_SESSION_STEER=0 # steer a busy Claude session through its peer inbox (default off)
420
+
421
+
422
+ # ── Answer with your own key (6.80.0, opt in) ──────────────────────────────────
423
+ # Off by default. With this unset, nothing about key lanes runs until COS Control turns them on
424
+ # from its Settings row (that writes data/key-lanes.json). 1 turns them on from the environment;
425
+ # 0 forces them off and wins over Control. Keys are saved only through COS Control on this Mac,
426
+ # in data/key-secrets/ (0700). Your key is saved on this Mac: apps and agents that run on this
427
+ # Mac as you can read it, so your provider's own spending limit is the real stop.
428
+ # COS_KEY_LANES=0
package/CHANGELOG.md CHANGED
@@ -1,3 +1,101 @@
1
+ ## 6.80.0 - Answer with your own key (opt in, off by default)
2
+
3
+ You can now give COS your own provider key (OpenRouter, Anthropic, OpenAI, or a compatible endpoint) and pick a key profile for a thread, so that thread answers on the key instead of your Claude or ChatGPT plan. **This is opt in. Without a key, nothing changes:** with `COS_KEY_LANES` unset or `0`, or with lanes on but no key profile saved, the server answers, spawns and writes exactly as 6.79.1 did. Turning it on and saving keys happens in COS Control on this Mac.
4
+
5
+ - **Client matrix.** Server only in this release. COS Control and the companion need their key-lane builds to show the setting, the picker entries and the cost label; older apps never call the new routes and see nothing new. `COS_KEY_LANES=0` (now forwarded to a managed server) forces everything off whatever Control saved.
6
+ - **How a keyed answer runs.** The real key stays inside the server. A keyed Claude or Codex turn talks to a loopback relay (127.0.0.1, an ephemeral port, opened only by the first keyed turn) with a one-turn token; the relay checks it, meters the turn against the key's daily cap, forwards with the key and settles what the answer cost. A direct profile (any OpenRouter model, or a compatible endpoint) answers in process through the same meter and breakers. A keyed turn never falls back to your plan: if the key, the relay or the cap says no, the lens says why and your plan is not used.
7
+ - **What the routes show.** While lanes are on: `/api/key-lanes/*` (status, keys, profiles, catalog, remove all, disable), and `details.keyLanes` on `/api/health`. Status is readable from the phone with the pairing token (cap, today's spend, the UTC reset time as `resetsAt`, never the key); every write stays Mac only. With a key profile saved: `keyProfiles` on `/api/models`, and a keyed answer's `done` event carries `lane`, `credential` (kind, key ref, profile id), `profile` and `usd`. The one route present while lanes are off is `POST /api/key-lanes/enable`, for COS Control on this Mac (token, loopback, no browser).
8
+ - **Errors say what happened.** When the key itself stops a turn (daily cap used, key rejected, out of credit, the model gone, or the key paused after three failures), the lens, the job record and COS Control show the matching one-line reason instead of the CLI's raw error. A turn refused before it starts reads the same way.
9
+ - **Recovered answers keep their cost.** A durable job answered on a key keeps `lane`, `credential`, `profile` and `usd` on its record and through a restart, so an answer the app recovers later still shows what it cost. A job on your plan writes exactly the record it did before.
10
+ - **Keyed Codex threads in Sessions.** With lanes on, a Codex thread that ran on a key is listed, named, searchable and shows its subagents like any other. With lanes off, only the usual Codex home is read.
11
+ - **Where the key lives, plainly.** Keys are saved in `data/key-secrets/provider-keys.json` (0600, in a 0700 folder of their own). While key lanes are on and a key is saved, every Claude run that can use tools is told it may not Read, Edit, Write or `cat` that folder (checked against the real Claude Code 2.1.304: both a Read and a Bash `cat` come back denied). Codex has no way to exclude a folder from what it can read. So the honest statement, which COS Control also shows: Your key is saved on this Mac. Apps and agents that run on this Mac as you can read it. Your provider's spending limit is the real stop. OpenRouter keys still need a credit limit set; Anthropic and OpenAI keys have no provider-side daily backstop, so the cap here is the only one.
12
+ - **The cap is checked mid-reply, and is not a hard stop.** A request reserves its own max_tokens within what the key has left today, and a reply whose running cost would pass the cap is cut and settled at what the provider billed. The cap can be exceeded by the last reply in progress: the provider bills what it already generated, so the overshoot is bounded by that one response's max_tokens (a real-key cut late in a 14k-token reply went $0.003 past $0.0065 of headroom). Your provider limit is the hard stop. A spend that a crash left open still counts after a restart.
13
+ - **More you can do from COS Control.** Reset a key paused after three failures (`POST /api/key-lanes/breaker/reset`); turning a profile off, or changing its key or model, stops its live turn at once, and a new key or model clears the profile's pause. The one-turn "run this one on your key" offer (`keyTurnOnly`) answers that turn on the key without moving the thread onto it.
14
+ - **Tighter edges.** Custom endpoints must be https unless they are on this Mac. Claude managed settings and `CLAUDE_CONFIG_DIR` are checked, and OAuth and Bedrock token names are never passed to a keyed run; a Codex project config that sets a provider refuses a keyed Codex turn. Rate limits, provider errors, refusals and an expired or stopped key turn each read as their own one-line reason.
15
+ - **Fixed from the real-key canaries.** A keyed Claude run now forces agent teams off even when a project settings file turns them on (checked with the real CLI). Parallel subagents on one keyed turn wait for a free slot (16 at once, then a queue) instead of failing, and a turn that still cannot get one reads "Too many steps at once. Your plan was not used." A Codex failure now costs at most 3 attempts on the key, and the key turn ends the moment the answer does. Cached prompt tokens are priced at their real rates, so a reply that fits under the cap is no longer cut, and a cut reply is reconciled to its real cost. Hook events of a keyed Claude session or Codex thread now carry their key profile, also on the `session-hook-events.jsonl` rows (resolved from the keyed-session record the bridge wrote; rows with lanes off are unchanged). Claude Code's reachability probe gets an empty answer instead of a 404.
16
+ - **Safety rails.** Daily cap per key, shared by every profile, turn and lane on it; the cap Control saved wins over any later edit of the key file. Three rejected-key, out-of-credit or gone-model failures pause that key or profile for an hour. Error bodies are scrubbed of the key. A session that ran on a key is never resumed, handed off, continued or forked on your plan (Continue and Fork answer the same 409 `keyed_session` body as handoffs). A keyed Codex thread keeps its own resume slot, so switching lanes never loses the other lane's thread. Scheduled briefs, task dispatch and Work drafts never run on a key.
17
+ - **Known limit (not from key lanes).** When a turn starts background subagents, the CLI can emit a first `result` before they finish ("Eight of the 12 have reported...") and a full one later. COS ends the turn on the first `result`, so the lens gets the partial answer. This is the same on your plan at 6.79.1 (a0035a8) and 6.79.2 (ba4fc66), in normal and steer mode (`claude-bridge-two-results.test.ts` passes on both unchanged), and a keyed turn runs with the same arguments as a plan turn (no stream-json input), so it is recorded here and not changed in this candidate.
18
+ - **Checks.** Full suite 8,399 passed, 0 failed, 3 skipped (553 files), every test that passed at ba4fc66 (6.79.2, 7,962) still passing by name; `tsc --noEmit` clean. Combined gate `server/scripts/key-lanes-mutation-gate.mjs` (not packaged): P1 48, P2 36, P3 34, P4 38, P5 28, P6 30, P8 22 and 104 integration mutants, every one killed by a named test against a green baseline. The off-golden re-recorded at ba4fc66 (6.79.2, a clean tree with no key code) compares each CLI's normalized stdin; it replays with an empty diff at `COS_KEY_LANES=0`; unset, the only difference is the Control enable door; at `=1` with no profile, exactly that door plus `details.keyLanes` and the status route. Journeys (`key-lanes-journeys.ts`, booting `server/index.ts` against a loopback mock): 24 of 24. `npm pack --dry-run` ships no test, gate or fixture.
19
+
20
+ ## 6.79.2 - Live cue limits from COS Control: the bar, cues per meeting, cooldown and a daily writer ceiling, applied without a restart
21
+
22
+ Miles, 2026-10-19: cap live-cue spend from COS Control. On 10/19, live cues ran 126 Sonnet writer calls (about 310K writer tokens) across 7 meetings.
23
+
24
+ - **Client matrix.**
25
+ - COS Control 0.5.328 (or later) reads and sets the limits.
26
+ - Control 0.5.327 and older keep working byte for byte: GET and POST without a version answer exactly the 6.79.1 body.
27
+ - No companion change is required. The lens shows a meeting stopped by the daily ceiling as `budget_exhausted`, a reason it already knows.
28
+ - **Four limits, stored in `live-cues-v2-settings.json`** (atomic write, mode 0600, the file that holds the on/off switch). They apply from each meeting's next evaluation, with no restart.
29
+ - `gate`: the gate's bar, 0.5 to 0.95. Default 0.8.
30
+ - `maxCuesPerMeeting`: 1 to 25, default 25. The writer cap per meeting stays twice this.
31
+ - `cooldownSeconds`: whole seconds, 20 to 900. Default 45.
32
+ - `dailyWriterCalls`: 1 to 500, default 150. **This is a new ceiling**: writer calls per local calendar day across all meetings. The weekly ceiling is unchanged, so this caps today at 150 writer calls a day where before only the weekly 750 applied.
33
+ - A value saved out of bounds (a hand edit) reads as unset, never as a looser limit.
34
+ - A file holding only the switch is still written as schema 1, so a 6.79.1 server reads it as before. Any saved limit makes it schema 2, which 6.79.1 also reads (it looks only at `enabled`).
35
+ - **Env wins and locks.** `COS_LIVE_CUES_V2_GATE` (its acceptance unchanged, 0.3 to 0.99), `COS_LIVE_CUES_V2_MAX_PER_MEETING` (new, whole 1 to 25), `COS_LIVE_CUES_V2_COOLDOWN_S` and `COS_LIVE_CUES_V2_DAILY_WRITER_CALLS` (new, whole 1 to 500).
36
+ - A valid env value is reported `source: "env", locked: true`.
37
+ - A POST naming a locked limit is 409 `env_locked` and saves nothing.
38
+ - An env value outside its range does not lock: the saved setting or the default applies, with one log line per value.
39
+ - Both new variables are in `managed-runtime-contract.json` `optionalEnvironment`.
40
+ - **Engine.**
41
+ - The gate, cue cap, writer cap and cooldown are read from the resolver on every feed and evaluation.
42
+ - A meeting already past a lowered cap stops at its next feed (skip `meeting_cap`, no gate call), and resumes if the cap is raised.
43
+ - The daily ceiling is checked before the gate (skip `daily_cap`, so no Jev spend either).
44
+ - Both the cap and the daily ceiling are checked again just before the writer is charged, so a limit lowered mid-evaluation applies at once (outcome `meeting_cap` or `daily_cap`).
45
+ - On the wire the daily ceiling reads as `budget_exhausted`: the shared stop-reason union, pinned with the companion's copy, has no `daily_cap`.
46
+ - Cue generation, prompts and the judge are unchanged.
47
+ - **Today's usage.** The durable state file gains `day` and `today` `{writerCalls, writerTokensIn, writerTokensOut, cues, jevTokens}` on the local calendar day of the zone it already stores.
48
+ - Writer calls count when charged, cues when shown, and tokens once per evaluation from the numbers it traced. `jevTokens` is the gate plus the judge, from the decision provider that ran them: Jev, or SubQ when SubQ runs cues.
49
+ - A future-dated, malformed or unreadable day reads its writer calls as spent, like the week: it fails toward not spending.
50
+ - A pre-6.79.2 file reads as zero for today.
51
+ - **Routes.** `GET /api/live-cues/v2/settings?v=2` and `POST {"v": 2, ...}` answer the v1 body plus `v`, `limits`, `writerCallsPerMeeting` and `today`.
52
+ - Each limit is reported as `{value, source, locked, min, max, default}`.
53
+ - POST v2 accepts any subset of `enabled`, `gate`, `maxCuesPerMeeting`, `cooldownSeconds` and `dailyWriterCalls`. Every field is checked before one write, so a refused request changes nothing.
54
+ - 400 `invalid_request` with `reason`: `out_of_bounds` (with `field`, `min`, `max`, `integer`), `unknown_field`, `invalid_enabled`, `empty` or `unsupported_version`.
55
+ - 409 `env_locked` (with `fields`), or `env_forced` when turning v2 off while env forces it on.
56
+ - An older server ignores `?v=2` and answers without `v`: that is Control's feature detection.
57
+ - **Dictation filler pass (from `320ecb7`, which shipped after 6.79.1 was published from `a0035a8`).**
58
+ - **Titles and abbreviations.** A Titlecase token right after a title or abbreviation (Mr., Mrs., Ms., Dr., Prof., St., Jr., Sr., vs., etc., e.g., i.e., a.m., p.m., U.S., an initial) is a name and is kept: "We met Mr. Ah and Ms. Er yesterday" and "Dr. Er said yes" stay as said.
59
+ - A lowercase filler there still goes, with no capital hand-off: "We met Mr. uh Jones" becomes "We met Mr. Jones", and "at 5 p.m. uh we meet" becomes "at 5 p.m. we meet". In 6.79.1 every token after a title was kept, so "Mr. uh Jones" kept the "uh", and Jr. and Sr. were not abbreviations.
60
+ - **Sentence ends.** An ellipsis does not end a sentence. The capital passes on only when the removed filler was itself a sentence's first word. The abbreviation check that became unreachable once the rule above runs first is removed.
61
+ - Dictation mutation gate `server/scripts/dictation-polish-mutation-gate.mjs`: 55 of 55 against a 133-test baseline.
62
+ - **Faster dictation polish: start the polish process at key press.**
63
+ - **Why.** Miles, 2026-10-19: polish takes 1.8 to 4.1 s with Haiku (median about 2.4 s). A trivial `claude -p` with the polish args takes 1.6 to 1.8 s, so most of the wait is the CLI starting, not the edit.
64
+ - **Canary on his Mac (Haiku, 9 real calls).** Cold: 1786, 1894, 1886, 1968 ms. A process started 3 to 5 s before the text answered 962 to 1305 ms after the text arrived; one started 1 s before took 1501 ms, because the CLI had not finished starting.
65
+ - **`POST /api/dictation/polish/prewarm`** `{model, surface?}` answers `{token, expiresMs}`. It spawns the exact polish process now (same args: no tools, MCP, hooks or session file, its own empty workspace) with `--input-format stream-json`, waiting for one message.
66
+ - Refusals are 409 `{error: "prewarm_unavailable", reason}`, with reason `disabled`, `model` (Luna cannot be warmed), `breaker_open`, `daily_cap` or `cli`.
67
+ - `DELETE /api/dictation/polish/prewarm/<token>` discards one.
68
+ - **`/api/dictation/finalize` takes an optional `prewarmToken`.**
69
+ - A live token for the same model gets the one message, then its stdin is closed and it exits: no history ever carries over.
70
+ - A process that died before the message falls back to today's cold spawn, the same single call. A failure after the message was sent is a real `failed`, never retried cold.
71
+ - With a token, the answer adds `prewarm`: `used`, `missed` (unknown, expired, evicted, wrong model or dead) or `discarded` (the model was not called, so the process was killed).
72
+ - A malformed token is 400 `invalid_prewarm_token`.
73
+ - **Bounds.**
74
+ - At most 2 warm processes; a third evicts the oldest.
75
+ - Each is killed 150 s after it starts if unused.
76
+ - Every live one is signalled when the server exits, claimed or not.
77
+ - The daily polish budget is spent only when one is used; a warm process makes no model call until its message.
78
+ - `COS_DICTATION_PREWARM=0` turns it off.
79
+ - **Clients.** No companion change. Control needs a change to call it, specified in the COS workspace (`wk43_2026/dictation_polish/PREWARM_CONTROL_SPEC.md`). Control 0.5.328's polish bridge ignores the new answer keys.
80
+ - **Skip the model when the text is clean (off by default).**
81
+ - **The rule.** With `COS_DICTATION_POLISH_SKIP_CLEAN=1`, finalize returns the filler-passed text with `polishSkipped: "clean"` unless the text has any of these:
82
+ - a backtrack marker
83
+ - a repeated word or restarted clause
84
+ - a filler-like ("like", "you know", "kind of", or a leading "So", "Well" or "Okay")
85
+ - a filler sound
86
+ - spoken formatting or punctuation
87
+ - no opening capital or no closing punctuation
88
+ - more than 40 words
89
+
90
+ The rule is `polishNeed` in `server/lib/dictation-polish-need.ts`, with a table test.
91
+ - **Eval on the 45 synthetic cases.**
92
+ - As dictated (lowercase, unpunctuated), all 45 still reach the model, so nothing changes.
93
+ - Capitalized with a closing period, as Mac dictation arrives, 15 would skip. 3 of those lose a word fix that v2 makes on both models: mis-heard names ("tablo" for Tableau and "jewel three sixty" for Jewel360, "preeya" for Priya and "hub spot" for HubSpot, "p o s" for POS). No rule can see those.
94
+ - It therefore stays off.
95
+ - **Managed runtime.** `COS_DICTATION_PREWARM` and `COS_DICTATION_POLISH_SKIP_CLEAN` are in `managed-runtime-contract.json` `optionalEnvironment`.
96
+ - **Dictation mutation gate** (`server/scripts/dictation-polish-mutation-gate.mjs`, not packaged): 92 of 92 against a 199-test baseline in 7 files. 37 of them are new for the prewarm and the skip rule.
97
+ - **Checks.** Full suite 7,896 passed, 3 skipped (510 files); `tsc --noEmit` clean. Mutation gate `server/scripts/live-cues-verdicts-mutation-gate.mjs` (not packaged): 179 of 179 guard mutants killed by named tests against a proven green baseline (149 tests in 8 files), 48 of them new for the limits. The existing pins (prompts, contract hash, calibrated defaults, the v1 settings body and its schema-1 file) are unchanged.
98
+
1
99
  ## 6.79.1 - Dictation polish: one budget per caller, a reason when it does not polish, no more um and uh
2
100
 
3
101
  Miles, 2026-10-19: AI polish for Mac dictation failed on every dictation from 11:39 CDT (`failed:not_polished` in about 60 ms). Every autoclean shared one daily cap (`COS_DICTATION_AUTOCLEAN_MAX_PER_DAY`). The glasses' live meeting lines (surface `meeting`, Sonnet, one call per 10-second chunk while the phone's Moonshine lane is up) spent it by late morning. Nothing in the answer said why.
package/README.md CHANGED
@@ -787,10 +787,19 @@ VOICE) follow the same settings. Contract and measurements:
787
787
  - Messages and prompt drafts drop standalone um, uh, er, ah, hmm, mm and mhm
788
788
  before the model and when it is skipped. Meeting lines are left as heard.
789
789
  It keeps ALL-CAPS tokens, a Titlecase token mid-sentence (a name) unless a
790
- comma follows it, any token after a title (Mr., Dr.) and quoted mentions.
790
+ comma follows it, a Titlecase token after a title or abbreviation (Mr. Ah,
791
+ Dr. Er) and quoted mentions.
791
792
  `COS_DICTATION_FILLER_PASS=0` turns the pass off.
792
793
  - `COS_DICTATION_POLISH_PROMPT=v1` restores the 6.79.0 polish prompt (default
793
794
  `v2`).
795
+ - 6.79.2: `POST /api/dictation/polish/prewarm` `{model}` starts the polish
796
+ process at key press and answers `{token, expiresMs}`. Pass the token as
797
+ `prewarmToken` to `/api/dictation/finalize` and the polish skips the CLI's
798
+ start-up (about 0.9 s on a Mac). At most 2 warm processes, 150 s each, one
799
+ message each. `COS_DICTATION_PREWARM=0` turns it off.
800
+ - 6.79.2: `COS_DICTATION_POLISH_SKIP_CLEAN=1` returns finished, clean text
801
+ without the model (`polishSkipped: "clean"`). Off by default: it cannot see
802
+ a mis-heard name.
794
803
 
795
804
  ### Parakeet (FluidAudio CoreML, offline)
796
805
 
@@ -1036,6 +1045,16 @@ usable on Fast and reports the downgrade truthfully. Set
1036
1045
  `COS_BATCH_LARGE_V3=0` to explicitly use turbo. Interactive HQ uses beam 2 by
1037
1046
  default (`COS_HQ_BEAM_INTERACTIVE`); meeting batch remains beam 5.
1038
1047
 
1048
+ ## Answer with your own key (6.80.0, opt in)
1049
+
1050
+ Off by default, and without a key nothing changes. Turn it on in COS Control (Settings, "Answer with your own key"), or set `COS_KEY_LANES=1`; `COS_KEY_LANES=0` forces it off.
1051
+
1052
+ - **What it does.** You save a provider key (OpenRouter, Anthropic, OpenAI or a compatible endpoint) in COS Control and make a key profile: which harness answers (Claude Code, Codex, or a direct call), which model, and a daily cap. A thread you put on that profile answers on the key instead of your plan. It never falls back to your plan: when the key, the cap or the provider says no, the lens says why.
1053
+ - **Where the key lives.** In `data/key-secrets/provider-keys.json` (0600, in a 0700 folder), used only by the server's loopback relay, which gives each keyed turn a one-turn token instead of the key. Every Claude spawn that can use tools gets deny rules for that folder. Codex has no sandbox read exclusion. Plainly: your key is saved on this Mac, apps and agents that run on this Mac as you can read it, and your provider's spending limit is the real stop. OpenRouter keys must have a credit limit set; Anthropic and OpenAI keys have no provider-side daily backstop, so the cap here is the only one.
1054
+ - **Spend.** A daily cap per key (UTC day), shared by every profile on it. A reply that would pass the cap is cut and the lens shows the cap line. The cap can be exceeded by the last reply in progress (by at most that reply's max_tokens); your provider limit is the hard stop. `GET /api/key-lanes/status` shows cap, today's spend and the reset time.
1055
+ - **Routes** (all 404 while off, except the enable door COS Control uses): `/api/key-lanes/status`, `keys`, `profiles`, `catalog`, `breaker/reset`, `remove-all`, `enable`, `disable`. Writes answer only COS Control on this Mac (token, loopback, no browser).
1056
+ - **Before you downgrade** below 6.80, remove your keys in COS Control. An older server cannot see them and would answer a keyed thread on your plan.
1057
+
1039
1058
  ## Run from source
1040
1059
 
1041
1060
  ```bash
@@ -33,6 +33,8 @@
33
33
  "COS_LIVE_CUES_V2_COOLDOWN_S",
34
34
  "COS_LIVE_CUES_V2_JUDGE_KEEP",
35
35
  "COS_LIVE_CUES_V2_REPEAT_OVERLAP",
36
+ "COS_LIVE_CUES_V2_MAX_PER_MEETING",
37
+ "COS_LIVE_CUES_V2_DAILY_WRITER_CALLS",
36
38
  "COS_LIVE_CUES_JEV_DAILY_TOKENS",
37
39
  "COS_LIVE_CUES_SUBQ_DAILY_TOKENS",
38
40
  "COS_DECISION_PROVIDER_CUES",
@@ -72,12 +74,15 @@
72
74
  "COS_PIN_BOOT_REAP",
73
75
  "COS_QUEUE_RESEND",
74
76
  "COS_SESSION_STEER",
77
+ "COS_KEY_LANES",
75
78
  "COS_DICTATION_AUTOCLEAN_MAX_PER_DAY",
76
79
  "COS_DICTATION_AUTOCLEAN_MAX_PER_DAY_MESSAGE",
77
80
  "COS_DICTATION_AUTOCLEAN_MAX_PER_DAY_MEETING",
78
81
  "COS_DICTATION_AUTOCLEAN_MAX_PER_DAY_PROMPT_DRAFT",
79
82
  "COS_DICTATION_POLISH_PROMPT",
80
- "COS_DICTATION_FILLER_PASS"
83
+ "COS_DICTATION_FILLER_PASS",
84
+ "COS_DICTATION_PREWARM",
85
+ "COS_DICTATION_POLISH_SKIP_CLEAN"
81
86
  ],
82
87
  "maintenance": {
83
88
  "scope": "cross_boot",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.79.1",
3
+ "version": "6.80.0",
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": {
@@ -73,6 +73,14 @@
73
73
  "!server/scripts/transcript-cleanup-mutation-gate.mjs",
74
74
  "!server/scripts/live-cues-v2-notes-mutation-gate.mjs",
75
75
  "!server/scripts/live-cues-verdicts-mutation-gate.mjs",
76
+ "!server/scripts/key-lanes-p1-mutation-gate.mjs",
77
+ "!server/scripts/key-lanes-p2-mutation-gate.mjs",
78
+ "!server/scripts/key-lanes-p3-mutation-gate.mjs",
79
+ "!server/scripts/key-lanes-p4-mutation-gate.mjs",
80
+ "!server/scripts/key-lanes-p5-mutation-gate.mjs",
81
+ "!server/scripts/key-lanes-p6-mutation-gate.mjs",
82
+ "!server/scripts/key-lanes-p8-mutation-gate.mjs",
83
+ "!server/scripts/key-lanes-mutation-gate.mjs",
76
84
  "!server/scripts/dictation-polish-eval.ts",
77
85
  "!server/scripts/dictation-polish-mutation-gate.mjs",
78
86
  "!server/lib/meeting-engine/__fixtures__/**",
package/server/index.ts CHANGED
@@ -79,6 +79,8 @@ import { createTranscriptCleanupRouter } from './routes/transcript-cleanup.js'
79
79
  import { meetingsRouter } from './routes/meetings.js'
80
80
  import { openaiCompatRouter } from './routes/openai-compat.js'
81
81
  import { openaiKeyRouter } from './routes/openai-key.js'
82
+ import { createKeyLanesBodyParser, createKeyLanesRouter } from './routes/key-lanes.js'
83
+ import { wireKeyLanes } from './lib/key-lanes-wiring.js'
82
84
  import { firefliesKeyRouter } from './routes/fireflies-key.js'
83
85
  import { meetingImportRouter } from './routes/meeting-import.js'
84
86
  import { startMeetingImportScheduler, stopMeetingImportScheduler } from './lib/meeting-import.js'
@@ -478,6 +480,10 @@ app.use('/api', (req, res, next) => {
478
480
  }
479
481
  })
480
482
 
483
+ // Key lanes (plan v4 3.1): parses its own small body ahead of the 10 MB parser, and only for a
484
+ // request the key-lanes router will answer. Every other request passes through untouched.
485
+ app.use('/api/key-lanes', ...createKeyLanesBodyParser({ apiToken: () => API_TOKEN }))
486
+
481
487
  // Authenticate before parsing large upload bodies. The 16 MB allowance stays
482
488
  // scoped to /api/media; every other route retains the 10 MB ceiling.
483
489
  app.use('/api/media/file', mediaBinaryBodyParser)
@@ -1165,6 +1171,11 @@ app.use('/api', memoryRouter)
1165
1171
  app.use('/api', threadsRouter)
1166
1172
  app.use('/api', skillsRouter)
1167
1173
  app.use('/api', openaiKeyRouter)
1174
+ // Key lanes (plan v4 3.1). Off (the default): every /api/key-lanes path falls through to the
1175
+ // usual 404, except an authenticated loopback POST /api/key-lanes/enable from COS Control.
1176
+ app.use('/api', createKeyLanesRouter({ apiToken: () => API_TOKEN }))
1177
+ // P11: fill the key-lane seams (map writes only; the relay's status seams only while enabled).
1178
+ wireKeyLanes()
1168
1179
  // Fireflies: the key, and importing meetings this Mac did not record (6.47.0).
1169
1180
  app.use('/api', firefliesKeyRouter)
1170
1181
  app.use('/api', meetingImportRouter)
@@ -40,8 +40,9 @@ import {
40
40
  codexUserText,
41
41
  listCodexJsonlFiles,
42
42
  loadClaudeStarredIds,
43
- loadCodexPinnedIds,
44
- loadCodexThreadNames,
43
+ codexSessionRootsOf,
44
+ loadCodexPinnedIdsAll,
45
+ loadCodexThreadNamesAll,
45
46
  loadCursorComposerNames,
46
47
  loadCursorPinnedIds,
47
48
  parseJsonLine,
@@ -314,10 +315,12 @@ async function collectClaudeDocs(roots: AgentSessionRoots, docs: SearchDoc[], bu
314
315
  }
315
316
 
316
317
  async function collectCodexDocs(roots: AgentSessionRoots, docs: SearchDoc[], budget: ScanBudget) {
317
- const names = await loadCodexThreadNames(roots.codexSessions)
318
- const pinned = await loadCodexPinnedIds(roots.codexSessions)
318
+ // Key lanes (P11): every Codex home, the server home first. One root with lanes off.
319
+ const names = await loadCodexThreadNamesAll(roots)
320
+ const pinned = await loadCodexPinnedIdsAll(roots)
319
321
  const candidates: Array<{ file: string; mtimeMs: number; birthtimeMs: number }> = []
320
- for (const file of await listCodexJsonlFiles(roots.codexSessions)) {
322
+ const files = (await Promise.all(codexSessionRootsOf(roots).map(root => listCodexJsonlFiles(root)))).flat()
323
+ for (const file of files) {
321
324
  const st = await fileStat(file)
322
325
  if (!st?.isFile) continue
323
326
  candidates.push({ file, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs })
@@ -12,6 +12,7 @@ import { join } from 'node:path'
12
12
  import { createInterface } from 'node:readline'
13
13
  import { promisify } from 'node:util'
14
14
  import { codexUserAsk, unwrapPeerMessage } from './session-stream-events.js'
15
+ import { profileCodexHomeSubdirs } from './codex-homes.js'
15
16
 
16
17
  const execFileAsync = promisify(execFile)
17
18
 
@@ -66,6 +67,12 @@ export interface AgentSessionRoots {
66
67
  cursorChats: string
67
68
  cursorComposerDb: string
68
69
  cursorWorkspaceStorage: string
70
+ /**
71
+ * Key lanes K1 (plan v4 8.2): `data/codex-homes/<id>/sessions` for each keyed Codex profile
72
+ * home. Present only while key lanes are on and a profile home exists; absent otherwise, so
73
+ * the roots (and every list and lookup) are the 963fe25 ones.
74
+ */
75
+ codexProfileSessions?: string[]
69
76
  }
70
77
 
71
78
  export function agentSessionRoots(home = process.env.COS_AGENT_SESSIONS_HOME || homedir()): AgentSessionRoots {
@@ -78,7 +85,39 @@ export function agentSessionRoots(home = process.env.COS_AGENT_SESSIONS_HOME ||
78
85
  cursorChats: join(home, '.cursor', 'chats'),
79
86
  cursorComposerDb: join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb'),
80
87
  cursorWorkspaceStorage: join(home, 'Library', 'Application Support', 'Cursor', 'User', 'workspaceStorage'),
88
+ ...codexProfileSessionsField(),
89
+ }
90
+ }
91
+
92
+ function codexProfileSessionsField(): { codexProfileSessions?: string[] } {
93
+ const dirs = profileCodexHomeSubdirs('sessions')
94
+ return dirs.length ? { codexProfileSessions: dirs } : {}
95
+ }
96
+
97
+ /** The server home's sessions root first, then each profile home's (plan 8.2 codexHomes order). */
98
+ export function codexSessionRootsOf(roots: AgentSessionRoots): string[] {
99
+ return [roots.codexSessions, ...(roots.codexProfileSessions ?? [])]
100
+ }
101
+
102
+ /**
103
+ * Key lanes (P11): thread names across every Codex home, the server home's first (it wins a
104
+ * clash). With lanes off there is one root and this is exactly loadCodexThreadNames(server).
105
+ */
106
+ export async function loadCodexThreadNamesAll(roots: AgentSessionRoots): Promise<Map<string, string>> {
107
+ const [server, ...profiles] = codexSessionRootsOf(roots)
108
+ const names = await loadCodexThreadNames(server!)
109
+ for (const root of profiles) {
110
+ for (const [id, name] of await loadCodexThreadNames(root)) if (!names.has(id)) names.set(id, name)
81
111
  }
112
+ return names
113
+ }
114
+
115
+ /** Key lanes (P11): pinned thread ids across every Codex home (one root with lanes off). */
116
+ export async function loadCodexPinnedIdsAll(roots: AgentSessionRoots): Promise<Set<string>> {
117
+ const [server, ...profiles] = codexSessionRootsOf(roots)
118
+ const pinned = await loadCodexPinnedIds(server!)
119
+ for (const root of profiles) for (const id of await loadCodexPinnedIds(root)) pinned.add(id)
120
+ return pinned
82
121
  }
83
122
 
84
123
  export function isSafeSessionId(value: string): boolean {
@@ -1392,7 +1431,7 @@ export async function listAgentSessions(
1392
1431
  let rows = dedupeSessions([
1393
1432
  ...enrichedLive,
1394
1433
  ...await listClaudeSessions(roots.claudeProjects, now, liveIds, cap, starredIds, roots.claudeCodeSessions, dropped, aliases),
1395
- ...await listCodexSessions(roots.codexSessions, now, cap, dropped),
1434
+ ...(await Promise.all(codexSessionRootsOf(roots).map(root => listCodexSessions(root, now, cap, dropped)))).flat(),
1396
1435
  ...await listCursorSessions(roots.cursorProjects, now, cap, roots.cursorComposerDb, cursorPinned, dropped),
1397
1436
  ])
1398
1437
  if (sort === 'opened') {
@@ -1458,9 +1497,11 @@ export async function findAgentSessionFile(
1458
1497
  return matches.length === 1 ? matches[0] : null
1459
1498
  }
1460
1499
  if (provider === 'codex') {
1461
- for (const file of await listCodexJsonlFiles(roots.codexSessions)) {
1462
- const name = file.split('/').pop()?.toLowerCase() ?? ''
1463
- if (name.includes(needle)) return file
1500
+ for (const root of codexSessionRootsOf(roots)) {
1501
+ for (const file of await listCodexJsonlFiles(root)) {
1502
+ const name = file.split('/').pop()?.toLowerCase() ?? ''
1503
+ if (name.includes(needle)) return file
1504
+ }
1464
1505
  }
1465
1506
  return null
1466
1507
  }
@@ -19,6 +19,7 @@ import { resolveExchangePairModel } from './conversation.js'
19
19
  import type { ModelPreference } from '../../shared/model-preference.js'
20
20
 
21
21
  import { dataPath } from './data-dir.js'
22
+ import { claudeKeySecretsGuardArgs } from './key-secrets-guard.js'
22
23
  const ARCHIVE_DIR = dataPath('archive')
23
24
 
24
25
  // ── Interfaces ──────────────────────────────────────────────
@@ -193,7 +194,7 @@ function fallbackDaySummary(chats: ArchivedChat[]): string {
193
194
  */
194
195
  function runClaudeArchiveSummary(input: string, instruction: string): Promise<string> {
195
196
  return new Promise((resolve, reject) => {
196
- const proc = spawn('claude', ['-p', '--model', 'sonnet', instruction], {
197
+ const proc = spawn('claude', ['-p', '--model', 'sonnet', ...claudeKeySecretsGuardArgs(), instruction], {
197
198
  stdio: ['pipe', 'pipe', 'pipe'],
198
199
  })
199
200
 
@@ -104,6 +104,8 @@ import { getCodexTrustMode } from './codex-run-ledger.js'
104
104
  import { CURSOR_SLOT_MODEL_IDS } from './cursor-model-catalog.js'
105
105
  import { cursorIsolationUnavailableError, cursorSpawnEnv, isCursorIsolationUnavailable, releaseCursorSpawnOnExit } from './cursor-spawn-env.js'
106
106
  import type { PermissionAllowance } from './banned-permission-args.js'
107
+ import { anyKeyedSessions, isKeyedSession } from './keyed-sessions.js'
108
+ import { claudeKeySecretsGuardArgs } from './key-secrets-guard.js'
107
109
  import {
108
110
  continueAllowanceFor,
109
111
  validateContinuePlan,
@@ -149,6 +151,8 @@ export type AttachedTurnFailure =
149
151
  | 'adapter_internal_error'
150
152
  /** 6.53.0: the caller's abort signal fired (a cancel from the lens). Never ambiguous. */
151
153
  | 'cancelled'
154
+ /** Key lanes K1 (plan v4 7.3): the thread ran on a key. Refused before anything is built. */
155
+ | 'keyed_session'
152
156
 
153
157
  /** See the header. The boundary is the first byte handed to the child's stdin. */
154
158
  export type AttachedDeliveryState = 'not_attempted' | 'aborted' | 'ambiguous' | 'delivered' | 'cancelled'
@@ -532,6 +536,7 @@ export function buildClaudeAttachedArgs(nativeThreadId: string): string[] {
532
536
  // never observe the session id we are required to verify.
533
537
  '--verbose',
534
538
  '--resume', nativeThreadId,
539
+ ...claudeKeySecretsGuardArgs(),
535
540
  ]
536
541
  }
537
542
 
@@ -820,6 +825,12 @@ async function run(
820
825
  }
821
826
  const nativeThreadId: string = request.nativeThreadId
822
827
 
828
+ // Key lanes K1 (plan v4 7.3): a thread that ran on a key is not continued here, where it would
829
+ // resume on the subscription. One failed stat when no keyed session was ever recorded.
830
+ if (anyKeyedSessions() && isKeyedSession(nativeThreadId)) {
831
+ return fail('keyed_session', 'not_attempted', { provider, nativeThreadId, durationMs: duration() })
832
+ }
833
+
823
834
  const base = { provider, nativeThreadId, durationMs: 0 }
824
835
 
825
836
  if (!isAttachedPermissionPolicy(request.policy)) {