@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
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import crypto from 'node:crypto'
|
|
2
2
|
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
3
3
|
import { dirname, resolve } from 'node:path'
|
|
4
|
-
import { OLLAMA_MODEL
|
|
4
|
+
import { OLLAMA_MODEL } from '../../shared/model-preference.js'
|
|
5
|
+
import { dataPath } from './data-dir.js'
|
|
6
|
+
import type { KeyLane } from './key-relay-contract.js'
|
|
5
7
|
|
|
6
8
|
const DEFAULT_MAX_RUNS = 100
|
|
7
9
|
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60_000
|
|
@@ -10,6 +12,13 @@ const RUNNING_STALE_MS = 30 * 60_000
|
|
|
10
12
|
|
|
11
13
|
export type OllamaRunStatus = 'running' | 'completed' | 'failed' | 'cancelled' | 'client_disconnected'
|
|
12
14
|
|
|
15
|
+
/**
|
|
16
|
+
* One run, Ollama or (key lanes K1 P5, plan v4 9.1) direct. `model` was OllamaModelPreference; it
|
|
17
|
+
* is a string now so a direct run can carry its `profile:<id>` preference. Ollama rows still write
|
|
18
|
+
* exactly `model: 'ollama'` and never a `lane`, `profile` or `providerModel` key, in the 6.79.0
|
|
19
|
+
* key order (pinned byte for byte by ollama-run-ledger.test.ts). Direct rows live in their own
|
|
20
|
+
* file (`direct-runs.jsonl` under the data dir), so the Ollama ledger only ever holds Ollama runs.
|
|
21
|
+
*/
|
|
13
22
|
export interface OllamaRunRecord {
|
|
14
23
|
runId: string
|
|
15
24
|
turnId?: string
|
|
@@ -18,8 +27,15 @@ export interface OllamaRunRecord {
|
|
|
18
27
|
status: OllamaRunStatus
|
|
19
28
|
createdAt: string
|
|
20
29
|
updatedAt: string
|
|
21
|
-
model:
|
|
22
|
-
|
|
30
|
+
model: string
|
|
31
|
+
/** Direct rows only. */
|
|
32
|
+
lane?: KeyLane
|
|
33
|
+
/** Direct rows only: the key profile id. */
|
|
34
|
+
profile?: string
|
|
35
|
+
/** Ollama rows: the pulled tag. Direct rows leave it out and use providerModel. */
|
|
36
|
+
ollamaModel?: string
|
|
37
|
+
/** Direct rows only: the provider model id or OpenRouter slug. */
|
|
38
|
+
providerModel?: string
|
|
23
39
|
origin: string
|
|
24
40
|
queryPreview?: string
|
|
25
41
|
outputPreview?: string
|
|
@@ -42,6 +58,11 @@ export function getOllamaLedgerPath(): string {
|
|
|
42
58
|
return resolve(process.env.COS_OLLAMA_RUN_LEDGER_FILE || resolve(import.meta.dirname, '..', 'data', 'ollama-runs.jsonl'))
|
|
43
59
|
}
|
|
44
60
|
|
|
61
|
+
/** Direct (Lane C1) runs. Written only by a keyed turn, so nothing is created while lanes are off. */
|
|
62
|
+
export function getDirectRunLedgerPath(): string {
|
|
63
|
+
return resolve(process.env.COS_DIRECT_RUN_LEDGER_FILE || dataPath('direct-runs.jsonl'))
|
|
64
|
+
}
|
|
65
|
+
|
|
45
66
|
function getMaxRuns(): number {
|
|
46
67
|
const raw = Number(process.env.COS_OLLAMA_RUN_LEDGER_MAX ?? DEFAULT_MAX_RUNS)
|
|
47
68
|
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_RUNS
|
|
@@ -52,9 +73,8 @@ function getTtlMs(): number {
|
|
|
52
73
|
return Number.isFinite(rawDays) && rawDays > 0 ? rawDays * 24 * 60 * 60_000 : DEFAULT_TTL_MS
|
|
53
74
|
}
|
|
54
75
|
|
|
55
|
-
function appendEvent(event: OllamaRunEvent): void {
|
|
76
|
+
function appendEvent(event: OllamaRunEvent, path: string = getOllamaLedgerPath()): void {
|
|
56
77
|
try {
|
|
57
|
-
const path = getOllamaLedgerPath()
|
|
58
78
|
mkdirSync(dirname(path), { recursive: true })
|
|
59
79
|
appendFileSync(path, JSON.stringify(event) + '\n')
|
|
60
80
|
} catch (err) {
|
|
@@ -62,8 +82,7 @@ function appendEvent(event: OllamaRunEvent): void {
|
|
|
62
82
|
}
|
|
63
83
|
}
|
|
64
84
|
|
|
65
|
-
function readEvents(): OllamaRunEvent[] {
|
|
66
|
-
const path = getOllamaLedgerPath()
|
|
85
|
+
function readEvents(path: string = getOllamaLedgerPath()): OllamaRunEvent[] {
|
|
67
86
|
if (!existsSync(path)) return []
|
|
68
87
|
try {
|
|
69
88
|
const events: OllamaRunEvent[] = []
|
|
@@ -129,23 +148,29 @@ export function startOllamaRun(input: {
|
|
|
129
148
|
return run
|
|
130
149
|
}
|
|
131
150
|
|
|
132
|
-
|
|
151
|
+
interface FinishRunInput {
|
|
133
152
|
status: Exclude<OllamaRunStatus, 'running'>
|
|
134
153
|
startedAtMs: number
|
|
135
154
|
output?: string
|
|
136
155
|
error?: string
|
|
137
|
-
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function finishRun(path: string, classify: (message: string) => string, runId: string, input: FinishRunInput): void {
|
|
138
159
|
const patch: Partial<OllamaRunRecord> = {
|
|
139
160
|
status: input.status,
|
|
140
161
|
durationMs: Math.max(0, Date.now() - input.startedAtMs),
|
|
141
162
|
}
|
|
142
163
|
if (input.output) patch.outputPreview = redactForOllamaLedger(input.output)
|
|
143
164
|
if (input.error) {
|
|
144
|
-
patch.errorCode =
|
|
165
|
+
patch.errorCode = classify(input.error)
|
|
145
166
|
patch.errorPreview = redactForOllamaLedger(input.error)
|
|
146
167
|
}
|
|
147
168
|
const ts = new Date().toISOString()
|
|
148
|
-
appendEvent({ runId, ts, patch: { ...patch, updatedAt: ts } })
|
|
169
|
+
appendEvent({ runId, ts, patch: { ...patch, updatedAt: ts } }, path)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function finishOllamaRun(runId: string, input: FinishRunInput): OllamaRunRecord | null {
|
|
173
|
+
finishRun(getOllamaLedgerPath(), classifyOllamaError, runId, input)
|
|
149
174
|
return getOllamaRun(runId)
|
|
150
175
|
}
|
|
151
176
|
|
|
@@ -154,10 +179,14 @@ export function getOllamaRun(runId: string): OllamaRunRecord | null {
|
|
|
154
179
|
}
|
|
155
180
|
|
|
156
181
|
export function listOllamaRuns(limit = 20, sessionId?: string): OllamaRunRecord[] {
|
|
182
|
+
return listRuns(getOllamaLedgerPath(), 'ollama.timeout', limit, sessionId)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function listRuns(path: string, staleCode: string, limit: number, sessionId?: string): OllamaRunRecord[] {
|
|
157
186
|
const runs = new Map<string, OllamaRunRecord>()
|
|
158
187
|
const order = new Map<string, number>()
|
|
159
188
|
let eventIndex = 0
|
|
160
|
-
for (const event of readEvents()) {
|
|
189
|
+
for (const event of readEvents(path)) {
|
|
161
190
|
eventIndex += 1
|
|
162
191
|
const existing = runs.get(event.runId)
|
|
163
192
|
const next = { ...(existing ?? {}), ...event.patch, runId: event.runId } as OllamaRunRecord
|
|
@@ -175,7 +204,7 @@ export function listOllamaRuns(limit = 20, sessionId?: string): OllamaRunRecord[
|
|
|
175
204
|
return {
|
|
176
205
|
...run,
|
|
177
206
|
status: 'client_disconnected' as const,
|
|
178
|
-
errorCode: run.errorCode ??
|
|
207
|
+
errorCode: run.errorCode ?? staleCode,
|
|
179
208
|
}
|
|
180
209
|
}
|
|
181
210
|
return run
|
|
@@ -183,3 +212,57 @@ export function listOllamaRuns(limit = 20, sessionId?: string): OllamaRunRecord[
|
|
|
183
212
|
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt))
|
|
184
213
|
.slice(0, limit)
|
|
185
214
|
}
|
|
215
|
+
|
|
216
|
+
// ── Direct runs (key lanes K1 P5, plan v4 9.1) ───────────────────────────────
|
|
217
|
+
|
|
218
|
+
/** `direct.<failure>` for a key-lane failure kind, else the closest generic code. */
|
|
219
|
+
export function classifyDirectError(message: string): string {
|
|
220
|
+
const m = /^direct-bridge: \[([a-z_]+)\]/.exec(message)
|
|
221
|
+
if (m) return `direct.${m[1]}`
|
|
222
|
+
const text = message.toLowerCase()
|
|
223
|
+
if (/tool round cap/.test(text)) return 'direct.tool_cap'
|
|
224
|
+
if (/tool aborted|tool call aborted/.test(text)) return 'direct.tool_abort'
|
|
225
|
+
if (/timeout|timed out|aborted/.test(text)) return 'direct.timeout'
|
|
226
|
+
return 'direct.error'
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function startDirectRun(input: {
|
|
230
|
+
turnId?: string
|
|
231
|
+
clientJobId?: string
|
|
232
|
+
cosSessionId: string
|
|
233
|
+
/** The COS preference the exchange is stamped with, `profile:<id>`. */
|
|
234
|
+
model: string
|
|
235
|
+
profile: string
|
|
236
|
+
providerModel: string
|
|
237
|
+
/** Endpoint origin only (scheme, host, port). Never a path, query or key. */
|
|
238
|
+
origin: string
|
|
239
|
+
query: string
|
|
240
|
+
}): OllamaRunRecord {
|
|
241
|
+
const now = new Date().toISOString()
|
|
242
|
+
const run: OllamaRunRecord = {
|
|
243
|
+
runId: `direct-${crypto.randomUUID().slice(0, 8)}`,
|
|
244
|
+
turnId: input.turnId,
|
|
245
|
+
clientJobId: input.clientJobId,
|
|
246
|
+
cosSessionId: input.cosSessionId,
|
|
247
|
+
status: 'running',
|
|
248
|
+
createdAt: now,
|
|
249
|
+
updatedAt: now,
|
|
250
|
+
model: input.model,
|
|
251
|
+
lane: 'direct',
|
|
252
|
+
profile: input.profile,
|
|
253
|
+
providerModel: input.providerModel,
|
|
254
|
+
origin: input.origin,
|
|
255
|
+
queryPreview: redactForOllamaLedger(input.query),
|
|
256
|
+
}
|
|
257
|
+
appendEvent({ runId: run.runId, ts: now, patch: run }, getDirectRunLedgerPath())
|
|
258
|
+
return run
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function finishDirectRun(runId: string, input: FinishRunInput): OllamaRunRecord | null {
|
|
262
|
+
finishRun(getDirectRunLedgerPath(), classifyDirectError, runId, input)
|
|
263
|
+
return listDirectRuns(getMaxRuns()).find(run => run.runId === runId) ?? null
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function listDirectRuns(limit = 20, sessionId?: string): OllamaRunRecord[] {
|
|
267
|
+
return listRuns(getDirectRunLedgerPath(), 'direct.timeout', limit, sessionId)
|
|
268
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
|
+
import { claudeKeySecretsGuardArgs } from './key-secrets-guard.js'
|
|
2
3
|
|
|
3
4
|
export const PROMPT_EDIT_DRAFT_MAX_CHARS = 24_000
|
|
4
5
|
export const PROMPT_EDIT_INSTRUCTION_MAX_CHARS = 2_000
|
|
@@ -90,6 +91,7 @@ export function spawnClaudeText(prompt: string, opts: SpawnClaudeTextOptions = {
|
|
|
90
91
|
const args = ['-p', '--model', model, '--effort', effort, '--output-format', 'text', '--no-session-persistence',
|
|
91
92
|
'--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}']
|
|
92
93
|
if (opts.systemPrompt) args.push('--system-prompt', opts.systemPrompt)
|
|
94
|
+
args.push(...claudeKeySecretsGuardArgs())
|
|
93
95
|
|
|
94
96
|
const proc = spawn('claude', args, {
|
|
95
97
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
type QueryJobSteerState,
|
|
32
32
|
type QueryJobStoreHealth,
|
|
33
33
|
} from './query-job-types.js'
|
|
34
|
+
import type { QueryJobKeyAnswerFields } from './key-answer-fields.js'
|
|
34
35
|
import { SteerPort, steerClock, steerCutoffMs, type PendingSteer, type QueryProvider } from './claude-steer.js'
|
|
35
36
|
import type { MaintenanceWorkLease } from './maintenance-lifecycle.js'
|
|
36
37
|
import { applyTrailToolMode, type JobTrailDraft } from './job-trail.js'
|
|
@@ -43,7 +44,7 @@ export interface QueryJobRunnerStart extends QueryJobProviderLinkage {
|
|
|
43
44
|
sessionId?: string
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
export interface QueryJobRunnerCompletion extends QueryJobProviderLinkage {
|
|
47
|
+
export interface QueryJobRunnerCompletion extends QueryJobProviderLinkage, QueryJobKeyAnswerFields {
|
|
47
48
|
text: string
|
|
48
49
|
attachments?: unknown
|
|
49
50
|
outputImageStats?: QueryJobOutputImageStats
|
|
@@ -9,7 +9,7 @@ import { dataPath } from './data-dir.js'
|
|
|
9
9
|
import { currentMessageEra, LEGACY_MESSAGE_ERA } from './message-era.js'
|
|
10
10
|
import { durableQueryJobsEnabled, messagesSteerEnabled, messagesTrailEnabled } from './query-job-feature.js'
|
|
11
11
|
import { claudeSupportsSteer } from './claude-cli-support.js'
|
|
12
|
-
import {
|
|
12
|
+
import { steerProviderFor } from './claude-steer.js'
|
|
13
13
|
import { QueryJobCoordinator, type QueryJobRunner } from './query-job-coordinator.js'
|
|
14
14
|
import { QueryJobStore, createQueryJobJournalStorage } from './query-job-store.js'
|
|
15
15
|
import {
|
|
@@ -17,7 +17,6 @@ import {
|
|
|
17
17
|
isCursorModel,
|
|
18
18
|
isOllamaModel,
|
|
19
19
|
normalizeEffortPreference,
|
|
20
|
-
normalizeModelPreference,
|
|
21
20
|
type CursorExecutionMode,
|
|
22
21
|
type ModelPreference,
|
|
23
22
|
} from '../../shared/model-preference.js'
|
|
@@ -26,10 +25,15 @@ import { attachmentHistoryPrefix, defaultAttachmentRequest } from '../../shared/
|
|
|
26
25
|
import {
|
|
27
26
|
findExchangesByJobIdentity,
|
|
28
27
|
flushConversationToDisk,
|
|
28
|
+
getSessionBinding,
|
|
29
29
|
getSessionModel,
|
|
30
30
|
reconcileExchangeByJobIdentity,
|
|
31
31
|
removeExchangesByJobIdentity,
|
|
32
|
+
type KeyProfileBinding,
|
|
33
|
+
type SessionModelBinding,
|
|
32
34
|
} from './conversation.js'
|
|
35
|
+
import { parsePreference, profilePreference } from '../../shared/key-preference.js'
|
|
36
|
+
import { linkageProviderForEngine, profileSteerProvider, type KeyProfileTurn } from './key-profile-routing.js'
|
|
33
37
|
import {
|
|
34
38
|
isTerminalQueryJobStatus,
|
|
35
39
|
type QueryJobProviderLinkage,
|
|
@@ -40,6 +44,7 @@ import { acquireMaintenanceWork } from './maintenance-lifecycle.js'
|
|
|
40
44
|
import type { JobTrailDraft } from './job-trail.js'
|
|
41
45
|
import { registerMessageReservationSource } from './message-reservations.js'
|
|
42
46
|
import { bootResumeGate } from './boot-resume.js'
|
|
47
|
+
import { keyAnswerCompletionFields, keyAnswerDoneFields } from './key-answer-fields.js'
|
|
43
48
|
|
|
44
49
|
const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
45
50
|
WebSearch: 'Searching web...',
|
|
@@ -118,6 +123,38 @@ export async function preparePublicDurableQueryAdmission(raw: unknown): Promise<
|
|
|
118
123
|
}
|
|
119
124
|
}
|
|
120
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Plan 5.2 Accept by syntax for the terminal projection: a model slot exactly as
|
|
128
|
+
* normalizeModelPreference gives it, or `profile:<id>`. Same rule as conversation's
|
|
129
|
+
* normalizeSessionBinding, computed here so the projection needs nothing new from the store.
|
|
130
|
+
*/
|
|
131
|
+
function requestBinding(model: unknown): SessionModelBinding | undefined {
|
|
132
|
+
const parsed = parsePreference(model)
|
|
133
|
+
if (!parsed) return undefined
|
|
134
|
+
return parsed.kind === 'model' ? parsed.value : profilePreference(parsed.id) as KeyProfileBinding
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Linkage provider for a keyed turn: the profile's engine. Direct (Lane C) carries none. */
|
|
138
|
+
function keyProfileLinkageProvider(turn: KeyProfileTurn): { provider?: 'claude' | 'codex' } {
|
|
139
|
+
const provider = linkageProviderForEngine(turn.profile.engine)
|
|
140
|
+
return provider ? { provider } : {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The steer gate's provider for a job (P6 owns this; P3 consumes it). With no profile anywhere
|
|
145
|
+
* this is exactly `providerOfModel(resolveRunModel(...))`, as in 6.78.0.
|
|
146
|
+
*/
|
|
147
|
+
export function steerProviderForJob(request: Pick<QueryJobRequest, 'model' | 'sessionId'>): ReturnType<typeof profileSteerProvider> {
|
|
148
|
+
const verdict = steerProviderFor(
|
|
149
|
+
request.model,
|
|
150
|
+
getSessionModel(request.sessionId),
|
|
151
|
+
process.env.COS_G2_DEFAULT_MODEL,
|
|
152
|
+
() => getSessionBinding(request.sessionId),
|
|
153
|
+
)
|
|
154
|
+
if (verdict.kind === 'profile_session') return profileSteerProvider(verdict.profileId)
|
|
155
|
+
return verdict.provider
|
|
156
|
+
}
|
|
157
|
+
|
|
121
158
|
function providerFor(model: ModelPreference): 'claude' | 'codex' | 'cursor' | 'ollama' {
|
|
122
159
|
if (isOllamaModel(model)) return 'ollama'
|
|
123
160
|
if (isCursorModel(model)) return 'cursor'
|
|
@@ -167,7 +204,7 @@ export async function projectPublicConversationTerminal(
|
|
|
167
204
|
request.globalMsgNum,
|
|
168
205
|
request.attachmentRefs,
|
|
169
206
|
request.messageEra,
|
|
170
|
-
|
|
207
|
+
requestBinding(request.model),
|
|
171
208
|
request.origin,
|
|
172
209
|
)
|
|
173
210
|
reconcileExchangeByJobIdentity(
|
|
@@ -178,7 +215,7 @@ export async function projectPublicConversationTerminal(
|
|
|
178
215
|
request.globalMsgNum,
|
|
179
216
|
mergeMediaAttachmentRefs(outputAttachments, existingOutputAttachments),
|
|
180
217
|
request.messageEra,
|
|
181
|
-
|
|
218
|
+
requestBinding(request.model),
|
|
182
219
|
request.origin,
|
|
183
220
|
)
|
|
184
221
|
flushConversationToDisk()
|
|
@@ -204,19 +241,29 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
204
241
|
sessionId: request.sessionId,
|
|
205
242
|
})
|
|
206
243
|
const imageInputs = resolvedAttachments.inputs.length > 0 ? resolvedAttachments.inputs : undefined
|
|
207
|
-
|
|
244
|
+
// Key lanes K1 (plan 5.2): Accept. A `profile:<id>` goes to model-router, which runs it on the
|
|
245
|
+
// profile's engine or refuses it; the closed slot keeps every family guard below unchanged.
|
|
246
|
+
const requestedBinding = parsePreference(request.model)
|
|
247
|
+
const validModel = requestedBinding?.kind === 'model' ? requestedBinding.value : undefined
|
|
208
248
|
const validEffort = normalizeEffortPreference(request.effort)
|
|
209
249
|
let activeModel = validModel
|
|
250
|
+
// Set only by model-router's onKeyProfileTurn, so a subscription turn never sees it.
|
|
251
|
+
let keyTurn: KeyProfileTurn | undefined
|
|
252
|
+
const linkageFor = (model: ModelPreference) => keyTurn
|
|
253
|
+
? { ...keyProfileLinkageProvider(keyTurn), resolvedModel: keyTurn.binding }
|
|
254
|
+
: { provider: providerFor(model), resolvedModel: model }
|
|
210
255
|
|
|
211
256
|
await callModelStreaming(
|
|
212
257
|
request.query,
|
|
213
258
|
request.sessionId,
|
|
214
259
|
{
|
|
260
|
+
...(requestedBinding?.kind === 'model' ? {} : {
|
|
261
|
+
onKeyProfileTurn: (turn: KeyProfileTurn) => { keyTurn = turn },
|
|
262
|
+
}),
|
|
215
263
|
onStart: async (model, sessionId, cliSessionId, metadata) => {
|
|
216
264
|
activeModel = model
|
|
217
265
|
const linkage = {
|
|
218
|
-
|
|
219
|
-
resolvedModel: model,
|
|
266
|
+
...linkageFor(model),
|
|
220
267
|
cliSessionId,
|
|
221
268
|
claudeRunId: metadata?.claudeRunId,
|
|
222
269
|
codexRunId: metadata?.codexRunId,
|
|
@@ -232,7 +279,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
232
279
|
turnId,
|
|
233
280
|
messageEra: request.messageEra,
|
|
234
281
|
globalMsgNum: request.globalMsgNum,
|
|
235
|
-
model,
|
|
282
|
+
model: keyTurn ? keyTurn.binding : model,
|
|
236
283
|
sessionId,
|
|
237
284
|
cliSessionId,
|
|
238
285
|
...metadata,
|
|
@@ -241,7 +288,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
241
288
|
},
|
|
242
289
|
onProviderProcess: metadata => callbacks.onProviderProcess({
|
|
243
290
|
provider: metadata.provider,
|
|
244
|
-
...(activeModel ? { resolvedModel: activeModel } : {}),
|
|
291
|
+
...(keyTurn ? { resolvedModel: keyTurn.binding } : activeModel ? { resolvedModel: activeModel } : {}),
|
|
245
292
|
...(metadata.provider === 'claude'
|
|
246
293
|
? { claudeRunId: metadata.runId }
|
|
247
294
|
: metadata.provider === 'cursor'
|
|
@@ -273,7 +320,8 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
273
320
|
onTrail: (draft: JobTrailDraft) => callbacks.onTrail(draft),
|
|
274
321
|
} : {}),
|
|
275
322
|
onAnswerReady: text => callbacks.onAnswerReady(text, {
|
|
276
|
-
...(
|
|
323
|
+
...(keyTurn ? { ...keyProfileLinkageProvider(keyTurn), resolvedModel: keyTurn.binding }
|
|
324
|
+
: activeModel ? { provider: providerFor(activeModel), resolvedModel: activeModel } : {}),
|
|
277
325
|
}),
|
|
278
326
|
onDone: async (fullText, model, cliSessionId, metadata) => {
|
|
279
327
|
const attachments = mergeMediaAttachmentRefs(
|
|
@@ -287,8 +335,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
287
335
|
? acquireMaintenanceWork('query_attachment_write', { allowDuringDrain: true })
|
|
288
336
|
: undefined
|
|
289
337
|
const linkage = {
|
|
290
|
-
|
|
291
|
-
resolvedModel: model,
|
|
338
|
+
...linkageFor(model),
|
|
292
339
|
cliSessionId,
|
|
293
340
|
claudeRunId: metadata?.claudeRunId,
|
|
294
341
|
codexRunId: metadata?.codexRunId,
|
|
@@ -304,6 +351,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
304
351
|
attachments,
|
|
305
352
|
outputImageStats: metadata?.outputImageStats,
|
|
306
353
|
...linkage,
|
|
354
|
+
...keyAnswerCompletionFields(metadata),
|
|
307
355
|
})
|
|
308
356
|
if (!terminalOwned) return
|
|
309
357
|
if (resolvedAttachments.ids.length > 0) {
|
|
@@ -323,9 +371,10 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
323
371
|
globalMsgNum: request.globalMsgNum,
|
|
324
372
|
text: fullText,
|
|
325
373
|
sessionId: request.sessionId,
|
|
326
|
-
model,
|
|
374
|
+
model: keyTurn ? keyTurn.binding : model,
|
|
327
375
|
cliSessionId,
|
|
328
376
|
...runMetadata,
|
|
377
|
+
...keyAnswerDoneFields(runMetadata),
|
|
329
378
|
...(attachments.length > 0 ? { attachments } : {}),
|
|
330
379
|
...originStamp(request),
|
|
331
380
|
} })
|
|
@@ -348,7 +397,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
348
397
|
} })
|
|
349
398
|
},
|
|
350
399
|
},
|
|
351
|
-
validModel,
|
|
400
|
+
requestedBinding?.kind === 'profile' ? requestBinding(request.model) : validModel,
|
|
352
401
|
imageInputs,
|
|
353
402
|
request.reference,
|
|
354
403
|
request.globalMsgNum,
|
|
@@ -371,6 +420,7 @@ const runner: QueryJobRunner = async ({ jobId, turnId, request, signal, callback
|
|
|
371
420
|
sessionLockHeld: true,
|
|
372
421
|
...(request.dispatch ? { dispatch: request.dispatch } : {}),
|
|
373
422
|
...(request.sessionName ? { sessionName: request.sessionName } : {}),
|
|
423
|
+
...(request.keyTurnOnly ? { keyTurnOnly: true } : {}),
|
|
374
424
|
// 6.78.0: only on a run the coordinator decided is steerable.
|
|
375
425
|
...(steer ? { steer } : {}),
|
|
376
426
|
},
|
|
@@ -399,9 +449,11 @@ export const queryJobCoordinator = new QueryJobCoordinator(queryJobStore, runner
|
|
|
399
449
|
bootResumeGate,
|
|
400
450
|
// 6.78.0 Messages steer (COS_MESSAGES_STEER, default off). The provider is resolved as
|
|
401
451
|
// model-router resolves it, so a run is steerable only when it will run on Claude.
|
|
452
|
+
// Key lanes K1 (plan 5.2): claude-steer stays closed and names a profile-bound job
|
|
453
|
+
// `profile_session`; the profile's engine then decides (only Lane B Claude steers).
|
|
402
454
|
steer: {
|
|
403
455
|
enabled: () => messagesSteerEnabled(),
|
|
404
|
-
provider: request =>
|
|
456
|
+
provider: request => steerProviderForJob(request),
|
|
405
457
|
cliSupported: () => claudeSupportsSteer(),
|
|
406
458
|
},
|
|
407
459
|
})
|
|
@@ -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
|
}
|