@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.
- package/.env.example +9 -0
- package/CHANGELOG.md +31 -0
- package/README.md +10 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +10 -1
- package/server/index.ts +14 -1
- package/server/lib/agent-session-search.ts +8 -5
- package/server/lib/agent-session-store.ts +45 -4
- package/server/lib/archive.ts +2 -1
- package/server/lib/attached-provider-adapter.ts +11 -0
- package/server/lib/claude-bridge.ts +112 -8
- package/server/lib/claude-settings-guard.ts +94 -0
- package/server/lib/claude-steer.ts +27 -0
- package/server/lib/codex-bridge.ts +205 -32
- package/server/lib/codex-engine-sessions.ts +27 -6
- package/server/lib/codex-homes.ts +134 -0
- package/server/lib/codex-live-queue.ts +14 -0
- package/server/lib/codex-profile-home.ts +452 -0
- package/server/lib/codex-queue-control.ts +17 -1
- package/server/lib/codex-subagent-activity.ts +22 -0
- package/server/lib/conversation.ts +92 -15
- package/server/lib/direct-bridge.ts +853 -0
- package/server/lib/direct-sse.ts +262 -0
- package/server/lib/fork-thread.ts +11 -0
- package/server/lib/key-answer-fields.ts +49 -0
- package/server/lib/key-catalog.ts +301 -0
- package/server/lib/key-lanes-pricing.ts +12 -0
- package/server/lib/key-lanes-wiring.ts +346 -0
- package/server/lib/key-lanes.ts +145 -0
- package/server/lib/key-ledger.ts +123 -0
- package/server/lib/key-notices.ts +486 -0
- package/server/lib/key-profile-rejects.ts +25 -0
- package/server/lib/key-profile-routing.ts +173 -0
- package/server/lib/key-profiles.ts +182 -0
- package/server/lib/key-relay-contract.ts +262 -0
- package/server/lib/key-relay-errors.ts +211 -0
- package/server/lib/key-relay-meter.ts +303 -0
- package/server/lib/key-relay.ts +1018 -0
- package/server/lib/key-secrets-guard.ts +52 -0
- package/server/lib/key-store.ts +228 -0
- package/server/lib/keyed-sessions.ts +154 -0
- package/server/lib/lane-b-env.ts +169 -0
- package/server/lib/meeting-autonomous-finalize.ts +267 -0
- package/server/lib/meeting-stop-intents.ts +277 -0
- package/server/lib/model-router.ts +96 -7
- package/server/lib/morning-brief-config.ts +3 -0
- package/server/lib/native-head.ts +13 -1
- package/server/lib/occupancy-probes.ts +4 -0
- package/server/lib/ollama-run-ledger.ts +96 -13
- package/server/lib/prompt-edit.ts +2 -0
- package/server/lib/query-job-coordinator.ts +2 -1
- package/server/lib/query-job-runtime.ts +67 -15
- package/server/lib/query-job-store.ts +7 -1
- package/server/lib/query-job-types.ts +15 -1
- package/server/lib/session-hook-events.ts +28 -0
- package/server/lib/session-hook-ledger.ts +10 -0
- package/server/lib/thread-occupancy.ts +22 -1
- package/server/lib/token-audit.ts +18 -4
- package/server/lib/work-handoff-requests.ts +3 -0
- package/server/routes/agent-session-bindings.ts +15 -0
- package/server/routes/agent-sessions.ts +17 -5
- package/server/routes/handoffs.ts +22 -1
- package/server/routes/health.ts +5 -0
- package/server/routes/key-lanes.ts +442 -0
- package/server/routes/meeting.ts +326 -47
- package/server/routes/openai-compat.ts +28 -3
- package/server/routes/query.ts +20 -6
- package/server/routes/transcribe-stream.ts +48 -1
- package/shared/key-preference.ts +57 -0
- 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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gotcos/glasses-server",
|
|
3
|
-
"version": "6.
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
318
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
1462
|
-
const
|
|
1463
|
-
|
|
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
|
}
|
package/server/lib/archive.ts
CHANGED
|
@@ -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}:`))
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
})
|