@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
@@ -86,6 +86,24 @@ import { terminalProviderAuthFailure } from './provider-terminal-error.js'
86
86
  import { terminateProviderProcess } from './provider-process-lifecycle.js'
87
87
  import { teeJobTrail } from './job-trail.js'
88
88
  import { applyTurnRequestEnv, createTurnRequestBinding, spawnWithTurnRequest } from './turn-request.js'
89
+ // Key lanes K1 P4 (plan v4 8): Lane B Codex. Every use below sits behind `options.keyProfile`;
90
+ // without one, argv, env, the catalog read and the resume binding are the 963fe25 ones.
91
+ import { randomUUID } from 'node:crypto'
92
+ import { profilePreference } from '../../shared/key-preference.js'
93
+ import type { AnswerKeyFields, KeyKind, KeyProfile, RelayTurnHandle } from './key-relay-contract.js'
94
+ import { getSharedKeyMeter } from './key-relay-meter.js'
95
+ import {
96
+ CODEX_LANE_B_COPY,
97
+ CODEX_RELAY_PROVIDER_ID,
98
+ applyCodexLaneBEnv,
99
+ codexKeyRelay,
100
+ codexProfilePreflight,
101
+ recordKeyedCodexThread,
102
+ renderCodexProfileHome,
103
+ writeCodexProfileHome,
104
+ type CodexKeyLaneCallOptions,
105
+ type RenderedCodexProfileHome,
106
+ } from './codex-profile-home.js'
89
107
 
90
108
  const INACTIVITY_MS = 180_000
91
109
  const WALL_MAX_MS = 900_000
@@ -240,6 +258,50 @@ export function buildCodexExecArgs(input: {
240
258
  return args
241
259
  }
242
260
 
261
+ /**
262
+ * Key lanes (plan v4 8.1): argv for a keyed Codex turn. The user's COS_CODEX_EXTRA_ARGS, the
263
+ * ChatGPT model catalog and its effort and service-tier settings never reach it: the profile's
264
+ * slug goes on `--model` and the generated home's relay is selected as the provider. Sandbox,
265
+ * publisher directory, resume and image handling are the subscription ones.
266
+ */
267
+ export function buildCodexProfileExecArgs(input: {
268
+ codexCwd: string
269
+ imagePaths?: string[]
270
+ persistentCodexSession: boolean
271
+ codexThreadId?: string
272
+ profileModel: string
273
+ publisherWritableDirectory?: string
274
+ }): string[] {
275
+ const imagePaths = input.imagePaths ?? []
276
+ const args = ['exec', ...codexSandboxArgs()]
277
+ if (input.publisherWritableDirectory) {
278
+ args.push('--add-dir', input.publisherWritableDirectory)
279
+ }
280
+ // A trusted project's own config could name another provider; argv wins over every layer.
281
+ args.push('-c', `model_provider="${CODEX_RELAY_PROVIDER_ID}"`)
282
+ if (input.codexThreadId) {
283
+ args.push('resume', '--json', '--all', '--model', input.profileModel)
284
+ for (const p of imagePaths) args.push('--image', p)
285
+ args.push(input.codexThreadId, '-')
286
+ return args
287
+ }
288
+ args.push('--json', '--cd', input.codexCwd, '--model', input.profileModel)
289
+ if (!input.persistentCodexSession) args.push('--ephemeral')
290
+ for (const p of imagePaths) args.push('--image', p)
291
+ args.push('-')
292
+ return args
293
+ }
294
+
295
+ /** Key lanes: a spawn that throws synchronously revokes the turn's nonce. Without one it is the call itself. */
296
+ function withKeyTurnRevokedOnThrow<T>(keyTurn: RelayTurnHandle | null, run: () => T): T {
297
+ try {
298
+ return run()
299
+ } catch (err) {
300
+ keyTurn?.revoke('cancel')
301
+ throw err
302
+ }
303
+ }
304
+
243
305
  function buildCodexPrompt(systemPrompt: string, fullQuery: string): string {
244
306
  return [
245
307
  'SYSTEM INSTRUCTIONS',
@@ -300,6 +362,23 @@ function toolStatus(event: any): string | undefined {
300
362
  return undefined
301
363
  }
302
364
 
365
+ /**
366
+ * Key lanes: the Codex runner in P6's engine-seam shape (`registerKeyProfileEngine('codex', ...)`
367
+ * in key-profile-routing.ts; P11 wires it). The turn's profile rides `options.keyProfile`.
368
+ */
369
+ export function runCodexKeyProfileTurn(
370
+ query: string,
371
+ sessionId: string,
372
+ callbacks: StreamCallbacks,
373
+ turn: { profile: KeyProfile },
374
+ images?: ModelImageInput[],
375
+ reference?: PromptReference,
376
+ globalMsgNum?: number,
377
+ options?: CallOptions,
378
+ ): Promise<string> {
379
+ return callCodexStreaming(query, sessionId, callbacks, undefined, images, reference, globalMsgNum, { ...options, keyProfile: turn.profile })
380
+ }
381
+
303
382
  function safeCodexUserError(message: string): string {
304
383
  const code = classifyCodexError(message)
305
384
  if (code === 'codex.cli_unavailable') return 'Codex CLI unavailable. Check server Settings.'
@@ -317,27 +396,55 @@ export async function callCodexStreaming(
317
396
  images?: ModelImageInput[],
318
397
  reference?: PromptReference,
319
398
  globalMsgNum?: number,
320
- options?: CallOptions,
399
+ options?: CallOptions & CodexKeyLaneCallOptions,
321
400
  ): Promise<string> {
322
401
  const sid = getOrCreateSession(sessionId)
323
- // Refresh is TTL-cached and coalesced, so every run sees the newest known
324
- // catalog without creating duplicate app-server discovery processes.
325
- await getCodexModelCatalog()
402
+ // Key lanes: a keyed turn passes every check before history, the run ledger or a process, and
403
+ // never reads the ChatGPT catalog or COS_CODEX_EXTRA_ARGS. A refusal ends the turn with its lens
404
+ // line; the subscription is never the fallback.
405
+ const keyProfile = options?.keyProfile
406
+ let keyKind: KeyKind | null = null
407
+ let profileHome: RenderedCodexProfileHome | null = null
408
+ if (keyProfile) {
409
+ const preflight = codexProfilePreflight({ profile: keyProfile, dispatch: !!options?.dispatch, cwd: getCodexExecutionCwd() })
410
+ if (!preflight.ok) {
411
+ await callbacks.onError(preflight.message)
412
+ return sid
413
+ }
414
+ keyKind = preflight.keyKind
415
+ try {
416
+ profileHome = renderCodexProfileHome(keyProfile)
417
+ } catch (err) {
418
+ console.error(`[codex-bridge] key profile home render failed: ${err instanceof Error ? err.message.slice(0, 200) : 'unknown'}`)
419
+ await callbacks.onError(CODEX_LANE_B_COPY.relayUnavailable)
420
+ return sid
421
+ }
422
+ } else {
423
+ // Refresh is TTL-cached and coalesced, so every run sees the newest known
424
+ // catalog without creating duplicate app-server discovery processes.
425
+ await getCodexModelCatalog()
426
+ }
326
427
  if (options?.abortSignal?.aborted) {
327
428
  throw new Error('codex-bridge: client disconnected before Codex started.')
328
429
  }
329
430
  let extra: string[]
330
- try {
331
- extra = parseCodexExtraArgs(process.env.COS_CODEX_EXTRA_ARGS)
332
- assertSafeCodexExtraArgs(extra)
333
- } catch (error) {
334
- if (error instanceof CodexExtraArgsError) {
335
- await callbacks.onError(error.message)
336
- return sid
431
+ if (keyProfile) {
432
+ extra = []
433
+ } else {
434
+ try {
435
+ extra = parseCodexExtraArgs(process.env.COS_CODEX_EXTRA_ARGS)
436
+ assertSafeCodexExtraArgs(extra)
437
+ } catch (error) {
438
+ if (error instanceof CodexExtraArgsError) {
439
+ await callbacks.onError(error.message)
440
+ return sid
441
+ }
442
+ throw error
337
443
  }
338
- throw error
339
444
  }
340
- const extraFingerprint = codexEngineFingerprint(extra)
445
+ // Plan 8.1 resume binding: a keyed thread resumes only under its own profile and generated
446
+ // config, and a subscription turn never resumes it (nor a keyed turn a subscription thread).
447
+ const extraFingerprint = profileHome ? profileHome.engineFingerprint : codexEngineFingerprint(extra)
341
448
  const history = getHistory(sid)
342
449
  const session = getSessionRaw(sid)
343
450
  const contextBreaks = session?.contextBreaks ?? []
@@ -346,8 +453,11 @@ export async function callCodexStreaming(
346
453
  const persistentCodexSession = isCodexPersistenceEnabled()
347
454
  const codexCwd = getCodexExecutionCwd()
348
455
  const codexTrustMode = getCodexTrustMode()
349
- const resolvedCodexModel = resolveCodexModelOption(model)
350
- const resolvedCodexEffort = resolveCodexEffortForModel(resolvedCodexModel, options?.effort)
456
+ // A keyed turn's model is the profile slug; the catalog is never consulted for it.
457
+ const resolvedCodexModel = keyProfile ? null : resolveCodexModelOption(model)
458
+ const resolvedCodexEffort = resolvedCodexModel ? resolveCodexEffortForModel(resolvedCodexModel, options?.effort) : 'provider-default'
459
+ // A keyed exchange is stamped with its profile (P6 persists it by syntax), never with the slot.
460
+ const exchangeModel = keyProfile ? profilePreference(keyProfile.id) as unknown as CodexModelPreference : model
351
461
  const engineSession = persistentCodexSession
352
462
  ? getCodexEngineSession({
353
463
  cosSessionId: sid,
@@ -355,6 +465,7 @@ export async function callCodexStreaming(
355
465
  cwd: codexCwd,
356
466
  trustMode: codexTrustMode,
357
467
  engineFingerprint: extraFingerprint,
468
+ ...(keyProfile ? { profileId: keyProfile.id } : {}),
358
469
  })
359
470
  : null
360
471
  const imageInputs: ModelImageInput[] = images ?? []
@@ -370,7 +481,7 @@ export async function callCodexStreaming(
370
481
  trustMode: codexTrustMode,
371
482
  codexThreadId: engineSession?.codexThreadId,
372
483
  expiresAt: engineSession?.expiresAt,
373
- cliModel: resolvedCodexModel.id || 'codex-cli-default',
484
+ cliModel: keyProfile ? keyProfile.model : (resolvedCodexModel?.id || 'codex-cli-default'),
374
485
  reasoningEffort: resolvedCodexEffort,
375
486
  query,
376
487
  })
@@ -438,7 +549,7 @@ export async function callCodexStreaming(
438
549
  globalMsgNum,
439
550
  inboundAttachments,
440
551
  exchangeProvenance,
441
- model,
552
+ exchangeModel,
442
553
  )
443
554
 
444
555
  const hasNonImageAttachment = requestRefs.some(ref => mediaCategoryOf(ref) !== 'image')
@@ -455,17 +566,26 @@ export async function callCodexStreaming(
455
566
  if (options?.attachmentPromptBlock) fullQuery = `${fullQuery}\n\n${options.attachmentPromptBlock}`
456
567
 
457
568
  const prompt = buildCodexPrompt(systemPrompt, fullQuery)
458
- const args = buildCodexExecArgs({
459
- codexCwd,
460
- imagePaths,
461
- persistentCodexSession,
462
- codexThreadId: engineSession?.codexThreadId,
463
- model,
464
- resolvedModel: resolvedCodexModel,
465
- effort: options?.effort,
466
- publisherWritableDirectory: outputImagePublisher?.writableDirectory,
467
- extra,
468
- })
569
+ const args = keyProfile
570
+ ? buildCodexProfileExecArgs({
571
+ codexCwd,
572
+ imagePaths,
573
+ persistentCodexSession,
574
+ codexThreadId: engineSession?.codexThreadId,
575
+ profileModel: keyProfile.model,
576
+ publisherWritableDirectory: outputImagePublisher?.writableDirectory,
577
+ })
578
+ : buildCodexExecArgs({
579
+ codexCwd,
580
+ imagePaths,
581
+ persistentCodexSession,
582
+ codexThreadId: engineSession?.codexThreadId,
583
+ model,
584
+ resolvedModel: resolvedCodexModel!,
585
+ effort: options?.effort,
586
+ publisherWritableDirectory: outputImagePublisher?.writableDirectory,
587
+ extra,
588
+ })
469
589
 
470
590
  const env = { ...process.env }
471
591
  delete env.CLAUDECODE
@@ -482,6 +602,43 @@ export async function callCodexStreaming(
482
602
  )
483
603
  }
484
604
 
605
+ // Key lanes: one nonce per turn from the relay (P2 contract), the generated home written with
606
+ // its origin, then the child env points at both. Only the child's copy is written. A relay
607
+ // that cannot issue, or a home that cannot be written, ends the turn before spawn.
608
+ let keyTurn: RelayTurnHandle | null = null
609
+ const keyTurnId = keyProfile && profileHome ? options?.turnId ?? randomUUID() : null
610
+ if (keyProfile && profileHome) {
611
+ try {
612
+ const relay = await codexKeyRelay()
613
+ keyTurn = await relay.issueTurn({
614
+ keyRef: keyProfile.keyRef,
615
+ profileId: keyProfile.id,
616
+ turnId: keyTurnId!,
617
+ sessionId: sid,
618
+ engine: 'codex',
619
+ allowedModel: keyProfile.model,
620
+ }, { wallMaxMs: WALL_MAX_MS })
621
+ writeCodexProfileHome(profileHome, keyTurn.origin)
622
+ applyCodexLaneBEnv(env, { home: profileHome.home, nonce: keyTurn.nonce, profileId: keyProfile.id })
623
+ } catch (err) {
624
+ keyTurn?.revoke('cancel')
625
+ outputImagePublisher?.cleanup()
626
+ cleanupModelImageInputs(imageInputs)
627
+ removeExchange(sid, pendingUserExchange)
628
+ try {
629
+ finishCodexRun(run.runId, { status: 'failed', startedAtMs: startTime, error: 'codex-bridge: key relay refused the turn.', exitCode: null })
630
+ } catch { /* the ledger is best effort here */ }
631
+ console.error(`[codex-bridge] key relay refused the turn: ${err instanceof Error ? err.message.slice(0, 200) : 'unknown'}`)
632
+ await callbacks.onError(CODEX_LANE_B_COPY.relayUnavailable)
633
+ return sid
634
+ }
635
+ }
636
+ const keyAuditFields = keyProfile && keyKind ? { profile: keyProfile.id, lane: 'key' as const, credentialKind: keyKind } : {}
637
+ // Key lanes (P11, plan 3.1): `lane` and `credential` on a keyed answer, omitted otherwise.
638
+ const keyLane: AnswerKeyFields | null = keyProfile && keyKind
639
+ ? { lane: 'key', credential: { kind: keyKind, ref: keyProfile.keyRef, profileId: keyProfile.id } }
640
+ : null
641
+
485
642
  // The COS UserPromptSubmit hook sees `prompt`, which is SYSTEM INSTRUCTIONS plus USER
486
643
  // REQUEST in one text, on a fresh thread and on resume alike. This private file hands it
487
644
  // the user's own words, bound by the hash of exactly what goes on stdin; the file dies
@@ -489,12 +646,12 @@ export async function callCodexStreaming(
489
646
  const turnRequest = createTurnRequestBinding({ userText: query, prompt, turnId: options?.turnId })
490
647
  applyTurnRequestEnv(env, turnRequest)
491
648
 
492
- const proc = spawnWithTurnRequest(turnRequest, () => spawn(resolvedCodex.path, args, {
649
+ const proc = withKeyTurnRevokedOnThrow(keyTurn, () => spawnWithTurnRequest(turnRequest, () => spawn(resolvedCodex.path, args, {
493
650
  stdio: ['pipe', 'pipe', 'pipe'],
494
651
  env,
495
652
  cwd: codexCwd,
496
653
  detached: true,
497
- }))
654
+ })))
498
655
 
499
656
  let fullText = ''
500
657
  let stderr = ''
@@ -519,7 +676,7 @@ export async function callCodexStreaming(
519
676
  function clearEngineSessionBestEffort(reason: string) {
520
677
  if (!engineSession) return
521
678
  try {
522
- clearCodexEngineSession(sid, model)
679
+ clearCodexEngineSession(sid, model, keyProfile?.id)
523
680
  } catch (error) {
524
681
  console.error(`[codex-bridge] engine session clear failed (${reason}):`, error)
525
682
  }
@@ -535,6 +692,7 @@ export async function callCodexStreaming(
535
692
  cwd: codexCwd,
536
693
  trustMode: codexTrustMode,
537
694
  engineFingerprint: extraFingerprint,
695
+ ...(keyProfile ? { profileId: keyProfile.id } : {}),
538
696
  })
539
697
  updateCodexRun(run.runId, { codexThreadId, expiresAt: saved.expiresAt })
540
698
  } catch (error) {
@@ -570,6 +728,8 @@ export async function callCodexStreaming(
570
728
  return
571
729
  }
572
730
  finalized = true
731
+ // Canary R14: the turn is over the moment the bridge says so; the child gets no more calls on the key.
732
+ keyTurn?.revoke('child_close')
573
733
  cleanup()
574
734
  cleanupImages()
575
735
 
@@ -615,7 +775,7 @@ export async function callCodexStreaming(
615
775
  globalMsgNum,
616
776
  undefined,
617
777
  exchangeProvenance,
618
- model,
778
+ exchangeModel,
619
779
  )
620
780
  if (imagePaths.length > 0) {
621
781
  replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
@@ -649,6 +809,8 @@ export async function callCodexStreaming(
649
809
  }
650
810
 
651
811
  const totalMs = Date.now() - startTime
812
+ // Key lanes (P11): this turn's settled spend on the key. Keyed turns only.
813
+ const keyUsdFields = keyTurnId ? { usd: Math.round(getSharedKeyMeter().turnSettledUsd(keyTurnId) * 1e6) / 1e6 } : {}
652
814
  logTokenAudit({
653
815
  source: options?.lightweight ? 'g2-voice' : 'g2-query',
654
816
  model,
@@ -656,6 +818,8 @@ export async function callCodexStreaming(
656
818
  outputChars: text.length,
657
819
  durationMs: totalMs,
658
820
  caller: options?.lightweight ? 'voice_query' : 'full_query',
821
+ ...keyAuditFields,
822
+ ...keyUsdFields,
659
823
  })
660
824
  saveEngineSessionBestEffort()
661
825
 
@@ -673,6 +837,7 @@ export async function callCodexStreaming(
673
837
  generation: options?.generation,
674
838
  ...(outputAttachments.length > 0 ? { outputAttachments } : {}),
675
839
  ...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
840
+ ...(keyLane ? { keyLane, ...keyUsdFields } : {}),
676
841
  })
677
842
  if (terminalOwned === false) return
678
843
  } catch (error) {
@@ -689,6 +854,8 @@ export async function callCodexStreaming(
689
854
  async function finalizeError(msg: string, exitCode?: number | null, status: Exclude<CodexRunStatus, 'running'> = 'failed') {
690
855
  if (finalized) return
691
856
  finalized = true
857
+ // Canary R14: a Codex child keeps retrying after the bridge ends the turn; its nonce dies now.
858
+ keyTurn?.revoke('cancel')
692
859
  cleanup()
693
860
  cleanupImages()
694
861
  outputImagePublisher?.cleanup()
@@ -739,6 +906,7 @@ export async function callCodexStreaming(
739
906
  }
740
907
 
741
908
  function handleAbort() {
909
+ keyTurn?.revoke('cancel')
742
910
  void terminateForTerminal('client disconnect', result => finalizeError(
743
911
  'codex-bridge: client disconnected before Codex completed.',
744
912
  result.code,
@@ -784,6 +952,9 @@ export async function callCodexStreaming(
784
952
  if (nextThreadId && nextThreadId !== codexThreadId) {
785
953
  codexThreadId = nextThreadId
786
954
  updateCodexRun(run.runId, { codexThreadId })
955
+ // Key lanes (plan 7.3): recorded before any answer text, so Continue, Fork, attach and
956
+ // handoff refuse this thread from the moment it exists.
957
+ if (keyProfile) recordKeyedCodexThread({ id: nextThreadId, engine: 'codex', profileId: keyProfile.id, cosSessionId: sid })
787
958
  }
788
959
  // 6.62.0 (plan 3.9): the job links to its native thread at the first event naming it,
789
960
  // not only at completion.
@@ -860,6 +1031,7 @@ export async function callCodexStreaming(
860
1031
  })
861
1032
 
862
1033
  proc.on('close', (code) => {
1034
+ keyTurn?.childClosed()
863
1035
  if (terminationRequested) return
864
1036
  if (buffer.trim()) {
865
1037
  try { handleEvent(JSON.parse(buffer.trim())) } catch { /* ignore */ }
@@ -880,6 +1052,7 @@ export async function callCodexStreaming(
880
1052
  })
881
1053
 
882
1054
  proc.on('error', (err) => {
1055
+ keyTurn?.revoke('cancel')
883
1056
  if (terminationRequested) return
884
1057
  finalizeError(`codex-bridge: ${err.message}`)
885
1058
  })
@@ -5,6 +5,7 @@ import {
5
5
  normalizeModelPreference,
6
6
  type CodexModelPreference,
7
7
  } from '../../shared/model-preference.js'
8
+ import { isKeyProfileId } from '../../shared/key-preference.js'
8
9
 
9
10
  export const CODEX_ENGINE_SESSION_TTL_MS = 2 * 60 * 60_000
10
11
 
@@ -21,6 +22,12 @@ export interface CodexEngineSession {
21
22
  lastUsedAt: number
22
23
  expiresAt: string
23
24
  engineFingerprint: string
25
+ /**
26
+ * Key lanes (P11): the key profile this thread runs under. Its own slot, so a keyed turn and a
27
+ * subscription turn of one COS session never overwrite each other's resume. Absent on every
28
+ * subscription row, which keeps its 6.79.0 key and bytes.
29
+ */
30
+ profileId?: string
24
31
  }
25
32
 
26
33
  interface CodexEngineSessionFile {
@@ -28,8 +35,12 @@ interface CodexEngineSessionFile {
28
35
  savedAt: string
29
36
  }
30
37
 
31
- function sessionKey(cosSessionId: string, model: CodexModelPreference): string {
32
- return `${cosSessionId}:${model}`
38
+ function sessionKey(cosSessionId: string, model: CodexModelPreference, profileId?: string): string {
39
+ return profileId ? `${cosSessionId}:${model}:profile:${profileId}` : `${cosSessionId}:${model}`
40
+ }
41
+
42
+ function slotProfile(raw: unknown): string | undefined {
43
+ return isKeyProfileId(raw) ? raw : undefined
33
44
  }
34
45
 
35
46
  export function getCodexEngineSessionPath(): string {
@@ -58,7 +69,8 @@ function readStore(): CodexEngineSessionFile {
58
69
  typeof raw?.lastUsedAt !== 'number' ||
59
70
  typeof raw?.expiresAt !== 'string'
60
71
  ) continue
61
- const key = sessionKey(raw.cosSessionId, model)
72
+ if (raw.profileId !== undefined && !slotProfile(raw.profileId)) continue
73
+ const key = sessionKey(raw.cosSessionId, model, slotProfile(raw.profileId))
62
74
  const normalized = { ...raw, key, model } as CodexEngineSession
63
75
  const prior = sessions[key]
64
76
  if (!prior || normalized.lastUsedAt > prior.lastUsedAt) sessions[key] = normalized
@@ -95,12 +107,13 @@ export function getCodexEngineSession(input: {
95
107
  cwd: string
96
108
  trustMode: CodexTrustMode
97
109
  engineFingerprint?: string
110
+ profileId?: string
98
111
  }): CodexEngineSession | null {
99
112
  const { engineFingerprint = '', ...rest } = input
100
113
  const store = readStore()
101
114
  const now = Date.now()
102
115
  const sessions = pruneExpired(store.sessions, now)
103
- const existing = sessions[sessionKey(rest.cosSessionId, rest.model)]
116
+ const existing = sessions[sessionKey(rest.cosSessionId, rest.model, slotProfile(rest.profileId))]
104
117
  if (!existing) {
105
118
  if (Object.keys(sessions).length !== Object.keys(store.sessions).length) {
106
119
  writeStore({ sessions, savedAt: new Date().toISOString() })
@@ -120,11 +133,13 @@ export function saveCodexEngineSession(input: {
120
133
  trustMode: CodexTrustMode
121
134
  now?: number
122
135
  engineFingerprint?: string
136
+ profileId?: string
123
137
  }): CodexEngineSession {
124
138
  const now = input.now ?? Date.now()
125
139
  const store = readStore()
126
140
  const sessions = pruneExpired(store.sessions, now)
127
- const key = sessionKey(input.cosSessionId, input.model)
141
+ const profileId = slotProfile(input.profileId)
142
+ const key = sessionKey(input.cosSessionId, input.model, profileId)
128
143
  const previous = sessions[key]
129
144
  const session: CodexEngineSession = {
130
145
  key,
@@ -137,13 +152,18 @@ export function saveCodexEngineSession(input: {
137
152
  lastUsedAt: now,
138
153
  expiresAt: expiresAtFrom(now),
139
154
  engineFingerprint: input.engineFingerprint ?? '',
155
+ ...(profileId ? { profileId } : {}),
140
156
  }
141
157
  sessions[key] = session
142
158
  writeStore({ sessions, savedAt: new Date().toISOString() })
143
159
  return session
144
160
  }
145
161
 
146
- export function clearCodexEngineSession(cosSessionId: string, model?: CodexModelPreference): number {
162
+ /**
163
+ * With a model, clears that model's slot of one lane only: the subscription slot, or with
164
+ * `profileId` that profile's slot. Without a model (deleting the COS session), every slot.
165
+ */
166
+ export function clearCodexEngineSession(cosSessionId: string, model?: CodexModelPreference, profileId?: string): number {
147
167
  const store = readStore()
148
168
  const sessions = pruneExpired(store.sessions)
149
169
  let removed = 0
@@ -151,6 +171,7 @@ export function clearCodexEngineSession(cosSessionId: string, model?: CodexModel
151
171
  const session = sessions[key]
152
172
  if (session.cosSessionId !== cosSessionId) continue
153
173
  if (model && session.model !== model) continue
174
+ if (model && (session.profileId ?? undefined) !== slotProfile(profileId)) continue
154
175
  delete sessions[key]
155
176
  removed += 1
156
177
  }
@@ -0,0 +1,134 @@
1
+ // Key lanes K1 P4 (plan v4 8.2): every Codex home a reader must look in.
2
+ //
3
+ // A keyed (Lane B) Codex turn runs in its own home, `data/codex-homes/<profileId>/`, so its
4
+ // rollouts, writer locks and queue database never land in the user's `~/.codex`. Readers that
5
+ // FIND threads (the session list, the native head, the queue counts, occupancy, hook events)
6
+ // look in every home; readers of config, catalog or models stay on the server home.
7
+ //
8
+ // codexHomes(serverHome) the server home FIRST, then each profile home. With key lanes off
9
+ // or no profile home on disk it is exactly `[serverHome]`, so every
10
+ // reader built on it is identical to 963fe25.
11
+ //
12
+ // HARD RULE (zero impact without a key): nothing here creates a file or a directory. A profile
13
+ // home is listed only while key lanes are enabled AND `data/codex-homes/` exists. Importing this
14
+ // module reads nothing.
15
+ import { lstatSync, readdirSync } from 'node:fs'
16
+ import { homedir } from 'node:os'
17
+ import { join, resolve, sep } from 'node:path'
18
+ import { dataPath } from './data-dir.js'
19
+ import { keyLanesEnabled } from './key-lanes.js'
20
+ import { isKeyProfileId } from '../../shared/key-preference.js'
21
+
22
+ /** The directory under the data dir. P1's Remove all keys deletes it (`routes/key-lanes.ts`). */
23
+ export const CODEX_PROFILE_HOMES_DIR = 'codex-homes'
24
+ /** A bound on how many profile homes one read lists (the profile store caps at 20). */
25
+ export const MAX_CODEX_PROFILE_HOMES = 64
26
+
27
+ /** The server's own Codex home: `CODEX_HOME`, else `~/.codex` (as the hooks installer reads it). */
28
+ export function serverCodexHome(env: NodeJS.ProcessEnv = process.env): string {
29
+ const raw = env.CODEX_HOME?.trim()
30
+ return raw ? resolve(raw) : join(homedir(), '.codex')
31
+ }
32
+
33
+ export function codexProfileHomesRoot(): string {
34
+ return dataPath(CODEX_PROFILE_HOMES_DIR)
35
+ }
36
+
37
+ /** `data/codex-homes/<id>`. Throws on a malformed id (a caller bug, never user input). */
38
+ export function codexProfileHome(profileId: string): string {
39
+ if (!isKeyProfileId(profileId)) throw new Error('invalid key profile id')
40
+ return join(codexProfileHomesRoot(), profileId)
41
+ }
42
+
43
+ export interface CodexProfileHomeEntry {
44
+ profileId: string
45
+ home: string
46
+ }
47
+
48
+ /**
49
+ * Profile homes on disk, sorted by id. Empty unless key lanes are enabled and the root exists.
50
+ * A symlink, a file or a badly named entry is skipped, never followed.
51
+ */
52
+ export function profileCodexHomes(env: NodeJS.ProcessEnv = process.env): CodexProfileHomeEntry[] {
53
+ if (!keyLanesEnabled(env)) return []
54
+ const root = codexProfileHomesRoot()
55
+ let names: string[]
56
+ try {
57
+ if (!lstatSync(root).isDirectory()) return []
58
+ names = readdirSync(root)
59
+ } catch {
60
+ return []
61
+ }
62
+ const out: CodexProfileHomeEntry[] = []
63
+ for (const name of names.sort()) {
64
+ if (!isKeyProfileId(name)) continue
65
+ const home = join(root, name)
66
+ try { if (!lstatSync(home).isDirectory()) continue } catch { continue }
67
+ out.push({ profileId: name, home })
68
+ if (out.length >= MAX_CODEX_PROFILE_HOMES) break
69
+ }
70
+ return out
71
+ }
72
+
73
+ /**
74
+ * Every Codex home, the server home first. A reader passes its own server home (some honour
75
+ * `COS_AGENT_SESSIONS_HOME`, some `CODEX_HOME`) so the first entry is exactly what it read before.
76
+ */
77
+ export function codexHomes(serverHome: string = serverCodexHome(), env: NodeJS.ProcessEnv = process.env): string[] {
78
+ return [serverHome, ...profileCodexHomes(env).map(entry => entry.home)]
79
+ }
80
+
81
+ /** The profile homes only, joined with a subpath (`sessions`, `thread-writer-locks`). */
82
+ export function profileCodexHomeSubdirs(sub: string, env: NodeJS.ProcessEnv = process.env): string[] {
83
+ return profileCodexHomes(env).map(entry => join(entry.home, sub))
84
+ }
85
+
86
+ /**
87
+ * The profile id a path lives under (`data/codex-homes/<id>/...`), or undefined. Prefix-exact on
88
+ * the real root, so a user folder that happens to be called `codex-homes` never matches.
89
+ */
90
+ export function profileIdForCodexPath(path: unknown): string | undefined {
91
+ if (typeof path !== 'string' || !path) return undefined
92
+ const root = codexProfileHomesRoot() + sep
93
+ if (!path.startsWith(root)) return undefined
94
+ const id = path.slice(root.length).split(sep)[0]
95
+ return isKeyProfileId(id) ? id : undefined
96
+ }
97
+
98
+ const ROLLOUT_ID_RE = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i
99
+ const MAX_DIR_ENTRIES = 512
100
+ const MAX_FILES = 20_000
101
+
102
+ /**
103
+ * The profile whose home holds this Codex thread's rollout, or undefined. Walks
104
+ * `sessions/<yyyy>/<mm>/<dd>/rollout-*-<id>.jsonl` in each profile home, bounded like
105
+ * native-head's walk. With no profile home it returns at once without touching the disk.
106
+ */
107
+ export function profileForCodexThread(threadId: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
108
+ const homes = profileCodexHomes(env)
109
+ if (homes.length === 0) return undefined
110
+ const needle = typeof threadId === 'string' ? threadId.trim().toLowerCase() : ''
111
+ if (!/^[0-9a-f-]{36}$/.test(needle)) return undefined
112
+ const list = (dir: string): string[] => {
113
+ try {
114
+ const names = readdirSync(dir)
115
+ return names.length > MAX_DIR_ENTRIES ? [] : names
116
+ } catch { return [] }
117
+ }
118
+ for (const { profileId, home } of homes) {
119
+ let scanned = 0
120
+ const root = join(home, 'sessions')
121
+ for (const y of list(root)) for (const m of list(join(root, y))) for (const d of list(join(root, y, m))) {
122
+ for (const name of list(join(root, y, m, d))) {
123
+ if (++scanned > MAX_FILES) break
124
+ if (!name.startsWith('rollout-')) continue
125
+ if (ROLLOUT_ID_RE.exec(name)?.[1]?.toLowerCase() === needle) return profileId
126
+ }
127
+ }
128
+ }
129
+ return undefined
130
+ }
131
+
132
+ export function isProfileCodexThread(threadId: string, env: NodeJS.ProcessEnv = process.env): boolean {
133
+ return profileForCodexThread(threadId, env) !== undefined
134
+ }
@@ -54,6 +54,7 @@
54
54
  // sentence into the thread twice. A clap usage error (exit 2) queued nothing.
55
55
 
56
56
  import { isValidNativeThreadId } from './native-thread-id.js'
57
+ import { isProfileCodexThread } from './codex-homes.js'
57
58
 
58
59
  export type CodexLiveReason =
59
60
  /** The row is in the holder's queue. */
@@ -71,6 +72,11 @@ export type CodexLiveReason =
71
72
  | 'refused'
72
73
  /** Something may be queued. Hold; never retry, never spawn over it. */
73
74
  | 'unverified'
75
+ /**
76
+ * Key lanes K1 (plan v4 8.1): the thread lives in a keyed profile home, never the Codex app's
77
+ * queue. Nothing sent; the steer lands after the running turn (CODEX_KEYED_STEER_NOTICE).
78
+ */
79
+ | 'keyed_session'
74
80
 
75
81
  export interface CodexLiveResult {
76
82
  ok: boolean
@@ -100,8 +106,13 @@ export interface CodexLiveDeps {
100
106
  run: (binary: string, args: readonly string[], timeoutMs: number) => Promise<CodexQueueRun>
101
107
  now: () => number
102
108
  log?: (level: 'info' | 'warn', event: string, detail: Record<string, unknown>) => void
109
+ /** Key lanes: is this thread a keyed profile thread? Defaults to a look in the profile homes. */
110
+ isKeyedThread?: (threadId: string) => boolean
103
111
  }
104
112
 
113
+ /** Plan 8.1 lens line for a steer into a keyed Codex session. */
114
+ export const CODEX_KEYED_STEER_NOTICE = 'Steer lands after this turn.'
115
+
105
116
  export interface CodexLiveRequest {
106
117
  provider: string
107
118
  sessionId: string
@@ -182,6 +193,9 @@ export async function deliverOverCodexQueue(request: CodexLiveRequest, deps: Cod
182
193
  })
183
194
  if (!request.enabled) return done('disabled')
184
195
  if (request.provider !== 'codex' || request.foreignHolder !== true) return done('no_holder')
196
+ // Key lanes: a keyed profile thread is never the Codex app's, so it never joins its queue.
197
+ // With no profile home this is one predicate call that touches nothing.
198
+ if ((deps.isKeyedThread ?? isProfileCodexThread)(request.sessionId)) return done('keyed_session')
185
199
  let args: string[]
186
200
  try {
187
201
  args = buildCodexQueueArgs(request.sessionId, request.prompt)