@gotcos/glasses-server 6.79.2 → 6.81.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 +31 -0
- package/README.md +10 -0
- package/managed-runtime-contract.json +1 -0
- package/package.json +10 -1
- package/server/index.ts +14 -1
- 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/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/meeting-autonomous-finalize.ts +267 -0
- package/server/lib/meeting-stop-intents.ts +277 -0
- 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/meeting.ts +326 -47
- package/server/routes/openai-compat.ts +28 -3
- package/server/routes/query.ts +20 -6
- package/server/routes/transcribe-stream.ts +48 -1
- package/shared/key-preference.ts +57 -0
- 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
|
-
:
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) })
|
package/server/routes/health.ts
CHANGED
|
@@ -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
|