@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.
Files changed (97) hide show
  1. package/.env.example +19 -0
  2. package/CHANGELOG.md +305 -0
  3. package/docs/web-key-verifier-contract.md +378 -0
  4. package/managed-runtime-contract.json +9 -1
  5. package/package.json +10 -3
  6. package/server/bootstrap.ts +10 -0
  7. package/server/index.ts +129 -23
  8. package/server/lib/agent-session-older.ts +218 -0
  9. package/server/lib/agent-session-recent.ts +321 -0
  10. package/server/lib/agent-session-store.ts +102 -20
  11. package/server/lib/api-auth.ts +37 -1
  12. package/server/lib/calendar-meetings.ts +70 -0
  13. package/server/lib/calendar-source.ts +54 -9
  14. package/server/lib/claude-hooks-installer.ts +111 -12
  15. package/server/lib/conversation.ts +56 -28
  16. package/server/lib/cors-reject-log.ts +51 -0
  17. package/server/lib/cos-started-sessions.ts +101 -0
  18. package/server/lib/disk-io-meter.ts +202 -0
  19. package/server/lib/drop-service.ts +47 -12
  20. package/server/lib/drop-store.ts +21 -1
  21. package/server/lib/event-loop-stall.ts +62 -0
  22. package/server/lib/full-hold-engine.ts +114 -0
  23. package/server/lib/full-hold.ts +124 -0
  24. package/server/lib/g2-turn-provenance.ts +128 -9
  25. package/server/lib/list-model-memo.ts +85 -0
  26. package/server/lib/listener-startup.ts +18 -0
  27. package/server/lib/meeting-batch-transcribe.ts +32 -5
  28. package/server/lib/model-efforts.ts +40 -0
  29. package/server/lib/permission-broker.ts +443 -11
  30. package/server/lib/secret-path-guard.ts +4 -1
  31. package/server/lib/session-cache-writer.ts +8 -1
  32. package/server/lib/session-hook-events.ts +23 -1
  33. package/server/lib/session-hooks-runtime.ts +34 -0
  34. package/server/lib/session-log.ts +313 -4
  35. package/server/lib/session-organizer.ts +305 -0
  36. package/server/lib/session-state-derive.ts +36 -0
  37. package/server/lib/session-stream-events.ts +39 -3
  38. package/server/lib/thread-turn-queue.ts +24 -0
  39. package/server/lib/today-brief.ts +522 -58
  40. package/server/lib/turn-end-ask.ts +320 -0
  41. package/server/lib/turn-footer.ts +3 -3
  42. package/server/lib/web-control-integrity.ts +160 -0
  43. package/server/lib/web-control-offnet-page.ts +58 -0
  44. package/server/lib/web-control-static.ts +53 -9
  45. package/server/lib/web-key-authority.ts +372 -0
  46. package/server/lib/web-key-envelope.ts +98 -0
  47. package/server/lib/web-key-handoff.ts +135 -0
  48. package/server/lib/web-key-origin.ts +21 -0
  49. package/server/lib/web-key-passkey.ts +155 -0
  50. package/server/lib/web-key-registry.ts +825 -0
  51. package/server/lib/web-key-signer-app.ts +152 -0
  52. package/server/lib/web-session-auth.ts +133 -15
  53. package/server/lib/whisper-local.ts +93 -7
  54. package/server/lib/whisper-preview.ts +5 -3
  55. package/server/lib/work-orchestrator/snapshot.ts +17 -3
  56. package/server/lib/work-review-runtime.ts +17 -4
  57. package/server/routes/agent-session-bindings.ts +16 -1
  58. package/server/routes/agent-session-stream.ts +7 -0
  59. package/server/routes/agent-sessions.ts +106 -2
  60. package/server/routes/drops.ts +7 -2
  61. package/server/routes/g2-turns.ts +35 -0
  62. package/server/routes/health.ts +14 -1
  63. package/server/routes/live-events.ts +6 -1
  64. package/server/routes/permission-broker.ts +26 -0
  65. package/server/routes/query-jobs.ts +3 -0
  66. package/server/routes/session-organizer.ts +149 -0
  67. package/server/routes/sessions.ts +15 -24
  68. package/server/routes/thread-turn-queue.ts +50 -14
  69. package/server/routes/today-brief.ts +91 -36
  70. package/server/routes/web-control.ts +37 -4
  71. package/server/routes/web-key.ts +284 -0
  72. package/server/routes/welcome-context.ts +6 -2
  73. package/server/routes/work-orchestrator.ts +12 -7
  74. package/server/scripts/write-web-control-manifest.mjs +43 -0
  75. package/server/web-control/.manifest.json +44 -0
  76. package/server/web-control/app.css +45 -0
  77. package/server/web-control/app.js +1680 -108
  78. package/server/web-control/apple-touch-icon.png +0 -0
  79. package/server/web-control/index.html +54 -17
  80. package/server/web-control/panes/cos-chat.css +52 -6
  81. package/server/web-control/panes/cos-chat.js +364 -32
  82. package/server/web-control/panes/questions.js +135 -14
  83. package/server/web-control/panes/sessions.css +280 -0
  84. package/server/web-control/panes/settings.js +1 -1
  85. package/server/web-control/panes/today-actions.css +35 -0
  86. package/server/web-control/panes/today-actions.js +121 -6
  87. package/server/web-control/panes/work.css +3 -0
  88. package/server/web-control/panes/work.js +185 -38
  89. package/server/web-control/theme.js +33 -0
  90. package/server/web-signer/.manifest.json +11 -0
  91. package/server/web-signer/index.html +74 -0
  92. package/server/web-signer/signer-proof.js +348 -0
  93. package/server/web-signer/signer-store.js +39 -0
  94. package/server/web-signer/signer.css +23 -0
  95. package/server/web-signer/signer.js +341 -0
  96. package/shared/web-approval-packets.golden.json +124 -0
  97. 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
- /** The same verdict, never vouched, with the reason why. */
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
- return { ...verdict, verifiedOrigin: false, reason }
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, whatever verdict reached here (header).
472
- const verifiedOrigin = input.verdict.verifiedOrigin === true && originKind !== 'web'
473
- const reason = verifiedOrigin ? null : (originKind === 'web' ? WEB_UNVOUCHED_REASON : (input.verdict.reason ?? 'unverified'))
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
- return this.queuedOrigin(provider, threadId, clientTurnId, prompt)?.verdict ?? null
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
- this.carries.set(token, { clientTurnId: turn.clientTurnId, verdict: held?.verdict ?? null, seal, textSha256: turnTextSha256(turn.prompt), expiresAt: at + TURN_CARRY_TTL_MS })
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
- if (originKind === 'web') reason = WEB_UNVOUCHED_REASON
659
- else if (record.verifiedOrigin !== true) reason = typeof record.reason === 'string' ? record.reason : 'unverified'
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 >= 3 || Date.now() < state.retryAfter)) return null
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
- state.failedAttempts = state.failedIdentity === identity ? state.failedAttempts + 1 : 1
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
+ }