@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.
- package/.env.example +9 -0
- package/CHANGELOG.md +98 -0
- package/README.md +20 -1
- package/managed-runtime-contract.json +6 -1
- package/package.json +9 -1
- package/server/index.ts +11 -0
- package/server/lib/agent-session-search.ts +8 -5
- package/server/lib/agent-session-store.ts +45 -4
- package/server/lib/archive.ts +2 -1
- package/server/lib/attached-provider-adapter.ts +11 -0
- package/server/lib/claude-bridge.ts +112 -8
- package/server/lib/claude-settings-guard.ts +94 -0
- package/server/lib/claude-steer.ts +27 -0
- package/server/lib/codex-bridge.ts +205 -32
- package/server/lib/codex-engine-sessions.ts +27 -6
- package/server/lib/codex-homes.ts +134 -0
- package/server/lib/codex-live-queue.ts +14 -0
- package/server/lib/codex-profile-home.ts +452 -0
- package/server/lib/codex-queue-control.ts +17 -1
- package/server/lib/codex-subagent-activity.ts +22 -0
- package/server/lib/conversation.ts +92 -15
- package/server/lib/dictation-clean.ts +13 -6
- package/server/lib/dictation-fillers.ts +17 -10
- package/server/lib/dictation-polish-need.ts +48 -0
- package/server/lib/dictation-prewarm.ts +209 -0
- package/server/lib/direct-bridge.ts +853 -0
- package/server/lib/direct-sse.ts +262 -0
- package/server/lib/fork-thread.ts +11 -0
- package/server/lib/key-answer-fields.ts +49 -0
- package/server/lib/key-catalog.ts +301 -0
- package/server/lib/key-lanes-pricing.ts +12 -0
- package/server/lib/key-lanes-wiring.ts +346 -0
- package/server/lib/key-lanes.ts +145 -0
- package/server/lib/key-ledger.ts +123 -0
- package/server/lib/key-notices.ts +486 -0
- package/server/lib/key-profile-rejects.ts +25 -0
- package/server/lib/key-profile-routing.ts +173 -0
- package/server/lib/key-profiles.ts +182 -0
- package/server/lib/key-relay-contract.ts +262 -0
- package/server/lib/key-relay-errors.ts +211 -0
- package/server/lib/key-relay-meter.ts +303 -0
- package/server/lib/key-relay.ts +1018 -0
- package/server/lib/key-secrets-guard.ts +52 -0
- package/server/lib/key-store.ts +228 -0
- package/server/lib/keyed-sessions.ts +154 -0
- package/server/lib/lane-b-env.ts +169 -0
- package/server/lib/live-cues-v2-settings.ts +51 -13
- package/server/lib/live-cues-v2.ts +147 -19
- package/server/lib/model-router.ts +96 -7
- package/server/lib/morning-brief-config.ts +3 -0
- package/server/lib/native-head.ts +13 -1
- package/server/lib/occupancy-probes.ts +4 -0
- package/server/lib/ollama-run-ledger.ts +96 -13
- package/server/lib/prompt-edit.ts +2 -0
- package/server/lib/query-job-coordinator.ts +2 -1
- package/server/lib/query-job-runtime.ts +67 -15
- package/server/lib/query-job-store.ts +7 -1
- package/server/lib/query-job-types.ts +15 -1
- package/server/lib/session-hook-events.ts +28 -0
- package/server/lib/session-hook-ledger.ts +10 -0
- package/server/lib/thread-occupancy.ts +22 -1
- package/server/lib/token-audit.ts +18 -4
- package/server/lib/work-handoff-requests.ts +3 -0
- package/server/routes/agent-session-bindings.ts +15 -0
- package/server/routes/agent-sessions.ts +17 -5
- package/server/routes/handoffs.ts +22 -1
- package/server/routes/health.ts +5 -0
- package/server/routes/key-lanes.ts +442 -0
- package/server/routes/live-cues.ts +39 -3
- package/server/routes/openai-compat.ts +28 -3
- package/server/routes/prompt-drafts.ts +75 -8
- package/server/routes/query.ts +20 -6
- package/shared/key-preference.ts +57 -0
- 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
|
-
//
|
|
324
|
-
// catalog
|
|
325
|
-
|
|
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
|
-
|
|
331
|
-
extra =
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
const
|
|
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
|
|
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
|
-
|
|
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 =
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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)
|