@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
@@ -64,6 +64,14 @@ import { claudeSupportsSessionName } from './claude-cli-support.js'
64
64
  import { applyTurnRequestEnv, createTurnRequestBinding, spawnWithTurnRequest } from './turn-request.js'
65
65
  import { STEER_REAPER_MS, STEER_SETTLE_MS, SteerTracker, composeFirstContent, encodeUserLine, steerClock, type SteerPort } from './claude-steer.js'
66
66
  import { randomUUID } from 'node:crypto'
67
+ // Key lanes K1 P3 (plan v4 7): Lane B Claude. Every use below sits behind `options.keyProfile`;
68
+ // without one, argv, env, session keys and the pre-warm handoff are the b7c620e ones.
69
+ import { profilePreference } from '../../shared/key-preference.js'
70
+ import type { AnswerKeyFields, KeyKind, KeyProfile, RelayTurnHandle } from './key-relay-contract.js'
71
+ import { applyLaneBClaudeEnv, LANE_B_COPY, laneBClaudePreflight, laneBRelay } from './lane-b-env.js'
72
+ import { recordKeyedSession } from './keyed-sessions.js'
73
+ import { getSharedKeyMeter } from './key-relay-meter.js'
74
+ import { claudeKeySecretsGuardArgs } from './key-secrets-guard.js'
67
75
 
68
76
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
69
77
 
@@ -117,9 +125,19 @@ function cliSessionKey(cosSessionId: string, model: ClaudeModelPreference): stri
117
125
  return `${cosSessionId}:${model}`
118
126
  }
119
127
 
128
+ /** Key lanes (plan v4 7.1): a keyed turn's CLI session is its own key, never `${sid}:${tier}`. */
129
+ export function keyedCliSessionKey(cosSessionId: string, tier: ClaudeModelPreference, profileId: string): string {
130
+ return `${cosSessionId}:${tier}:p:${profileId}`
131
+ }
132
+
120
133
  function getAnyCliSessionId(cosSessionId: string): string | undefined {
121
134
  for (const [key, cliId] of cliSessionMap) {
122
- if (key === cosSessionId || key.startsWith(`${cosSessionId}:`)) return cliId
135
+ if (key === cosSessionId || key.startsWith(`${cosSessionId}:`)) {
136
+ // Key lanes: a keyed CLI session is never handed out here (handoff, health, Continue). Only
137
+ // the keyed turn itself reaches it, through its own exact key.
138
+ if (key.includes(':p:', cosSessionId.length)) continue
139
+ return cliId
140
+ }
123
141
  }
124
142
  return undefined
125
143
  }
@@ -235,6 +253,7 @@ export async function preWarmCLI(): Promise<void> {
235
253
  '--output-format', 'stream-json',
236
254
  '--verbose',
237
255
  ...claudePermissionArgs(getClaudeTrustMode(), null),
256
+ ...claudeKeySecretsGuardArgs(),
238
257
  '--system-prompt', buildPrewarmSystemPrompt(),
239
258
  ], {
240
259
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -324,6 +343,12 @@ export interface ModelRunMetadata {
324
343
  ollamaRunId?: string
325
344
  outputAttachments?: MediaAttachmentRef[]
326
345
  outputImageStats?: RunOutputImageCollectionStats
346
+ /** Key lanes (plan v4 3.1): present only on a keyed turn. The `model` beside it is then the timeout tier. */
347
+ keyLane?: AnswerKeyFields
348
+ /** Key lanes (P11): what this keyed answer cost on the key, settled by the relay. Keyed turns only. */
349
+ usd?: number
350
+ /** Key lanes (P5): the direct lane's run ledger id. Direct turns only. */
351
+ directRunId?: string
327
352
  }
328
353
 
329
354
  /** Public-safe provider launch metadata for durable job coordination. It
@@ -417,6 +442,17 @@ export interface CallOptions {
417
442
  * 6.77.0 one. A dispatch never steers.
418
443
  */
419
444
  steer?: SteerPort
445
+ /**
446
+ * Key lanes K1 (plan v4 7): run this turn on a saved key profile (Lane B Claude) through the
447
+ * loopback relay. The profile's timeoutTier sets the timeouts and its model goes on `--model`;
448
+ * the `model` argument is ignored. Absent, every argument, env name, session key and stdin
449
+ * byte is the b7c620e one. Refused (onError, nothing spawned) when lanes are not active, the
450
+ * profile is off or not a Claude profile, its key is gone, a Claude settings file would bypass
451
+ * the relay, no relay is registered, or this is a dispatch.
452
+ */
453
+ keyProfile?: KeyProfile
454
+ /** Key lanes (QA U9): run this one profile turn without binding the session to the profile. */
455
+ keyTurnOnly?: boolean
420
456
  }
421
457
 
422
458
  export async function callClaudeStreaming(
@@ -431,6 +467,25 @@ export async function callClaudeStreaming(
431
467
  ): Promise<string> {
432
468
  // Get or create session
433
469
  const sid = getOrCreateSession(sessionId)
470
+ // Key lanes: every check a keyed turn needs runs here, before history, the run ledger or a
471
+ // process. A refusal ends the turn with its lens line; the subscription is never the fallback.
472
+ const keyProfile = options?.keyProfile
473
+ let keyKind: KeyKind | null = null
474
+ if (keyProfile) {
475
+ const preflight = laneBClaudePreflight({
476
+ profile: keyProfile,
477
+ dispatch: !!options?.dispatch,
478
+ cwd: resolveProviderWorkDir({ scriptsDir: COS_SCRIPTS_DIR }),
479
+ })
480
+ if (!preflight.ok) {
481
+ await callbacks.onError(preflight.message)
482
+ return sid
483
+ }
484
+ keyKind = preflight.keyKind
485
+ }
486
+ const keyLane: AnswerKeyFields | null = keyProfile && keyKind
487
+ ? { lane: 'key', credential: { kind: keyKind, ref: keyProfile.keyRef, profileId: keyProfile.id } }
488
+ : null
434
489
  const history = getHistory(sid)
435
490
  const session = getSessionRaw(sid)
436
491
  const contextBreaks = session?.contextBreaks ?? []
@@ -439,14 +494,16 @@ export async function callClaudeStreaming(
439
494
 
440
495
  // Resolve model: per-message > session preference > public-server default.
441
496
  const sessionModel = getSessionModel(sid)
442
- const resolvedModel: ClaudeModelPreference = model
443
- ?? (sessionModel && isClaudeModel(sessionModel) ? sessionModel : DEFAULT_MODEL)
497
+ const resolvedModel: ClaudeModelPreference = keyProfile ? keyProfile.timeoutTier : (model
498
+ ?? (sessionModel && isClaudeModel(sessionModel) ? sessionModel : DEFAULT_MODEL))
444
499
  const resolvedEffort = options?.effort
445
500
  const cliEffortFlag = resolvedEffort ? resolveCliEffortFlag(resolvedEffort) : getClaudeEffortLevel()
446
501
 
447
502
  // Notify client immediately — model is known before any async work
448
503
  // Pass existing CLI session ID if resuming (new sessions get it after first result)
449
- const resolvedCliKey = cliSessionKey(sid, resolvedModel)
504
+ const resolvedCliKey = keyProfile ? keyedCliSessionKey(sid, resolvedModel, keyProfile.id) : cliSessionKey(sid, resolvedModel)
505
+ // A keyed exchange is stamped with its profile (P6 persists it by syntax), never with the tier.
506
+ const exchangeModel = keyProfile ? profilePreference(keyProfile.id) as unknown as ModelPreference : resolvedModel
450
507
  let existingCliSession = options?.dispatch ? undefined : cliSessionMap.get(resolvedCliKey)
451
508
  // 6.58.2: decided before the pre-warmed session below stands in: a COS session that has no
452
509
  // Claude session of its own yet starts one here, and only that one may be named.
@@ -454,6 +511,7 @@ export async function callClaudeStreaming(
454
511
  callbacks.onStart?.(resolvedModel, sid, existingCliSession, {
455
512
  clientJobId: options?.clientJobId,
456
513
  generation: options?.generation,
514
+ ...(keyLane ? { keyLane } : {}),
457
515
  })
458
516
 
459
517
  // Phase: context loading (skipped in lightweight mode)
@@ -531,7 +589,7 @@ export async function callClaudeStreaming(
531
589
  globalMsgNum,
532
590
  inboundAttachments,
533
591
  exchangeProvenance,
534
- resolvedModel,
592
+ exchangeModel,
535
593
  )
536
594
 
537
595
  // Vision queries need the Read tool to see the image files
@@ -559,7 +617,8 @@ export async function callClaudeStreaming(
559
617
  // 6.58.2: nor does a named New session (COS Control Work). The warmed session opens with the
560
618
  // boot prompt "ready" under the glasses prompt; a session someone will open in the Claude app
561
619
  // under its task's name starts clean.
562
- if (!options?.dispatch && !options?.sessionName && !existingCliSession && preWarmedCliSessionId && resolvedModel === DEFAULT_MODEL) {
620
+ // Key lanes: a keyed turn never takes the warmed subscription session.
621
+ if (!options?.dispatch && !options?.sessionName && !existingCliSession && preWarmedCliSessionId && resolvedModel === DEFAULT_MODEL && !keyProfile) {
563
622
  // Only the default model consumes the pre-warmed session.
564
623
  // Hey Even (Haiku) cold-starts its own session to avoid model contamination.
565
624
  existingCliSession = preWarmedCliSessionId
@@ -582,6 +641,8 @@ export async function callClaudeStreaming(
582
641
  '--system-prompt', systemPrompt,
583
642
  ...(options?.dispatch ? [] : mcpConfigArgs),
584
643
  ]
644
+ // Key lanes (plan 7.1): a keyed turn names the profile slug on --model (never ANTHROPIC_MODEL, no [1m]).
645
+ if (keyProfile) args[2] = keyProfile.model
585
646
 
586
647
  // Full and lightweight paths share the same explicit MCP selector contract.
587
648
  if (options?.dispatch) {
@@ -599,6 +660,9 @@ export async function callClaudeStreaming(
599
660
  } else {
600
661
  args.push(...claudePermissionArgs(getClaudeTrustMode(), tools), '--include-partial-messages')
601
662
  }
663
+ // QA B1: deny the key secrets dir while lanes are on and a key is saved ([] otherwise). A keyed
664
+ // turn's layer also forces agent teams off over any settings file (canary R12).
665
+ args.push(...claudeKeySecretsGuardArgs(keyProfile ? { laneB: true } : {}))
602
666
 
603
667
  if (existingCliSession) {
604
668
  // Resume prior CLI session — reuses cached context, avoids cold start
@@ -625,6 +689,33 @@ export async function callClaudeStreaming(
625
689
  ? Math.max(defaultWallMax, WALL_MAX_DEEP_EFFORT_MS)
626
690
  : defaultWallMax
627
691
  const wallMax = isExtendedQuery(query) ? Math.max(WALL_MAX_EXTENDED_MS, effortWallMax) : effortWallMax
692
+ // Key lanes: one nonce per turn from the relay (P2 contract), then the child env points at it.
693
+ // Only the child's copy is written. A relay that cannot issue ends the turn before spawn.
694
+ let keyTurn: RelayTurnHandle | null = null
695
+ const keyTurnId = keyProfile ? options?.turnId ?? randomUUID() : null
696
+ if (keyProfile) {
697
+ try {
698
+ const relay = await laneBRelay()
699
+ keyTurn = await relay.issueTurn({
700
+ keyRef: keyProfile.keyRef,
701
+ profileId: keyProfile.id,
702
+ turnId: keyTurnId!,
703
+ sessionId: sid,
704
+ engine: 'claude',
705
+ allowedModel: keyProfile.model,
706
+ }, { wallMaxMs: wallMax })
707
+ applyLaneBClaudeEnv(env, { origin: keyTurn.origin, nonce: keyTurn.nonce, slug: keyProfile.model, profileId: keyProfile.id })
708
+ } catch (err) {
709
+ keyTurn?.revoke('cancel')
710
+ outputImagePublisher?.cleanup()
711
+ cleanupModelImageInputs(imageInputs)
712
+ removeExchange(sid, pendingUserExchange)
713
+ console.error(`[claude-bridge] key relay refused the turn: ${err instanceof Error ? err.message.slice(0, 200) : 'unknown'}`)
714
+ await callbacks.onError(LANE_B_COPY.relayUnavailable)
715
+ return sid
716
+ }
717
+ }
718
+ const keyAuditFields = keyProfile && keyKind ? { profile: keyProfile.id, lane: 'key' as const, credentialKind: keyKind } : {}
628
719
  const startTime = Date.now()
629
720
  // 6.78.0 steer (P7 S7): the wall clock's start on the monotonic steer clock, next to the
630
721
  // wall timer it describes, so the coordinator's cut-off reads the same clock after a sleep.
@@ -639,7 +730,7 @@ export async function callClaudeStreaming(
639
730
  wallMaxMs: wallMax,
640
731
  query: fullQuery,
641
732
  effortLevel: resolvedEffort ?? getClaudeEffortLevel(),
642
- cliModelId: resolveClaudeCliModelId(resolvedModel),
733
+ cliModelId: keyProfile ? keyProfile.model : resolveClaudeCliModelId(resolvedModel),
643
734
  })
644
735
 
645
736
  // Ultracode is a CLI-only orchestration keyword; history/chat keeps the
@@ -699,6 +790,7 @@ export async function callClaudeStreaming(
699
790
  * CLI session mapping is dropped so the next Ask starts a fresh session.
700
791
  */
701
792
  function abandonUncoveredSteers() {
793
+ keyTurn?.revoke('steer_abort')
702
794
  steerStdinEnded = true
703
795
  steerPort!.close()
704
796
  if (cliSessionMap.delete(resolvedCliKey)) scheduleCliSessionSave()
@@ -791,7 +883,7 @@ export async function callClaudeStreaming(
791
883
  globalMsgNum,
792
884
  undefined,
793
885
  exchangeProvenance,
794
- resolvedModel,
886
+ exchangeModel,
795
887
  )
796
888
  if (imagePaths.length > 0) {
797
889
  replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
@@ -827,6 +919,8 @@ export async function callClaudeStreaming(
827
919
  // Token audit — log every completed claude -p call
828
920
  const totalMs = Date.now() - startTime
829
921
  const inputEstimate = systemPrompt.length + fullQuery.length + contextPrompt.length
922
+ // Key lanes (P11): this turn's settled spend on the key. Keyed turns only.
923
+ const keyUsdFields = keyTurnId ? { usd: Math.round(getSharedKeyMeter().turnSettledUsd(keyTurnId) * 1e6) / 1e6 } : {}
830
924
  logTokenAudit({
831
925
  source: options?.lightweight ? 'g2-voice' : 'g2-query',
832
926
  model: resolvedModel,
@@ -834,6 +928,8 @@ export async function callClaudeStreaming(
834
928
  outputChars: text.length,
835
929
  durationMs: totalMs,
836
930
  caller: options?.lightweight ? 'voice_query' : 'full_query',
931
+ ...keyAuditFields,
932
+ ...keyUsdFields,
837
933
  })
838
934
 
839
935
  finishClaudeRun(run.runId, {
@@ -849,6 +945,7 @@ export async function callClaudeStreaming(
849
945
  generation: options?.generation,
850
946
  ...(outputAttachments.length > 0 ? { outputAttachments } : {}),
851
947
  ...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
948
+ ...(keyLane ? { keyLane, ...keyUsdFields } : {}),
852
949
  })
853
950
  if (terminalOwned === false) return
854
951
 
@@ -901,6 +998,7 @@ export async function callClaudeStreaming(
901
998
  }
902
999
 
903
1000
  function handleAbort() {
1001
+ keyTurn?.revoke('cancel')
904
1002
  void terminateForTerminal('client disconnect', result => finalizeError(
905
1003
  'claude-bridge: client disconnected before Claude completed.',
906
1004
  result.code,
@@ -1022,6 +1120,9 @@ export async function callClaudeStreaming(
1022
1120
  // recorded as before, so the finished job names the session the result names.
1023
1121
  if (!nativeSessionAnnounced && !options?.dispatch && typeof event.session_id === 'string' && event.session_id) {
1024
1122
  nativeSessionAnnounced = true
1123
+ // Key lanes (plan 7.3): recorded before any answer text, so Continue, Fork, attach and
1124
+ // handoff refuse this transcript from the moment it exists.
1125
+ if (keyProfile) recordKeyedSession({ id: event.session_id, engine: 'claude', profileId: keyProfile.id, cosSessionId: sid })
1025
1126
  updateClaudeRun(run.runId, { cliSessionId: event.session_id })
1026
1127
  callbacks.onNativeSession?.({ cliSessionId: event.session_id })
1027
1128
  }
@@ -1112,6 +1213,7 @@ export async function callClaudeStreaming(
1112
1213
  }
1113
1214
  // Capture CLI session ID for future --resume (avoids cold start on next query)
1114
1215
  if (event.session_id && !options?.dispatch) {
1216
+ if (keyProfile) recordKeyedSession({ id: event.session_id, engine: 'claude', profileId: keyProfile.id, cosSessionId: sid })
1115
1217
  cliSessionMap.set(resolvedCliKey, event.session_id)
1116
1218
  scheduleCliSessionSave()
1117
1219
  updateClaudeRun(run.runId, { cliSessionId: event.session_id })
@@ -1160,6 +1262,7 @@ export async function callClaudeStreaming(
1160
1262
 
1161
1263
  proc.on('close', (code) => {
1162
1264
  childClosed = true
1265
+ keyTurn?.childClosed()
1163
1266
  if (steerReaper) clearTimeout(steerReaper)
1164
1267
  steerPort?.close()
1165
1268
  if (terminationRequested) return
@@ -1196,6 +1299,7 @@ export async function callClaudeStreaming(
1196
1299
  })
1197
1300
 
1198
1301
  proc.on('error', (err) => {
1302
+ keyTurn?.revoke('child_close')
1199
1303
  if (terminationRequested) return
1200
1304
  finalizeError(`claude-bridge: ${err.message}`, null)
1201
1305
  })
@@ -0,0 +1,94 @@
1
+ // Key lanes K1 P3 (plan v4 7.2, W17): the Claude settings-layer check before a keyed spawn.
2
+ //
3
+ // Claude Code applies the `env` block of its settings files over the process environment, and an
4
+ // `apiKeyHelper` hands it a credential of its own. Either would point a keyed turn somewhere other
5
+ // than the loopback relay, so the meter and the cap would not see it (or the user's other
6
+ // credential would pay for it). Before a profile spawn the three files the CLI reads for this
7
+ // working directory are checked; any `ANTHROPIC_*` or `CLAUDE_CODE_USE_*` name in `env`, or an
8
+ // `apiKeyHelper`, refuses the profile with a line that names it.
9
+ //
10
+ // Read only on a keyed turn. Never writes, never creates, never reads a value aloud: the refusal
11
+ // names the setting, not what it is set to. A file that does not parse is skipped, because the CLI
12
+ // cannot apply a file it cannot parse either.
13
+ import { existsSync, readFileSync, statSync } from 'node:fs'
14
+ import { homedir } from 'node:os'
15
+ import { join } from 'node:path'
16
+
17
+ export const CLAUDE_SETTINGS_REFUSED_ENV = /^(ANTHROPIC_|CLAUDE_CODE_USE_|CLAUDE_CODE_OAUTH_TOKEN$|AWS_BEARER_TOKEN_BEDROCK$)/
18
+
19
+ /** QA W4 / G7: the managed (enterprise) settings files Claude Code reads above every user file. */
20
+ export const CLAUDE_MANAGED_SETTINGS_FILES: readonly string[] = [
21
+ '/Library/Application Support/ClaudeCode/managed-settings.json',
22
+ '/etc/claude-code/managed-settings.json',
23
+ ]
24
+
25
+ export interface ClaudeSettingsRefusal {
26
+ /** The file that sets it. */
27
+ file: string
28
+ /** The env name, or `apiKeyHelper`. */
29
+ name: string
30
+ message: string
31
+ }
32
+
33
+ export function claudeSettingsFiles(input: { cwd: string; home?: string; env?: NodeJS.ProcessEnv; managed?: readonly string[] }): string[] {
34
+ const home = input.home ?? homedir()
35
+ const env = input.env ?? process.env
36
+ // QA W4 / G7: CLAUDE_CONFIG_DIR moves the user settings; both places are checked.
37
+ const configDir = typeof env.CLAUDE_CONFIG_DIR === 'string' && env.CLAUDE_CONFIG_DIR.trim() ? env.CLAUDE_CONFIG_DIR.trim() : null
38
+ return [
39
+ ...(input.managed ?? CLAUDE_MANAGED_SETTINGS_FILES),
40
+ ...(configDir ? [join(configDir, 'settings.json')] : []),
41
+ join(home, '.claude', 'settings.json'),
42
+ join(input.cwd, '.claude', 'settings.json'),
43
+ join(input.cwd, '.claude', 'settings.local.json'),
44
+ ]
45
+ }
46
+
47
+ function readSettings(file: string): Record<string, unknown> | null {
48
+ try {
49
+ if (!existsSync(file) || !statSync(file).isFile()) return null
50
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
51
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ export function claudeSettingsRefusalMessage(name: string): string {
58
+ return `A Claude settings file sets ${name}. Remove it to use a key.`
59
+ }
60
+
61
+ /**
62
+ * Canary R12: names a settings file may set that do not bypass the relay but that a keyed run
63
+ * must not inherit. They are not refused (MU's own project file sets one); the keyed run's flag
64
+ * layer forces them off, and this reports which file set them so the log says so.
65
+ */
66
+ export const CLAUDE_SETTINGS_WATCHED_ENV: readonly string[] = ['CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS']
67
+
68
+ export function claudeSettingsWatched(input: { cwd: string; home?: string; env?: NodeJS.ProcessEnv; managed?: readonly string[] }): Array<{ file: string; name: string }> {
69
+ const out: Array<{ file: string; name: string }> = []
70
+ for (const file of claudeSettingsFiles(input)) {
71
+ const env = readSettings(file)?.env
72
+ if (!env || typeof env !== 'object' || Array.isArray(env)) continue
73
+ for (const name of CLAUDE_SETTINGS_WATCHED_ENV) if (Object.prototype.hasOwnProperty.call(env, name)) out.push({ file, name })
74
+ }
75
+ return out
76
+ }
77
+
78
+ /** The first setting that would bypass the relay, or null when a keyed spawn may go ahead. */
79
+ export function claudeSettingsRefusal(input: { cwd: string; home?: string; env?: NodeJS.ProcessEnv; managed?: readonly string[] }): ClaudeSettingsRefusal | null {
80
+ for (const file of claudeSettingsFiles(input)) {
81
+ const settings = readSettings(file)
82
+ if (!settings) continue
83
+ if (settings.apiKeyHelper !== undefined && settings.apiKeyHelper !== null && settings.apiKeyHelper !== '') {
84
+ return { file, name: 'apiKeyHelper', message: claudeSettingsRefusalMessage('apiKeyHelper') }
85
+ }
86
+ const env = settings.env
87
+ if (env && typeof env === 'object' && !Array.isArray(env)) {
88
+ for (const name of Object.keys(env).sort()) {
89
+ if (CLAUDE_SETTINGS_REFUSED_ENV.test(name)) return { file, name, message: claudeSettingsRefusalMessage(name) }
90
+ }
91
+ }
92
+ }
93
+ return null
94
+ }
@@ -21,7 +21,9 @@
21
21
  // SteerTracker sent and covered uuids; what a result means (finish, wait, steering off)
22
22
  // steerCutoffMs how close to a deadline a steer is refused as too late
23
23
  // resolveRunModel the model a run will use, exactly as model-router resolves it
24
+ // steerProviderFor the steer gate's provider; a key-profile job is `profile_session`
24
25
  import { DEFAULT_MODEL, isCodexModel, isCursorModel, isOllamaModel, normalizeModelPreference, type ModelPreference } from '../../shared/model-preference.js'
26
+ import { parsePreference } from '../../shared/key-preference.js'
25
27
 
26
28
  /** Quiet after a result that left a steer uncovered, before stdin is closed (plan P4). */
27
29
  export const STEER_SETTLE_MS = 20_000
@@ -257,3 +259,28 @@ export function providerOfModel(model: ModelPreference): QueryProvider {
257
259
  if (isCursorModel(model)) return 'cursor'
258
260
  return isCodexModel(model) ? 'codex' : 'claude'
259
261
  }
262
+
263
+ export type SteerProviderVerdict =
264
+ | { kind: 'provider'; provider: QueryProvider }
265
+ | { kind: 'profile_session'; profileId: string }
266
+
267
+ /**
268
+ * Key lanes K1 (plan 5.2): the explicit guard in front of resolveRunModel / providerOfModel,
269
+ * which stay CLOSED. A job whose request names `profile:<id>`, or that names no model in a
270
+ * session bound to one, is a `profile_session`; the caller asks the profile's engine whether it
271
+ * steers. Without this guard both fall through to the Claude default and a Codex profile's job
272
+ * would be offered Claude steering. With no profile anywhere the verdict is exactly
273
+ * `providerOfModel(resolveRunModel(model, sessionModel, envDefault))`. The session binding is read
274
+ * only when nothing else names the model, as model-router does.
275
+ */
276
+ export function steerProviderFor(
277
+ model: unknown,
278
+ sessionModel: ModelPreference | null | undefined,
279
+ envDefault: unknown,
280
+ sessionBinding: () => unknown,
281
+ ): SteerProviderVerdict {
282
+ const requested = parsePreference(model)
283
+ const bound = requested ?? (sessionModel == null ? parsePreference(sessionBinding()) : undefined)
284
+ if (bound?.kind === 'profile') return { kind: 'profile_session', profileId: bound.id }
285
+ return { kind: 'provider', provider: providerOfModel(resolveRunModel(model, sessionModel, envDefault)) }
286
+ }