@gotcos/glasses-server 6.79.2 → 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 (66) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +10 -0
  4. package/managed-runtime-contract.json +1 -0
  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/direct-bridge.ts +853 -0
  23. package/server/lib/direct-sse.ts +262 -0
  24. package/server/lib/fork-thread.ts +11 -0
  25. package/server/lib/key-answer-fields.ts +49 -0
  26. package/server/lib/key-catalog.ts +301 -0
  27. package/server/lib/key-lanes-pricing.ts +12 -0
  28. package/server/lib/key-lanes-wiring.ts +346 -0
  29. package/server/lib/key-lanes.ts +145 -0
  30. package/server/lib/key-ledger.ts +123 -0
  31. package/server/lib/key-notices.ts +486 -0
  32. package/server/lib/key-profile-rejects.ts +25 -0
  33. package/server/lib/key-profile-routing.ts +173 -0
  34. package/server/lib/key-profiles.ts +182 -0
  35. package/server/lib/key-relay-contract.ts +262 -0
  36. package/server/lib/key-relay-errors.ts +211 -0
  37. package/server/lib/key-relay-meter.ts +303 -0
  38. package/server/lib/key-relay.ts +1018 -0
  39. package/server/lib/key-secrets-guard.ts +52 -0
  40. package/server/lib/key-store.ts +228 -0
  41. package/server/lib/keyed-sessions.ts +154 -0
  42. package/server/lib/lane-b-env.ts +169 -0
  43. package/server/lib/model-router.ts +96 -7
  44. package/server/lib/morning-brief-config.ts +3 -0
  45. package/server/lib/native-head.ts +13 -1
  46. package/server/lib/occupancy-probes.ts +4 -0
  47. package/server/lib/ollama-run-ledger.ts +96 -13
  48. package/server/lib/prompt-edit.ts +2 -0
  49. package/server/lib/query-job-coordinator.ts +2 -1
  50. package/server/lib/query-job-runtime.ts +67 -15
  51. package/server/lib/query-job-store.ts +7 -1
  52. package/server/lib/query-job-types.ts +15 -1
  53. package/server/lib/session-hook-events.ts +28 -0
  54. package/server/lib/session-hook-ledger.ts +10 -0
  55. package/server/lib/thread-occupancy.ts +22 -1
  56. package/server/lib/token-audit.ts +18 -4
  57. package/server/lib/work-handoff-requests.ts +3 -0
  58. package/server/routes/agent-session-bindings.ts +15 -0
  59. package/server/routes/agent-sessions.ts +17 -5
  60. package/server/routes/handoffs.ts +22 -1
  61. package/server/routes/health.ts +5 -0
  62. package/server/routes/key-lanes.ts +442 -0
  63. package/server/routes/openai-compat.ts +28 -3
  64. package/server/routes/query.ts +20 -6
  65. package/shared/key-preference.ts +57 -0
  66. package/shared/model-preference.ts +20 -2
@@ -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
  }
@@ -543,11 +543,32 @@ export function codexOwners(
543
543
  return { owners, doubt }
544
544
  }
545
545
 
546
+ /**
547
+ * Key lanes K1 (plan v4 8.2): the server home's writer locks, then each keyed profile home's.
548
+ * A keyed home that is absent adds nothing (its detector was never the server's to lose); with
549
+ * no profile home this is exactly `codexOwners` on the server home.
550
+ */
551
+ export function codexOwnersAcrossHomes(threadId: string, probes: OccupancyProbes, dirs: OccupancyDirs): ScanResult {
552
+ const base = codexOwners(threadId, probes, dirs.codexLocksDir)
553
+ if (!dirs.codexProfileLocksDirs) return base
554
+ const owners = [...base.owners]
555
+ let doubt = base.doubt
556
+ for (const dir of dirs.codexProfileLocksDirs) {
557
+ if (!probes.dirExists(dir)) continue
558
+ const keyed = codexOwners(threadId, probes, dir)
559
+ owners.push(...keyed.owners)
560
+ if (keyed.doubt !== null && keyed.doubt !== 'detector_unavailable') doubt ??= keyed.doubt
561
+ }
562
+ return { ...base, owners, doubt }
563
+ }
564
+
546
565
  export interface OccupancyDirs {
547
566
  /** `<CLAUDE_CONFIG_DIR|~/.claude>/sessions` */
548
567
  claudeSessionsDir: string
549
568
  /** `<CODEX_HOME|~/.codex>/thread-writer-locks` */
550
569
  codexLocksDir: string
570
+ /** Key lanes K1 (plan v4 8.2): each keyed profile home's `thread-writer-locks`. Absent with no profile home. */
571
+ codexProfileLocksDirs?: string[]
551
572
  /** `<COS_AGENT_SESSIONS_HOME|~>/.cursor/chats` */
552
573
  cursorChatsDir: string
553
574
  }
@@ -698,7 +719,7 @@ export function threadOccupancy(
698
719
  try {
699
720
  result = provider === 'claude'
700
721
  ? claudeOwners(threadId, probes, dirs.claudeSessionsDir)
701
- : codexOwners(threadId, probes, dirs.codexLocksDir)
722
+ : codexOwnersAcrossHomes(threadId, probes, dirs)
702
723
  } catch {
703
724
  return { attachable: false, owners: [], reason: 'probe_failed' }
704
725
  }
@@ -7,6 +7,7 @@ import { resolve } from 'node:path'
7
7
  import { homedir } from 'node:os'
8
8
 
9
9
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
10
+ import type { TokenAuditKeyFields } from './key-relay-contract.js'
10
11
 
11
12
  // Shared JSONL file — same path as Python's llm_client.py uses
12
13
  // Uses COS_SCRIPTS_DIR when available (server mode), falls back to user home
@@ -21,7 +22,7 @@ function estimateTokens(chars: number): number {
21
22
  return Math.max(1, Math.floor(chars / CHARS_PER_TOKEN))
22
23
  }
23
24
 
24
- export interface TokenAuditEntry {
25
+ export interface TokenAuditEntry extends TokenAuditKeyFields {
25
26
  source: string // "g2-voice", "g2-prewarm", "g2-archive", "g2-query"
26
27
  model: string // "opus", "sonnet", "haiku", "cursor-composer", ...
27
28
  inputChars: number
@@ -35,9 +36,14 @@ export interface TokenAuditEntry {
35
36
  usageKind?: 'provider_final' | 'estimated'
36
37
  }
37
38
 
38
- export function logTokenAudit(entry: TokenAuditEntry): void {
39
- const record = {
40
- ts: new Date().toISOString(),
39
+ /**
40
+ * The JSONL record. Key-lane fields (plan v4 3.1: `profile`, `lane`, `credentialKind`, `usd`) are
41
+ * conditional spreads AFTER every 6.79.0 key, written only when the caller sets them, so a Lane A
42
+ * (subscription) row is byte-identical to 6.79.0. Callers set them only on keyed turns.
43
+ */
44
+ export function buildTokenAuditRecord(entry: TokenAuditEntry, now: Date = new Date()): Record<string, unknown> {
45
+ return {
46
+ ts: now.toISOString(),
41
47
  source: entry.source,
42
48
  model: entry.model,
43
49
  input_chars: entry.inputChars,
@@ -51,7 +57,15 @@ export function logTokenAudit(entry: TokenAuditEntry): void {
51
57
  ...(entry.runId ? { run_id: entry.runId } : {}),
52
58
  ...(entry.sessionId ? { session_id: entry.sessionId } : {}),
53
59
  ...(entry.clientJobId ? { client_job_id: entry.clientJobId } : {}),
60
+ ...(entry.profile ? { profile: entry.profile } : {}),
61
+ ...(entry.lane ? { lane: entry.lane } : {}),
62
+ ...(entry.credentialKind ? { credentialKind: entry.credentialKind } : {}),
63
+ ...(typeof entry.usd === 'number' && Number.isFinite(entry.usd) ? { usd: entry.usd } : {}),
54
64
  }
65
+ }
66
+
67
+ export function logTokenAudit(entry: TokenAuditEntry): void {
68
+ const record = buildTokenAuditRecord(entry)
55
69
  try {
56
70
  appendFileSync(AUDIT_FILE, JSON.stringify(record) + '\n', { encoding: 'utf8', mode: 0o600 })
57
71
  chmodSync(AUDIT_FILE, 0o600)
@@ -23,6 +23,7 @@ import { isSafeDomainName } from './domains.js'
23
23
  import { acquireServerInstanceLock, type ServerInstanceLock } from './server-instance-lock.js'
24
24
  import { parseWorkSessionId, modelProvider, UUID_V4, FORK_PROVIDERS } from './work-activity.js'
25
25
  import { normalizeModelPreference } from '../../shared/model-preference.js'
26
+ import { KEY_PROFILE_REJECT_CODE, KEY_PROFILE_WORK_REJECT, keyProfileRejectApplies } from './key-profile-rejects.js'
26
27
 
27
28
  export class WorkHandoffRequestError extends Error {
28
29
  /** `extra` goes into the error body next to the code (the daily cap's `resetsAt`). */
@@ -136,6 +137,8 @@ export function parseHandoffRequest(body: unknown, defaultModel: string): { inpu
136
137
  let model: string | undefined
137
138
  if (given(body.model)) {
138
139
  model = normalizeModelPreference(body.model)
140
+ // Key lanes K1 (plan 5.2): Reject, and say why. Work hands off subscription sessions only.
141
+ if (!model && keyProfileRejectApplies(body.model)) throw new WorkHandoffRequestError(KEY_PROFILE_REJECT_CODE, 400, KEY_PROFILE_WORK_REJECT)
139
142
  if (!model) throw invalid('Choose a model slot this server knows.')
140
143
  }
141
144
  let note: string | undefined
@@ -131,6 +131,7 @@ import { isValidNativeThreadId } from '../lib/native-thread-id.js'
131
131
  import { FORK_PROVIDER_FAILURES, type ForkProviderFailure } from '../lib/fork-thread.js'
132
132
  import { CLIENT_FORK_ID_RE, ForkJobLedger, type ForkJobRow } from '../lib/fork-job-ledger.js'
133
133
  import { PEER_VERIFY_TIMEOUT_MS } from '../lib/session-peer-inbox.js'
134
+ import { KEYED_SESSION_REFUSAL } from '../lib/keyed-sessions.js'
134
135
  import {
135
136
  CANCEL_QUEUE_HOLD_MS,
136
137
  CLIENT_CANCEL_ID_RE,
@@ -652,6 +653,9 @@ export type WriteRefusal =
652
653
  // person said stop. Deliberately NOT a fence (plan validation B2): the next Continue
653
654
  // must work, and it re-reads the head.
654
655
  | 'turn_cancelled'
656
+ // Key lanes (P3, wired by P11): the thread ran on a key profile. Refused before any spawn, with
657
+ // the same 409 body every other keyed entry point answers (keyed-sessions.ts).
658
+ | 'keyed_session'
655
659
  // ------------------------------------------------------------------- fork
656
660
  //
657
661
  // Fork gets its OWN members rather than reusing the ones above, and the reason
@@ -739,6 +743,7 @@ export const WRITE_REASON_COPY: Record<Exclude<WriteRefusal, OccupancyReason>, s
739
743
  'COS could not run this turn. Nothing was sent. You can try again.',
740
744
  turn_cancelled:
741
745
  'Run cancelled.',
746
+ keyed_session: KEYED_SESSION_REFUSAL.message,
742
747
 
743
748
  // Fork copy. No sentence here may end with "Fork it instead" — this IS the fork,
744
749
  // and pointing a failed fork back at itself is a dead end rather than an action.
@@ -2698,6 +2703,10 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
2698
2703
  return refuseFork('fork_orphan_possible', { orphanPossible: true })
2699
2704
  }
2700
2705
 
2706
+ // Key lanes: fork-thread refused a keyed source before any spawn (P3). The exact keyed 409.
2707
+ if (raw && typeof raw === 'object' && (raw as { ok?: unknown }).ok === false && (raw as { reason?: unknown }).reason === 'keyed_session') {
2708
+ return refuseFork('keyed_session', { ...KEYED_SESSION_REFUSAL, retryable: false })
2709
+ }
2701
2710
  const outcome = classifyFork(raw, threadIdParam)
2702
2711
  {
2703
2712
  // 6.62.1: one line per attempt. On 2026-10-05 a fork's real cause existed only
@@ -3458,6 +3467,12 @@ export function createAgentSessionBindingsRouter(deps: AgentSessionBindingsDeps)
3458
3467
  return refuseTurn('turn_cancelled', { retryable: false, deliveryState: 'cancelled' })
3459
3468
  }
3460
3469
 
3470
+ // Key lanes: the adapter refused a keyed thread before any spawn (P3). The exact keyed 409.
3471
+ if (adapterEvidence.adapterReason === 'keyed_session') {
3472
+ spawnMay = false // refused before any spawn
3473
+ return refuseTurn('keyed_session', { ...KEYED_SESSION_REFUSAL, retryable: false })
3474
+ }
3475
+
3461
3476
  if (delivery.kind === 'aborted') {
3462
3477
  // The adapter's own proof that the child never opened the session.
3463
3478
  spawnMay = false
@@ -1,5 +1,14 @@
1
1
  import { workObservationFields } from '../lib/session-work-observation.js'
2
- import { readCodexSubagents } from '../lib/codex-subagent-activity.js'
2
+ import { readCodexSubagentsAcross } from '../lib/codex-subagent-activity.js'
3
+ import { listKeyedSessions } from '../lib/keyed-sessions.js'
4
+ import { codexProfileHome } from '../lib/codex-homes.js'
5
+ import { join as joinPath } from 'node:path'
6
+
7
+ /** Key lanes (P11): a keyed Codex thread's own sessions root (P3's keyed-session record). */
8
+ function keyedCodexRootOf(): (id: string) => string | undefined {
9
+ const byId = new Map(listKeyedSessions().filter(r => r.engine === 'codex').map(r => [r.id.toLowerCase(), joinPath(codexProfileHome(r.profileId), 'sessions')]))
10
+ return id => byId.get(id.toLowerCase())
11
+ }
3
12
  // GET /api/agent-sessions
4
13
  // GET /api/agent-sessions/search?q=
5
14
  // GET /api/agent-sessions/:provider/:sessionId
@@ -31,7 +40,8 @@ import {
31
40
  emptySessionListDropped,
32
41
  loadClaudeDesktopAliases,
33
42
  loadCursorComposerNames,
34
- loadCodexThreadNames,
43
+ codexSessionRootsOf,
44
+ loadCodexThreadNamesAll,
35
45
  idFromCodexFilename,
36
46
  parseAgentSession,
37
47
  type AgentProvider,
@@ -518,7 +528,8 @@ agentSessionsRouter.get('/agent-sessions', async (req, res) => {
518
528
  derivedById.set(row.session_id, deriveCodexRow(row.session_id, running.occupied.has(row.session_id), facts, now))
519
529
  }
520
530
  const queuedOf = await allQueuedWaitingLookup(now)
521
- const subagents = await readCodexSubagents(sessions.filter(row => row.provider === 'codex').map(row => row.session_id), roots.codexSessions, now)
531
+ const codexRoots = codexSessionRootsOf(roots)
532
+ const subagents = await readCodexSubagentsAcross(sessions.filter(row => row.provider === 'codex').map(row => row.session_id), codexRoots, now, codexRoots.length > 1 ? keyedCodexRootOf() : undefined)
522
533
  res.json({
523
534
  sessions: sessions.map((row, index) => {
524
535
  const derived = derivedById.get(row.session_id)
@@ -624,7 +635,7 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
624
635
  // parent's id (see `listCodexSessions`), and the parent's name would be the wrong one.
625
636
  let codexThreadName: string | undefined
626
637
  if (provider === 'codex') {
627
- const names = await loadCodexThreadNames(agentSessionRoots().codexSessions)
638
+ const names = await loadCodexThreadNamesAll(agentSessionRoots())
628
639
  const fileId = idFromCodexFilename(found.split('/').pop() || '')
629
640
  codexThreadName = (fileId ? names.get(fileId) : undefined) || names.get(sessionId) || undefined
630
641
  }
@@ -649,7 +660,8 @@ agentSessionsRouter.get('/agent-sessions/:provider/:sessionId', async (req, res)
649
660
  const now = Date.now()
650
661
  const rollout = provider === 'codex' ? codexRolloutFacts(found, now) : null
651
662
  const observed = provider === 'codex' ? codexCompactionFields(workObservationFor(parsed.session_id), rollout, now) : workObservationFields(workObservationFor(parsed.session_id))
652
- const subagents = provider === 'codex' ? (await readCodexSubagents([sessionId], agentSessionRoots().codexSessions)).get(sessionId.toLowerCase()) : observed.subagent_activity
663
+ const detailRoots = provider === 'codex' ? codexSessionRootsOf(agentSessionRoots()) : []
664
+ const subagents = provider === 'codex' ? (await readCodexSubagentsAcross([sessionId], detailRoots, undefined, detailRoots.length > 1 ? keyedCodexRootOf() : undefined)).get(sessionId.toLowerCase()) : observed.subagent_activity
653
665
  const modified = st.mtime.toISOString()
654
666
  const activity = await readSessionActivity(provider, found)
655
667
  const running = runningForThread(provider, parsed.session_id, activityClockMs(st.mtimeMs, activity))
@@ -10,6 +10,7 @@ import {
10
10
  import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
11
11
  import { getCodexExecutionCwd, getCodexTrustMode } from '../lib/codex-run-ledger.js'
12
12
  import { normalizeHandoffCode } from '../../shared/handoff-intent.js'
13
+ import { anyKeyedSessions, isKeyedCosSession, isKeyedSession, KEYED_SESSION_REFUSAL } from '../lib/keyed-sessions.js'
13
14
 
14
15
  export const handoffsRouter = Router()
15
16
 
@@ -17,6 +18,20 @@ function runtimeExpiresAt(): string {
17
18
  return new Date(Date.now() + 2 * 60 * 60_000).toISOString()
18
19
  }
19
20
 
21
+ /**
22
+ * Key lanes K1 (plan v4 7.1, 7.3): a handoff carries a CLI session id that another surface
23
+ * resumes, on the subscription. One that ran on a key is refused in K1. With no keyed session
24
+ * ever recorded this is one failed stat and the route is the b7c620e one.
25
+ */
26
+ function keyedHandoff(body: Record<string, any> | null | undefined, resolvedCliSessionId?: string): boolean {
27
+ if (!anyKeyedSessions()) return false
28
+ const runtime = body?.runtime && typeof body.runtime === 'object' ? body.runtime : {}
29
+ return isKeyedCosSession(body?.sessionId)
30
+ || isKeyedSession(runtime.claude?.cliSessionId)
31
+ || isKeyedSession(runtime.codex?.codexThreadId)
32
+ || isKeyedSession(resolvedCliSessionId)
33
+ }
34
+
20
35
  function enrichRuntime(body: Record<string, any>): HandoffCreateInput {
21
36
  const input: HandoffCreateInput = { ...body }
22
37
  const runtime = body.runtime && typeof body.runtime === 'object' ? { ...body.runtime } : {}
@@ -53,7 +68,9 @@ function enrichRuntime(body: Record<string, any>): HandoffCreateInput {
53
68
  handoffsRouter.post('/handoffs', async (req, res) => {
54
69
  try {
55
70
  const body = req.body && typeof req.body === 'object' ? req.body : {}
56
- const handoff = await createHandoff(enrichRuntime(body))
71
+ const input = enrichRuntime(body)
72
+ if (keyedHandoff(body, (input.runtime as any)?.claude?.cliSessionId)) return res.status(409).json(KEYED_SESSION_REFUSAL)
73
+ const handoff = await createHandoff(input)
57
74
  res.status(201).json({ handoff, context: buildHandoffPromptContext(handoff) })
58
75
  } catch (err: any) {
59
76
  res.status(500).json({ error: err?.message ?? 'handoff create failed' })
@@ -92,6 +109,10 @@ handoffsRouter.post('/handoffs/:code/claim', async (req, res) => {
92
109
  if (!code) return res.status(404).json({ error: 'handoff not found' })
93
110
  try {
94
111
  const claimedBy = typeof req.body?.claimedBy === 'string' ? req.body.claimedBy : 'unknown'
112
+ // Key lanes: a handoff saved before its session was known to be keyed is not claimed either.
113
+ if (anyKeyedSessions() && keyedHandoff((await getHandoff(code)) as Record<string, any> | null)) {
114
+ return res.status(409).json(KEYED_SESSION_REFUSAL)
115
+ }
95
116
  const handoff = await claimHandoff(code, claimedBy)
96
117
  if (!handoff) return res.status(404).json({ error: 'handoff not found' })
97
118
  res.json({ handoff, context: buildHandoffPromptContext(handoff) })
@@ -32,6 +32,7 @@ import { meetingAskCapabilities } from '../lib/meeting-ask-runtime.js'
32
32
  import { lensGistPublicHealth } from '../lib/lens-gist.js'
33
33
  import { meetingSummaryLLMEnabled } from '../lib/meeting-summary.js'
34
34
  import { getKeyStatus } from '../lib/openai-key.js'
35
+ import { keyLanesHealthFields, keyProfilesModelsField } from '../lib/key-lanes.js'
35
36
  import {
36
37
  getCodexModelCatalog,
37
38
  getCodexModelCatalogSnapshot,
@@ -550,6 +551,8 @@ healthRouter.get('/health', async (_req, res) => {
550
551
  // count only (health is public); the items and their Retry are behind the token at
551
552
  // GET /api/maintenance/status.
552
553
  crash_loop_parking: crashLoopParkingHealth(),
554
+ // Key lanes (plan v4 3.1): `details.keyLanes` only while enabled. Absent, never null, when off.
555
+ ...keyLanesHealthFields(),
553
556
  })
554
557
  })
555
558
 
@@ -616,6 +619,8 @@ healthRouter.get('/models', async (req, res) => {
616
619
  refreshedAt: ollamaCatalog.refreshedAt,
617
620
  },
618
621
  ollamaReady: isOllamaProviderReady(),
622
+ // Key lanes (plan v4 3.1): `keyProfiles` only while a profile is active. Absent when off.
623
+ ...keyProfilesModelsField(),
619
624
  serverInstanceId: getServerInstanceId(),
620
625
  // Minted here rather than on a route of its own: this response is already
621
626
  // authenticated, and the mint is stateless, so it costs nothing and stores