@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
@@ -0,0 +1,452 @@
1
+ // Key lanes K1 P4 (plan v4 8.1, D9): the generated Codex home a keyed (Lane B) Codex turn runs in.
2
+ //
3
+ // A keyed Codex turn never runs in the user's `~/.codex`. Canary C2d (2026-10-19): even with
4
+ // `-c` overrides on the real home, Codex rewrote `auth.json` (a ChatGPT token refresh) during a
5
+ // key run. So each profile gets `data/codex-homes/<id>/` (0700), regenerated before every turn:
6
+ //
7
+ // config.toml the loopback relay as the ONLY provider (`wire_api = "responses"`, C2a/C2b; the
8
+ // chat wire is refused at config load), retries 2, plus these tables copied
9
+ // read-only from the server home's config: `[mcp_servers.*]` (D9), `[projects.*]`
10
+ // (folder trust, so project hooks and config load as they do on Lane A),
11
+ // `[shell_environment_policy*]`, `[sandbox_workspace_write]`, and the hook trust
12
+ // entries that still describe the hooks written here.
13
+ // hooks.json the COS hook blocks, from the installer's own writer, for exactly the events the
14
+ // user's `~/.codex/hooks.json` carries ours for (mirror, never more).
15
+ // auth.json NEVER. One found there is removed before the turn: the key reaches Codex only as
16
+ // a turn nonce in `COS_RELAY_TOKEN`, and the real key only ever lives in the relay.
17
+ //
18
+ // The user's `~/.codex` is read, never written. HARD RULE: nothing here runs unless a caller has
19
+ // a key profile in hand; importing this module reads no file, opens no socket and starts no timer.
20
+ import { createHash } from 'node:crypto'
21
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, unlinkSync } from 'node:fs'
22
+ import { dirname, join, resolve as resolvePath } from 'node:path'
23
+ import { homedir } from 'node:os'
24
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
25
+ import { currentHookPaths, type HookPaths } from './claude-hooks-installer.js'
26
+ import { codexSubscribedEvents, mergeCodexHooks, stableCodexHookScriptPath } from './codex-hooks-installer.js'
27
+ import { codexProfileHome, codexProfileHomesRoot, serverCodexHome } from './codex-homes.js'
28
+ import { CODEX_KEYED_STEER_NOTICE } from './codex-live-queue.js'
29
+ import { keyLanesActive } from './key-lanes.js'
30
+ import { KEY_LANE_REFUSAL_COPY } from './key-notices.js'
31
+ import { isLaneBStrippedName, LANE_B_STRIPPED_NAMES } from './lane-b-env.js'
32
+ import { engineModelProblem } from './key-profiles.js'
33
+ import { KEY_LANE_CHILD_ENV, RELAY_BASE_PATHS } from './key-relay-contract.js'
34
+ import type { KeyKind, KeyProfile, KeyRelay } from './key-relay-contract.js'
35
+ import { getPublicKey } from './key-store.js'
36
+
37
+ /** The provider id the generated config defines and argv selects. */
38
+ export const CODEX_RELAY_PROVIDER_ID = 'cos-relay'
39
+ /** Plan 4.6: the child retries a 429 or 5xx twice; the relay never retries itself. R15 checks the names. */
40
+ export const CODEX_RELAY_RETRIES = 2
41
+ /**
42
+ * Canary R14 / R15: Codex multiplies the two (each stream reconnect runs its own request retries:
43
+ * 7 to 9 upstream calls per failure). Stream reconnects are off, so one failure costs at most
44
+ * 1 + CODEX_RELAY_RETRIES = 3 attempts, the plan 4.6 budget.
45
+ */
46
+ export const CODEX_RELAY_STREAM_RETRIES = 0
47
+ /** Top-level config tables copied from the server home (first key segment). */
48
+ export const CODEX_COPIED_TABLES: readonly string[] = ['mcp_servers', 'projects', 'shell_environment_policy', 'sandbox_workspace_write']
49
+
50
+ /** Call options a keyed Codex turn adds. Same field name and type P3 adds to CallOptions. */
51
+ export interface CodexKeyLaneCallOptions {
52
+ keyProfile?: KeyProfile
53
+ }
54
+
55
+ // ── Child environment (plan 7.2 base, 8.1 additions) ────────────────────────
56
+
57
+ /**
58
+ * Provider-credential names a Lane B child never inherits: P3's one list (lane-b-env.ts, deduped by
59
+ * P11), plus `CODEX_HOME`, which the profile home replaces. Every other name is kept.
60
+ */
61
+ export const CODEX_LANE_B_STRIPPED_NAMES: readonly string[] = [...LANE_B_STRIPPED_NAMES, 'CODEX_HOME']
62
+
63
+ export function isCodexLaneBStrippedName(name: string): boolean {
64
+ return name === 'CODEX_HOME' || isLaneBStrippedName(name)
65
+ }
66
+
67
+ /** The names applyCodexLaneBEnv sets. With the stripped names this is the whole A-vs-B difference. */
68
+ export const CODEX_LANE_B_ADDED_NAMES: readonly string[] = [
69
+ 'CODEX_HOME',
70
+ KEY_LANE_CHILD_ENV.codexToken,
71
+ KEY_LANE_CHILD_ENV.profileMarker,
72
+ ]
73
+
74
+ /** Point a child env copy (never process.env) at the profile home and the relay nonce. */
75
+ export function applyCodexLaneBEnv(env: NodeJS.ProcessEnv, spec: { home: string; nonce: string; profileId: string }): void {
76
+ if (env === process.env) throw new Error('codex-profile-home: refusing to write process.env')
77
+ if (!spec.home || !spec.nonce || !spec.profileId) throw new Error('codex-profile-home: incomplete spec')
78
+ for (const name of Object.keys(env)) {
79
+ if (isCodexLaneBStrippedName(name)) delete env[name]
80
+ }
81
+ env.CODEX_HOME = spec.home
82
+ env[KEY_LANE_CHILD_ENV.codexToken] = spec.nonce
83
+ env[KEY_LANE_CHILD_ENV.profileMarker] = spec.profileId
84
+ }
85
+
86
+ // ── Seams (P2 relay, P3 keyed-session record). P11 wires both. ───────────────
87
+
88
+ type RelayResolver = () => KeyRelay | Promise<KeyRelay>
89
+ let relayResolver: RelayResolver | null = null
90
+
91
+ export function registerCodexKeyRelay(resolver: RelayResolver | null): void {
92
+ relayResolver = resolver
93
+ }
94
+ export function codexKeyRelayRegistered(): boolean {
95
+ return relayResolver !== null
96
+ }
97
+ export async function codexKeyRelay(): Promise<KeyRelay> {
98
+ if (!relayResolver) throw new Error('codex-profile-home: no key relay registered')
99
+ return await relayResolver()
100
+ }
101
+
102
+ export interface KeyedCodexThread {
103
+ id: string
104
+ engine: 'codex'
105
+ profileId: string
106
+ cosSessionId: string
107
+ }
108
+ type KeyedThreadRecorder = (thread: KeyedCodexThread) => void
109
+ let keyedThreadRecorder: KeyedThreadRecorder | null = null
110
+
111
+ /** Plan 7.3: P11 points this at P3's `recordKeyedSession`. */
112
+ export function registerCodexKeyedThreadRecorder(recorder: KeyedThreadRecorder | null): void {
113
+ keyedThreadRecorder = recorder
114
+ }
115
+ export function recordKeyedCodexThread(thread: KeyedCodexThread): void {
116
+ try { keyedThreadRecorder?.(thread) } catch (err) {
117
+ console.error(`[codex-profile-home] keyed thread record failed: ${err instanceof Error ? err.message.slice(0, 120) : 'unknown'}`)
118
+ }
119
+ }
120
+
121
+ /** Tests only. */
122
+ export function __resetCodexProfileSeamsForTests(): void {
123
+ relayResolver = null
124
+ keyedThreadRecorder = null
125
+ }
126
+
127
+ // ── Lens copy (plan 11.3). P8's key-notices.ts is the single source once merged. ─────────────
128
+
129
+ export const CODEX_LANE_B_COPY = {
130
+ lanesOff: KEY_LANE_REFUSAL_COPY.lanesOff,
131
+ profileOff: KEY_LANE_REFUSAL_COPY.profileOff,
132
+ wrongEngine: KEY_LANE_REFUSAL_COPY.wrongEngineCodex,
133
+ keyMissing: KEY_LANE_REFUSAL_COPY.keyMissing,
134
+ modelNotAllowed: KEY_LANE_REFUSAL_COPY.codexModelNotAllowed,
135
+ scheduled: KEY_LANE_REFUSAL_COPY.scheduled,
136
+ relayUnavailable: KEY_LANE_REFUSAL_COPY.relayUnavailable,
137
+ /** Plan 8.1: a keyed Codex session never joins the Codex app queue. */
138
+ steerAfterTurn: CODEX_KEYED_STEER_NOTICE,
139
+ } as const
140
+
141
+ export type CodexProfilePreflight = { ok: true; keyKind: KeyKind } | { ok: false; message: string }
142
+
143
+ /** Project-config names that could point a keyed Codex turn at a provider other than the relay. */
144
+ const CODEX_PROJECT_PROVIDER_RE = /^\s*(?:\[\s*model_providers\b|(?:model_provider|model_providers|openai_base_url|chatgpt_base_url)\s*[=.])/m
145
+
146
+ /**
147
+ * QA W4: a trusted project's `.codex/config.toml` is merged over the profile home's config, so
148
+ * one that sets a provider (or redefines `cos-relay`) could send a keyed turn around the meter.
149
+ * Walks from the working directory up to the repository root (a `.git` entry) or the filesystem
150
+ * root, never into the user's home itself (that `.codex` is the global config the profile home
151
+ * replaces). Returns the file and the name it sets, or null. Read only on a keyed turn.
152
+ */
153
+ export function codexProjectConfigRefusal(cwd: string, home: string = homedir()): { file: string; name: string } | null {
154
+ let dir = resolvePath(cwd)
155
+ for (let i = 0; i < 32; i++) {
156
+ if (dir !== home) {
157
+ const file = join(dir, '.codex', 'config.toml')
158
+ try {
159
+ if (lstatSync(file).isFile()) {
160
+ const m = CODEX_PROJECT_PROVIDER_RE.exec(readFileSync(file, 'utf8'))
161
+ if (m) return { file, name: m[0].replace(/[\s\[=.]/g, '') || 'model_provider' }
162
+ }
163
+ } catch { /* none here */ }
164
+ try { if (lstatSync(join(dir, '.git'))) break } catch { /* not the repo root */ }
165
+ }
166
+ const up = dirname(dir)
167
+ if (up === dir) break
168
+ dir = up
169
+ }
170
+ return null
171
+ }
172
+
173
+ /** Everything a keyed Codex turn passes before history, the run ledger or a process. Fails closed. */
174
+ export function codexProfilePreflight(input: { profile: KeyProfile; dispatch: boolean; cwd?: string }): CodexProfilePreflight {
175
+ const { profile } = input
176
+ if (!keyLanesActive()) return { ok: false, message: CODEX_LANE_B_COPY.lanesOff }
177
+ if (profile.engine !== 'codex') return { ok: false, message: CODEX_LANE_B_COPY.wrongEngine(profile.label) }
178
+ if (!profile.enabled) return { ok: false, message: CODEX_LANE_B_COPY.profileOff(profile.label) }
179
+ if (input.dispatch) return { ok: false, message: CODEX_LANE_B_COPY.scheduled }
180
+ const key = getPublicKey(profile.keyRef)
181
+ if (!key) return { ok: false, message: CODEX_LANE_B_COPY.keyMissing(profile.label) }
182
+ if (engineModelProblem('codex', profile.model, key.kind)) return { ok: false, message: CODEX_LANE_B_COPY.modelNotAllowed(profile.label) }
183
+ const project = input.cwd ? codexProjectConfigRefusal(input.cwd) : null
184
+ if (project) return { ok: false, message: KEY_LANE_REFUSAL_COPY.codexProjectConfig(project.name) }
185
+ if (!codexKeyRelayRegistered()) return { ok: false, message: CODEX_LANE_B_COPY.relayUnavailable }
186
+ return { ok: true, keyKind: key.kind }
187
+ }
188
+
189
+ // ── TOML, just enough (the server has no TOML dependency) ───────────────────
190
+
191
+ const BARE_KEY_RE = /^[A-Za-z0-9_-]+$/
192
+
193
+ /** A dotted table key (`a."b c".d`) to its segments, or null when it is not a key. */
194
+ export function parseTomlKeyPath(inner: string): string[] | null {
195
+ const segs: string[] = []
196
+ let i = 0
197
+ const s = inner.trim()
198
+ const skipWs = () => { while (i < s.length && (s[i] === ' ' || s[i] === '\t')) i++ }
199
+ while (i < s.length) {
200
+ skipWs()
201
+ if (s[i] === '"') {
202
+ let out = ''
203
+ i++
204
+ for (;;) {
205
+ if (i >= s.length) return null
206
+ const ch = s[i]
207
+ if (ch === '"') { i++; break }
208
+ if (ch === '\\') {
209
+ const nx = s[i + 1]
210
+ const map: Record<string, string> = { '"': '"', '\\': '\\', n: '\n', t: '\t', r: '\r', b: '\b', f: '\f' }
211
+ if (nx && nx in map) { out += map[nx]; i += 2; continue }
212
+ if (nx === 'u' || nx === 'U') {
213
+ const len = nx === 'u' ? 4 : 8
214
+ const hex = s.slice(i + 2, i + 2 + len)
215
+ if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length !== len) return null
216
+ out += String.fromCodePoint(parseInt(hex, 16))
217
+ i += 2 + len
218
+ continue
219
+ }
220
+ return null
221
+ }
222
+ out += ch
223
+ i++
224
+ }
225
+ segs.push(out)
226
+ } else if (s[i] === "'") {
227
+ const end = s.indexOf("'", i + 1)
228
+ if (end < 0) return null
229
+ segs.push(s.slice(i + 1, end))
230
+ i = end + 1
231
+ } else {
232
+ const m = /^[A-Za-z0-9_-]+/.exec(s.slice(i))
233
+ if (!m) return null
234
+ segs.push(m[0])
235
+ i += m[0].length
236
+ }
237
+ skipWs()
238
+ if (i >= s.length) break
239
+ if (s[i] !== '.') return null
240
+ i++
241
+ }
242
+ return segs.length ? segs : null
243
+ }
244
+
245
+ export function encodeTomlKeySegment(seg: string): string {
246
+ return BARE_KEY_RE.test(seg) ? seg : tomlBasicString(seg)
247
+ }
248
+
249
+ /** A TOML basic string, always quoted. */
250
+ export function tomlBasicString(seg: string): string {
251
+ let out = '"'
252
+ for (const ch of seg) {
253
+ const cp = ch.codePointAt(0)!
254
+ if (ch === '"') out += '\\"'
255
+ else if (ch === '\\') out += '\\\\'
256
+ else if (cp < 0x20 || cp === 0x7f) out += `\\u${cp.toString(16).padStart(4, '0')}`
257
+ else out += ch
258
+ }
259
+ return out + '"'
260
+ }
261
+
262
+ export interface TomlSection {
263
+ /** Null for the leading top-level keys, before any header. */
264
+ path: string[] | null
265
+ arrayTable: boolean
266
+ header: string
267
+ body: string[]
268
+ }
269
+
270
+ const HEADER_RE = /^\s*(\[\[?)\s*(.+?)\s*(\]\]?)\s*(#.*)?$/
271
+
272
+ /** Split TOML text into sections at table headers. Multi-line strings never split a section. */
273
+ export function splitTomlSections(text: string): TomlSection[] {
274
+ const sections: TomlSection[] = [{ path: null, arrayTable: false, header: '', body: [] }]
275
+ let inMulti: '"""' | "'''" | null = null
276
+ for (const line of text.split(/\r?\n/)) {
277
+ if (inMulti) {
278
+ sections[sections.length - 1]!.body.push(line)
279
+ if ((line.split(inMulti).length - 1) % 2 === 1) inMulti = null
280
+ continue
281
+ }
282
+ const m = HEADER_RE.exec(line)
283
+ const open = m?.[1], close = m?.[3]
284
+ const path = m && ((open === '[' && close === ']') || (open === '[[' && close === ']]')) ? parseTomlKeyPath(m[2]!) : null
285
+ if (m && path) {
286
+ sections.push({ path, arrayTable: open === '[[', header: line, body: [] })
287
+ continue
288
+ }
289
+ sections[sections.length - 1]!.body.push(line)
290
+ for (const q of ['"""', "'''"] as const) {
291
+ if ((line.split(q).length - 1) % 2 === 1) { inMulti = q; break }
292
+ }
293
+ }
294
+ return sections
295
+ }
296
+
297
+ function headerFor(path: string[], arrayTable: boolean): string {
298
+ const name = path.map(encodeTomlKeySegment).join('.')
299
+ return arrayTable ? `[[${name}]]` : `[${name}]`
300
+ }
301
+
302
+ function camelToSnake(event: string): string {
303
+ return event.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
304
+ }
305
+
306
+ const HOOK_TRUST_KEY_RE = /^(.*):([a-z_]+):(\d+):(\d+)$/
307
+
308
+ // ── The generated home ───────────────────────────────────────────────────────
309
+
310
+ export interface CodexProfileHomeSources {
311
+ /** The server's Codex home, read only. Default `CODEX_HOME` or `~/.codex`. */
312
+ serverHome?: string
313
+ scriptPath?: string
314
+ hookPaths?: HookPaths
315
+ }
316
+
317
+ export interface RenderedCodexProfileHome {
318
+ profileId: string
319
+ home: string
320
+ /** config.toml with the relay origin still a placeholder. */
321
+ configTemplate: string
322
+ hooksText: string
323
+ /** Plan 8.1 resume binding: `profile:<id>:<sha>` over the template and hooks (never the port). */
324
+ engineFingerprint: string
325
+ copiedTables: string[]
326
+ mirroredHookEvents: string[]
327
+ }
328
+
329
+ const ORIGIN_PLACEHOLDER = '__COS_RELAY_ORIGIN__'
330
+
331
+ function readTextOrNull(path: string): string | null {
332
+ try {
333
+ if (lstatSync(path).isSymbolicLink()) return null
334
+ return readFileSync(path, 'utf8')
335
+ } catch { return null }
336
+ }
337
+
338
+ /** Pure apart from READING the server home's config.toml and hooks.json. Writes nothing. */
339
+ export function renderCodexProfileHome(profile: KeyProfile, sources: CodexProfileHomeSources = {}): RenderedCodexProfileHome {
340
+ const serverHome = sources.serverHome ?? serverCodexHome()
341
+ const home = codexProfileHome(profile.id)
342
+ const scriptPath = sources.scriptPath ?? stableCodexHookScriptPath()
343
+ const hookPaths = sources.hookPaths ?? currentHookPaths()
344
+ const serverHooksPath = join(serverHome, 'hooks.json')
345
+ const profileHooksPath = join(home, 'hooks.json')
346
+
347
+ // hooks.json: our canonical blocks, for exactly the events the user's file has ours for.
348
+ let userHooks: unknown = {}
349
+ const userHooksText = readTextOrNull(serverHooksPath)
350
+ if (userHooksText !== null) { try { userHooks = JSON.parse(userHooksText) } catch { userHooks = {} } }
351
+ const subscribed = codexSubscribedEvents(userHooks, scriptPath, hookPaths).subscribed
352
+ const merged = mergeCodexHooks({}, scriptPath, hookPaths)
353
+ const allBlocks = merged.ok ? (merged.settings.hooks as Record<string, unknown>) : {}
354
+ const hooks: Record<string, unknown> = {}
355
+ for (const event of subscribed) if (allBlocks[event] !== undefined) hooks[event] = allBlocks[event]
356
+ const hooksText = JSON.stringify({ hooks }, null, 2) + '\n'
357
+
358
+ // Where our block sits in the user's file, per event (snake name), for the trust remap.
359
+ const userTable = userHooks && typeof userHooks === 'object' ? (userHooks as { hooks?: Record<string, unknown> }).hooks ?? {} : {}
360
+ const ourIndex = new Map<string, number>()
361
+ for (const event of subscribed) {
362
+ const blocks = Array.isArray(userTable[event]) ? userTable[event] as unknown[] : []
363
+ const canonical = JSON.stringify((allBlocks[event] as unknown[] | undefined)?.[0])
364
+ const at = blocks.findIndex(b => JSON.stringify(b) === canonical)
365
+ if (at >= 0) ourIndex.set(camelToSnake(event), at)
366
+ }
367
+
368
+ const copied: string[] = []
369
+ const out: string[] = []
370
+ const configText = readTextOrNull(join(serverHome, 'config.toml'))
371
+ if (configText !== null) {
372
+ for (const section of splitTomlSections(configText)) {
373
+ const path = section.path
374
+ if (!path) continue
375
+ if (CODEX_COPIED_TABLES.includes(path[0]!)) {
376
+ out.push(section.header, ...section.body)
377
+ copied.push(path.join('.'))
378
+ continue
379
+ }
380
+ // Hook trust: Codex keys each hook by `<file>:<event>:<block>:<hook>`. A project file's key
381
+ // names the same file on both lanes and is copied as it stands. A key on the user's own
382
+ // hooks.json is moved to this home's hooks.json, only where our block is the one trusted.
383
+ if (path[0] === 'hooks' && path[1] === 'state' && path.length === 3) {
384
+ const key = path[2]!
385
+ const parsed = HOOK_TRUST_KEY_RE.exec(key)
386
+ if (parsed && parsed[1] === serverHooksPath) {
387
+ const at = ourIndex.get(parsed[2]!)
388
+ if (at === undefined || Number(parsed[3]) !== at) continue
389
+ const remapped = `${profileHooksPath}:${parsed[2]}:0:${parsed[4]}`
390
+ out.push(headerFor(['hooks', 'state', remapped], false), ...section.body)
391
+ copied.push(`hooks.state.${parsed[2]}`)
392
+ continue
393
+ }
394
+ if (parsed && parsed[1]!.startsWith(home)) continue
395
+ out.push(section.header, ...section.body)
396
+ copied.push('hooks.state')
397
+ }
398
+ }
399
+ }
400
+
401
+ const head = [
402
+ `# Generated by COS for key profile ${profile.id} before every keyed turn. COS rewrites it; do not edit.`,
403
+ `model = ${tomlBasicString(profile.model)}`,
404
+ `model_provider = "${CODEX_RELAY_PROVIDER_ID}"`,
405
+ '',
406
+ `[model_providers.${CODEX_RELAY_PROVIDER_ID}]`,
407
+ 'name = "COS key relay"',
408
+ `base_url = "${ORIGIN_PLACEHOLDER}${RELAY_BASE_PATHS.codex}"`,
409
+ `env_key = "${KEY_LANE_CHILD_ENV.codexToken}"`,
410
+ 'wire_api = "responses"',
411
+ `request_max_retries = ${CODEX_RELAY_RETRIES}`,
412
+ `stream_max_retries = ${CODEX_RELAY_STREAM_RETRIES}`,
413
+ '',
414
+ ]
415
+ const tail = out.length ? [`# Copied read-only from ${join(serverHome, 'config.toml')} (plan D9).`, ...out] : []
416
+ const configTemplate = [...head, ...tail].join('\n').replace(/\n*$/, '\n')
417
+ const sha = createHash('sha256').update(configTemplate).update('\0').update(hooksText).digest('hex').slice(0, 16)
418
+ return {
419
+ profileId: profile.id,
420
+ home,
421
+ configTemplate,
422
+ hooksText,
423
+ engineFingerprint: `profile:${profile.id}:${sha}`,
424
+ copiedTables: [...new Set(copied)],
425
+ mirroredHookEvents: Object.keys(hooks),
426
+ }
427
+ }
428
+
429
+ const LOOPBACK_ORIGIN_RE = /^http:\/\/127\.0\.0\.1:\d{1,5}$/
430
+
431
+ /**
432
+ * Write the rendered home with the relay origin filled in: dirs 0700, files 0600, atomic. Refuses
433
+ * a symlinked root or home. Any `auth.json` in the home is removed: a keyed home never holds one.
434
+ */
435
+ export function writeCodexProfileHome(rendered: RenderedCodexProfileHome, relayOrigin: string): { configPath: string; hooksPath: string } {
436
+ if (!LOOPBACK_ORIGIN_RE.test(relayOrigin)) throw new Error('codex-profile-home: the relay origin is not loopback')
437
+ const root = codexProfileHomesRoot()
438
+ for (const dir of [root, rendered.home]) {
439
+ let st: ReturnType<typeof lstatSync> | null = null
440
+ try { st = lstatSync(dir) } catch { /* created below */ }
441
+ if (st && (st.isSymbolicLink() || !st.isDirectory())) throw new Error('codex-profile-home: refusing a symlinked or non-directory home')
442
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
443
+ chmodSync(dir, 0o700)
444
+ }
445
+ const auth = join(rendered.home, 'auth.json')
446
+ try { lstatSync(auth); unlinkSync(auth) } catch { /* none, as it should be */ }
447
+ const configPath = join(rendered.home, 'config.toml')
448
+ const hooksPath = join(rendered.home, 'hooks.json')
449
+ durableAtomicWriteFileSync(configPath, rendered.configTemplate.split(ORIGIN_PLACEHOLDER).join(relayOrigin), { mode: 0o600 })
450
+ durableAtomicWriteFileSync(hooksPath, rendered.hooksText, { mode: 0o600 })
451
+ return { configPath, hooksPath }
452
+ }
@@ -6,6 +6,7 @@ import { existsSync } from 'node:fs'
6
6
  import { homedir } from 'node:os'
7
7
  import { join } from 'node:path'
8
8
  import { resolveProviderBinary } from './provider-binary.js'
9
+ import { codexHomes } from './codex-homes.js'
9
10
 
10
11
  export interface NativeQueuedSubmission { id: string; input: Array<{ type: string; text?: string; text_elements?: unknown[] }>; clientUserMessageId: string }
11
12
  export interface NativeQueueControl {
@@ -89,7 +90,22 @@ export const nativeQueueControl: NativeQueueControl = {
89
90
  // One read-only aggregate per session-list request, not 80 protocol processes.
90
91
  // No queue file is ever edited via SQL; mutations use Codex's own protocol above.
91
92
  export async function nativeQueuedCounts(): Promise<Map<string, number> | null> {
92
- const db = join(process.env.CODEX_HOME || join(homedir(), '.codex'), 'queue_1.sqlite')
93
+ // Key lanes K1 (plan v4 8.2): the server home first, then each keyed profile home. With no
94
+ // profile home this is exactly the one read it always was.
95
+ const homes = codexHomes(process.env.CODEX_HOME || join(homedir(), '.codex'))
96
+ if (homes.length === 1) return queuedCountsIn(homes[0]!)
97
+ const total = new Map<string, number>()
98
+ for (const home of homes) {
99
+ const counts = await queuedCountsIn(home)
100
+ // An unreadable keyed home never degrades the server home's answer.
101
+ if (counts === null) { if (home === homes[0]) return null; continue }
102
+ for (const [thread, n] of counts) total.set(thread, (total.get(thread) ?? 0) + n)
103
+ }
104
+ return total
105
+ }
106
+
107
+ async function queuedCountsIn(home: string): Promise<Map<string, number> | null> {
108
+ const db = join(home, 'queue_1.sqlite')
93
109
  if (!existsSync(db)) return new Map()
94
110
  try {
95
111
  const { stdout } = await promisify(execFile)('/usr/bin/sqlite3', ['-readonly', '-json', db, 'SELECT thread_id, count(*) AS n FROM queued_items GROUP BY thread_id'], { timeout: 2000, maxBuffer: 1024 * 1024 })
@@ -81,6 +81,28 @@ async function childState(file: string, root: string, now: number, budget: { byt
81
81
  }
82
82
 
83
83
  /** One bounded read-only SQLite snapshot per list/detail read. */
84
+ /**
85
+ * Key lanes (P11): readCodexSubagents over every Codex home. Each parent is read in the home that
86
+ * holds its thread (`rootOf`, a keyed thread's profile home), every other one in the server home.
87
+ * With one root (key lanes off, or no profile home) it is exactly the one server-home call.
88
+ */
89
+ export async function readCodexSubagentsAcross(
90
+ parents: readonly string[], sessionsRoots: readonly string[], now?: number, rootOf?: (id: string) => string | undefined,
91
+ ): Promise<Map<string, SubagentActivity>> {
92
+ const server = sessionsRoots[0]!
93
+ const read = (ids: readonly string[], root: string) => now === undefined ? readCodexSubagents(ids, root) : readCodexSubagents(ids, root, now)
94
+ if (sessionsRoots.length <= 1 || !rootOf) return read(parents, server)
95
+ const groups = new Map<string, string[]>()
96
+ for (const id of parents) {
97
+ const own = rootOf(id)
98
+ const root = own && sessionsRoots.includes(own) ? own : server
99
+ groups.set(root, [...(groups.get(root) ?? []), id])
100
+ }
101
+ const out = new Map<string, SubagentActivity>()
102
+ for (const [root, ids] of groups) for (const [id, activity] of await read(ids, root)) out.set(id, activity)
103
+ return out
104
+ }
105
+
84
106
  export async function readCodexSubagents(
85
107
  parents: readonly string[], sessionsRoot: string, now = Date.now(),
86
108
  ): Promise<Map<string, SubagentActivity>> {