@gotcos/glasses-server 6.79.2 → 6.81.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 (70) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +31 -0
  3. package/README.md +10 -0
  4. package/managed-runtime-contract.json +1 -0
  5. package/package.json +10 -1
  6. package/server/index.ts +14 -1
  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/direct-bridge.ts +853 -0
  23. package/server/lib/direct-sse.ts +262 -0
  24. package/server/lib/fork-thread.ts +11 -0
  25. package/server/lib/key-answer-fields.ts +49 -0
  26. package/server/lib/key-catalog.ts +301 -0
  27. package/server/lib/key-lanes-pricing.ts +12 -0
  28. package/server/lib/key-lanes-wiring.ts +346 -0
  29. package/server/lib/key-lanes.ts +145 -0
  30. package/server/lib/key-ledger.ts +123 -0
  31. package/server/lib/key-notices.ts +486 -0
  32. package/server/lib/key-profile-rejects.ts +25 -0
  33. package/server/lib/key-profile-routing.ts +173 -0
  34. package/server/lib/key-profiles.ts +182 -0
  35. package/server/lib/key-relay-contract.ts +262 -0
  36. package/server/lib/key-relay-errors.ts +211 -0
  37. package/server/lib/key-relay-meter.ts +303 -0
  38. package/server/lib/key-relay.ts +1018 -0
  39. package/server/lib/key-secrets-guard.ts +52 -0
  40. package/server/lib/key-store.ts +228 -0
  41. package/server/lib/keyed-sessions.ts +154 -0
  42. package/server/lib/lane-b-env.ts +169 -0
  43. package/server/lib/meeting-autonomous-finalize.ts +267 -0
  44. package/server/lib/meeting-stop-intents.ts +277 -0
  45. package/server/lib/model-router.ts +96 -7
  46. package/server/lib/morning-brief-config.ts +3 -0
  47. package/server/lib/native-head.ts +13 -1
  48. package/server/lib/occupancy-probes.ts +4 -0
  49. package/server/lib/ollama-run-ledger.ts +96 -13
  50. package/server/lib/prompt-edit.ts +2 -0
  51. package/server/lib/query-job-coordinator.ts +2 -1
  52. package/server/lib/query-job-runtime.ts +67 -15
  53. package/server/lib/query-job-store.ts +7 -1
  54. package/server/lib/query-job-types.ts +15 -1
  55. package/server/lib/session-hook-events.ts +28 -0
  56. package/server/lib/session-hook-ledger.ts +10 -0
  57. package/server/lib/thread-occupancy.ts +22 -1
  58. package/server/lib/token-audit.ts +18 -4
  59. package/server/lib/work-handoff-requests.ts +3 -0
  60. package/server/routes/agent-session-bindings.ts +15 -0
  61. package/server/routes/agent-sessions.ts +17 -5
  62. package/server/routes/handoffs.ts +22 -1
  63. package/server/routes/health.ts +5 -0
  64. package/server/routes/key-lanes.ts +442 -0
  65. package/server/routes/meeting.ts +326 -47
  66. package/server/routes/openai-compat.ts +28 -3
  67. package/server/routes/query.ts +20 -6
  68. package/server/routes/transcribe-stream.ts +48 -1
  69. package/shared/key-preference.ts +57 -0
  70. 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,34 @@
1
+ ## 6.81.0 - The Mac saves a stopped meeting on its own once it holds every chunk
2
+
3
+ Miles, 2026-10-20: "if the application isn't in the foreground, it can fail to save the meetings". Both G2 meetings that day saved only once the phone came back on screen: the phone's Stop needs IndexedDB, and iOS suspends IndexedDB while the companion is hidden (11:19 qbbgyq: Stop 12:43, `stop_blocked`, "Load failed", saved 12:47 by a new WebView; 14:07 mip9qb: Stop 15:13:50, saved 15:14:37 only after the phone was visible). The full map is `docs/meeting-autonomous-save.md`.
4
+
5
+ - **Client matrix.** Server only. Nothing changes until the companion sends the new Stop (a companion release, not in this one). Older companions never call the new route and save exactly as before.
6
+ - **The phone's Stop, durable.** `POST /api/meeting/sessions/:id/stop` `{finalChunkIndex, serverInstanceId}`: one small fetch a hidden phone can still send. The server pin is required. It is written to `data/meeting-stop-intents.json` (0600, atomic) before the 202, so a restart keeps it. A repeat is idempotent; a higher index raises it, a lower one never lowers it. A Stop behind what the Mac already holds is refused 409 `final_index_behind_server`. It never closes the session and never refuses a chunk.
7
+ - **The Mac saves only a complete capture.** Once every chunk 0..finalChunkIndex has arrived and finished ASR, and 45 s have passed since the Stop and since the last chunk (so a phone on screen saves first), the Mac runs the same save the phone's `POST /meeting/save` runs: one lock, one idempotent receipt. A gap means audio exists only on the phone, so the Mac waits and the late chunks land in the SAME live session. There is no timeout that saves with a gap: a gap that never fills is left to the existing 4 h stranded sweep, unchanged. A chunk past the Stop revokes it (the meeting is still recording). Checked every 15 s while a Stop waits, only while admissions are open and session recovery has run.
8
+ - **The phone coming back.** Status answers `saved` with the receipt; `POST /meeting/save` replays it; and a chunk the phone re-sends because its answer was lost while hidden is answered from the saved sidecar instead of 410, so the phone no longer reads that state as a collision. This replay is only for meetings the Mac saved itself; every other closed session still answers 410.
9
+ - **Telemetry.** `[meeting/auto-save]` lines and `data/meeting-autonomous-save.jsonl`: `stop_received`, `waiting` (once per change, with missing count and sample), `finalized` (chunks, canonical, gaps, waited ms), `phone_saved` (or `client_saved` for an unpinned save such as the stranded sweep's), `phone_confirmed` (how and how long after), `late_replay`, `late_chunk_refused`, `revoked`, `stood_down`, `finalize_failed`. The status route carries `stopIntent` additively. `recording_stop` carries `savedBy: "server"` for the Mac's own saves.
10
+ - **Unchanged.** Transcription engines, the G2 matching thresholds, the restart and maintenance gate semantics, the stranded sweeper, and the save route's answers (its body moved into one shared `performSave`; the mutation gate anchor `cx-6533-save-nothing-to-save` follows it).
11
+ - **Checks.** Full suite 8,462 passed, 0 failed, 3 skipped (557 files); `tsc --noEmit` clean. `server/scripts/autonomous-save-mutation-gate.mjs` (not packaged): green baseline (63 tests in 4 files), 42 of 42 mutants killed, each by a named test. The moved `cx-6533-save-nothing-to-save` anchor re-runs killed. Journeys: phone hidden after Stop (the Mac saves alone), phone back late with missing chunks (merged into one meeting), phone and Mac racing (one meeting), against the real chunk and save routes. Needs the companion release to send the Stop before it does anything on a real phone.
12
+
13
+ ## 6.80.0 - Answer with your own key (opt in, off by default)
14
+
15
+ 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.
16
+
17
+ - **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.
18
+ - **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.
19
+ - **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).
20
+ - **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.
21
+ - **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.
22
+ - **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.
23
+ - **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.
24
+ - **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.
25
+ - **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.
26
+ - **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.
27
+ - **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.
28
+ - **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.
29
+ - **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.
30
+ - **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.
31
+
1
32
  ## 6.79.2 - Live cue limits from COS Control: the bar, cues per meeting, cooldown and a daily writer ceiling, applied without a restart
2
33
 
3
34
  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.
package/README.md CHANGED
@@ -1045,6 +1045,16 @@ usable on Fast and reports the downgrade truthfully. Set
1045
1045
  `COS_BATCH_LARGE_V3=0` to explicitly use turbo. Interactive HQ uses beam 2 by
1046
1046
  default (`COS_HQ_BEAM_INTERACTIVE`); meeting batch remains beam 5.
1047
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
+
1048
1058
  ## Run from source
1049
1059
 
1050
1060
  ```bash
@@ -74,6 +74,7 @@
74
74
  "COS_PIN_BOOT_REAP",
75
75
  "COS_QUEUE_RESEND",
76
76
  "COS_SESSION_STEER",
77
+ "COS_KEY_LANES",
77
78
  "COS_DICTATION_AUTOCLEAN_MAX_PER_DAY",
78
79
  "COS_DICTATION_AUTOCLEAN_MAX_PER_DAY_MESSAGE",
79
80
  "COS_DICTATION_AUTOCLEAN_MAX_PER_DAY_MEETING",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.79.2",
3
+ "version": "6.81.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": {
@@ -54,6 +54,7 @@
54
54
  "!server/scripts/work-search-mutation-gate.mjs",
55
55
  "!server/scripts/fact-check-mutation-gate.mjs",
56
56
  "!server/scripts/stale-session-mutation-gate.mjs",
57
+ "!server/scripts/autonomous-save-mutation-gate.mjs",
57
58
  "!server/scripts/stt-mutation-gate.mjs",
58
59
  "!server/scripts/dictation-stt-mutation-gate.mjs",
59
60
  "!server/scripts/pairing-mutation-gate.mjs",
@@ -73,6 +74,14 @@
73
74
  "!server/scripts/transcript-cleanup-mutation-gate.mjs",
74
75
  "!server/scripts/live-cues-v2-notes-mutation-gate.mjs",
75
76
  "!server/scripts/live-cues-verdicts-mutation-gate.mjs",
77
+ "!server/scripts/key-lanes-p1-mutation-gate.mjs",
78
+ "!server/scripts/key-lanes-p2-mutation-gate.mjs",
79
+ "!server/scripts/key-lanes-p3-mutation-gate.mjs",
80
+ "!server/scripts/key-lanes-p4-mutation-gate.mjs",
81
+ "!server/scripts/key-lanes-p5-mutation-gate.mjs",
82
+ "!server/scripts/key-lanes-p6-mutation-gate.mjs",
83
+ "!server/scripts/key-lanes-p8-mutation-gate.mjs",
84
+ "!server/scripts/key-lanes-mutation-gate.mjs",
76
85
  "!server/scripts/dictation-polish-eval.ts",
77
86
  "!server/scripts/dictation-polish-mutation-gate.mjs",
78
87
  "!server/lib/meeting-engine/__fixtures__/**",
package/server/index.ts CHANGED
@@ -74,11 +74,13 @@ import { nativeHead, realNativeHeadDeps, registerClaudeTranscriptHint } from './
74
74
  import { threadOccupancy, holderActivity } from './lib/thread-occupancy.js'
75
75
  import { displayRouter } from './routes/display.js'
76
76
  import { transcribeStreamRouter, getActiveTranscriptionSessionCount } from './routes/transcribe-stream.js'
77
- import { meetingRouter, resumeMeetingFinalizationJobs, scheduleTranscriptCleanupSweep, transcriptCleanupRouteDeps } from './routes/meeting.js'
77
+ import { meetingRouter, resumeAutonomousMeetingSaves, resumeMeetingFinalizationJobs, scheduleTranscriptCleanupSweep, transcriptCleanupRouteDeps } from './routes/meeting.js'
78
78
  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)
@@ -1462,6 +1473,8 @@ listenRequiredServers(listeners).then(() => {
1462
1473
  // Resume already-saved meetings whose post-response HQ/operations work
1463
1474
  // was interrupted by a prior server update or process exit.
1464
1475
  resumeMeetingFinalizationJobs()
1476
+ // 6.81.0: Stops the phone sent before the last exit; the server saves them once complete.
1477
+ resumeAutonomousMeetingSaves()
1465
1478
  // 6.77.0: transcript cleanup data whose meeting is gone, and screen text past retention.
1466
1479
  scheduleTranscriptCleanupSweep()
1467
1480
 
@@ -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)) {
@@ -64,6 +64,14 @@ import { claudeSupportsSessionName } from './claude-cli-support.js'
64
64
  import { applyTurnRequestEnv, createTurnRequestBinding, spawnWithTurnRequest } from './turn-request.js'
65
65
  import { STEER_REAPER_MS, STEER_SETTLE_MS, SteerTracker, composeFirstContent, encodeUserLine, steerClock, type SteerPort } from './claude-steer.js'
66
66
  import { randomUUID } from 'node:crypto'
67
+ // Key lanes K1 P3 (plan v4 7): Lane B Claude. Every use below sits behind `options.keyProfile`;
68
+ // without one, argv, env, session keys and the pre-warm handoff are the b7c620e ones.
69
+ import { profilePreference } from '../../shared/key-preference.js'
70
+ import type { AnswerKeyFields, KeyKind, KeyProfile, RelayTurnHandle } from './key-relay-contract.js'
71
+ import { applyLaneBClaudeEnv, LANE_B_COPY, laneBClaudePreflight, laneBRelay } from './lane-b-env.js'
72
+ import { recordKeyedSession } from './keyed-sessions.js'
73
+ import { getSharedKeyMeter } from './key-relay-meter.js'
74
+ import { claudeKeySecretsGuardArgs } from './key-secrets-guard.js'
67
75
 
68
76
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
69
77
 
@@ -117,9 +125,19 @@ function cliSessionKey(cosSessionId: string, model: ClaudeModelPreference): stri
117
125
  return `${cosSessionId}:${model}`
118
126
  }
119
127
 
128
+ /** Key lanes (plan v4 7.1): a keyed turn's CLI session is its own key, never `${sid}:${tier}`. */
129
+ export function keyedCliSessionKey(cosSessionId: string, tier: ClaudeModelPreference, profileId: string): string {
130
+ return `${cosSessionId}:${tier}:p:${profileId}`
131
+ }
132
+
120
133
  function getAnyCliSessionId(cosSessionId: string): string | undefined {
121
134
  for (const [key, cliId] of cliSessionMap) {
122
- if (key === cosSessionId || key.startsWith(`${cosSessionId}:`)) return cliId
135
+ if (key === cosSessionId || key.startsWith(`${cosSessionId}:`)) {
136
+ // Key lanes: a keyed CLI session is never handed out here (handoff, health, Continue). Only
137
+ // the keyed turn itself reaches it, through its own exact key.
138
+ if (key.includes(':p:', cosSessionId.length)) continue
139
+ return cliId
140
+ }
123
141
  }
124
142
  return undefined
125
143
  }
@@ -235,6 +253,7 @@ export async function preWarmCLI(): Promise<void> {
235
253
  '--output-format', 'stream-json',
236
254
  '--verbose',
237
255
  ...claudePermissionArgs(getClaudeTrustMode(), null),
256
+ ...claudeKeySecretsGuardArgs(),
238
257
  '--system-prompt', buildPrewarmSystemPrompt(),
239
258
  ], {
240
259
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -324,6 +343,12 @@ export interface ModelRunMetadata {
324
343
  ollamaRunId?: string
325
344
  outputAttachments?: MediaAttachmentRef[]
326
345
  outputImageStats?: RunOutputImageCollectionStats
346
+ /** Key lanes (plan v4 3.1): present only on a keyed turn. The `model` beside it is then the timeout tier. */
347
+ keyLane?: AnswerKeyFields
348
+ /** Key lanes (P11): what this keyed answer cost on the key, settled by the relay. Keyed turns only. */
349
+ usd?: number
350
+ /** Key lanes (P5): the direct lane's run ledger id. Direct turns only. */
351
+ directRunId?: string
327
352
  }
328
353
 
329
354
  /** Public-safe provider launch metadata for durable job coordination. It
@@ -417,6 +442,17 @@ export interface CallOptions {
417
442
  * 6.77.0 one. A dispatch never steers.
418
443
  */
419
444
  steer?: SteerPort
445
+ /**
446
+ * Key lanes K1 (plan v4 7): run this turn on a saved key profile (Lane B Claude) through the
447
+ * loopback relay. The profile's timeoutTier sets the timeouts and its model goes on `--model`;
448
+ * the `model` argument is ignored. Absent, every argument, env name, session key and stdin
449
+ * byte is the b7c620e one. Refused (onError, nothing spawned) when lanes are not active, the
450
+ * profile is off or not a Claude profile, its key is gone, a Claude settings file would bypass
451
+ * the relay, no relay is registered, or this is a dispatch.
452
+ */
453
+ keyProfile?: KeyProfile
454
+ /** Key lanes (QA U9): run this one profile turn without binding the session to the profile. */
455
+ keyTurnOnly?: boolean
420
456
  }
421
457
 
422
458
  export async function callClaudeStreaming(
@@ -431,6 +467,25 @@ export async function callClaudeStreaming(
431
467
  ): Promise<string> {
432
468
  // Get or create session
433
469
  const sid = getOrCreateSession(sessionId)
470
+ // Key lanes: every check a keyed turn needs runs here, before history, the run ledger or a
471
+ // process. A refusal ends the turn with its lens line; the subscription is never the fallback.
472
+ const keyProfile = options?.keyProfile
473
+ let keyKind: KeyKind | null = null
474
+ if (keyProfile) {
475
+ const preflight = laneBClaudePreflight({
476
+ profile: keyProfile,
477
+ dispatch: !!options?.dispatch,
478
+ cwd: resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR }),
479
+ })
480
+ if (!preflight.ok) {
481
+ await callbacks.onError(preflight.message)
482
+ return sid
483
+ }
484
+ keyKind = preflight.keyKind
485
+ }
486
+ const keyLane: AnswerKeyFields | null = keyProfile && keyKind
487
+ ? { lane: 'key', credential: { kind: keyKind, ref: keyProfile.keyRef, profileId: keyProfile.id } }
488
+ : null
434
489
  const history = getHistory(sid)
435
490
  const session = getSessionRaw(sid)
436
491
  const contextBreaks = session?.contextBreaks ?? []
@@ -439,14 +494,16 @@ export async function callClaudeStreaming(
439
494
 
440
495
  // Resolve model: per-message > session preference > public-server default.
441
496
  const sessionModel = getSessionModel(sid)
442
- const resolvedModel: ClaudeModelPreference = model
443
- ?? (sessionModel && isClaudeModel(sessionModel) ? sessionModel : DEFAULT_MODEL)
497
+ const resolvedModel: ClaudeModelPreference = keyProfile ? keyProfile.timeoutTier : (model
498
+ ?? (sessionModel && isClaudeModel(sessionModel) ? sessionModel : DEFAULT_MODEL))
444
499
  const resolvedEffort = options?.effort
445
500
  const cliEffortFlag = resolvedEffort ? resolveCliEffortFlag(resolvedEffort) : getClaudeEffortLevel()
446
501
 
447
502
  // Notify client immediately — model is known before any async work
448
503
  // Pass existing CLI session ID if resuming (new sessions get it after first result)
449
- const resolvedCliKey = cliSessionKey(sid, resolvedModel)
504
+ const resolvedCliKey = keyProfile ? keyedCliSessionKey(sid, resolvedModel, keyProfile.id) : cliSessionKey(sid, resolvedModel)
505
+ // A keyed exchange is stamped with its profile (P6 persists it by syntax), never with the tier.
506
+ const exchangeModel = keyProfile ? profilePreference(keyProfile.id) as unknown as ModelPreference : resolvedModel
450
507
  let existingCliSession = options?.dispatch ? undefined : cliSessionMap.get(resolvedCliKey)
451
508
  // 6.58.2: decided before the pre-warmed session below stands in: a COS session that has no
452
509
  // Claude session of its own yet starts one here, and only that one may be named.
@@ -454,6 +511,7 @@ export async function callClaudeStreaming(
454
511
  callbacks.onStart?.(resolvedModel, sid, existingCliSession, {
455
512
  clientJobId: options?.clientJobId,
456
513
  generation: options?.generation,
514
+ ...(keyLane ? { keyLane } : {}),
457
515
  })
458
516
 
459
517
  // Phase: context loading (skipped in lightweight mode)
@@ -531,7 +589,7 @@ export async function callClaudeStreaming(
531
589
  globalMsgNum,
532
590
  inboundAttachments,
533
591
  exchangeProvenance,
534
- resolvedModel,
592
+ exchangeModel,
535
593
  )
536
594
 
537
595
  // Vision queries need the Read tool to see the image files
@@ -559,7 +617,8 @@ export async function callClaudeStreaming(
559
617
  // 6.58.2: nor does a named New session (COS Control Work). The warmed session opens with the
560
618
  // boot prompt "ready" under the glasses prompt; a session someone will open in the Claude app
561
619
  // under its task's name starts clean.
562
- if (!options?.dispatch && !options?.sessionName && !existingCliSession && preWarmedCliSessionId && resolvedModel === DEFAULT_MODEL) {
620
+ // Key lanes: a keyed turn never takes the warmed subscription session.
621
+ if (!options?.dispatch && !options?.sessionName && !existingCliSession && preWarmedCliSessionId && resolvedModel === DEFAULT_MODEL && !keyProfile) {
563
622
  // Only the default model consumes the pre-warmed session.
564
623
  // Hey Even (Haiku) cold-starts its own session to avoid model contamination.
565
624
  existingCliSession = preWarmedCliSessionId
@@ -582,6 +641,8 @@ export async function callClaudeStreaming(
582
641
  '--system-prompt', systemPrompt,
583
642
  ...(options?.dispatch ? [] : mcpConfigArgs),
584
643
  ]
644
+ // Key lanes (plan 7.1): a keyed turn names the profile slug on --model (never ANTHROPIC_MODEL, no [1m]).
645
+ if (keyProfile) args[2] = keyProfile.model
585
646
 
586
647
  // Full and lightweight paths share the same explicit MCP selector contract.
587
648
  if (options?.dispatch) {
@@ -599,6 +660,9 @@ export async function callClaudeStreaming(
599
660
  } else {
600
661
  args.push(...claudePermissionArgs(getClaudeTrustMode(), tools), '--include-partial-messages')
601
662
  }
663
+ // QA B1: deny the key secrets dir while lanes are on and a key is saved ([] otherwise). A keyed
664
+ // turn's layer also forces agent teams off over any settings file (canary R12).
665
+ args.push(...claudeKeySecretsGuardArgs(keyProfile ? { laneB: true } : {}))
602
666
 
603
667
  if (existingCliSession) {
604
668
  // Resume prior CLI session — reuses cached context, avoids cold start
@@ -625,6 +689,33 @@ export async function callClaudeStreaming(
625
689
  ? Math.max(defaultWallMax, WALL_MAX_DEEP_EFFORT_MS)
626
690
  : defaultWallMax
627
691
  const wallMax = isExtendedQuery(query) ? Math.max(WALL_MAX_EXTENDED_MS, effortWallMax) : effortWallMax
692
+ // Key lanes: one nonce per turn from the relay (P2 contract), then the child env points at it.
693
+ // Only the child's copy is written. A relay that cannot issue ends the turn before spawn.
694
+ let keyTurn: RelayTurnHandle | null = null
695
+ const keyTurnId = keyProfile ? options?.turnId ?? randomUUID() : null
696
+ if (keyProfile) {
697
+ try {
698
+ const relay = await laneBRelay()
699
+ keyTurn = await relay.issueTurn({
700
+ keyRef: keyProfile.keyRef,
701
+ profileId: keyProfile.id,
702
+ turnId: keyTurnId!,
703
+ sessionId: sid,
704
+ engine: 'claude',
705
+ allowedModel: keyProfile.model,
706
+ }, { wallMaxMs: wallMax })
707
+ applyLaneBClaudeEnv(env, { origin: keyTurn.origin, nonce: keyTurn.nonce, slug: keyProfile.model, profileId: keyProfile.id })
708
+ } catch (err) {
709
+ keyTurn?.revoke('cancel')
710
+ outputImagePublisher?.cleanup()
711
+ cleanupModelImageInputs(imageInputs)
712
+ removeExchange(sid, pendingUserExchange)
713
+ console.error(`[claude-bridge] key relay refused the turn: ${err instanceof Error ? err.message.slice(0, 200) : 'unknown'}`)
714
+ await callbacks.onError(LANE_B_COPY.relayUnavailable)
715
+ return sid
716
+ }
717
+ }
718
+ const keyAuditFields = keyProfile && keyKind ? { profile: keyProfile.id, lane: 'key' as const, credentialKind: keyKind } : {}
628
719
  const startTime = Date.now()
629
720
  // 6.78.0 steer (P7 S7): the wall clock's start on the monotonic steer clock, next to the
630
721
  // wall timer it describes, so the coordinator's cut-off reads the same clock after a sleep.
@@ -639,7 +730,7 @@ export async function callClaudeStreaming(
639
730
  wallMaxMs: wallMax,
640
731
  query: fullQuery,
641
732
  effortLevel: resolvedEffort ?? getClaudeEffortLevel(),
642
- cliModelId: resolveClaudeCliModelId(resolvedModel),
733
+ cliModelId: keyProfile ? keyProfile.model : resolveClaudeCliModelId(resolvedModel),
643
734
  })
644
735
 
645
736
  // Ultracode is a CLI-only orchestration keyword; history/chat keeps the
@@ -699,6 +790,7 @@ export async function callClaudeStreaming(
699
790
  * CLI session mapping is dropped so the next Ask starts a fresh session.
700
791
  */
701
792
  function abandonUncoveredSteers() {
793
+ keyTurn?.revoke('steer_abort')
702
794
  steerStdinEnded = true
703
795
  steerPort!.close()
704
796
  if (cliSessionMap.delete(resolvedCliKey)) scheduleCliSessionSave()
@@ -791,7 +883,7 @@ export async function callClaudeStreaming(
791
883
  globalMsgNum,
792
884
  undefined,
793
885
  exchangeProvenance,
794
- resolvedModel,
886
+ exchangeModel,
795
887
  )
796
888
  if (imagePaths.length > 0) {
797
889
  replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
@@ -827,6 +919,8 @@ export async function callClaudeStreaming(
827
919
  // Token audit — log every completed claude -p call
828
920
  const totalMs = Date.now() - startTime
829
921
  const inputEstimate = systemPrompt.length + fullQuery.length + contextPrompt.length
922
+ // Key lanes (P11): this turn's settled spend on the key. Keyed turns only.
923
+ const keyUsdFields = keyTurnId ? { usd: Math.round(getSharedKeyMeter().turnSettledUsd(keyTurnId) * 1e6) / 1e6 } : {}
830
924
  logTokenAudit({
831
925
  source: options?.lightweight ? 'g2-voice' : 'g2-query',
832
926
  model: resolvedModel,
@@ -834,6 +928,8 @@ export async function callClaudeStreaming(
834
928
  outputChars: text.length,
835
929
  durationMs: totalMs,
836
930
  caller: options?.lightweight ? 'voice_query' : 'full_query',
931
+ ...keyAuditFields,
932
+ ...keyUsdFields,
837
933
  })
838
934
 
839
935
  finishClaudeRun(run.runId, {
@@ -849,6 +945,7 @@ export async function callClaudeStreaming(
849
945
  generation: options?.generation,
850
946
  ...(outputAttachments.length > 0 ? { outputAttachments } : {}),
851
947
  ...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
948
+ ...(keyLane ? { keyLane, ...keyUsdFields } : {}),
852
949
  })
853
950
  if (terminalOwned === false) return
854
951
 
@@ -901,6 +998,7 @@ export async function callClaudeStreaming(
901
998
  }
902
999
 
903
1000
  function handleAbort() {
1001
+ keyTurn?.revoke('cancel')
904
1002
  void terminateForTerminal('client disconnect', result => finalizeError(
905
1003
  'claude-bridge: client disconnected before Claude completed.',
906
1004
  result.code,
@@ -1022,6 +1120,9 @@ export async function callClaudeStreaming(
1022
1120
  // recorded as before, so the finished job names the session the result names.
1023
1121
  if (!nativeSessionAnnounced && !options?.dispatch && typeof event.session_id === 'string' && event.session_id) {
1024
1122
  nativeSessionAnnounced = true
1123
+ // Key lanes (plan 7.3): recorded before any answer text, so Continue, Fork, attach and
1124
+ // handoff refuse this transcript from the moment it exists.
1125
+ if (keyProfile) recordKeyedSession({ id: event.session_id, engine: 'claude', profileId: keyProfile.id, cosSessionId: sid })
1025
1126
  updateClaudeRun(run.runId, { cliSessionId: event.session_id })
1026
1127
  callbacks.onNativeSession?.({ cliSessionId: event.session_id })
1027
1128
  }
@@ -1112,6 +1213,7 @@ export async function callClaudeStreaming(
1112
1213
  }
1113
1214
  // Capture CLI session ID for future --resume (avoids cold start on next query)
1114
1215
  if (event.session_id && !options?.dispatch) {
1216
+ if (keyProfile) recordKeyedSession({ id: event.session_id, engine: 'claude', profileId: keyProfile.id, cosSessionId: sid })
1115
1217
  cliSessionMap.set(resolvedCliKey, event.session_id)
1116
1218
  scheduleCliSessionSave()
1117
1219
  updateClaudeRun(run.runId, { cliSessionId: event.session_id })
@@ -1160,6 +1262,7 @@ export async function callClaudeStreaming(
1160
1262
 
1161
1263
  proc.on('close', (code) => {
1162
1264
  childClosed = true
1265
+ keyTurn?.childClosed()
1163
1266
  if (steerReaper) clearTimeout(steerReaper)
1164
1267
  steerPort?.close()
1165
1268
  if (terminationRequested) return
@@ -1196,6 +1299,7 @@ export async function callClaudeStreaming(
1196
1299
  })
1197
1300
 
1198
1301
  proc.on('error', (err) => {
1302
+ keyTurn?.revoke('child_close')
1199
1303
  if (terminationRequested) return
1200
1304
  finalizeError(`claude-bridge: ${err.message}`, null)
1201
1305
  })