@gotcos/glasses-server 6.79.1 → 6.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +98 -0
  3. package/README.md +20 -1
  4. package/managed-runtime-contract.json +6 -1
  5. package/package.json +9 -1
  6. package/server/index.ts +11 -0
  7. package/server/lib/agent-session-search.ts +8 -5
  8. package/server/lib/agent-session-store.ts +45 -4
  9. package/server/lib/archive.ts +2 -1
  10. package/server/lib/attached-provider-adapter.ts +11 -0
  11. package/server/lib/claude-bridge.ts +112 -8
  12. package/server/lib/claude-settings-guard.ts +94 -0
  13. package/server/lib/claude-steer.ts +27 -0
  14. package/server/lib/codex-bridge.ts +205 -32
  15. package/server/lib/codex-engine-sessions.ts +27 -6
  16. package/server/lib/codex-homes.ts +134 -0
  17. package/server/lib/codex-live-queue.ts +14 -0
  18. package/server/lib/codex-profile-home.ts +452 -0
  19. package/server/lib/codex-queue-control.ts +17 -1
  20. package/server/lib/codex-subagent-activity.ts +22 -0
  21. package/server/lib/conversation.ts +92 -15
  22. package/server/lib/dictation-clean.ts +13 -6
  23. package/server/lib/dictation-fillers.ts +17 -10
  24. package/server/lib/dictation-polish-need.ts +48 -0
  25. package/server/lib/dictation-prewarm.ts +209 -0
  26. package/server/lib/direct-bridge.ts +853 -0
  27. package/server/lib/direct-sse.ts +262 -0
  28. package/server/lib/fork-thread.ts +11 -0
  29. package/server/lib/key-answer-fields.ts +49 -0
  30. package/server/lib/key-catalog.ts +301 -0
  31. package/server/lib/key-lanes-pricing.ts +12 -0
  32. package/server/lib/key-lanes-wiring.ts +346 -0
  33. package/server/lib/key-lanes.ts +145 -0
  34. package/server/lib/key-ledger.ts +123 -0
  35. package/server/lib/key-notices.ts +486 -0
  36. package/server/lib/key-profile-rejects.ts +25 -0
  37. package/server/lib/key-profile-routing.ts +173 -0
  38. package/server/lib/key-profiles.ts +182 -0
  39. package/server/lib/key-relay-contract.ts +262 -0
  40. package/server/lib/key-relay-errors.ts +211 -0
  41. package/server/lib/key-relay-meter.ts +303 -0
  42. package/server/lib/key-relay.ts +1018 -0
  43. package/server/lib/key-secrets-guard.ts +52 -0
  44. package/server/lib/key-store.ts +228 -0
  45. package/server/lib/keyed-sessions.ts +154 -0
  46. package/server/lib/lane-b-env.ts +169 -0
  47. package/server/lib/live-cues-v2-settings.ts +51 -13
  48. package/server/lib/live-cues-v2.ts +147 -19
  49. package/server/lib/model-router.ts +96 -7
  50. package/server/lib/morning-brief-config.ts +3 -0
  51. package/server/lib/native-head.ts +13 -1
  52. package/server/lib/occupancy-probes.ts +4 -0
  53. package/server/lib/ollama-run-ledger.ts +96 -13
  54. package/server/lib/prompt-edit.ts +2 -0
  55. package/server/lib/query-job-coordinator.ts +2 -1
  56. package/server/lib/query-job-runtime.ts +67 -15
  57. package/server/lib/query-job-store.ts +7 -1
  58. package/server/lib/query-job-types.ts +15 -1
  59. package/server/lib/session-hook-events.ts +28 -0
  60. package/server/lib/session-hook-ledger.ts +10 -0
  61. package/server/lib/thread-occupancy.ts +22 -1
  62. package/server/lib/token-audit.ts +18 -4
  63. package/server/lib/work-handoff-requests.ts +3 -0
  64. package/server/routes/agent-session-bindings.ts +15 -0
  65. package/server/routes/agent-sessions.ts +17 -5
  66. package/server/routes/handoffs.ts +22 -1
  67. package/server/routes/health.ts +5 -0
  68. package/server/routes/key-lanes.ts +442 -0
  69. package/server/routes/live-cues.ts +39 -3
  70. package/server/routes/openai-compat.ts +28 -3
  71. package/server/routes/prompt-drafts.ts +75 -8
  72. package/server/routes/query.ts +20 -6
  73. package/shared/key-preference.ts +57 -0
  74. package/shared/model-preference.ts +20 -2
@@ -1,7 +1,9 @@
1
1
  import crypto from 'node:crypto'
2
2
  import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
3
3
  import { dirname, resolve } from 'node:path'
4
- import { OLLAMA_MODEL, type OllamaModelPreference } from '../../shared/model-preference.js'
4
+ import { OLLAMA_MODEL } from '../../shared/model-preference.js'
5
+ import { dataPath } from './data-dir.js'
6
+ import type { KeyLane } from './key-relay-contract.js'
5
7
 
6
8
  const DEFAULT_MAX_RUNS = 100
7
9
  const DEFAULT_TTL_MS = 7 * 24 * 60 * 60_000
@@ -10,6 +12,13 @@ const RUNNING_STALE_MS = 30 * 60_000
10
12
 
11
13
  export type OllamaRunStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'client_disconnected'
12
14
 
15
+ /**
16
+ * One run, Ollama or (key lanes K1 P5, plan v4 9.1) direct. `model` was OllamaModelPreference; it
17
+ * is a string now so a direct run can carry its `profile:<id>` preference. Ollama rows still write
18
+ * exactly `model: 'ollama'` and never a `lane`, `profile` or `providerModel` key, in the 6.79.0
19
+ * key order (pinned byte for byte by ollama-run-ledger.test.ts). Direct rows live in their own
20
+ * file (`direct-runs.jsonl` under the data dir), so the Ollama ledger only ever holds Ollama runs.
21
+ */
13
22
  export interface OllamaRunRecord {
14
23
  runId: string
15
24
  turnId?: string
@@ -18,8 +27,15 @@ export interface OllamaRunRecord {
18
27
  status: OllamaRunStatus
19
28
  createdAt: string
20
29
  updatedAt: string
21
- model: OllamaModelPreference
22
- ollamaModel: string
30
+ model: string
31
+ /** Direct rows only. */
32
+ lane?: KeyLane
33
+ /** Direct rows only: the key profile id. */
34
+ profile?: string
35
+ /** Ollama rows: the pulled tag. Direct rows leave it out and use providerModel. */
36
+ ollamaModel?: string
37
+ /** Direct rows only: the provider model id or OpenRouter slug. */
38
+ providerModel?: string
23
39
  origin: string
24
40
  queryPreview?: string
25
41
  outputPreview?: string
@@ -42,6 +58,11 @@ export function getOllamaLedgerPath(): string {
42
58
  return resolve(process.env.COS_OLLAMA_RUN_LEDGER_FILE || resolve(import.meta.dirname, '..', 'data', 'ollama-runs.jsonl'))
43
59
  }
44
60
 
61
+ /** Direct (Lane C1) runs. Written only by a keyed turn, so nothing is created while lanes are off. */
62
+ export function getDirectRunLedgerPath(): string {
63
+ return resolve(process.env.COS_DIRECT_RUN_LEDGER_FILE || dataPath('direct-runs.jsonl'))
64
+ }
65
+
45
66
  function getMaxRuns(): number {
46
67
  const raw = Number(process.env.COS_OLLAMA_RUN_LEDGER_MAX ?? DEFAULT_MAX_RUNS)
47
68
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_RUNS
@@ -52,9 +73,8 @@ function getTtlMs(): number {
52
73
  return Number.isFinite(rawDays) && rawDays > 0 ? rawDays * 24 * 60 * 60_000 : DEFAULT_TTL_MS
53
74
  }
54
75
 
55
- function appendEvent(event: OllamaRunEvent): void {
76
+ function appendEvent(event: OllamaRunEvent, path: string = getOllamaLedgerPath()): void {
56
77
  try {
57
- const path = getOllamaLedgerPath()
58
78
  mkdirSync(dirname(path), { recursive: true })
59
79
  appendFileSync(path, JSON.stringify(event) + '\n')
60
80
  } catch (err) {
@@ -62,8 +82,7 @@ function appendEvent(event: OllamaRunEvent): void {
62
82
  }
63
83
  }
64
84
 
65
- function readEvents(): OllamaRunEvent[] {
66
- const path = getOllamaLedgerPath()
85
+ function readEvents(path: string = getOllamaLedgerPath()): OllamaRunEvent[] {
67
86
  if (!existsSync(path)) return []
68
87
  try {
69
88
  const events: OllamaRunEvent[] = []
@@ -129,23 +148,29 @@ export function startOllamaRun(input: {
129
148
  return run
130
149
  }
131
150
 
132
- export function finishOllamaRun(runId: string, input: {
151
+ interface FinishRunInput {
133
152
  status: Exclude<OllamaRunStatus, 'running'>
134
153
  startedAtMs: number
135
154
  output?: string
136
155
  error?: string
137
- }): OllamaRunRecord | null {
156
+ }
157
+
158
+ function finishRun(path: string, classify: (message: string) => string, runId: string, input: FinishRunInput): void {
138
159
  const patch: Partial<OllamaRunRecord> = {
139
160
  status: input.status,
140
161
  durationMs: Math.max(0, Date.now() - input.startedAtMs),
141
162
  }
142
163
  if (input.output) patch.outputPreview = redactForOllamaLedger(input.output)
143
164
  if (input.error) {
144
- patch.errorCode = classifyOllamaError(input.error)
165
+ patch.errorCode = classify(input.error)
145
166
  patch.errorPreview = redactForOllamaLedger(input.error)
146
167
  }
147
168
  const ts = new Date().toISOString()
148
- appendEvent({ runId, ts, patch: { ...patch, updatedAt: ts } })
169
+ appendEvent({ runId, ts, patch: { ...patch, updatedAt: ts } }, path)
170
+ }
171
+
172
+ export function finishOllamaRun(runId: string, input: FinishRunInput): OllamaRunRecord | null {
173
+ finishRun(getOllamaLedgerPath(), classifyOllamaError, runId, input)
149
174
  return getOllamaRun(runId)
150
175
  }
151
176
 
@@ -154,10 +179,14 @@ export function getOllamaRun(runId: string): OllamaRunRecord | null {
154
179
  }
155
180
 
156
181
  export function listOllamaRuns(limit = 20, sessionId?: string): OllamaRunRecord[] {
182
+ return listRuns(getOllamaLedgerPath(), 'ollama.timeout', limit, sessionId)
183
+ }
184
+
185
+ function listRuns(path: string, staleCode: string, limit: number, sessionId?: string): OllamaRunRecord[] {
157
186
  const runs = new Map<string, OllamaRunRecord>()
158
187
  const order = new Map<string, number>()
159
188
  let eventIndex = 0
160
- for (const event of readEvents()) {
189
+ for (const event of readEvents(path)) {
161
190
  eventIndex += 1
162
191
  const existing = runs.get(event.runId)
163
192
  const next = { ...(existing ?? {}), ...event.patch, runId: event.runId } as OllamaRunRecord
@@ -175,7 +204,7 @@ export function listOllamaRuns(limit = 20, sessionId?: string): OllamaRunRecord[
175
204
  return {
176
205
  ...run,
177
206
  status: 'client_disconnected' as const,
178
- errorCode: run.errorCode ?? 'ollama.timeout',
207
+ errorCode: run.errorCode ?? staleCode,
179
208
  }
180
209
  }
181
210
  return run
@@ -183,3 +212,57 @@ export function listOllamaRuns(limit = 20, sessionId?: string): OllamaRunRecord[
183
212
  .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
184
213
  .slice(0, limit)
185
214
  }
215
+
216
+ // ── Direct runs (key lanes K1 P5, plan v4 9.1) ───────────────────────────────
217
+
218
+ /** `direct.<failure>` for a key-lane failure kind, else the closest generic code. */
219
+ export function classifyDirectError(message: string): string {
220
+ const m = /^direct-bridge: \[([a-z_]+)\]/.exec(message)
221
+ if (m) return `direct.${m[1]}`
222
+ const text = message.toLowerCase()
223
+ if (/tool round cap/.test(text)) return 'direct.tool_cap'
224
+ if (/tool aborted|tool call aborted/.test(text)) return 'direct.tool_abort'
225
+ if (/timeout|timed out|aborted/.test(text)) return 'direct.timeout'
226
+ return 'direct.error'
227
+ }
228
+
229
+ export function startDirectRun(input: {
230
+ turnId?: string
231
+ clientJobId?: string
232
+ cosSessionId: string
233
+ /** The COS preference the exchange is stamped with, `profile:<id>`. */
234
+ model: string
235
+ profile: string
236
+ providerModel: string
237
+ /** Endpoint origin only (scheme, host, port). Never a path, query or key. */
238
+ origin: string
239
+ query: string
240
+ }): OllamaRunRecord {
241
+ const now = new Date().toISOString()
242
+ const run: OllamaRunRecord = {
243
+ runId: `direct-${crypto.randomUUID().slice(0, 8)}`,
244
+ turnId: input.turnId,
245
+ clientJobId: input.clientJobId,
246
+ cosSessionId: input.cosSessionId,
247
+ status: 'running',
248
+ createdAt: now,
249
+ updatedAt: now,
250
+ model: input.model,
251
+ lane: 'direct',
252
+ profile: input.profile,
253
+ providerModel: input.providerModel,
254
+ origin: input.origin,
255
+ queryPreview: redactForOllamaLedger(input.query),
256
+ }
257
+ appendEvent({ runId: run.runId, ts: now, patch: run }, getDirectRunLedgerPath())
258
+ return run
259
+ }
260
+
261
+ export function finishDirectRun(runId: string, input: FinishRunInput): OllamaRunRecord | null {
262
+ finishRun(getDirectRunLedgerPath(), classifyDirectError, runId, input)
263
+ return listDirectRuns(getMaxRuns()).find(run => run.runId === runId) ?? null
264
+ }
265
+
266
+ export function listDirectRuns(limit = 20, sessionId?: string): OllamaRunRecord[] {
267
+ return listRuns(getDirectRunLedgerPath(), 'direct.timeout', limit, sessionId)
268
+ }
@@ -1,4 +1,5 @@
1
1
  import { spawn } from 'node:child_process'
2
+ import { claudeKeySecretsGuardArgs } from './key-secrets-guard.js'
2
3
 
3
4
  export const PROMPT_EDIT_DRAFT_MAX_CHARS = 24_000
4
5
  export const PROMPT_EDIT_INSTRUCTION_MAX_CHARS = 2_000
@@ -90,6 +91,7 @@ export function spawnClaudeText(prompt: string, opts: SpawnClaudeTextOptions = {
90
91
  const args = ['-p', '--model', model, '--effort', effort, '--output-format', 'text', '--no-session-persistence',
91
92
  '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}']
92
93
  if (opts.systemPrompt) args.push('--system-prompt', opts.systemPrompt)
94
+ args.push(...claudeKeySecretsGuardArgs())
93
95
 
94
96
  const proc = spawn('claude', args, {
95
97
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -31,6 +31,7 @@ import {
31
31
  type QueryJobSteerState,
32
32
  type QueryJobStoreHealth,
33
33
  } from './query-job-types.js'
34
+ import type { QueryJobKeyAnswerFields } from './key-answer-fields.js'
34
35
  import { SteerPort, steerClock, steerCutoffMs, type PendingSteer, type QueryProvider } from './claude-steer.js'
35
36
  import type { MaintenanceWorkLease } from './maintenance-lifecycle.js'
36
37
  import { applyTrailToolMode, type JobTrailDraft } from './job-trail.js'
@@ -43,7 +44,7 @@ export interface QueryJobRunnerStart extends QueryJobProviderLinkage {
43
44
  sessionId?: string
44
45
  }
45
46
 
46
- export interface QueryJobRunnerCompletion extends QueryJobProviderLinkage {
47
+ export interface QueryJobRunnerCompletion extends QueryJobProviderLinkage, QueryJobKeyAnswerFields {
47
48
  text: string
48
49
  attachments?: unknown
49
50
  outputImageStats?: QueryJobOutputImageStats
@@ -9,7 +9,7 @@ import { dataPath } from './data-dir.js'
9
9
  import { currentMessageEra, LEGACY_MESSAGE_ERA } from './message-era.js'
10
10
  import { durableQueryJobsEnabled, messagesSteerEnabled, messagesTrailEnabled } from './query-job-feature.js'
11
11
  import { claudeSupportsSteer } from './claude-cli-support.js'
12
- import { providerOfModel, resolveRunModel } from './claude-steer.js'
12
+ import { steerProviderFor } from './claude-steer.js'
13
13
  import { QueryJobCoordinator, type QueryJobRunner } from './query-job-coordinator.js'
14
14
  import { QueryJobStore, createQueryJobJournalStorage } from './query-job-store.js'
15
15
  import {
@@ -17,7 +17,6 @@ import {
17
17
  isCursorModel,
18
18
  isOllamaModel,
19
19
  normalizeEffortPreference,
20
- normalizeModelPreference,
21
20
  type CursorExecutionMode,
22
21
  type ModelPreference,
23
22
  } from '../../shared/model-preference.js'
@@ -26,10 +25,15 @@ import { attachmentHistoryPrefix, defaultAttachmentRequest } from '../../shared/
26
25
  import {
27
26
  findExchangesByJobIdentity,
28
27
  flushConversationToDisk,
28
+ getSessionBinding,
29
29
  getSessionModel,
30
30
  reconcileExchangeByJobIdentity,
31
31
  removeExchangesByJobIdentity,
32
+ type KeyProfileBinding,
33
+ type SessionModelBinding,
32
34
  } from './conversation.js'
35
+ import { parsePreference, profilePreference } from '../../shared/key-preference.js'
36
+ import { linkageProviderForEngine, profileSteerProvider, type KeyProfileTurn } from './key-profile-routing.js'
33
37
  import {
34
38
  isTerminalQueryJobStatus,
35
39
  type QueryJobProviderLinkage,
@@ -40,6 +44,7 @@ import { acquireMaintenanceWork } from './maintenance-lifecycle.js'
40
44
  import type { JobTrailDraft } from './job-trail.js'
41
45
  import { registerMessageReservationSource } from './message-reservations.js'
42
46
  import { bootResumeGate } from './boot-resume.js'
47
+ import { keyAnswerCompletionFields, keyAnswerDoneFields } from './key-answer-fields.js'
43
48
 
44
49
  const TOOL_STATUS_MESSAGES: Record<string, string> = {
45
50
  WebSearch: 'Searching web...',
@@ -118,6 +123,38 @@ export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<
118
123
  }
119
124
  }
120
125
 
126
+ /**
127
+ * Plan 5.2 Accept by syntax for the terminal projection: a model slot exactly as
128
+ * normalizeModelPreference gives it, or `profile:<id>`. Same rule as conversation's
129
+ * normalizeSessionBinding, computed here so the projection needs nothing new from the store.
130
+ */
131
+ function requestBinding(model: unknown): SessionModelBinding | undefined {
132
+ const parsed = parsePreference(model)
133
+ if (!parsed) return undefined
134
+ return parsed.kind === 'model' ? parsed.value : profilePreference(parsed.id) as KeyProfileBinding
135
+ }
136
+
137
+ /** Linkage provider for a keyed turn: the profile's engine. Direct (Lane C) carries none. */
138
+ function keyProfileLinkageProvider(turn: KeyProfileTurn): { provider?: 'claude' | 'codex' } {
139
+ const provider = linkageProviderForEngine(turn.profile.engine)
140
+ return provider ? { provider } : {}
141
+ }
142
+
143
+ /**
144
+ * The steer gate's provider for a job (P6 owns this; P3 consumes it). With no profile anywhere
145
+ * this is exactly `providerOfModel(resolveRunModel(...))`, as in 6.78.0.
146
+ */
147
+ export function steerProviderForJob(request: Pick<QueryJobRequest, 'model' | 'sessionId'>): ReturnType<typeof profileSteerProvider> {
148
+ const verdict = steerProviderFor(
149
+ request.model,
150
+ getSessionModel(request.sessionId),
151
+ process.env.COS_G2_DEFAULT_MODEL,
152
+ () => getSessionBinding(request.sessionId),
153
+ )
154
+ if (verdict.kind === 'profile_session') return profileSteerProvider(verdict.profileId)
155
+ return verdict.provider
156
+ }
157
+
121
158
  function providerFor(model: ModelPreference): 'claude' | 'codex' | 'cursor' | 'ollama' {
122
159
  if (isOllamaModel(model)) return 'ollama'
123
160
  if (isCursorModel(model)) return 'cursor'
@@ -167,7 +204,7 @@ export async function projectPublicConversationTerminal(
167
204
  request.globalMsgNum,
168
205
  request.attachmentRefs,
169
206
  request.messageEra,
170
- normalizeModelPreference(request.model),
207
+ requestBinding(request.model),
171
208
  request.origin,
172
209
  )
173
210
  reconcileExchangeByJobIdentity(
@@ -178,7 +215,7 @@ export async function projectPublicConversationTerminal(
178
215
  request.globalMsgNum,
179
216
  mergeMediaAttachmentRefs(outputAttachments, existingOutputAttachments),
180
217
  request.messageEra,
181
- normalizeModelPreference(request.model),
218
+ requestBinding(request.model),
182
219
  request.origin,
183
220
  )
184
221
  flushConversationToDisk()
@@ -204,19 +241,29 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
204
241
  sessionId: request.sessionId,
205
242
  })
206
243
  const imageInputs = resolvedAttachments.inputs.length > 0 ? resolvedAttachments.inputs : undefined
207
- const validModel = normalizeModelPreference(request.model)
244
+ // Key lanes K1 (plan 5.2): Accept. A `profile:<id>` goes to model-router, which runs it on the
245
+ // profile's engine or refuses it; the closed slot keeps every family guard below unchanged.
246
+ const requestedBinding = parsePreference(request.model)
247
+ const validModel = requestedBinding?.kind === 'model' ? requestedBinding.value : undefined
208
248
  const validEffort = normalizeEffortPreference(request.effort)
209
249
  let activeModel = validModel
250
+ // Set only by model-router's onKeyProfileTurn, so a subscription turn never sees it.
251
+ let keyTurn: KeyProfileTurn | undefined
252
+ const linkageFor = (model: ModelPreference) => keyTurn
253
+ ? { ...keyProfileLinkageProvider(keyTurn), resolvedModel: keyTurn.binding }
254
+ : { provider: providerFor(model), resolvedModel: model }
210
255
 
211
256
  await callModelStreaming(
212
257
  request.query,
213
258
  request.sessionId,
214
259
  {
260
+ ...(requestedBinding?.kind === 'model' ? {} : {
261
+ onKeyProfileTurn: (turn: KeyProfileTurn) => { keyTurn = turn },
262
+ }),
215
263
  onStart: async (model, sessionId, cliSessionId, metadata) => {
216
264
  activeModel = model
217
265
  const linkage = {
218
- provider: providerFor(model),
219
- resolvedModel: model,
266
+ ...linkageFor(model),
220
267
  cliSessionId,
221
268
  claudeRunId: metadata?.claudeRunId,
222
269
  codexRunId: metadata?.codexRunId,
@@ -232,7 +279,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
232
279
  turnId,
233
280
  messageEra: request.messageEra,
234
281
  globalMsgNum: request.globalMsgNum,
235
- model,
282
+ model: keyTurn ? keyTurn.binding : model,
236
283
  sessionId,
237
284
  cliSessionId,
238
285
  ...metadata,
@@ -241,7 +288,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
241
288
  },
242
289
  onProviderProcess: metadata => callbacks.onProviderProcess({
243
290
  provider: metadata.provider,
244
- ...(activeModel ? { resolvedModel: activeModel } : {}),
291
+ ...(keyTurn ? { resolvedModel: keyTurn.binding } : activeModel ? { resolvedModel: activeModel } : {}),
245
292
  ...(metadata.provider === 'claude'
246
293
  ? { claudeRunId: metadata.runId }
247
294
  : metadata.provider === 'cursor'
@@ -273,7 +320,8 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
273
320
  onTrail: (draft: JobTrailDraft) => callbacks.onTrail(draft),
274
321
  } : {}),
275
322
  onAnswerReady: text => callbacks.onAnswerReady(text, {
276
- ...(activeModel ? { provider: providerFor(activeModel), resolvedModel: activeModel } : {}),
323
+ ...(keyTurn ? { ...keyProfileLinkageProvider(keyTurn), resolvedModel: keyTurn.binding }
324
+ : activeModel ? { provider: providerFor(activeModel), resolvedModel: activeModel } : {}),
277
325
  }),
278
326
  onDone: async (fullText, model, cliSessionId, metadata) => {
279
327
  const attachments = mergeMediaAttachmentRefs(
@@ -287,8 +335,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
287
335
  ? acquireMaintenanceWork('query_attachment_write', { allowDuringDrain: true })
288
336
  : undefined
289
337
  const linkage = {
290
- provider: providerFor(model),
291
- resolvedModel: model,
338
+ ...linkageFor(model),
292
339
  cliSessionId,
293
340
  claudeRunId: metadata?.claudeRunId,
294
341
  codexRunId: metadata?.codexRunId,
@@ -304,6 +351,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
304
351
  attachments,
305
352
  outputImageStats: metadata?.outputImageStats,
306
353
  ...linkage,
354
+ ...keyAnswerCompletionFields(metadata),
307
355
  })
308
356
  if (!terminalOwned) return
309
357
  if (resolvedAttachments.ids.length > 0) {
@@ -323,9 +371,10 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
323
371
  globalMsgNum: request.globalMsgNum,
324
372
  text: fullText,
325
373
  sessionId: request.sessionId,
326
- model,
374
+ model: keyTurn ? keyTurn.binding : model,
327
375
  cliSessionId,
328
376
  ...runMetadata,
377
+ ...keyAnswerDoneFields(runMetadata),
329
378
  ...(attachments.length > 0 ? { attachments } : {}),
330
379
  ...originStamp(request),
331
380
  } })
@@ -348,7 +397,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
348
397
  } })
349
398
  },
350
399
  },
351
- validModel,
400
+ requestedBinding?.kind === 'profile' ? requestBinding(request.model) : validModel,
352
401
  imageInputs,
353
402
  request.reference,
354
403
  request.globalMsgNum,
@@ -371,6 +420,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
371
420
  sessionLockHeld: true,
372
421
  ...(request.dispatch ? { dispatch: request.dispatch } : {}),
373
422
  ...(request.sessionName ? { sessionName: request.sessionName } : {}),
423
+ ...(request.keyTurnOnly ? { keyTurnOnly: true } : {}),
374
424
  // 6.78.0: only on a run the coordinator decided is steerable.
375
425
  ...(steer ? { steer } : {}),
376
426
  },
@@ -399,9 +449,11 @@ export const queryJobCoordinator = new QueryJobCoordinator(queryJobStore, runner
399
449
  bootResumeGate,
400
450
  // 6.78.0 Messages steer (COS_MESSAGES_STEER, default off). The provider is resolved as
401
451
  // model-router resolves it, so a run is steerable only when it will run on Claude.
452
+ // Key lanes K1 (plan 5.2): claude-steer stays closed and names a profile-bound job
453
+ // `profile_session`; the profile's engine then decides (only Lane B Claude steers).
402
454
  steer: {
403
455
  enabled: () => messagesSteerEnabled(),
404
- provider: request => providerOfModel(resolveRunModel(request.model, getSessionModel(request.sessionId), process.env.COS_G2_DEFAULT_MODEL)),
456
+ provider: request => steerProviderForJob(request),
405
457
  cliSupported: () => claudeSupportsSteer(),
406
458
  },
407
459
  })
@@ -39,6 +39,7 @@ import {
39
39
  type QueryJobSteerState,
40
40
  type QueryJobTrailEntry,
41
41
  } from './query-job-types.js'
42
+ import { keyAnswerJobFields, type QueryJobKeyAnswerFields } from './key-answer-fields.js'
42
43
  import {
43
44
  jobTrailTextChars,
44
45
  parseJobTrailEventData,
@@ -868,6 +869,7 @@ export class QueryJobStore {
868
869
  const outputImageStats = parseQueryJobOutputImageStats(patch.outputImageStats)
869
870
  if (outputImageStats) snapshot.outputImageStats = outputImageStats
870
871
  else delete snapshot.outputImageStats
872
+ Object.assign(snapshot, keyAnswerJobFields(patch))
871
873
  this.applyLinkage(snapshot, patch)
872
874
  delete snapshot.error
873
875
  } else if (record.type === 'failed' || record.type === 'canceled' || record.type === 'interrupted') {
@@ -1466,13 +1468,15 @@ export class QueryJobStore {
1466
1468
 
1467
1469
  async complete(
1468
1470
  jobId: string,
1469
- input: { text: string; attachments?: unknown; outputImageStats?: unknown } & QueryJobProviderLinkage,
1471
+ input: { text: string; attachments?: unknown; outputImageStats?: unknown } & QueryJobProviderLinkage & QueryJobKeyAnswerFields,
1470
1472
  ): Promise<QueryJobMutationResult> {
1471
1473
  const response = boundedText(input.text, QUERY_JOB_LIMITS.terminalResponseChars)
1472
1474
  const partial = boundedText(input.text, QUERY_JOB_LIMITS.partialChars)
1473
1475
  const linkage = this.safeLinkage(input)
1474
1476
  const attachments = parseMediaAttachmentRefs(input.attachments)
1475
1477
  const outputImageStats = parseQueryJobOutputImageStats(input.outputImageStats)
1478
+ // Key lanes (P11): {} unless a key profile answered, so a subscription record is unchanged.
1479
+ const keyAnswer = keyAnswerJobFields(input)
1476
1480
  const completedAt = this.now().toISOString()
1477
1481
  return this.transition(jobId, 'completed', 'completed', {
1478
1482
  ...linkage,
@@ -1483,12 +1487,14 @@ export class QueryJobStore {
1483
1487
  partialTruncated: partial.truncated,
1484
1488
  attachments,
1485
1489
  ...(outputImageStats ? { outputImageStats } : {}),
1490
+ ...keyAnswer,
1486
1491
  }, {
1487
1492
  ...linkage,
1488
1493
  response: response.text,
1489
1494
  responseTruncated: response.truncated,
1490
1495
  attachments,
1491
1496
  ...(outputImageStats ? { outputImageStats } : {}),
1497
+ ...keyAnswer,
1492
1498
  })
1493
1499
  }
1494
1500
 
@@ -1,5 +1,7 @@
1
1
  import { providerFailureCode } from './provider-failure-text.js'
2
2
  import { createHash } from 'node:crypto'
3
+ import type { QueryJobKeyAnswerFields } from './key-answer-fields.js'
4
+ import { isProfilePreference } from '../../shared/key-preference.js'
3
5
  import {
4
6
  parseMediaAttachmentRefs,
5
7
  parseMediaIdList,
@@ -132,6 +134,12 @@ export interface QueryJobRequest {
132
134
  * it names the session, it does not change what is asked.
133
135
  */
134
136
  sessionName?: string
137
+ /**
138
+ * Key lanes (QA U9 / C10): run THIS turn on the key profile it names without binding the
139
+ * session to it (the plan-limit "run this one on your key" offer). Kept only with a
140
+ * `profile:<id>` model; in the fingerprint, since it changes what the turn does.
141
+ */
142
+ keyTurnOnly?: true
135
143
  /**
136
144
  * 6.78.0: the client can steer this run (it read `durableQueryJobs.steer`). Only `true` is
137
145
  * kept. Excluded from the fingerprint: it says how the answer may be guided, not what is
@@ -437,6 +445,11 @@ export interface QueryJobSnapshot extends QueryJobProviderLinkage {
437
445
  response?: string
438
446
  responseTruncated?: boolean
439
447
  outputImageStats?: QueryJobOutputImageStats
448
+ /** Key lanes (P11, plan 3.1): present only on a job a key profile answered; omitted, never null. */
449
+ lane?: QueryJobKeyAnswerFields['lane']
450
+ credential?: QueryJobKeyAnswerFields['credential']
451
+ profile?: string
452
+ usd?: number
440
453
  error?: QueryJobError
441
454
  /** 6.78.0: what a restart cut off (see QueryJobInterruption). Only on a job a restart ended. */
442
455
  interruption?: QueryJobInterruption
@@ -657,6 +670,7 @@ export function parseQueryJobRequest(raw: unknown, options: ParseQueryJobRequest
657
670
  ...(input.handoffLatest === true ? { handoffLatest: true } : {}),
658
671
  ...(clientQueueItemId ? { clientQueueItemId } : {}),
659
672
  ...(sessionName ? { sessionName } : {}),
673
+ ...(input.keyTurnOnly === true && isProfilePreference(model) ? { keyTurnOnly: true as const } : {}),
660
674
  ...(input.steerable === true ? { steerable: true as const } : {}),
661
675
  attachmentIds,
662
676
  attachmentRefs,
@@ -721,7 +735,7 @@ export const FINGERPRINT_KEYS = [
721
735
  'clientJobId', 'generation', 'query', 'sessionId', 'model', 'effort',
722
736
  'cursorExecutionMode', 'messageEra', 'globalMsgNum', 'reference', 'handoffCode',
723
737
  'handoffLatest', 'clientQueueItemId', 'attachmentIds', 'attachmentRefs',
724
- 'activityToolMode',
738
+ 'activityToolMode', 'keyTurnOnly',
725
739
  ] as const satisfies readonly (keyof QueryJobRequest)[]
726
740
 
727
741
  /** The request keys deliberately OUTSIDE the identity. Every key of
@@ -14,6 +14,10 @@
14
14
  // whose shapes are still the documented ones until a Desktop recording lands.
15
15
 
16
16
  import { createHash } from 'node:crypto'
17
+ import { profileIdForCodexPath } from './codex-homes.js'
18
+ import { isKeyProfileId } from '../../shared/key-preference.js'
19
+ import { keyLanesEnabled } from './key-lanes.js'
20
+ import { keyedSessionProfile } from './keyed-sessions.js'
17
21
 
18
22
  /** Events the installer subscribes and the reducer understands. Anything else is dropped. */
19
23
  export const HOOK_EVENT_NAMES = [
@@ -71,9 +75,29 @@ export function inferHookProvider(stamped: unknown, payload: Record<string, unkn
71
75
  if (isHookProvider(stamped)) return stamped
72
76
  if ('cursor_version' in payload) return 'cursor'
73
77
  if (typeof payload.transcript_path === 'string' && payload.transcript_path.includes('/.codex/sessions/')) return 'codex'
78
+ // Key lanes K1 (plan v4 8.2): a keyed Codex profile home keeps its rollouts under
79
+ // `data/codex-homes/<id>/sessions/`, which never contains `/.codex/`.
80
+ if (profileIdForCodexPath(payload.transcript_path) !== undefined) return 'codex'
74
81
  return 'claude'
75
82
  }
76
83
 
84
+ /**
85
+ * Key lanes K1 (plan v4 7.3): the key profile a hook event ran under, or undefined. Read from
86
+ * the envelope's own `keyProfile` stamp (a hook script that forwards `COS_KEY_PROFILE`), else
87
+ * from a transcript inside a keyed Codex profile home, else (canary R7: the installed hook script
88
+ * does not stamp it, and changing the script would mark every install outdated) from the
89
+ * keyed-session record the bridge wrote for that CLI session id. Lanes off: never looked up.
90
+ * Undefined for every Lane A event.
91
+ */
92
+ export function hookKeyProfile(stamped: unknown, payload: Record<string, unknown>, sessionId?: string | null): string | undefined {
93
+ if (isKeyProfileId(stamped)) return stamped
94
+ const codex = profileIdForCodexPath(payload.transcript_path)
95
+ if (codex) return codex
96
+ if (!sessionId || !keyLanesEnabled()) return undefined
97
+ const recorded = keyedSessionProfile(sessionId)
98
+ return isKeyProfileId(recorded) ? recorded : undefined
99
+ }
100
+
77
101
  /** 6.62.0: the cap on an agent thought kept in memory and sent on the live feed. */
78
102
  export const AGENT_THOUGHT_MAX = 280
79
103
 
@@ -99,6 +123,8 @@ export interface HookEnvelope {
99
123
  payload: Record<string, unknown>
100
124
  /** 6.62.0: set by `parseHookEnvelope` and a 6.62 ledger row; absent on older rows and hand-built envelopes. */
101
125
  provider?: HookProvider
126
+ /** Key lanes K1 (plan v4 7.3): the key profile this event ran under. Absent on every Lane A envelope. */
127
+ keyProfile?: string
102
128
  /**
103
129
  * 6.62.0 (QA W2): the envelope itself named the provider: a 6.62 hook script (from the Codex
104
130
  * command, or from a Codex payload) or the Cursor observer wrote it. Inferred is not stamped.
@@ -150,6 +176,7 @@ export function parseHookEnvelope(text: string): ParsedHookEnvelope {
150
176
  const sessionId = typeof p.session_id === 'string' && SESSION_ID_RE.test(p.session_id) ? p.session_id.toLowerCase() : null
151
177
  if (!sessionId) return { ok: false, reason: 'no_session', salvaged: null }
152
178
  const ppid = Number(r.ppid)
179
+ const keyProfile = hookKeyProfile(r.keyProfile, p, sessionId)
153
180
  return {
154
181
  ok: true,
155
182
  envelope: {
@@ -160,6 +187,7 @@ export function parseHookEnvelope(text: string): ParsedHookEnvelope {
160
187
  payload: p,
161
188
  provider: inferHookProvider(r.provider, p),
162
189
  ...(isHookProvider(r.provider) ? { stamped: true as const } : {}),
190
+ ...(keyProfile ? { keyProfile } : {}),
163
191
  },
164
192
  }
165
193
  }
@@ -9,6 +9,8 @@
9
9
  import { appendFileSync, existsSync, readFileSync, renameSync, statSync } from 'node:fs'
10
10
  import type { HookEnvelope, HookEventName, HookProvider } from './session-hook-events.js'
11
11
  import { projectHookPayload, isHookEventName, isHookProvider, SESSION_ID_RE } from './session-hook-events.js'
12
+ import { keyLanesEnabled } from './key-lanes.js'
13
+ import { isKeyProfileId } from '../../shared/key-preference.js'
12
14
 
13
15
  export const LEDGER_ROTATE_BYTES = 10 * 1024 * 1024
14
16
 
@@ -30,6 +32,12 @@ export interface LedgerRow {
30
32
  provider?: HookProvider
31
33
  /** 6.62.0 (QA W2): the envelope carried the provider stamp; a replay keeps the evidence. */
32
34
  stamped?: true
35
+ /**
36
+ * Key lanes K1 (canary rerun R8): the key profile the event ran under, as `hookKeyProfile`
37
+ * resolved it at ingest (for a Codex thread, from the keyed-thread record the bridge wrote).
38
+ * Written only while lanes are on, so a row with lanes off is byte-identical to before.
39
+ */
40
+ keyProfile?: string
33
41
  payload: Record<string, unknown>
34
42
  }
35
43
 
@@ -77,6 +85,7 @@ export class SessionHookLedger {
77
85
  ...(entrypoint ? { entrypoint } : {}),
78
86
  ...(env.provider ? { provider: env.provider } : {}),
79
87
  ...(env.stamped ? { stamped: true as const } : {}),
88
+ ...(env.keyProfile && keyLanesEnabled() ? { keyProfile: env.keyProfile } : {}),
80
89
  payload: projectHookPayload(env.event, env.payload),
81
90
  }
82
91
  } catch (error) {
@@ -137,6 +146,7 @@ export class SessionHookLedger {
137
146
  payload: { session_id: r.session_id, ...payload },
138
147
  ...(isHookProvider(r.provider) ? { provider: r.provider } : {}),
139
148
  ...(r.stamped === true && isHookProvider(r.provider) ? { stamped: true as const } : {}),
149
+ ...(isKeyProfileId(r.keyProfile) ? { keyProfile: r.keyProfile } : {}),
140
150
  }, r.key, r.child === true, entrypoint)
141
151
  applied++
142
152
  }