@gotcos/glasses-server 7.0.7 → 7.0.27
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 +19 -0
- package/CHANGELOG.md +305 -0
- package/docs/web-key-verifier-contract.md +378 -0
- package/managed-runtime-contract.json +9 -1
- package/package.json +10 -3
- package/server/bootstrap.ts +10 -0
- package/server/index.ts +129 -23
- package/server/lib/agent-session-older.ts +218 -0
- package/server/lib/agent-session-recent.ts +321 -0
- package/server/lib/agent-session-store.ts +102 -20
- package/server/lib/api-auth.ts +37 -1
- package/server/lib/calendar-meetings.ts +70 -0
- package/server/lib/calendar-source.ts +54 -9
- package/server/lib/claude-hooks-installer.ts +111 -12
- package/server/lib/conversation.ts +56 -28
- package/server/lib/cors-reject-log.ts +51 -0
- package/server/lib/cos-started-sessions.ts +101 -0
- package/server/lib/disk-io-meter.ts +202 -0
- package/server/lib/drop-service.ts +47 -12
- package/server/lib/drop-store.ts +21 -1
- package/server/lib/event-loop-stall.ts +62 -0
- package/server/lib/full-hold-engine.ts +114 -0
- package/server/lib/full-hold.ts +124 -0
- package/server/lib/g2-turn-provenance.ts +128 -9
- package/server/lib/list-model-memo.ts +85 -0
- package/server/lib/listener-startup.ts +18 -0
- package/server/lib/meeting-batch-transcribe.ts +32 -5
- package/server/lib/model-efforts.ts +40 -0
- package/server/lib/permission-broker.ts +443 -11
- package/server/lib/secret-path-guard.ts +4 -1
- package/server/lib/session-cache-writer.ts +8 -1
- package/server/lib/session-hook-events.ts +23 -1
- package/server/lib/session-hooks-runtime.ts +34 -0
- package/server/lib/session-log.ts +313 -4
- package/server/lib/session-organizer.ts +305 -0
- package/server/lib/session-state-derive.ts +36 -0
- package/server/lib/session-stream-events.ts +39 -3
- package/server/lib/thread-turn-queue.ts +24 -0
- package/server/lib/today-brief.ts +522 -58
- package/server/lib/turn-end-ask.ts +320 -0
- package/server/lib/turn-footer.ts +3 -3
- package/server/lib/web-control-integrity.ts +160 -0
- package/server/lib/web-control-offnet-page.ts +58 -0
- package/server/lib/web-control-static.ts +53 -9
- package/server/lib/web-key-authority.ts +372 -0
- package/server/lib/web-key-envelope.ts +98 -0
- package/server/lib/web-key-handoff.ts +135 -0
- package/server/lib/web-key-origin.ts +21 -0
- package/server/lib/web-key-passkey.ts +155 -0
- package/server/lib/web-key-registry.ts +825 -0
- package/server/lib/web-key-signer-app.ts +152 -0
- package/server/lib/web-session-auth.ts +133 -15
- package/server/lib/whisper-local.ts +93 -7
- package/server/lib/whisper-preview.ts +5 -3
- package/server/lib/work-orchestrator/snapshot.ts +17 -3
- package/server/lib/work-review-runtime.ts +17 -4
- package/server/routes/agent-session-bindings.ts +16 -1
- package/server/routes/agent-session-stream.ts +7 -0
- package/server/routes/agent-sessions.ts +106 -2
- package/server/routes/drops.ts +7 -2
- package/server/routes/g2-turns.ts +35 -0
- package/server/routes/health.ts +14 -1
- package/server/routes/live-events.ts +6 -1
- package/server/routes/permission-broker.ts +26 -0
- package/server/routes/query-jobs.ts +3 -0
- package/server/routes/session-organizer.ts +149 -0
- package/server/routes/sessions.ts +15 -24
- package/server/routes/thread-turn-queue.ts +50 -14
- package/server/routes/today-brief.ts +91 -36
- package/server/routes/web-control.ts +37 -4
- package/server/routes/web-key.ts +284 -0
- package/server/routes/welcome-context.ts +6 -2
- package/server/routes/work-orchestrator.ts +12 -7
- package/server/scripts/write-web-control-manifest.mjs +43 -0
- package/server/web-control/.manifest.json +44 -0
- package/server/web-control/app.css +45 -0
- package/server/web-control/app.js +1680 -108
- package/server/web-control/apple-touch-icon.png +0 -0
- package/server/web-control/index.html +54 -17
- package/server/web-control/panes/cos-chat.css +52 -6
- package/server/web-control/panes/cos-chat.js +364 -32
- package/server/web-control/panes/questions.js +135 -14
- package/server/web-control/panes/sessions.css +280 -0
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/today-actions.css +35 -0
- package/server/web-control/panes/today-actions.js +121 -6
- package/server/web-control/panes/work.css +3 -0
- package/server/web-control/panes/work.js +185 -38
- package/server/web-control/theme.js +33 -0
- package/server/web-signer/.manifest.json +11 -0
- package/server/web-signer/index.html +74 -0
- package/server/web-signer/signer-proof.js +348 -0
- package/server/web-signer/signer-store.js +39 -0
- package/server/web-signer/signer.css +23 -0
- package/server/web-signer/signer.js +341 -0
- package/shared/web-approval-packets.golden.json +124 -0
- package/shared/web-turn-envelope.ts +271 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// X1 (2026-11-09): the full hold. A session question (AskUserQuestion) or a tool approval held for
|
|
2
|
+
// the WHOLE wait of the PermissionRequest hook, whether or not a COS device is live when it is asked.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS IS SAFE AT THE DESK (canaries, operations/personal/wk46_2026/production_hardening/
|
|
5
|
+
// x1_question_fetch/CANARIES.md): on the engines below, Claude Code shows its own question or
|
|
6
|
+
// approval dialog at once WHILE the PermissionRequest hook is still waiting (C1, C5). An answer at
|
|
7
|
+
// the Mac wins and a later hook answer is ignored (C3, C4); a hook answer withdraws the dialog (C2;
|
|
8
|
+
// the desktop engine sends `control_cancel_request` to the app, C6). A hold of 3,703 s worked (C7).
|
|
9
|
+
// So holding the hook never delays the Mac's own prompt (Miles's option B, 11/05) ON THESE ENGINES.
|
|
10
|
+
// An engine that is not listed, or reads older, keeps today's rule: it may not show its dialog
|
|
11
|
+
// until the hook returns, and a long hold there would keep the desk waiting.
|
|
12
|
+
//
|
|
13
|
+
// Both switches default OFF. With both off nothing in this file is read on the permission path.
|
|
14
|
+
//
|
|
15
|
+
// Pure, no imports beyond the one switch reader.
|
|
16
|
+
|
|
17
|
+
import { readSwitch } from './env-switch.js'
|
|
18
|
+
|
|
19
|
+
/** `COS_QUESTION_FULL_HOLD`: hold a question for the whole hook wait. Default OFF. Read per request. */
|
|
20
|
+
export function questionFullHoldEnabled(env: NodeJS.ProcessEnv): boolean {
|
|
21
|
+
return readSwitch(env.COS_QUESTION_FULL_HOLD, false)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `COS_APPROVAL_FULL_HOLD`: hold a tool approval for the whole hook wait while the desk stays idle. Default OFF. */
|
|
25
|
+
export function approvalFullHoldEnabled(env: NodeJS.ProcessEnv): boolean {
|
|
26
|
+
return readSwitch(env.COS_APPROVAL_FULL_HOLD, false)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `COS_WAITING_QUESTION_WORDS`: a session waiting on a question carries the first 80 characters of its
|
|
31
|
+
* first question (`waiting_detail`, through `toolTarget`), so Today and the Sessions rows say what it
|
|
32
|
+
* asks. Default ON: Miles, 2026-11-09 22:10 CST, "Yes, show by default", told that the text is written
|
|
33
|
+
* to the on-disk session-hook ledger and sent to any signed-in browser, including for questions
|
|
34
|
+
* answered at the desk. `0` restores 7.0.13's empty detail exactly. Question text only: never an
|
|
35
|
+
* option, a description or an answer. The `waiting` list with full words and options is NOT this
|
|
36
|
+
* switch: it is on only while a full-hold switch is.
|
|
37
|
+
*/
|
|
38
|
+
export function waitingQuestionWordsEnabled(env: NodeJS.ProcessEnv): boolean {
|
|
39
|
+
return readSwitch(env.COS_WAITING_QUESTION_WORDS, true)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Control and format characters (bidi overrides, zero-width) taken out of a question before it becomes row text. */
|
|
43
|
+
export function questionRowText(question: unknown): string {
|
|
44
|
+
if (typeof question !== 'string') return ''
|
|
45
|
+
return question.replace(/[\p{Cc}\p{Cf}]+/gu, ' ')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface FullHoldSwitches { questions: boolean; approvals: boolean }
|
|
49
|
+
|
|
50
|
+
export function fullHoldSwitches(env: NodeJS.ProcessEnv): FullHoldSwitches {
|
|
51
|
+
return { questions: questionFullHoldEnabled(env), approvals: approvalFullHoldEnabled(env) }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The oldest engine of each kind proven to show its own dialog while the hook waits, by the
|
|
56
|
+
* registry record's `entrypoint` and `version` (`~/.claude/sessions/<pid>.json`).
|
|
57
|
+
* - `cli` 2.1.321: canaries C1 to C4, C7, C9 (2026-11-09, tmux, real TTY).
|
|
58
|
+
* - `claude-desktop` 2.1.317: the engine Claude Desktop runs, driven over its stream-json
|
|
59
|
+
* protocol, C5, C6, C9 (and the 6.71.7 Desktop D1 canary of 2026-10-11 in the real app).
|
|
60
|
+
* Anything else (`sdk-cli`, an unknown entrypoint, no version) is not proven.
|
|
61
|
+
*/
|
|
62
|
+
export const FULL_HOLD_ENGINE_FLOORS: Readonly<Record<string, readonly number[]>> = Object.freeze({
|
|
63
|
+
cli: Object.freeze([2, 1, 321]),
|
|
64
|
+
'claude-desktop': Object.freeze([2, 1, 317]),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
/** Where the engine runs (`lib/full-hold-engine.ts` `processHost`): Claude.app's helper, a terminal, or neither. */
|
|
68
|
+
export type EngineHost = 'desktop-app' | 'terminal' | 'other'
|
|
69
|
+
|
|
70
|
+
export interface EngineFacts { entrypoint: string | null; version: string | null; host?: EngineHost }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Q1 O2: the host each entrypoint must run in. A `claude -p` print run takes its entrypoint from
|
|
74
|
+
* whatever launched it, has no dialog, and runs under neither (`lib/full-hold-engine.ts`).
|
|
75
|
+
*/
|
|
76
|
+
export const FULL_HOLD_ENGINE_HOSTS: Readonly<Record<string, EngineHost>> = Object.freeze({
|
|
77
|
+
cli: 'terminal',
|
|
78
|
+
'claude-desktop': 'desktop-app',
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/** `2.1.321` as numbers; null for anything that is not one to four dot-separated whole numbers. */
|
|
82
|
+
export function parseEngineVersion(version: unknown): number[] | null {
|
|
83
|
+
if (typeof version !== 'string') return null
|
|
84
|
+
const trimmed = version.trim()
|
|
85
|
+
if (!/^\d{1,6}(?:\.\d{1,6}){0,3}$/.test(trimmed)) return null
|
|
86
|
+
return trimmed.split('.').map(Number)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True when `version` is at or above `floor`, part by part (a missing part reads 0). */
|
|
90
|
+
export function versionAtLeast(version: readonly number[], floor: readonly number[]): boolean {
|
|
91
|
+
const length = Math.max(version.length, floor.length)
|
|
92
|
+
for (let i = 0; i < length; i++) {
|
|
93
|
+
const v = version[i] ?? 0
|
|
94
|
+
const f = floor[i] ?? 0
|
|
95
|
+
if (v !== f) return v > f
|
|
96
|
+
}
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Whether this engine is proven to keep its dialog on screen while the hook waits, and runs where that dialog is. */
|
|
101
|
+
export function engineProvenForFullHold(engine: EngineFacts | null | undefined): boolean {
|
|
102
|
+
if (!engine || typeof engine.entrypoint !== 'string') return false
|
|
103
|
+
if (!Object.prototype.hasOwnProperty.call(FULL_HOLD_ENGINE_FLOORS, engine.entrypoint)) return false
|
|
104
|
+
if (engine.host !== FULL_HOLD_ENGINE_HOSTS[engine.entrypoint]) return false
|
|
105
|
+
const floor = FULL_HOLD_ENGINE_FLOORS[engine.entrypoint]!
|
|
106
|
+
const version = parseEngineVersion(engine.version)
|
|
107
|
+
return version !== null && versionAtLeast(version, floor)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The PermissionRequest wait the installer writes for a kind under the full hold. */
|
|
111
|
+
export const FULL_HOLD_HOOK_WAIT_S = 3600
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The matcher of the non-question PermissionRequest block once the hook is split (canary C9:
|
|
115
|
+
* honoured on both engines; a question runs only the `AskUserQuestion` block, everything else
|
|
116
|
+
* only this one).
|
|
117
|
+
*/
|
|
118
|
+
export const NON_QUESTION_MATCHER = '^(?!AskUserQuestion$).*'
|
|
119
|
+
export const QUESTION_MATCHER = 'AskUserQuestion'
|
|
120
|
+
|
|
121
|
+
/** How a web (browser session) answer proves a person just touched the page, while a flag is on. */
|
|
122
|
+
export const WEB_ANSWER_TOUCH_MAX_MS = 60_000
|
|
123
|
+
/** How long a web approval's confirm step stays usable. */
|
|
124
|
+
export const WEB_CONFIRM_TTL_MS = 60_000
|
|
@@ -56,6 +56,16 @@
|
|
|
56
56
|
// even one with no origin left to carry, so the drain is the one caller allowed to add to the
|
|
57
57
|
// parked turn's ledger record.
|
|
58
58
|
//
|
|
59
|
+
// WEB-KEY AUTHORITY (6.99.31, behind COS_WEB_KEY_AUTHORITY, default off). A web turn that also
|
|
60
|
+
// carries a `cos-web-turn/1` envelope signed by an enrolled phone key (lib/web-key-authority.ts)
|
|
61
|
+
// can be vouched as `authority: 'web-key'`. originOf only NOTES the envelope headers on a web
|
|
62
|
+
// verdict (`webKeyCandidate`); nothing is vouched until the route, knowing its real target, the
|
|
63
|
+
// exact words and the files, calls acceptWebKey. A route that never calls it leaves the web turn
|
|
64
|
+
// exactly as 6.99.29 left it. Off, no candidate is ever noted, so every web turn is recorded with
|
|
65
|
+
// WEB_UNVOUCHED_REASON and no footer, byte for byte as 6.99.29. A web-key record is self-proving
|
|
66
|
+
// (envelope + signature), so the endpoint can vouch for it after a restart, unlike the in-memory
|
|
67
|
+
// seal; a queued web-key origin is re-checked at its drain (expiry, revocation, epoch, words).
|
|
68
|
+
//
|
|
59
69
|
// TURN IDS ARE NOT REUSABLE. A record this process holds is never replaced by a different turn
|
|
60
70
|
// under the same id (a client-chosen id could otherwise overwrite a vouched record: a denial of
|
|
61
71
|
// service on verification). Only an identical retry (same target, same text, same origin) or the
|
|
@@ -67,6 +77,8 @@ import { appendFileSync, existsSync, renameSync, statSync } from 'node:fs'
|
|
|
67
77
|
import { readFile } from 'node:fs/promises'
|
|
68
78
|
import { networkInterfaces } from 'node:os'
|
|
69
79
|
import { turnFooter } from './turn-footer.js'
|
|
80
|
+
import { webKeyCandidateOf, type WebKeyAuthority, type WebKeyCandidate, type WebKeyContext, type WebKeyProof } from './web-key-authority.js'
|
|
81
|
+
import { MESSAGE_SCOPE_MEANING, notAuthorizedFor } from '../../shared/web-turn-envelope.js'
|
|
70
82
|
|
|
71
83
|
export { FOOTER_LINE_REFUSED_COPY, G2_TURN_FOOTER_RE, g2TurnFooter, hasFooterShapedLine, stripG2TurnFooter, TURN_FOOTER_SHAPED_RE, TURN_ID_IN_USE_COPY, turnFooter, turnFooterTag, withoutTurnFooter } from './turn-footer.js'
|
|
72
84
|
|
|
@@ -134,6 +146,14 @@ export interface OriginVerdict {
|
|
|
134
146
|
reason?: string | null
|
|
135
147
|
/** When this origin was judged (ms). A queued origin lapses from here, never later. */
|
|
136
148
|
judgedAt?: number
|
|
149
|
+
/** 6.99.31: set only by acceptWebKey on a signed, accepted web turn. */
|
|
150
|
+
authority?: 'web-key'
|
|
151
|
+
/** 6.99.31: the accepted envelope and signature (self-proving). Present only with `authority`. */
|
|
152
|
+
webKey?: WebKeyProof
|
|
153
|
+
/** 6.99.31: envelope headers a web request carried, not yet judged (flag on only). Vouches for nothing. */
|
|
154
|
+
webKeyCandidate?: WebKeyCandidate
|
|
155
|
+
/** 6.99.31: why a signed web turn was not vouched (the route's acceptWebKey or a drain re-check). */
|
|
156
|
+
webKeyReason?: string
|
|
137
157
|
}
|
|
138
158
|
|
|
139
159
|
export interface G2TurnRecord {
|
|
@@ -155,6 +175,10 @@ export interface G2TurnRecord {
|
|
|
155
175
|
* re-audit of 910eb90): the copy is judged by the request that sent it again, nothing is inherited.
|
|
156
176
|
*/
|
|
157
177
|
resendOf?: string
|
|
178
|
+
/** 6.99.31: a vouched web turn signed by an enrolled phone key. */
|
|
179
|
+
authority?: 'web-key'
|
|
180
|
+
/** 6.99.31: the envelope bytes and signature that vouch for it (self-proving, so durable). */
|
|
181
|
+
webKey?: WebKeyProof
|
|
158
182
|
}
|
|
159
183
|
|
|
160
184
|
/** The shape of an Express request this module reads. Nothing else. */
|
|
@@ -186,12 +210,29 @@ export function turnTextSha256(text: string): string {
|
|
|
186
210
|
export function footerApplies(verdict: OriginVerdict): boolean {
|
|
187
211
|
if (verdict.verifiedOrigin !== true) return false
|
|
188
212
|
const kind = originKindOf(verdict.origin.client)
|
|
213
|
+
// 6.99.31: a web turn is footed (COS-WEB) only when an enrolled phone key's envelope was accepted.
|
|
214
|
+
if (kind === 'web') return verdict.authority === 'web-key' && verdict.webKey !== undefined
|
|
189
215
|
return kind === 'g2' || kind === 'phone'
|
|
190
216
|
}
|
|
191
217
|
|
|
192
|
-
/**
|
|
218
|
+
/** Whether a verdict is a vouched web-key turn (the only vouched web turn there is). */
|
|
219
|
+
function isWebKeyVerdict(verdict: OriginVerdict): boolean {
|
|
220
|
+
return verdict.verifiedOrigin === true && verdict.authority === 'web-key' && verdict.webKey !== undefined
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The same verdict, never vouched, with the reason why. Any web-key authority is dropped; envelope
|
|
225
|
+
* headers not yet judged stay, so a Send again or an edit can still be judged on its own signature.
|
|
226
|
+
*/
|
|
193
227
|
export function unvouched(verdict: OriginVerdict, reason: string): OriginVerdict {
|
|
194
|
-
|
|
228
|
+
const { authority: _a, webKey: _w, ...rest } = verdict
|
|
229
|
+
return { ...rest, verifiedOrigin: false, reason }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** A signed web turn that was not vouched: unvouched, its envelope spent or dropped, the reason kept. */
|
|
233
|
+
function webKeyRefused(verdict: OriginVerdict, reason: string): OriginVerdict {
|
|
234
|
+
const { authority: _a, webKey: _w, webKeyCandidate: _c, ...rest } = verdict
|
|
235
|
+
return { ...rest, verifiedOrigin: false, reason, webKeyReason: reason }
|
|
195
236
|
}
|
|
196
237
|
|
|
197
238
|
/** The vias of a turn still parked in the queue: the only records a continuation may extend. */
|
|
@@ -354,6 +395,11 @@ export interface G2TurnProvenanceDeps {
|
|
|
354
395
|
* request with no X-Cos-Token header. Absent, or a throw: no turn is ever a web turn.
|
|
355
396
|
*/
|
|
356
397
|
webSession?: (req: OriginRequest) => boolean
|
|
398
|
+
/**
|
|
399
|
+
* 6.99.31: the enrolled phone key authority. Absent, or its flag off: no envelope is ever read and
|
|
400
|
+
* every web turn is what 6.99.29 made it.
|
|
401
|
+
*/
|
|
402
|
+
webKey?: WebKeyAuthority
|
|
357
403
|
now?: () => number
|
|
358
404
|
randomToken?: () => string
|
|
359
405
|
}
|
|
@@ -448,14 +494,56 @@ export class G2TurnProvenance {
|
|
|
448
494
|
verifiedOrigin = paired !== null && paired === clientInstance
|
|
449
495
|
if (!verifiedOrigin) reason = 'not_ring_owner'
|
|
450
496
|
}
|
|
497
|
+
// 6.99.31: a web request (cookie path, no token, not this Mac) that carries an envelope has it
|
|
498
|
+
// NOTED, not judged: acceptWebKey judges it once the route knows the target. Flag off: never noted.
|
|
499
|
+
let webKeyCandidate: WebKeyCandidate | null = null
|
|
500
|
+
if (client === 'web' && this.webKeyOn()) {
|
|
501
|
+
try { webKeyCandidate = webKeyCandidateOf(req.headers) } catch { webKeyCandidate = null }
|
|
502
|
+
}
|
|
451
503
|
return {
|
|
452
504
|
origin: { remote: known ? remote : 'unknown', loopback, ownAddress, clientInstance, client, tokenPresented, webSession },
|
|
453
505
|
verifiedOrigin,
|
|
454
506
|
reason,
|
|
455
507
|
judgedAt: this.now(),
|
|
508
|
+
...(webKeyCandidate ? { webKeyCandidate } : {}),
|
|
456
509
|
}
|
|
457
510
|
}
|
|
458
511
|
|
|
512
|
+
private webKeyOn(): boolean {
|
|
513
|
+
try { return this.deps.webKey?.enabled() === true } catch { return false }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** The web-key authority, when wired (the routes and GET /api/g2-turns read it). */
|
|
517
|
+
get webKey(): WebKeyAuthority | null { return this.deps.webKey ?? null }
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* 6.99.31: judge the envelope a web verdict carries against the route's REAL target, the exact
|
|
521
|
+
* words and the ordered files. Accepted: vouched, `authority: 'web-key'`, footed COS-WEB. Refused:
|
|
522
|
+
* unvouched with the specific reason. A verdict with no envelope comes back unchanged, so every
|
|
523
|
+
* caller can pass every verdict. Never throws.
|
|
524
|
+
*/
|
|
525
|
+
acceptWebKey(verdict: OriginVerdict, ctx: WebKeyContext): OriginVerdict {
|
|
526
|
+
const candidate = verdict.webKeyCandidate
|
|
527
|
+
if (!candidate) return verdict
|
|
528
|
+
// A fork's receiving child has no id the phone could have signed: never vouched (plan §4.4).
|
|
529
|
+
if (ctx.fork === true) return webKeyRefused(verdict, 'fork_destination_unbound')
|
|
530
|
+
if (verdict.origin.client !== 'web' || !this.deps.webKey) return webKeyRefused(verdict, 'web_key_authority_off')
|
|
531
|
+
const judged = this.deps.webKey.accept(candidate, ctx)
|
|
532
|
+
if (!judged.ok) {
|
|
533
|
+
console.warn(`[g2-turn-ledger] web-key turn not vouched turnId=${ctx.turnId} reason=${judged.reason}`)
|
|
534
|
+
return webKeyRefused(verdict, judged.reason)
|
|
535
|
+
}
|
|
536
|
+
const { webKeyCandidate: _c, webKeyReason: _r, ...rest } = verdict
|
|
537
|
+
return { ...rest, verifiedOrigin: true, reason: null, authority: 'web-key', webKey: judged.proof }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** A held web-key origin still holds at its drain only while recheck passes (plan §4.5). */
|
|
541
|
+
private reheld(verdict: OriginVerdict, prompt: string, attachments: string): OriginVerdict {
|
|
542
|
+
if (verdict.authority !== 'web-key' || !verdict.webKey) return verdict
|
|
543
|
+
const reason = this.deps.webKey ? this.deps.webKey.recheck(verdict.webKey, prompt, attachments) : 'web_key_authority_off'
|
|
544
|
+
return reason === null ? verdict : webKeyRefused(verdict, reason)
|
|
545
|
+
}
|
|
546
|
+
|
|
459
547
|
/**
|
|
460
548
|
* The text to deliver: with the footer for a vouched glasses or phone turn, otherwise unchanged.
|
|
461
549
|
* `provider` is the harness the text is delivered into (claude, codex or cursor), named in the
|
|
@@ -468,9 +556,10 @@ export class G2TurnProvenance {
|
|
|
468
556
|
|
|
469
557
|
private build(input: { turnId: string; prompt: string; verdict: OriginVerdict; provider: string; sessionId: string; via: string; fork?: boolean; resendOf?: string }): G2TurnRecord {
|
|
470
558
|
const originKind = originKindOf(input.verdict.origin.client)
|
|
471
|
-
// A web turn is never vouched
|
|
472
|
-
const
|
|
473
|
-
const
|
|
559
|
+
// A web turn is never vouched unless an enrolled phone key's envelope was accepted (6.99.31).
|
|
560
|
+
const webKey = originKind === 'web' && isWebKeyVerdict(input.verdict)
|
|
561
|
+
const verifiedOrigin = input.verdict.verifiedOrigin === true && (originKind !== 'web' || webKey)
|
|
562
|
+
const reason = verifiedOrigin ? null : (originKind === 'web' ? (input.verdict.webKeyReason ?? WEB_UNVOUCHED_REASON) : (input.verdict.reason ?? 'unverified'))
|
|
474
563
|
return {
|
|
475
564
|
turnId: input.turnId,
|
|
476
565
|
createdAt: new Date(this.now()).toISOString(),
|
|
@@ -483,6 +572,7 @@ export class G2TurnProvenance {
|
|
|
483
572
|
target: { provider: input.provider, sessionId: input.sessionId, ...(input.fork ? { fork: true as const } : {}) },
|
|
484
573
|
via: input.via,
|
|
485
574
|
...(typeof input.resendOf === 'string' && input.resendOf.length > 0 ? { resendOf: input.resendOf } : {}),
|
|
575
|
+
...(webKey && verifiedOrigin ? { authority: 'web-key' as const, webKey: input.verdict.webKey } : {}),
|
|
486
576
|
}
|
|
487
577
|
}
|
|
488
578
|
|
|
@@ -571,7 +661,8 @@ export class G2TurnProvenance {
|
|
|
571
661
|
* within QUEUED_ORIGIN_TTL_MS of when it was judged. A lapsed origin is forgotten here.
|
|
572
662
|
*/
|
|
573
663
|
queuedVerdict(provider: string, threadId: string, clientTurnId: string, prompt: string): OriginVerdict | null {
|
|
574
|
-
|
|
664
|
+
const held = this.queuedOrigin(provider, threadId, clientTurnId, prompt)
|
|
665
|
+
return held ? this.reheld(held.verdict, prompt, held.attachments) : null
|
|
575
666
|
}
|
|
576
667
|
|
|
577
668
|
private queuedOrigin(provider: string, threadId: string, clientTurnId: string, prompt: string): QueuedOrigin | null {
|
|
@@ -613,7 +704,10 @@ export class G2TurnProvenance {
|
|
|
613
704
|
// The seal rides with the origin: the turn route checks the binding's provider and thread and
|
|
614
705
|
// the files it is about to compose against it, and refuses a vouched turn that no longer matches.
|
|
615
706
|
const seal: QueueSeal | null = held ? { provider: turn.provider, threadId: turn.threadId, attachments: held.attachments } : null
|
|
616
|
-
|
|
707
|
+
// 6.99.31: a parked web-key approval is re-checked here, at its drain: expired, revoked, a moved
|
|
708
|
+
// epoch or changed words carry it unvouched (never re-dated).
|
|
709
|
+
const verdict = held ? this.reheld(held.verdict, turn.prompt, held.attachments) : null
|
|
710
|
+
this.carries.set(token, { clientTurnId: turn.clientTurnId, verdict, seal, textSha256: turnTextSha256(turn.prompt), expiresAt: at + TURN_CARRY_TTL_MS })
|
|
617
711
|
return token
|
|
618
712
|
} catch {
|
|
619
713
|
return null
|
|
@@ -655,8 +749,18 @@ export async function describeG2Turn(provenance: G2TurnProvenance, turnId: strin
|
|
|
655
749
|
// Only a record this process wrote and still holds vouches for anything (see the header), never a
|
|
656
750
|
// web turn (its cookie is derivable from the token), and never past G2_TURN_VOUCH_MAX_MS.
|
|
657
751
|
let reason: string | null = null
|
|
658
|
-
|
|
659
|
-
|
|
752
|
+
// 6.99.31: a web-key record is self-proving (envelope + signature): re-verified here against the
|
|
753
|
+
// registry, so it vouches after a restart too, and reads revoked the moment its key is revoked.
|
|
754
|
+
// Flag off: every web record reads WEB_UNVOUCHED_REASON, exactly as 6.99.29.
|
|
755
|
+
const authority = provenance.webKey
|
|
756
|
+
const webKeyOn = authority !== null && authority.enabled()
|
|
757
|
+
const webKeyRecord = originKind === 'web' && record.authority === 'web-key' && record.verifiedOrigin === true && !!record.webKey
|
|
758
|
+
const webKeyReview = webKeyRecord && webKeyOn && authority ? authority.review(record.webKey as WebKeyProof, { turnId: record.turnId, target: record.target }) : null
|
|
759
|
+
if (originKind === 'web') {
|
|
760
|
+
if (webKeyReview === null) reason = webKeyOn && typeof record.reason === 'string' && record.verifiedOrigin !== true ? record.reason : WEB_UNVOUCHED_REASON
|
|
761
|
+
else if (webKeyReview.reason !== null) reason = webKeyReview.reason
|
|
762
|
+
else if (ageMs === null || ageMs > G2_TURN_VOUCH_MAX_MS) reason = 'record_too_old'
|
|
763
|
+
} else if (record.verifiedOrigin !== true) reason = typeof record.reason === 'string' ? record.reason : 'unverified'
|
|
660
764
|
else if (!sealed) reason = 'not_sealed'
|
|
661
765
|
else if (ageMs === null || ageMs > G2_TURN_VOUCH_MAX_MS) reason = 'record_too_old'
|
|
662
766
|
return {
|
|
@@ -675,5 +779,20 @@ export async function describeG2Turn(provenance: G2TurnProvenance, turnId: strin
|
|
|
675
779
|
target: record.target,
|
|
676
780
|
via: record.via,
|
|
677
781
|
...(typeof record.resendOf === 'string' ? { resendOf: record.resendOf } : {}),
|
|
782
|
+
...(webKeyReview !== null && record.webKey ? {
|
|
783
|
+
authority: 'web-key',
|
|
784
|
+
kid: record.webKey.kid,
|
|
785
|
+
scope: record.webKey.scope,
|
|
786
|
+
// Review B4: what this scope means and what it never authorizes, in the answer itself.
|
|
787
|
+
scopeMeaning: record.webKey.scope?.kind === 'approve' ? 'approve' : MESSAGE_SCOPE_MEANING,
|
|
788
|
+
notAuthorized: notAuthorizedFor(record.webKey.scope?.kind === 'approve' ? record.webKey.scope : { kind: 'message' }),
|
|
789
|
+
envelope: record.webKey.envelope,
|
|
790
|
+
signature: record.webKey.signature,
|
|
791
|
+
passkey: record.webKey.passkey ?? null,
|
|
792
|
+
authId: record.webKey.authId,
|
|
793
|
+
registryEntry: webKeyReview.entry,
|
|
794
|
+
// Anchor (B1), epoch and revocations (W5), integrity (W1), consume keys (B3): docs/web-key-verifier-contract.md section 6.
|
|
795
|
+
...(webKeyReview.facts ?? {}),
|
|
796
|
+
} : {}),
|
|
678
797
|
}
|
|
679
798
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `reported_model` on LIST rows without the list ever waiting on a model read (step 2a, S1_CONTRACT_step2a.md B3).
|
|
3
|
+
*
|
|
4
|
+
* The 6.62.0 QA rule W11 (routes/agent-sessions.ts) keeps model reads off the list path: a list of 60 rows must not
|
|
5
|
+
* hold a tail read per row. So list rows carry the model ONLY from this memo. After a list answers, `warmListModels`
|
|
6
|
+
* reads the rows the memo missed in the background: at most 4 at a time, Claude and Codex only, and a file that keeps
|
|
7
|
+
* changing is re-read at most once a minute (a Codex posture scan can be long). The value then appears on a later poll.
|
|
8
|
+
*
|
|
9
|
+
* The value and format are exactly the detail route's (session-continue-facts.ts): Claude a bare slug, Codex
|
|
10
|
+
* `<model> <effort>`. Cursor rows are not warmed here (their model sits in Cursor's database; the detail route has it).
|
|
11
|
+
*/
|
|
12
|
+
import { stat } from 'node:fs/promises'
|
|
13
|
+
import { codexReportedModel, readCodexSessionPosture } from './codex-session-posture.js'
|
|
14
|
+
import { readClaudeReportedModel, sanitizeReportedModel } from './session-continue-facts.js'
|
|
15
|
+
|
|
16
|
+
export const LIST_MODEL_WARM_CONCURRENCY = 4
|
|
17
|
+
export const LIST_MODEL_REREAD_MS = 60_000
|
|
18
|
+
const MEMO_MAX = 2_000
|
|
19
|
+
|
|
20
|
+
interface Memo { mtimeMs: number; size: number; readAt: number; model: string | null }
|
|
21
|
+
const memo = new Map<string, Memo>()
|
|
22
|
+
const inflight = new Set<string>()
|
|
23
|
+
let running = 0
|
|
24
|
+
const queue: Array<() => Promise<void>> = []
|
|
25
|
+
export const listModelStats = { reads: 0 }
|
|
26
|
+
|
|
27
|
+
type Provider = 'claude' | 'codex' | 'cursor'
|
|
28
|
+
const keyOf = (provider: Provider, file: string) => `${provider}:${file}`
|
|
29
|
+
|
|
30
|
+
/** The memo's model for this file, or undefined. Never reads the file's contents. A value read within the last
|
|
31
|
+
* minute is served even if the file has moved on since (a busy session's model rarely changes mid-minute). */
|
|
32
|
+
export function cachedListModel(provider: Provider, file: string | undefined, fileMtimeMs: number | null, now = Date.now()): string | undefined {
|
|
33
|
+
if (!file) return undefined
|
|
34
|
+
const m = memo.get(keyOf(provider, file))
|
|
35
|
+
if (!m || m.model === null) return undefined
|
|
36
|
+
// `modified` on a row is an ISO time (whole milliseconds); the memo holds the stat's fractional mtime.
|
|
37
|
+
if (fileMtimeMs === null || Math.floor(m.mtimeMs) === Math.floor(fileMtimeMs) || now - m.readAt < LIST_MODEL_REREAD_MS) return m.model
|
|
38
|
+
return undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readModel(provider: Provider, file: string): Promise<string | null> {
|
|
42
|
+
listModelStats.reads++
|
|
43
|
+
if (provider === 'claude') return readClaudeReportedModel(file)
|
|
44
|
+
if (provider === 'codex') return sanitizeReportedModel(codexReportedModel(await readCodexSessionPosture(file)))
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function pump(): void {
|
|
49
|
+
while (running < LIST_MODEL_WARM_CONCURRENCY && queue.length) {
|
|
50
|
+
const job = queue.shift()!
|
|
51
|
+
running++
|
|
52
|
+
void job().finally(() => { running--; pump() })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Queue background reads for the rows the memo misses. Returns at once; never throws. */
|
|
57
|
+
export function warmListModels(rows: ReadonlyArray<{ provider: Provider; file?: string }>, now = Date.now()): void {
|
|
58
|
+
for (const row of rows) {
|
|
59
|
+
if (row.provider === 'cursor' || !row.file) continue
|
|
60
|
+
const key = keyOf(row.provider, row.file)
|
|
61
|
+
if (inflight.has(key)) continue
|
|
62
|
+
const m = memo.get(key)
|
|
63
|
+
if (m && now - m.readAt < LIST_MODEL_REREAD_MS) continue
|
|
64
|
+
inflight.add(key)
|
|
65
|
+
const { provider, file } = row
|
|
66
|
+
queue.push(async () => {
|
|
67
|
+
try {
|
|
68
|
+
const st = await stat(file)
|
|
69
|
+
const prev = memo.get(key)
|
|
70
|
+
if (prev && prev.mtimeMs === st.mtimeMs && prev.size === st.size) { prev.readAt = Date.now(); return }
|
|
71
|
+
const model = await readModel(provider, file)
|
|
72
|
+
memo.delete(key)
|
|
73
|
+
memo.set(key, { mtimeMs: st.mtimeMs, size: st.size, readAt: Date.now(), model })
|
|
74
|
+
while (memo.size > MEMO_MAX) memo.delete(memo.keys().next().value!)
|
|
75
|
+
} catch { /* unknown stays unknown */ } finally { inflight.delete(key) }
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
pump()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Tests only. */
|
|
82
|
+
export async function __drainListModelWarmForTests(): Promise<void> {
|
|
83
|
+
while (running > 0 || queue.length > 0) await new Promise(resolve => setTimeout(resolve, 5))
|
|
84
|
+
}
|
|
85
|
+
export function __resetListModelMemoForTests(): void { memo.clear(); inflight.clear(); queue.length = 0; listModelStats.reads = 0 }
|
|
@@ -27,3 +27,21 @@ export async function listenRequiredServers(listeners: RequiredListener[]): Prom
|
|
|
27
27
|
throw error
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
export interface ListenerPlanEntry { label: 'HTTPS' | 'HTTPS signer' | 'HTTP'; port: number; tls: boolean }
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Which listeners this boot REQUIRES (every one goes through listenRequiredServers, so a port another
|
|
35
|
+
* process holds fails the boot closed). 6.99.31: the phone key's signer origin (https, its own
|
|
36
|
+
* port, review B2/W2) is added only with COS_WEB_KEY_AUTHORITY on and only beside HTTPS. Flag off:
|
|
37
|
+
* exactly the 6.99.29 set.
|
|
38
|
+
*/
|
|
39
|
+
export function listenerPlan(opts: { httpsAvailable: boolean; webKeyAuthority: boolean; httpPort: number; httpsPort: number; signerPort: number }): ListenerPlanEntry[] {
|
|
40
|
+
const plan: ListenerPlanEntry[] = []
|
|
41
|
+
if (opts.httpsAvailable) {
|
|
42
|
+
plan.push({ label: 'HTTPS', port: opts.httpsPort, tls: true })
|
|
43
|
+
if (opts.webKeyAuthority) plan.push({ label: 'HTTPS signer', port: opts.signerPort, tls: true })
|
|
44
|
+
}
|
|
45
|
+
plan.push({ label: 'HTTP', port: opts.httpPort, tls: false })
|
|
46
|
+
return plan
|
|
47
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
transcribeHighQuality,
|
|
17
17
|
type HighQualityTranscriptionResult,
|
|
18
18
|
type WhisperWord,
|
|
19
|
+
isWhisperHqTimeout,
|
|
19
20
|
} from './whisper-local.js'
|
|
20
21
|
import { asrFrontEnd } from './stt-live.js'
|
|
21
22
|
import { measureSpeechSeconds } from './vad-silero.js'
|
|
@@ -412,6 +413,7 @@ export const __progressiveHqTesting = {
|
|
|
412
413
|
segmentRangeIsComplete,
|
|
413
414
|
nextMissingSealedSegment,
|
|
414
415
|
progressiveFailureIdentity,
|
|
416
|
+
recordProgressiveFailure,
|
|
415
417
|
resolveProgressiveHqPolicy,
|
|
416
418
|
progressiveSessionIsIdle,
|
|
417
419
|
yieldIdleProgressiveSessions,
|
|
@@ -1091,6 +1093,30 @@ function yieldIdleProgressiveSessions(activeSessionId: string, now = Date.now())
|
|
|
1091
1093
|
}
|
|
1092
1094
|
}
|
|
1093
1095
|
|
|
1096
|
+
/** Attempts a progressive checkpoint gets before it waits for the save-time job. */
|
|
1097
|
+
const PROGRESSIVE_MAX_ATTEMPTS = 3
|
|
1098
|
+
const PROGRESSIVE_RETRY_BACKOFF_MS = [60_000, 120_000, 300_000]
|
|
1099
|
+
|
|
1100
|
+
/** S1 (2026-11-09): what one failed checkpoint decode means for its retries.
|
|
1101
|
+
* A timeout is not retried. The retry would run the same audio on the same CPU lane at the
|
|
1102
|
+
* same background QoS against the same 240 s ceiling: 10/26 to 11/09, 112 segments timed out
|
|
1103
|
+
* and 85 of them timed out on all three attempts, so 178 of 290 timeouts (about 12 h of
|
|
1104
|
+
* CPU during meetings) were retries. The checkpoint is disposable; the save-time job decodes
|
|
1105
|
+
* that window. Any other failure keeps the 60/120/300 s backoff. Returns whether it is terminal. */
|
|
1106
|
+
function recordProgressiveFailure(
|
|
1107
|
+
state: Pick<ProgressiveSessionState, 'failedIdentity' | 'failedAttempts' | 'retryAfter'>,
|
|
1108
|
+
identity: string,
|
|
1109
|
+
error: unknown,
|
|
1110
|
+
now = Date.now(),
|
|
1111
|
+
): { terminal: boolean; timedOut: boolean } {
|
|
1112
|
+
const timedOut = isWhisperHqTimeout(error)
|
|
1113
|
+
const attempts = state.failedIdentity === identity ? state.failedAttempts + 1 : 1
|
|
1114
|
+
state.failedAttempts = timedOut ? Math.max(attempts, PROGRESSIVE_MAX_ATTEMPTS) : attempts
|
|
1115
|
+
state.failedIdentity = identity
|
|
1116
|
+
state.retryAfter = now + PROGRESSIVE_RETRY_BACKOFF_MS[Math.min(PROGRESSIVE_RETRY_BACKOFF_MS.length - 1, state.failedAttempts - 1)]
|
|
1117
|
+
return { terminal: state.failedAttempts >= PROGRESSIVE_MAX_ATTEMPTS, timedOut }
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1094
1120
|
function segmentRangeIsComplete(segment: BatchSegment, completedIndices: number[]): boolean {
|
|
1095
1121
|
const completed = new Set(completedIndices)
|
|
1096
1122
|
for (let index = segment.startChunkIdx; index <= segment.endChunkIdx; index++) {
|
|
@@ -1111,7 +1137,7 @@ function nextMissingSealedSegment(
|
|
|
1111
1137
|
if (!cached) {
|
|
1112
1138
|
const identity = progressiveFailureIdentity(state.audioDir, segment, previousText)
|
|
1113
1139
|
if (!identity) return null
|
|
1114
|
-
if (state.failedIdentity === identity && (state.failedAttempts >=
|
|
1140
|
+
if (state.failedIdentity === identity && (state.failedAttempts >= PROGRESSIVE_MAX_ATTEMPTS || Date.now() < state.retryAfter)) return null
|
|
1115
1141
|
return { segment, previousText }
|
|
1116
1142
|
}
|
|
1117
1143
|
previousText = cached.text
|
|
@@ -1191,12 +1217,13 @@ function queueProgressiveWork(sessionId: string, state: ProgressiveSessionState)
|
|
|
1191
1217
|
if ((error as Error)?.name !== 'AbortError') {
|
|
1192
1218
|
const identity = progressiveFailureIdentity(state.audioDir, pending.segment, pending.previousText)
|
|
1193
1219
|
if (!identity) return
|
|
1194
|
-
|
|
1195
|
-
state.failedIdentity = identity
|
|
1196
|
-
state.retryAfter = Date.now() + [60_000, 120_000, 300_000][Math.min(2, state.failedAttempts - 1)]
|
|
1220
|
+
const outcome = recordProgressiveFailure(state, identity, error)
|
|
1197
1221
|
console.warn(
|
|
1198
1222
|
`[meeting-hq-checkpoint] ${sessionId} ${pending.segment.startChunkIdx}-${pending.segment.endChunkIdx}: `
|
|
1199
|
-
+ `${error instanceof Error ? error.message : String(error)}
|
|
1223
|
+
+ `${error instanceof Error ? error.message : String(error)}`
|
|
1224
|
+
+ (outcome.terminal
|
|
1225
|
+
? (outcome.timedOut ? ' (timeout: not retried; the save-time job decodes this window)' : ' (attempts exhausted; the save-time job decodes this window)')
|
|
1226
|
+
: ` (attempt ${state.failedAttempts} of ${PROGRESSIVE_MAX_ATTEMPTS})`),
|
|
1200
1227
|
)
|
|
1201
1228
|
}
|
|
1202
1229
|
} finally {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Each agent's efforts for /api/models (Sessions chat-style layout, step 2a; S1_CONTRACT_step2a.md section C).
|
|
3
|
+
*
|
|
4
|
+
* One source for every picker, web and Mac: ghost report item 6 found the model and effort lists hard-coded in seven
|
|
5
|
+
* places. The values are exactly what POST /api/query-jobs accepts as `effort` (EFFORT_OPTIONS; anything else is
|
|
6
|
+
* dropped by the job route today, ghost item 5), and each entry says what the agent actually runs with it, from the
|
|
7
|
+
* same mapping functions the bridges call. Cursor's bridge takes no effort, so its list is empty.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_EFFORT,
|
|
11
|
+
EFFORT_OPTIONS,
|
|
12
|
+
effortLabel,
|
|
13
|
+
resolveCliEffortFlag,
|
|
14
|
+
resolveCodexReasoningEffort,
|
|
15
|
+
type EffortPreference,
|
|
16
|
+
} from '../../shared/model-preference.js'
|
|
17
|
+
import { resolveOllamaThink } from './ollama-bridge.js'
|
|
18
|
+
|
|
19
|
+
export interface EffortChoice { value: EffortPreference; label: string; runs: string }
|
|
20
|
+
export interface ModelEfforts {
|
|
21
|
+
default: EffortPreference
|
|
22
|
+
byAgent: { claude: EffortChoice[]; codex: EffortChoice[]; ollama: EffortChoice[]; cursor: EffortChoice[] }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function modelEfforts(env: NodeJS.ProcessEnv = process.env): ModelEfforts {
|
|
26
|
+
const choices = (runs: (effort: EffortPreference) => string): EffortChoice[] =>
|
|
27
|
+
EFFORT_OPTIONS.map(value => ({ value, label: effortLabel(value), runs: runs(value) }))
|
|
28
|
+
return {
|
|
29
|
+
default: DEFAULT_EFFORT,
|
|
30
|
+
byAgent: {
|
|
31
|
+
// `--effort`; Ultracode is a COS choice that runs Claude at xhigh.
|
|
32
|
+
claude: choices(e => resolveCliEffortFlag(e)),
|
|
33
|
+
// `model_reasoning_effort`.
|
|
34
|
+
codex: choices(e => resolveCodexReasoningEffort(e)),
|
|
35
|
+
// Ollama's `think` (COS_OLLAMA_THINK, when set, pins it whatever the effort).
|
|
36
|
+
ollama: choices(e => { const think = resolveOllamaThink(env.COS_OLLAMA_THINK, e); return think === false ? 'off' : think === true ? 'on' : think }),
|
|
37
|
+
cursor: [],
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
}
|