@gotcos/glasses-server 6.79.2 → 6.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +19 -0
  3. package/README.md +10 -0
  4. package/managed-runtime-contract.json +1 -0
  5. package/package.json +9 -1
  6. package/server/index.ts +11 -0
  7. package/server/lib/agent-session-search.ts +8 -5
  8. package/server/lib/agent-session-store.ts +45 -4
  9. package/server/lib/archive.ts +2 -1
  10. package/server/lib/attached-provider-adapter.ts +11 -0
  11. package/server/lib/claude-bridge.ts +112 -8
  12. package/server/lib/claude-settings-guard.ts +94 -0
  13. package/server/lib/claude-steer.ts +27 -0
  14. package/server/lib/codex-bridge.ts +205 -32
  15. package/server/lib/codex-engine-sessions.ts +27 -6
  16. package/server/lib/codex-homes.ts +134 -0
  17. package/server/lib/codex-live-queue.ts +14 -0
  18. package/server/lib/codex-profile-home.ts +452 -0
  19. package/server/lib/codex-queue-control.ts +17 -1
  20. package/server/lib/codex-subagent-activity.ts +22 -0
  21. package/server/lib/conversation.ts +92 -15
  22. package/server/lib/direct-bridge.ts +853 -0
  23. package/server/lib/direct-sse.ts +262 -0
  24. package/server/lib/fork-thread.ts +11 -0
  25. package/server/lib/key-answer-fields.ts +49 -0
  26. package/server/lib/key-catalog.ts +301 -0
  27. package/server/lib/key-lanes-pricing.ts +12 -0
  28. package/server/lib/key-lanes-wiring.ts +346 -0
  29. package/server/lib/key-lanes.ts +145 -0
  30. package/server/lib/key-ledger.ts +123 -0
  31. package/server/lib/key-notices.ts +486 -0
  32. package/server/lib/key-profile-rejects.ts +25 -0
  33. package/server/lib/key-profile-routing.ts +173 -0
  34. package/server/lib/key-profiles.ts +182 -0
  35. package/server/lib/key-relay-contract.ts +262 -0
  36. package/server/lib/key-relay-errors.ts +211 -0
  37. package/server/lib/key-relay-meter.ts +303 -0
  38. package/server/lib/key-relay.ts +1018 -0
  39. package/server/lib/key-secrets-guard.ts +52 -0
  40. package/server/lib/key-store.ts +228 -0
  41. package/server/lib/keyed-sessions.ts +154 -0
  42. package/server/lib/lane-b-env.ts +169 -0
  43. package/server/lib/model-router.ts +96 -7
  44. package/server/lib/morning-brief-config.ts +3 -0
  45. package/server/lib/native-head.ts +13 -1
  46. package/server/lib/occupancy-probes.ts +4 -0
  47. package/server/lib/ollama-run-ledger.ts +96 -13
  48. package/server/lib/prompt-edit.ts +2 -0
  49. package/server/lib/query-job-coordinator.ts +2 -1
  50. package/server/lib/query-job-runtime.ts +67 -15
  51. package/server/lib/query-job-store.ts +7 -1
  52. package/server/lib/query-job-types.ts +15 -1
  53. package/server/lib/session-hook-events.ts +28 -0
  54. package/server/lib/session-hook-ledger.ts +10 -0
  55. package/server/lib/thread-occupancy.ts +22 -1
  56. package/server/lib/token-audit.ts +18 -4
  57. package/server/lib/work-handoff-requests.ts +3 -0
  58. package/server/routes/agent-session-bindings.ts +15 -0
  59. package/server/routes/agent-sessions.ts +17 -5
  60. package/server/routes/handoffs.ts +22 -1
  61. package/server/routes/health.ts +5 -0
  62. package/server/routes/key-lanes.ts +442 -0
  63. package/server/routes/openai-compat.ts +28 -3
  64. package/server/routes/query.ts +20 -6
  65. package/shared/key-preference.ts +57 -0
  66. package/shared/model-preference.ts +20 -2
@@ -4,10 +4,12 @@ import { callCursorStreaming } from './cursor-bridge.js'
4
4
  import { callOllamaStreaming } from './ollama-bridge.js'
5
5
  import {
6
6
  getOrCreateSession,
7
+ getSessionBinding,
7
8
  getSessionModel,
8
9
  setSessionModel,
9
10
  type ModelPreference,
10
11
  type PromptReference,
12
+ type SessionModelBinding,
11
13
  } from './conversation.js'
12
14
  import {
13
15
  DEFAULT_MODEL,
@@ -15,7 +17,6 @@ import {
15
17
  isClaudeModel,
16
18
  isCursorModel,
17
19
  isOllamaModel,
18
- normalizeModelPreference,
19
20
  } from '../../shared/model-preference.js'
20
21
  import {
21
22
  getCursorModelCatalog,
@@ -27,6 +28,14 @@ import {
27
28
  isOllamaProviderReady,
28
29
  } from './ollama-catalog.js'
29
30
  import type { ModelImageInput } from './model-image-input.js'
31
+ import { parsePreference } from '../../shared/key-preference.js'
32
+ import {
33
+ closedEnvDefaultModel,
34
+ keyProfileEngineRunner,
35
+ keyProfileUnavailableLine,
36
+ resolveKeyProfileTurn,
37
+ type KeyProfileTurnCallbacks,
38
+ } from './key-profile-routing.js'
30
39
 
31
40
  // Bridges return as soon as their subprocess is spawned, while completion is
32
41
  // delivered later through callbacks. This keyed tail queue therefore releases
@@ -57,8 +66,8 @@ export async function acquireModelSessionRunLock(sessionId: string): Promise<()
57
66
  export async function callModelStreaming(
58
67
  query: string,
59
68
  sessionId: string | undefined,
60
- callbacks: StreamCallbacks,
61
- model?: ModelPreference,
69
+ callbacks: StreamCallbacks & KeyProfileTurnCallbacks,
70
+ model?: SessionModelBinding,
62
71
  images?: ModelImageInput[],
63
72
  reference?: PromptReference,
64
73
  globalMsgNum?: number,
@@ -71,12 +80,21 @@ export async function callModelStreaming(
71
80
  throw new Error('model-router: request aborted before the model run started.')
72
81
  }
73
82
 
83
+ // Key lanes K1 (plan 5.2): the request model is ACCEPTED here. parsePreference returns exactly
84
+ // normalizeModelPreference for every model slot; only a well formed `profile:<id>` is new.
85
+ const requested = parsePreference(model)
74
86
  const sessionModel = getSessionModel(sid)
87
+ // A session bound to a profile reads null from the closed getSessionModel. It is looked up
88
+ // only when nothing else names the model, so every 6.79.0 path stays exactly as it was.
89
+ const bound = requested ?? (sessionModel === null ? parsePreference(getSessionBinding(sid)) : undefined)
90
+ const profileId = bound?.kind === 'profile' ? bound.id : undefined
91
+ if (profileId !== undefined) return await runKeyProfileRoute(query, sid, callbacks, profileId, release, images, reference, globalMsgNum, options)
92
+
75
93
  // COS_G2_DEFAULT_MODEL is the documented default-model switch (CHANGELOG 6.1.0);
76
94
  // it must win over the hardcoded DEFAULT_MODEL on this primary query path, not
77
- // just the OpenAI-compat surface.
78
- const envDefault = normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL)
79
- const resolvedModel = normalizeModelPreference(model) ?? sessionModel ?? envDefault ?? DEFAULT_MODEL
95
+ // just the OpenAI-compat surface. A profile id there is ignored (plan 5.2: closed).
96
+ const envDefault = closedEnvDefaultModel(process.env.COS_G2_DEFAULT_MODEL)
97
+ const resolvedModel = (requested?.kind === 'model' ? requested.value : undefined) ?? sessionModel ?? envDefault ?? DEFAULT_MODEL
80
98
 
81
99
  setSessionModel(sid, resolvedModel)
82
100
 
@@ -86,8 +104,9 @@ export async function callModelStreaming(
86
104
  terminal = true
87
105
  release()
88
106
  }
107
+ const { onKeyProfileTurn: _onKeyProfileTurn, ...streamCallbacks } = callbacks
89
108
  const lockedCallbacks: StreamCallbacks = {
90
- ...callbacks,
109
+ ...streamCallbacks,
91
110
  onDone: async (fullText, completedModel, cliSessionId, metadata) => {
92
111
  try {
93
112
  return await callbacks.onDone(fullText, completedModel, cliSessionId, metadata)
@@ -142,3 +161,73 @@ export async function callModelStreaming(
142
161
  throw err
143
162
  }
144
163
  }
164
+
165
+ /**
166
+ * Key lanes K1 (plan 5.2): a turn bound to `profile:<id>`. Refused with a lens line, never
167
+ * answered on the subscription, when lanes are off, the profile is unknown, disabled or keyless,
168
+ * no runner is registered for its engine, or it is a scheduled dispatch.
169
+ */
170
+ async function runKeyProfileRoute(
171
+ query: string,
172
+ sid: string,
173
+ callbacks: StreamCallbacks & KeyProfileTurnCallbacks,
174
+ profileId: string,
175
+ release: () => void,
176
+ images?: ModelImageInput[],
177
+ reference?: PromptReference,
178
+ globalMsgNum?: number,
179
+ options?: CallOptions,
180
+ ): Promise<string> {
181
+ let terminal = false
182
+ const releaseTerminal = () => {
183
+ if (terminal) return
184
+ terminal = true
185
+ release()
186
+ }
187
+ const { onKeyProfileTurn, ...streamCallbacks } = callbacks
188
+ const lockedCallbacks: StreamCallbacks = {
189
+ ...streamCallbacks,
190
+ onDone: async (fullText, completedModel, cliSessionId, metadata) => {
191
+ try {
192
+ return await callbacks.onDone(fullText, completedModel, cliSessionId, metadata)
193
+ } finally {
194
+ releaseTerminal()
195
+ }
196
+ },
197
+ onError: async (error) => {
198
+ try {
199
+ await callbacks.onError(error)
200
+ } finally {
201
+ releaseTerminal()
202
+ }
203
+ },
204
+ }
205
+ try {
206
+ if (options?.dispatch) {
207
+ console.info(`[key-lanes] refused kind=dispatch profile=${profileId}`)
208
+ await lockedCallbacks.onError('dispatch_requires_claude')
209
+ return sid
210
+ }
211
+ const resolved = resolveKeyProfileTurn(profileId)
212
+ if (!resolved.ok) {
213
+ // QA G4: one line per refusal kind.
214
+ console.info(`[key-lanes] refused kind=${resolved.code} profile=${profileId}`)
215
+ await lockedCallbacks.onError(resolved.message)
216
+ return sid
217
+ }
218
+ const runner = keyProfileEngineRunner(resolved.turn.profile.engine)
219
+ if (!runner) {
220
+ console.info(`[key-lanes] refused kind=no_runner engine=${resolved.turn.profile.engine}`)
221
+ await lockedCallbacks.onError(keyProfileUnavailableLine(resolved.turn.profile.label))
222
+ return sid
223
+ }
224
+ // Persisted by syntax (conversation.ts) so the next turn of this session stays on the key,
225
+ // unless this is the one-turn plan-limit offer (QA U9): then the session keeps its model.
226
+ if (!options?.keyTurnOnly) setSessionModel(sid, resolved.turn.binding)
227
+ onKeyProfileTurn?.(resolved.turn)
228
+ return await runner(query, sid, lockedCallbacks, resolved.turn, images, reference, globalMsgNum, options)
229
+ } catch (err) {
230
+ releaseTerminal()
231
+ throw err
232
+ }
233
+ }
@@ -19,6 +19,7 @@ import { chmodSync, mkdirSync } from 'node:fs'
19
19
  import { dirname } from 'node:path'
20
20
  import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
21
21
  import { dataPath } from './data-dir.js'
22
+ import { KEY_PROFILE_REJECT_CODE, KEY_PROFILE_SCHEDULED_REJECT, keyProfileRejectApplies } from './key-profile-rejects.js'
22
23
  import {
23
24
  normalizeEffortPreference,
24
25
  normalizeModelPreference,
@@ -410,6 +411,8 @@ export function applyMorningBriefPatch(current: MorningBriefConfig, raw: unknown
410
411
  delete next.model
411
412
  } else {
412
413
  const model = normalizeModelPreference(patch.model)
414
+ // Key lanes K1 (plan 5.2): Reject, and say why. A brief always runs on the subscription.
415
+ if (!model && keyProfileRejectApplies(patch.model)) throw new MorningBriefConfigError(KEY_PROFILE_REJECT_CODE, KEY_PROFILE_SCHEDULED_REJECT)
413
416
  if (!model) throw new MorningBriefConfigError('invalid_model', 'Unknown model preference.')
414
417
  next.model = model
415
418
  }
@@ -195,6 +195,8 @@ export interface NativeHeadDirs {
195
195
  claudeProjectsDir: string
196
196
  /** `<COS_AGENT_SESSIONS_HOME|~>/.codex/sessions` */
197
197
  codexSessionsDir: string
198
+ /** Key lanes (plan v4 8.2): each keyed profile home's `sessions`, searched after the server's. Absent with no profile home. */
199
+ codexProfileSessionsDirs?: string[]
198
200
  /** `<COS_AGENT_SESSIONS_HOME|~>/.cursor/projects` */
199
201
  cursorProjectsDir: string
200
202
  }
@@ -495,7 +497,16 @@ function claudeTranscriptPath(threadId: string, deps: NativeHeadDeps): string |
495
497
  * certified.
496
498
  */
497
499
  function codexRolloutPath(threadId: string, deps: NativeHeadDeps): string | null {
498
- const root = deps.dirs.codexSessionsDir
500
+ const found = codexRolloutPathIn(deps.dirs.codexSessionsDir, threadId, deps)
501
+ if (found !== null || !deps.dirs.codexProfileSessionsDirs) return found
502
+ for (const root of deps.dirs.codexProfileSessionsDirs) {
503
+ const keyed = codexRolloutPathIn(root, threadId, deps)
504
+ if (keyed !== null) return keyed
505
+ }
506
+ return null
507
+ }
508
+
509
+ function codexRolloutPathIn(root: string, threadId: string, deps: NativeHeadDeps): string | null {
499
510
  if (!deps.dirExists(root)) return null
500
511
 
501
512
  let scanned = 0
@@ -626,6 +637,7 @@ export function realNativeHeadDirs(): NativeHeadDirs {
626
637
  claudeProjectsDir: roots.claudeProjects,
627
638
  codexSessionsDir: roots.codexSessions,
628
639
  cursorProjectsDir: roots.cursorProjects,
640
+ ...(roots.codexProfileSessions ? { codexProfileSessionsDirs: roots.codexProfileSessions } : {}),
629
641
  }
630
642
  }
631
643
 
@@ -63,6 +63,7 @@ import { activeSqliteReaderPids, cliChatLastWriteMs } from './cursor-session-mod
63
63
  // about which directory "empty" was observed in.
64
64
  export { claudeSessionsDir } from '../routes/claude-sessions.js'
65
65
  import { claudeSessionsDir } from '../routes/claude-sessions.js'
66
+ import { profileCodexHomeSubdirs } from './codex-homes.js'
66
67
 
67
68
  /**
68
69
  * `<CODEX_HOME|~/.codex>/thread-writer-locks`.
@@ -78,10 +79,13 @@ export function codexLocksDir(): string {
78
79
 
79
80
  export function realOccupancyDirs(): OccupancyDirs {
80
81
  const home = process.env.COS_AGENT_SESSIONS_HOME?.trim() || homedir()
82
+ const profileLocks = profileCodexHomeSubdirs('thread-writer-locks')
81
83
  return {
82
84
  claudeSessionsDir: claudeSessionsDir(),
83
85
  codexLocksDir: codexLocksDir(),
84
86
  cursorChatsDir: join(home, '.cursor', 'chats'),
87
+ // Key lanes K1 (plan v4 8.2): absent with no keyed profile home, so the dirs are 963fe25's.
88
+ ...(profileLocks.length ? { codexProfileLocksDirs: profileLocks } : {}),
85
89
  }
86
90
  }
87
91
 
@@ -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, type OllamaModelPreference } from '../../shared/model-preference.js'
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: OllamaModelPreference
22
- ollamaModel: string
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
- export function finishOllamaRun(runId: string, input: {
151
+ interface FinishRunInput {
133
152
  status: Exclude<OllamaRunStatus, 'running'>
134
153
  startedAtMs: number
135
154
  output?: string
136
155
  error?: string
137
- }): OllamaRunRecord | null {
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 = classifyOllamaError(input.error)
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 ?? 'ollama.timeout',
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 { providerOfModel, resolveRunModel } from './claude-steer.js'
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
- normalizeModelPreference(request.model),
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
- normalizeModelPreference(request.model),
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
- const validModel = normalizeModelPreference(request.model)
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
- provider: providerFor(model),
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
- ...(activeModel ? { provider: providerFor(activeModel), resolvedModel: activeModel } : {}),
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
- provider: providerFor(model),
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 => providerOfModel(resolveRunModel(request.model, getSessionModel(request.sessionId), process.env.COS_G2_DEFAULT_MODEL)),
456
+ provider: request => steerProviderForJob(request),
405
457
  cliSupported: () => claudeSupportsSteer(),
406
458
  },
407
459
  })