@gotcos/glasses-server 6.11.0 → 6.12.1

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 CHANGED
@@ -58,8 +58,10 @@ BIND_HOST=0.0.0.0
58
58
 
59
59
  # ── VOICE (optional) ────────────────────────────────────────────────────
60
60
  # Local transcription is FREE via whisper.cpp (brew install whisper-cpp; the
61
- # model auto-downloads on first run). If whisper.cpp is absent, voice falls
62
- # back to the OpenAI API (~$0.006/min), which needs this key.
61
+ # model auto-downloads on first run). Voice is local-only by default. Merely
62
+ # configuring a key never uploads audio. To allow OpenAI Whisper only after a
63
+ # local failure, set BOTH the exact opt-in and a key:
64
+ # COS_OPENAI_WHISPER_FALLBACK=1
63
65
  # OPENAI_API_KEY=sk-...
64
66
 
65
67
  # ── FULL COS PIPELINE (optional — leave unset for standalone) ────────────
package/CHANGELOG.md CHANGED
@@ -1,5 +1,60 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.12.1
4
+
5
+ Public-safe CLI diagnostics for the COS Glasses Recovery Center.
6
+
7
+ - **Both local agents are visible.** Authenticated clients can inspect a
8
+ versioned Claude Code and Codex status summary at `/api/cli/debug`, including
9
+ provider support, persistence readiness, workspace configuration, and the
10
+ latest run's safe status metadata.
11
+ - **False success is fenced.** Claude Code or Codex output that reports a
12
+ machine-shaped authentication failure while the CLI exits `0` is finalized
13
+ as a typed `auth_error`, never projected as a completed assistant reply.
14
+ - **Build 210 stays compatible.** Sanitized `/api/cli/runs` and
15
+ `/api/codex/runs` projections preserve the fields older Recovery Centers can
16
+ render while newer clients adopt the combined contract.
17
+ - **Diagnostics do not become an exfiltration path.** Responses use explicit
18
+ allowlists and omit commands, filesystem paths, trust modes, prompts,
19
+ answers, tool payloads, content previews, raw run/session/thread IDs,
20
+ resumable handles, environment values, and tokens. Legacy display IDs are
21
+ omitted and workspace state is a fixed label only.
22
+ - **Authentication is mandatory.** All three diagnostic routes remain behind
23
+ the existing `/api` token boundary. Preview-enabled ledger fixtures are
24
+ covered by recursive forbidden-field and private-value tests.
25
+ - **Unauthenticated health is capability-only.** `/api/health` and
26
+ `/api/models` advertise `capabilities.cliDebug`; health exposes only a CLI
27
+ session-availability boolean rather than the resumable session ID.
28
+ - **Backward compatible.** Query, prompt, meeting, media, display, model, and
29
+ recovery behavior is unchanged. Older clients and servers continue to use
30
+ their existing paths; a missing CLI Debug capability remains an unsupported
31
+ feature rather than a connection failure.
32
+
33
+ ## 6.12.0
34
+
35
+ Local-first transcription policy and capability-safe recovery diagnostics for
36
+ COS Glasses build 210+.
37
+
38
+ - **Local means local.** Prompt, one-shot, and meeting transcription now remain
39
+ on local Whisper by default. Finding an OpenAI key is not permission to upload
40
+ audio. Cloud Whisper is reachable only when the exact
41
+ `COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a resolved key are both present.
42
+ - **Every cloud chokepoint is fenced.** Both one-shot/prompt finalization and
43
+ continuous meeting transcription recheck the policy immediately before any
44
+ OpenAI request, preventing a future call-site regression from bypassing the
45
+ top-level selection logic.
46
+ - **Failure stays recoverable.** A local ASR outage returns a typed retryable
47
+ `503` instead of silently switching providers. Durable prompt chunks and raw
48
+ meeting audio remain available for retry; meeting receipt and batch-audio
49
+ retention behavior is unchanged.
50
+ - **Clients can tell policy from health.** `/api/health` and `/api/models`
51
+ publish additive `capabilities.transcription` fields. Public installs also
52
+ advertise every privileged recovery control as unsupported, allowing newer
53
+ phone Recovery Centers to hide controls instead of reporting false outages.
54
+ - **Backward compatible.** Existing routes and response fields remain in place.
55
+ Older apps keep their current query, prompt, meeting, image, and display
56
+ paths; cloud fallback remains available to operators who explicitly enable it.
57
+
3
58
  ## 6.11.0
4
59
 
5
60
  Local-first meeting recovery for COS Glasses build 209+.
package/README.md CHANGED
@@ -27,7 +27,7 @@ without silently losing completed replies.
27
27
  - **Claude Code CLI** (Opus/Fable/Sonnet) — https://claude.ai/download, then `claude login`
28
28
  _or_ **Codex CLI** (GPT Frontier/Balanced) — https://developers.openai.com/codex/, then `codex login`
29
29
  - **Even G2 glasses** + the **COS Glasses** app from the Even Hub
30
- - _Optional:_ `brew install whisper-cpp` for free local voice (otherwise OpenAI API)
30
+ - `brew install whisper-cpp` for free local voice (the launcher can download the model)
31
31
  - _Optional:_ `brew install ffmpeg` for phone/output image attachments (text chat remains available without it)
32
32
  - _Optional:_ **Tailscale** so your phone reaches your Mac from anywhere
33
33
 
@@ -76,7 +76,10 @@ The built-in IP allowlist blocks public-internet traffic regardless.
76
76
  locally through a network interruption. Reconnecting reconciles the exact
77
77
  chunks already stored by the Mac, uploads only missing audio, and finalizes
78
78
  through an idempotent save receipt without duplicating the meeting.
79
- - Local whisper.cpp transcription (free) with OpenAI fallback (optional)
79
+ - Local whisper.cpp transcription (free and local-only by default). OpenAI
80
+ Whisper fallback is optional and requires both the exact
81
+ `COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a configured key; a key alone never
82
+ uploads audio.
80
83
  - Tasks / calendar / people context **if** you run the
81
84
  [COS Starter Kit](https://www.gotcos.com) (`COS_SCRIPTS_DIR`); otherwise it is
82
85
  glasses + AI only
@@ -85,7 +88,8 @@ The built-in IP allowlist blocks public-internet traffic regardless.
85
88
 
86
89
  Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
87
90
  optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
88
- `COS_API_TOKEN` (auto if unset), `OPENAI_API_KEY` (cloud voice fallback),
91
+ `COS_API_TOKEN` (auto if unset), `COS_OPENAI_WHISPER_FALLBACK=1` plus
92
+ `OPENAI_API_KEY` (explicit cloud voice fallback),
89
93
  `COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=1` (build 204+
90
94
  server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
91
95
  location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
@@ -105,7 +109,13 @@ BIND_HOST=0.0.0.0 npm run start:server
105
109
  - *Phone can't connect* — check `BIND_HOST=0.0.0.0`, the same Tailscale account on both devices, and the correct `100.x` IP + token.
106
110
  - *Safari connects but the app does not* — confirm `npx @gotcos/glasses-server@latest` is 6.6.0+, then use the app's server reconnect/edit control to verify the current URL and token. Do not run a second source or `npx` server alongside it.
107
111
  - *AI queries fail* — run `claude --version` / `codex --version`, then `claude login` / `codex login`.
108
- - *Voice getting billed?* — install `whisper-cpp` for free local transcription.
112
+ - *Voice getting billed?* — voice is local-only by default in 6.12.0+. Confirm
113
+ `/api/health` reports `capabilities.transcription.mode: "local-only"`. Remove
114
+ `COS_OPENAI_WHISPER_FALLBACK` (or set it to `0`) to disable an earlier opt-in.
115
+ - *Local voice unavailable?* — install `whisper-cpp`, restart the server, and
116
+ confirm `/api/health` reports `features.whisper: true`. A typed retryable 503
117
+ keeps compatible prompt/meeting audio available for retry instead of silently
118
+ sending it to OpenAI.
109
119
  - *Photos unavailable?* — install `ffmpeg`, restart the server, and confirm `/api/health` reports `features.mediaProcessingReady: true`.
110
120
  - *Prompt recovery unavailable?* — update with `npx @gotcos/glasses-server@latest`, then confirm `/api/health` reports `features.promptRecovery: true`.
111
121
  - *Durable query recovery unavailable?* — build 204+ requires server 6.10.0+ and
package/bin/cli.cjs CHANGED
@@ -133,7 +133,8 @@ if (!existsSync(PROFILE_FILE) && existsSync(PROFILE_EXAMPLE)) {
133
133
  }
134
134
  if (!process.env.COS_PROFILE_PATH) process.env.COS_PROFILE_PATH = PROFILE_FILE
135
135
 
136
- // Step 5: local Whisper detection + model download (free voice; OpenAI fallback otherwise)
136
+ // Step 5: local Whisper detection + model download. Voice stays local-only by
137
+ // default; cloud fallback requires an explicit flag plus a configured key.
137
138
  const WHISPER_KNOWN_PATHS = ['/opt/homebrew/bin/whisper-cli', '/usr/local/bin/whisper-cli']
138
139
  const WHISPER_MODEL_DIR = join(homedir(), '.local/share/whisper-models')
139
140
  const WHISPER_MODEL_PATH = join(WHISPER_MODEL_DIR, 'ggml-large-v3-turbo.bin')
@@ -161,10 +162,10 @@ if (whisperCliPath && hasValidModel) {
161
162
  if (existsSync(WHISPER_MODEL_PATH)) { try { unlinkSync(WHISPER_MODEL_PATH) } catch {} }
162
163
  if (existsSync(WHISPER_MODEL_PARTIAL)) { try { unlinkSync(WHISPER_MODEL_PARTIAL) } catch {} }
163
164
  console.log(yellow(' ⚠') + ' whisper.cpp installed but model missing')
164
- console.log(' ' + dim('Downloading ggml-large-v3-turbo (~1.5 GB). Ctrl-C to skip (uses OpenAI API instead).'))
165
+ console.log(' ' + dim('Downloading ggml-large-v3-turbo (~1.5 GB). Ctrl-C to skip (voice remains unavailable by default).'))
165
166
  console.log(' ' + dim('Skip permanently: SKIP_WHISPER_DOWNLOAD=1 npx @gotcos/glasses-server'))
166
167
  if (process.env.SKIP_WHISPER_DOWNLOAD === '1') {
167
- console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — voice will use OpenAI API')
168
+ console.log(yellow(' ⚠') + ' SKIP_WHISPER_DOWNLOAD=1 — local voice unavailable')
168
169
  } else {
169
170
  try {
170
171
  mkdirSync(WHISPER_MODEL_DIR, { recursive: true })
@@ -175,14 +176,19 @@ if (whisperCliPath && hasValidModel) {
175
176
  console.log(green(' ✓') + ' Model downloaded ' + dim('— voice = local (FREE)'))
176
177
  } catch (err) {
177
178
  try { unlinkSync(WHISPER_MODEL_PARTIAL) } catch {}
178
- console.log(red(' ✗') + ' Model download failed ' + dim('— voice will use OpenAI API'))
179
+ console.log(red(' ✗') + ' Model download failed ' + dim('— local voice unavailable'))
179
180
  console.log(' ' + dim('Error: ' + (err.message || err).toString().slice(0, 120)))
180
181
  }
181
182
  }
182
183
  } else {
183
- console.log(yellow(' ⚠') + ' whisper.cpp not installed ' + dim('— voice will use OpenAI API ($0.006/min)'))
184
+ console.log(yellow(' ⚠') + ' whisper.cpp not installed ' + dim('— local voice unavailable'))
184
185
  console.log(' Free local voice: ' + bold('brew install whisper-cpp') + dim(' (no Homebrew? https://brew.sh)'))
185
186
  }
187
+ if (process.env.COS_OPENAI_WHISPER_FALLBACK === '1') {
188
+ console.log(yellow(' ⚠') + ' Explicit OpenAI Whisper fallback requested ' + dim('— activates only if a key resolves; see /api/health'))
189
+ } else {
190
+ console.log(green(' ✓') + ' Transcription policy: local-only ' + dim('— a key alone never uploads audio'))
191
+ }
186
192
 
187
193
  // Step 6: image capability — ffmpeg validates, strips metadata, normalizes,
188
194
  // and builds the exact 288x144 G2 variant. It is optional so text/voice remain
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.11.0",
3
+ "version": "6.12.1",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -27,6 +27,7 @@ import { archiveRouter } from './routes/archive.js'
27
27
  import { sessionsRouter } from './routes/sessions.js'
28
28
  import { mediaRouter, mediaBodyParser } from './routes/media.js'
29
29
  import { promptDraftsRouter } from './routes/prompt-drafts.js'
30
+ import { cliDebugRouter } from './routes/cli-debug.js'
30
31
  import { prewarmContext } from './lib/context-builder.js'
31
32
  import { preWarmCLI } from './lib/claude-bridge.js'
32
33
  import { getCodexRunConfig } from './lib/codex-run-ledger.js'
@@ -163,6 +164,7 @@ app.use('/api', archiveRouter)
163
164
  app.use('/api', sessionsRouter)
164
165
  app.use('/api', mediaRouter)
165
166
  app.use('/api', promptDraftsRouter)
167
+ app.use('/api', cliDebugRouter)
166
168
 
167
169
  // OpenAI-compatible endpoint for the G2 Agent (ER "Add Agent")
168
170
  // Mounted at root — routes are /v1/chat/completions and /v1/models
@@ -47,6 +47,7 @@ import {
47
47
  MAX_ATTACHMENTS_PER_PROMPT,
48
48
  type MediaAttachmentRef,
49
49
  } from '../../shared/media-attachment.js'
50
+ import { terminalProviderAuthFailure } from './provider-terminal-error.js'
50
51
 
51
52
  // Inactivity = no stdout data for this long → kill (catches stalls)
52
53
  const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
@@ -325,12 +326,15 @@ export interface StreamCallbacks {
325
326
  /** Claude CLI can emit `subtype: success` with `is_error: true`; the boolean
326
327
  * is authoritative and must win before session ids or result text are saved. */
327
328
  export function claudeResultErrorMessage(event: any): string | null {
328
- if (event?.type !== 'result' || (event?.is_error !== true && event?.subtype !== 'error')) return null
329
+ if (event?.type !== 'result') return null
329
330
  const raw = typeof event?.result === 'string' ? event.result
330
331
  : typeof event?.error === 'string' ? event.error
331
332
  : typeof event?.error?.message === 'string' ? event.error.message
332
333
  : typeof event?.message === 'string' ? event.message
333
334
  : ''
335
+ const authFailure = terminalProviderAuthFailure('claude', raw, event?.error)
336
+ if (authFailure) return authFailure
337
+ if (event?.is_error !== true && event?.subtype !== 'error') return null
334
338
  const detail = raw.replace(/\s+/g, ' ').trim().slice(0, 240)
335
339
  return detail ? `claude-bridge: ${detail}` : 'claude-bridge: Claude CLI returned an error result.'
336
340
  }
@@ -545,6 +549,7 @@ export async function callClaudeStreaming(
545
549
  let stderr = ''
546
550
  let buffer = ''
547
551
  let finalized = false // Guard against double onDone/onError
552
+ let terminalTextError: string | null = null
548
553
  let lastActivity = Date.now() // Tracks last stdout data for inactivity timeout
549
554
  let receivedStreamEvents = false // Track if CLI emits stream_event (vs older assistant-only format)
550
555
  const toolInputs = new Map<number, { name: string; json: string }>()
@@ -570,6 +575,13 @@ export async function callClaudeStreaming(
570
575
 
571
576
  async function finalize(text: string) {
572
577
  if (finalized) return
578
+ const responseAuthFailure = terminalProviderAuthFailure('claude', text)
579
+ const authFailure = responseAuthFailure
580
+ ?? (!text.trim() ? terminalTextError ?? terminalProviderAuthFailure('claude', stderr) : null)
581
+ if (authFailure) {
582
+ await finalizeError(authFailure, 0)
583
+ return
584
+ }
573
585
  finalized = true
574
586
  cleanup()
575
587
  cleanupImages()
@@ -831,9 +843,16 @@ export async function callClaudeStreaming(
831
843
  text = event.content
832
844
  }
833
845
  if (text) {
834
- phase = 'generating'
835
- fullText += text
836
- callbacks.onChunk(text)
846
+ // Withhold only strongly machine-shaped provider auth output so
847
+ // credentials cannot flash through onChunk. Human sign-in
848
+ // instructions do not match the terminal classifier.
849
+ const authFailure = terminalProviderAuthFailure('claude', text)
850
+ if (authFailure) terminalTextError = authFailure
851
+ else {
852
+ phase = 'generating'
853
+ fullText += text
854
+ callbacks.onChunk(text)
855
+ }
837
856
  }
838
857
  }
839
858
  } else if (event.type === 'user') {
@@ -855,7 +874,9 @@ export async function callClaudeStreaming(
855
874
  }
856
875
  // tool_use/tool_result/other events still reset inactivity (we got stdout data)
857
876
  } catch {
858
- // Not valid JSON ignore partial lines
877
+ // Older CLI builds can emit a terminal auth error as plain text while
878
+ // still exiting 0. Remember only the canonical classification.
879
+ terminalTextError ??= terminalProviderAuthFailure('claude', trimmed)
859
880
  }
860
881
  }
861
882
  })
@@ -883,13 +904,19 @@ export async function callClaudeStreaming(
883
904
  } catch { /* ignore */ }
884
905
  }
885
906
 
886
- if (code !== 0 && !fullText) {
887
- finalizeError(`claude-bridge: exit ${code} ${stderr.trim().slice(0, 200)}`, code)
888
- } else if (fullText) {
889
- // If we got text but no explicit result event, still finalize
907
+ if (fullText) {
908
+ // If we got text but no explicit result event, still finalize. A sole,
909
+ // machine-shaped auth response is classified inside finalize.
890
910
  void finalize(fullText)
891
911
  } else {
892
- finalizeError('claude-bridge: Claude completed without a response.', code)
912
+ const authFailure = terminalTextError ?? terminalProviderAuthFailure('claude', stderr, buffer)
913
+ if (authFailure) {
914
+ finalizeError(authFailure, code)
915
+ } else if (code !== 0) {
916
+ finalizeError(`claude-bridge: exit ${code} — ${stderr.trim().slice(0, 200)}`, code)
917
+ } else {
918
+ finalizeError('claude-bridge: Claude completed without a response.', code)
919
+ }
893
920
  }
894
921
  })
895
922
 
@@ -0,0 +1,213 @@
1
+ import type { ClaudeRunConfig, ClaudeRunRecord } from './claude-run-ledger.js'
2
+ import type { CodexRunConfig, CodexRunRecord } from './codex-run-ledger.js'
3
+
4
+ export const CLI_DEBUG_CAPABILITY = Object.freeze({
5
+ schemaVersion: 1,
6
+ providers: Object.freeze({ claude: true, codex: true }),
7
+ metadataOnly: true,
8
+ })
9
+
10
+ export type SafeCliRunStatus =
11
+ | 'running'
12
+ | 'completed'
13
+ | 'failed'
14
+ | 'cancelled'
15
+ | 'client_disconnected'
16
+
17
+ export interface SafeCliDebugLatestRun {
18
+ status: SafeCliRunStatus
19
+ model: string
20
+ concreteModel?: string
21
+ effort?: string
22
+ resumed: boolean
23
+ durationMs?: number
24
+ updatedAt: string
25
+ errorCode?: string
26
+ }
27
+
28
+ export interface SafeCliDebugProvider {
29
+ supported: true
30
+ persistenceEnabled: boolean
31
+ workspaceConfigured: boolean
32
+ latestRun: SafeCliDebugLatestRun | null
33
+ }
34
+
35
+ export interface SafeCliDebugResponse {
36
+ schemaVersion: 1
37
+ providers: {
38
+ claude: SafeCliDebugProvider
39
+ codex: SafeCliDebugProvider
40
+ }
41
+ }
42
+
43
+ function optionalString(value: unknown): string | undefined {
44
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
45
+ }
46
+
47
+ function optionalFiniteNumber(value: unknown): number | undefined {
48
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined
49
+ }
50
+
51
+ function safeStatus(value: unknown): SafeCliRunStatus {
52
+ return value === 'running' || value === 'completed' || value === 'failed'
53
+ || value === 'cancelled' || value === 'client_disconnected'
54
+ ? value
55
+ : 'failed'
56
+ }
57
+
58
+ function safeModelLabel(value: unknown, provider: 'claude' | 'codex'): string | undefined {
59
+ const model = optionalString(value)
60
+ if (!model || model.length > 96) return undefined
61
+ const allowed = provider === 'claude'
62
+ ? /^(?:claude-|opus(?:\[1m\])?$|sonnet(?:\[1m\])?$|fable(?:\[1m\])?$|haiku(?:\[1m\])?$)[a-z0-9._\[\]-]*$/i
63
+ : /^(?:gpt-|codex-)[a-z0-9._\[\]-]*$/i
64
+ return allowed.test(model) ? model : undefined
65
+ }
66
+
67
+ function safeEffort(value: unknown): string | undefined {
68
+ const effort = optionalString(value)?.toLowerCase()
69
+ return effort && ['low', 'medium', 'high', 'xhigh', 'max', 'ultra', 'ultracode'].includes(effort)
70
+ ? effort
71
+ : undefined
72
+ }
73
+
74
+ function safeTimestamp(value: unknown): string {
75
+ const timestamp = optionalString(value)
76
+ const parsed = timestamp ? Date.parse(timestamp) : Number.NaN
77
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : new Date(0).toISOString()
78
+ }
79
+
80
+ function safeErrorCode(provider: 'claude' | 'codex', value: unknown): string | undefined {
81
+ const candidate = optionalString(value)
82
+ if (!candidate) return undefined
83
+ const allowed = new Set([
84
+ `${provider}.cli_unavailable`,
85
+ `${provider}.permission_denied`,
86
+ `${provider}.auth_error`,
87
+ `${provider}.timeout`,
88
+ `${provider}.nonzero_exit`,
89
+ `${provider}.error`,
90
+ `${provider}.interrupted`,
91
+ ])
92
+ return allowed.has(candidate) ? candidate : `${provider}.error`
93
+ }
94
+
95
+ export function safeClaudeLatestRun(run?: ClaudeRunRecord): SafeCliDebugLatestRun | null {
96
+ if (!run) return null
97
+ const concreteModel = safeModelLabel(run.resolvedModelId ?? run.cliModelId, 'claude')
98
+ const effort = safeEffort(run.effortLevel)
99
+ const errorCode = safeErrorCode('claude', run.errorCode)
100
+ return {
101
+ status: safeStatus(run.status),
102
+ model: safeModelLabel(run.model, 'claude') ?? 'unknown',
103
+ ...(concreteModel ? { concreteModel } : {}),
104
+ ...(effort ? { effort } : {}),
105
+ resumed: run.resumed === true,
106
+ ...(optionalFiniteNumber(run.durationMs) !== undefined ? { durationMs: run.durationMs } : {}),
107
+ updatedAt: safeTimestamp(run.updatedAt),
108
+ ...(errorCode ? { errorCode } : {}),
109
+ }
110
+ }
111
+
112
+ export function safeCodexLatestRun(run?: CodexRunRecord): SafeCliDebugLatestRun | null {
113
+ if (!run) return null
114
+ const concreteModel = safeModelLabel(run.cliModel, 'codex')
115
+ const effort = safeEffort(run.reasoningEffort)
116
+ const errorCode = safeErrorCode('codex', run.errorCode)
117
+ return {
118
+ status: safeStatus(run.status),
119
+ model: safeModelLabel(run.model, 'codex') ?? 'unknown',
120
+ ...(concreteModel ? { concreteModel } : {}),
121
+ ...(effort ? { effort } : {}),
122
+ resumed: run.resumed === true,
123
+ ...(optionalFiniteNumber(run.durationMs) !== undefined ? { durationMs: run.durationMs } : {}),
124
+ updatedAt: safeTimestamp(run.updatedAt),
125
+ ...(errorCode ? { errorCode } : {}),
126
+ }
127
+ }
128
+
129
+ export function safeCliDebugResponse(
130
+ claudeConfig: ClaudeRunConfig,
131
+ claudeRun: ClaudeRunRecord | undefined,
132
+ codexConfig: CodexRunConfig,
133
+ codexRun: CodexRunRecord | undefined,
134
+ ): SafeCliDebugResponse {
135
+ return {
136
+ schemaVersion: 1,
137
+ providers: {
138
+ claude: {
139
+ supported: true,
140
+ persistenceEnabled: claudeConfig.persistenceEnabled === true,
141
+ workspaceConfigured: Boolean(claudeConfig.cwd),
142
+ latestRun: safeClaudeLatestRun(claudeRun),
143
+ },
144
+ codex: {
145
+ supported: true,
146
+ persistenceEnabled: codexConfig.persistenceEnabled === true,
147
+ workspaceConfigured: Boolean(codexConfig.cwd),
148
+ latestRun: safeCodexLatestRun(codexRun),
149
+ },
150
+ },
151
+ }
152
+ }
153
+
154
+ /** Compatibility projection for build 210 and earlier Settings panels.
155
+ * Every returned key is an explicit public-safe allowlist entry. */
156
+ export function safeLegacyClaudeResponse(config: ClaudeRunConfig, runs: ClaudeRunRecord[]) {
157
+ return {
158
+ schemaVersion: 1,
159
+ config: {
160
+ persistenceEnabled: config.persistenceEnabled === true,
161
+ workspaceConfigured: Boolean(config.cwd),
162
+ cwd: config.cwd ? 'Configured workspace' : 'Not configured',
163
+ defaultEffortLevel: safeEffort(config.defaultEffortLevel) ?? 'high',
164
+ contentPreviewsEnabled: false,
165
+ },
166
+ runs: runs.map(run => {
167
+ const latest = safeClaudeLatestRun(run)!
168
+ return {
169
+ status: latest.status,
170
+ model: latest.model,
171
+ ...(latest.concreteModel ? {
172
+ cliModelId: latest.concreteModel,
173
+ resolvedModelId: latest.concreteModel,
174
+ } : {}),
175
+ ...(latest.effort ? { effortLevel: latest.effort } : {}),
176
+ ...(typeof latest.resumed === 'boolean' ? { resumed: latest.resumed } : {}),
177
+ ...(latest.durationMs !== undefined ? { durationMs: latest.durationMs } : {}),
178
+ ...(latest.updatedAt ? { updatedAt: latest.updatedAt } : {}),
179
+ ...(latest.errorCode ? { errorCode: latest.errorCode } : {}),
180
+ }
181
+ }),
182
+ }
183
+ }
184
+
185
+ /** Compatibility projection for build 210 and earlier Settings panels.
186
+ * Engine sessions are deliberately absent: their thread ids are resumable
187
+ * runtime handles, not display metadata. */
188
+ export function safeLegacyCodexResponse(config: CodexRunConfig, runs: CodexRunRecord[]) {
189
+ return {
190
+ schemaVersion: 1,
191
+ config: {
192
+ persistenceEnabled: config.persistenceEnabled === true,
193
+ workspaceConfigured: Boolean(config.cwd),
194
+ cwd: config.cwd ? 'Configured workspace' : 'Not configured',
195
+ cliModel: safeModelLabel(config.cliModel, 'codex') ?? 'unknown',
196
+ reasoningEffort: safeEffort(config.reasoningEffort) ?? 'high',
197
+ contentPreviewsEnabled: false,
198
+ },
199
+ runs: runs.map(run => {
200
+ const latest = safeCodexLatestRun(run)!
201
+ return {
202
+ status: latest.status,
203
+ model: latest.model,
204
+ ...(latest.concreteModel ? { cliModel: latest.concreteModel } : {}),
205
+ ...(latest.effort ? { reasoningEffort: latest.effort } : {}),
206
+ ...(typeof latest.resumed === 'boolean' ? { resumed: latest.resumed } : {}),
207
+ ...(latest.durationMs !== undefined ? { durationMs: latest.durationMs } : {}),
208
+ ...(latest.updatedAt ? { updatedAt: latest.updatedAt } : {}),
209
+ ...(latest.errorCode ? { errorCode: latest.errorCode } : {}),
210
+ }
211
+ }),
212
+ }
213
+ }
@@ -60,6 +60,7 @@ import {
60
60
  MAX_ATTACHMENTS_PER_PROMPT,
61
61
  type MediaAttachmentRef,
62
62
  } from '../../shared/media-attachment.js'
63
+ import { terminalProviderAuthFailure } from './provider-terminal-error.js'
63
64
 
64
65
  const INACTIVITY_MS = 180_000
65
66
  const WALL_MAX_MS = 900_000
@@ -366,6 +367,7 @@ export async function callCodexStreaming(
366
367
  let stderr = ''
367
368
  let buffer = ''
368
369
  let finalized = false
370
+ let terminalTextError: string | null = null
369
371
  let lastActivity = Date.now()
370
372
  const emittedBlocks = new Set<string>()
371
373
 
@@ -425,6 +427,13 @@ export async function callCodexStreaming(
425
427
 
426
428
  async function finalize(text: string) {
427
429
  if (finalized) return
430
+ const responseAuthFailure = terminalProviderAuthFailure('codex', text)
431
+ const authFailure = responseAuthFailure
432
+ ?? (!text.trim() ? terminalTextError ?? terminalProviderAuthFailure('codex', stderr) : null)
433
+ if (authFailure) {
434
+ await finalizeError(authFailure, 0)
435
+ return
436
+ }
428
437
  finalized = true
429
438
  cleanup()
430
439
  cleanupImages()
@@ -614,13 +623,32 @@ export async function callCodexStreaming(
614
623
  }
615
624
 
616
625
  const text = extractCodexResponseText(event)
617
- if (text) emitText(text)
626
+ if (text) {
627
+ // Withhold only strongly machine-shaped provider auth output so
628
+ // credentials cannot flash through onChunk. Human sign-in instructions
629
+ // do not match the terminal classifier.
630
+ const authFailure = terminalProviderAuthFailure('codex', text)
631
+ if (authFailure) terminalTextError = authFailure
632
+ else emitText(text)
633
+ }
618
634
 
619
635
  const type = String(event?.type ?? '')
620
636
  if (type === 'turn.completed') {
621
637
  void finalize(fullText)
622
638
  } else if (type === 'turn.failed' || type === 'error') {
623
- finalizeError(`codex-bridge: ${event?.error ?? event?.message ?? 'unknown error'}`)
639
+ const rawProviderError = typeof event?.error === 'string' ? event.error
640
+ : typeof event?.error?.message === 'string' ? event.error.message
641
+ : typeof event?.message === 'string' ? event.message
642
+ : 'unknown error'
643
+ const structuredProviderError = event?.error && typeof event.error === 'object'
644
+ ? JSON.stringify(event.error)
645
+ : ''
646
+ const authenticationError = terminalProviderAuthFailure(
647
+ 'codex',
648
+ rawProviderError,
649
+ structuredProviderError,
650
+ )
651
+ finalizeError(authenticationError ?? `codex-bridge: ${rawProviderError}`)
624
652
  }
625
653
  }
626
654
 
@@ -636,7 +664,9 @@ export async function callCodexStreaming(
636
664
  try {
637
665
  handleEvent(JSON.parse(trimmed))
638
666
  } catch {
639
- // Ignore non-JSON status lines from older CLI builds.
667
+ // Older CLI builds can emit a terminal auth error as plain text while
668
+ // still exiting 0. Remember only the canonical classification.
669
+ terminalTextError ??= terminalProviderAuthFailure('codex', trimmed)
640
670
  }
641
671
  }
642
672
  })
@@ -651,10 +681,15 @@ export async function callCodexStreaming(
651
681
  try { handleEvent(JSON.parse(buffer.trim())) } catch { /* ignore */ }
652
682
  }
653
683
  if (finalized) return
654
- if (code !== 0) {
655
- finalizeError(`codex-bridge: exit ${code} — ${stderr.trim().slice(0, 240)}`, code)
656
- } else if (fullText) {
684
+ if (fullText) {
657
685
  void finalize(fullText)
686
+ return
687
+ }
688
+ const authFailure = terminalTextError ?? terminalProviderAuthFailure('codex', stderr, buffer)
689
+ if (authFailure) {
690
+ finalizeError(authFailure, code)
691
+ } else if (code !== 0) {
692
+ finalizeError(`codex-bridge: exit ${code} — ${stderr.trim().slice(0, 240)}`, code)
658
693
  } else {
659
694
  finalizeError('codex-bridge: Codex completed without a response.')
660
695
  }
@@ -0,0 +1,55 @@
1
+ export type CliProvider = 'claude' | 'codex'
2
+
3
+ const AUTH_CODE = /^(?:401|403|unauthori[sz]ed|forbidden|authentication_error|authorization_error|invalid_api_key|not_authenticated)$/i
4
+ // Match whole, terminal-looking provider failures only. Natural assistant
5
+ // answers such as "Please sign in to the customer portal, then..." must not
6
+ // be reinterpreted as failures merely because their first words mention auth.
7
+ const AUTH_FAILURE_PREFIX = /^(?:\s*(?:api\s+|http\s+|request\s+)?error(?:\[[^\]]+\])?\s*:\s*.{0,180}\b(?:401|403|unauthori[sz]ed|forbidden|authentication\s+(?:failed|required)|login\s+required|not\s+(?:logged|signed)\s+in)\b.*|\s*(?:http\s+)?(?:401|403)(?:\s+(?:unauthori[sz]ed|forbidden))?(?:\s*[:.\-]\s*.*)?|\s*(?:unauthori[sz]ed|forbidden)(?:\s*[:.\-]\s*.*)?|\s*(?:authentication|authorization)\s+(?:failed|required|error|missing|denied)(?:\s*[:.\-]\s*.*)?|\s*(?:login|sign[ -]?in)\s+(?:required|failed)(?:\s*[:.\-]\s*.*)?|\s*(?:you(?:'re| are)\s+)?not\s+(?:logged|signed)\s+in(?:\s*[:.\-]\s*.*)?|\s*please\s+run\s+(?:[`'"]?(?:claude|codex)[`'"]?\s+)?(?:[`'"]?\/?login[`'"]?|[`'"]?auth(?:enticate)?[`'"]?)\b.*)\s*$/i
8
+
9
+ function stripAnsi(value: string): string {
10
+ // eslint-disable-next-line no-control-regex
11
+ return value.replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
12
+ }
13
+
14
+ function structuredAuthFailure(value: unknown): boolean {
15
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false
16
+ const record = value as Record<string, unknown>
17
+ if (record.status === 401 || record.status === 403 || record.statusCode === 401 || record.statusCode === 403) return true
18
+ for (const key of ['code', 'type']) {
19
+ if (typeof record[key] === 'string' && AUTH_CODE.test(record[key])) return true
20
+ }
21
+ if (typeof record.error === 'string') {
22
+ return AUTH_CODE.test(record.error.trim()) || AUTH_FAILURE_PREFIX.test(record.error)
23
+ }
24
+ return structuredAuthFailure(record.error)
25
+ }
26
+
27
+ function looksLikeTerminalAuthFailure(value: unknown): boolean {
28
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
29
+ return structuredAuthFailure(value)
30
+ }
31
+ if (typeof value !== 'string') return false
32
+ const text = stripAnsi(value).trim()
33
+ if (!text || text.length > 2_000) return false
34
+ if (AUTH_FAILURE_PREFIX.test(text.replace(/\s+/g, ' '))) return true
35
+ if (!(text.startsWith('{') && text.endsWith('}'))) return false
36
+ try {
37
+ return structuredAuthFailure(JSON.parse(text))
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Some CLI versions report authentication failures as successful process
45
+ * output and exit 0. Detect only terminal, machine-shaped auth messages and
46
+ * return a canonical error that cannot echo credentials or provider output.
47
+ */
48
+ export function terminalProviderAuthFailure(
49
+ provider: CliProvider,
50
+ ...terminalValues: unknown[]
51
+ ): string | null {
52
+ return terminalValues.some(looksLikeTerminalAuthFailure)
53
+ ? `${provider}-bridge: authentication required.`
54
+ : null
55
+ }
@@ -21,6 +21,7 @@ import {
21
21
  countVocabTerms,
22
22
  } from './hallucination-filter.js'
23
23
  import { getOpenAIKey, tryGetOpenAIKey } from './openai-key.js'
24
+ import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from './transcription-policy.js'
24
25
 
25
26
  export { OpenAIWhisperBudgetExhaustedError, estimateAudioSeconds }
26
27
 
@@ -61,17 +62,30 @@ export class NoSpeechDetectedError extends Error {
61
62
  // ceiling (anything longer is a dictation, not a query — use meetings instead).
62
63
  const HQ_MAX_SECONDS = 60
63
64
 
65
+ function unavailableAfterLocalFailure(): TranscriptionUnavailableError | null {
66
+ const fallback = getTranscriptionPolicySnapshot()
67
+ if (fallback.openaiFallbackReady) return null
68
+ return fallback.openaiFallbackConfigured
69
+ ? new TranscriptionUnavailableError('openai_key_missing', 'Local transcription is unavailable and the explicitly configured OpenAI fallback has no key')
70
+ : new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
71
+ }
72
+
64
73
  /** Transcribe via OpenAI Whisper API (cloud fallback).
65
74
  * Budget-gated: throws OpenAIWhisperBudgetExhaustedError if today's $5 cap is spent.
66
75
  * Ledger only ticks on SUCCESSFUL responses so retries that never reach the API
67
76
  * aren't double-counted. */
68
77
  async function transcribeCloud(audioBuffer: Buffer): Promise<string> {
69
- assertOpenAIWhisperBudget()
78
+ // Defense in depth: every cloud chokepoint rechecks the explicit two-factor
79
+ // opt-in. A key alone is never authority to upload user audio.
80
+ if (!isOpenAIWhisperFallbackReady()) {
81
+ throw unavailableAfterLocalFailure()!
82
+ }
70
83
 
71
84
  if (!tryGetOpenAIKey()) {
72
- throw new TranscriptionUnavailableError('openai_key_missing', 'OpenAI key missing; local audio is preserved for retry')
85
+ throw new TranscriptionUnavailableError('openai_key_missing', 'OpenAI key missing; retry after local Whisper recovers')
73
86
  }
74
87
 
88
+ assertOpenAIWhisperBudget()
75
89
  const key = getOpenAIKey()
76
90
  const audioSeconds = estimateAudioSeconds(audioBuffer)
77
91
 
@@ -157,10 +171,14 @@ export async function transcribeAudioBuffer(
157
171
  backend = `fast-local-${result.backend}`
158
172
  actualQuality = 'fast'
159
173
  } catch (localErr: any) {
160
- if (policy === 'local-only') {
161
- throw new TranscriptionUnavailableError('local_asr_unavailable', `Local transcription unavailable; audio is preserved for retry (${localErr.message})`)
174
+ const unavailable = policy === 'local-only'
175
+ ? new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
176
+ : unavailableAfterLocalFailure()
177
+ if (unavailable) {
178
+ console.warn(`[transcribe] Fast local unavailable; preserving audio for retry: ${localErr.message}`)
179
+ throw unavailable
162
180
  }
163
- console.warn(`[transcribe] Fast local also failed, falling back to cloud: ${localErr.message}`)
181
+ console.warn(`[transcribe] Fast local also failed; using explicitly enabled OpenAI fallback: ${localErr.message}`)
164
182
  text = await transcribeCloud(audioBuffer)
165
183
  backend = 'cloud'
166
184
  actualQuality = 'cloud'
@@ -173,17 +191,24 @@ export async function transcribeAudioBuffer(
173
191
  backend = `fast-local-${result.backend}`
174
192
  actualQuality = 'fast'
175
193
  } catch (localErr: any) {
176
- if (policy === 'local-only') {
177
- throw new TranscriptionUnavailableError('local_asr_unavailable', `Local transcription unavailable; audio is preserved for retry (${localErr.message})`)
194
+ const unavailable = policy === 'local-only'
195
+ ? new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
196
+ : unavailableAfterLocalFailure()
197
+ if (unavailable) {
198
+ console.warn(`[transcribe] Local unavailable; preserving audio for retry: ${localErr.message}`)
199
+ throw unavailable
178
200
  }
179
- console.warn(`[transcribe] Local whisper failed (${getWhisperBackend()}), falling back to cloud: ${localErr.message}`)
201
+ console.warn(`[transcribe] Local whisper failed (${getWhisperBackend()}); using explicitly enabled OpenAI fallback: ${localErr.message}`)
180
202
  text = await transcribeCloud(audioBuffer)
181
203
  backend = 'cloud'
182
204
  actualQuality = 'cloud'
183
205
  }
184
206
  } else {
185
- if (policy === 'local-only') {
186
- throw new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription unavailable; audio is preserved for retry')
207
+ const unavailable = policy === 'local-only'
208
+ ? new TranscriptionUnavailableError('local_asr_unavailable', 'Local transcription is unavailable; retry after Whisper recovers')
209
+ : unavailableAfterLocalFailure()
210
+ if (unavailable) {
211
+ throw unavailable
187
212
  }
188
213
  text = await transcribeCloud(audioBuffer)
189
214
  backend = 'cloud'
@@ -0,0 +1,29 @@
1
+ import { getKeyStatus } from './openai-key.js'
2
+
3
+ /**
4
+ * Cloud transcription is a two-factor opt-in. Merely having an OpenAI key on
5
+ * the machine must never route voice away from local Whisper.
6
+ */
7
+ export const OPENAI_WHISPER_FALLBACK_ENV = 'COS_OPENAI_WHISPER_FALLBACK'
8
+
9
+ export interface TranscriptionPolicySnapshot {
10
+ mode: 'local-only' | 'local-then-openai'
11
+ localRequired: true
12
+ openaiFallbackConfigured: boolean
13
+ openaiFallbackReady: boolean
14
+ }
15
+
16
+ export function getTranscriptionPolicySnapshot(): TranscriptionPolicySnapshot {
17
+ const openaiFallbackConfigured = process.env[OPENAI_WHISPER_FALLBACK_ENV] === '1'
18
+ const openaiFallbackReady = openaiFallbackConfigured && getKeyStatus().hasKey
19
+ return {
20
+ mode: openaiFallbackReady ? 'local-then-openai' : 'local-only',
21
+ localRequired: true,
22
+ openaiFallbackConfigured,
23
+ openaiFallbackReady,
24
+ }
25
+ }
26
+
27
+ export function isOpenAIWhisperFallbackReady(): boolean {
28
+ return getTranscriptionPolicySnapshot().openaiFallbackReady
29
+ }
@@ -636,7 +636,7 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
636
636
  }
637
637
 
638
638
  if (!serverAvailable && (serverStarting || serverRestarting)) {
639
- throw new Error('whisper-server starting — use preserved/cloud fallback')
639
+ throw new Error('whisper-server starting — preserve audio for retry')
640
640
  }
641
641
 
642
642
  // Try whisper-server first (fastest: ~50-100ms, includes DTW word timestamps)
@@ -690,13 +690,15 @@ export async function transcribeLocal(audioBuffer: Buffer, context?: string, isQ
690
690
  restartWhisperServer()
691
691
  }
692
692
 
693
- // Throw so caller uses cloud fallback CLI is intentionally skipped for real-time
694
- throw new Error('whisper-server unavailable use cloud fallback')
693
+ // Throw so the caller applies the configured recovery policy. CLI is
694
+ // intentionally skipped for real-time transcription.
695
+ throw new Error('whisper-server unavailable — apply configured recovery policy')
695
696
  }
696
697
 
697
698
  /**
698
699
  * Auto-restart whisper-server after circuit breaker triggers.
699
- * Non-blocking — runs in background while callers use cloud fallback.
700
+ * Non-blocking — runs in background while callers preserve audio or apply the
701
+ * explicitly configured fallback policy.
700
702
  */
701
703
  async function restartWhisperServer(): Promise<void> {
702
704
  if (serverRestarting) return
@@ -734,7 +736,7 @@ async function restartWhisperServer(): Promise<void> {
734
736
  // Without this, the counter stays >= threshold but serverRestarting is false,
735
737
  // so every subsequent call would re-trigger restart in a tight loop
736
738
  serverConsecutiveFailures = 0
737
- console.error('[whisper-local] Server restart failed — reset counter, will retry after next 3 failures. Using cloud fallback.')
739
+ console.error('[whisper-local] Server restart failed — reset counter, will retry after next 3 failures. Caller recovery policy remains active.')
738
740
  }
739
741
  } catch (err: any) {
740
742
  serverConsecutiveFailures = 0 // Same reset — allow future retry cycle
@@ -0,0 +1,66 @@
1
+ import { Router } from 'express'
2
+ import { normalizeModelPreference, isClaudeModel } from '../../shared/model-preference.js'
3
+ import {
4
+ getClaudeRunConfig,
5
+ listClaudeRuns,
6
+ } from '../lib/claude-run-ledger.js'
7
+ import {
8
+ getCodexRunConfig,
9
+ listCodexRuns,
10
+ } from '../lib/codex-run-ledger.js'
11
+ import {
12
+ safeCliDebugResponse,
13
+ safeLegacyClaudeResponse,
14
+ safeLegacyCodexResponse,
15
+ } from '../lib/cli-debug-view.js'
16
+
17
+ export const cliDebugRouter = Router()
18
+
19
+ function boundedLimit(value: unknown): number {
20
+ const raw = Number(value ?? 20)
21
+ return Number.isFinite(raw) && raw > 0 ? Math.min(Math.floor(raw), 50) : 20
22
+ }
23
+
24
+ function optionalSessionId(value: unknown): string | undefined {
25
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined
26
+ }
27
+
28
+ function optionalClaudeModel(value: unknown) {
29
+ const raw = typeof value === 'string' ? normalizeModelPreference(value) : undefined
30
+ return raw && isClaudeModel(raw) ? raw : undefined
31
+ }
32
+
33
+ // Versioned public-safe view consumed by Recovery Center. Global /api auth
34
+ // protects this route; it must never be added to the unauthenticated allowlist.
35
+ cliDebugRouter.get('/cli/debug', (req, res) => {
36
+ const limit = boundedLimit(req.query.limit)
37
+ const sessionId = optionalSessionId(req.query.sessionId)
38
+ const model = optionalClaudeModel(req.query.model)
39
+ const claudeConfig = getClaudeRunConfig()
40
+ const codexConfig = getCodexRunConfig()
41
+ const claudeRuns = listClaudeRuns(limit, sessionId, model)
42
+ const codexRuns = listCodexRuns(limit, sessionId)
43
+ res.json(safeCliDebugResponse(
44
+ claudeConfig,
45
+ claudeRuns[0],
46
+ codexConfig,
47
+ codexRuns[0],
48
+ ))
49
+ })
50
+
51
+ // Build-210 compatibility. These legacy shapes retain only the fields the old
52
+ // panel can render safely; they do not expose raw ledger records.
53
+ cliDebugRouter.get('/cli/runs', (req, res) => {
54
+ const limit = boundedLimit(req.query.limit)
55
+ const sessionId = optionalSessionId(req.query.sessionId)
56
+ const model = optionalClaudeModel(req.query.model)
57
+ const config = getClaudeRunConfig()
58
+ res.json(safeLegacyClaudeResponse(config, listClaudeRuns(limit, sessionId, model)))
59
+ })
60
+
61
+ cliDebugRouter.get('/codex/runs', (req, res) => {
62
+ const limit = boundedLimit(req.query.limit)
63
+ const sessionId = optionalSessionId(req.query.sessionId)
64
+ const config = getCodexRunConfig()
65
+ res.json(safeLegacyCodexResponse(config, listCodexRuns(limit, sessionId)))
66
+ })
@@ -19,6 +19,8 @@ import { isMediaProcessingReady } from '../lib/image-safety.js'
19
19
  import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
20
20
  import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
21
21
  import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
22
+ import { getTranscriptionPolicySnapshot } from '../lib/transcription-policy.js'
23
+ import { CLI_DEBUG_CAPABILITY } from '../lib/cli-debug-view.js'
22
24
 
23
25
  export const healthRouter = Router()
24
26
 
@@ -52,7 +54,7 @@ function durableQueryJobStatus() {
52
54
  }
53
55
 
54
56
  healthRouter.get('/health', async (_req, res) => {
55
- const checks: Record<string, string | number> = {
57
+ const checks: Record<string, string | number | boolean> = {
56
58
  status: 'ok',
57
59
  mode: COS_MODE ? 'cos' : 'standalone',
58
60
  server: 'ok',
@@ -127,9 +129,11 @@ healthRouter.get('/health', async (_req, res) => {
127
129
 
128
130
  checks.silero_vad = isSileroAvailable() ? 'active' : 'disabled'
129
131
 
130
- // Include CLI session ID if available (pre-warmed or active)
132
+ // Health is unauthenticated. Publish only availability; the actual CLI
133
+ // session id is a resumable runtime handle and belongs on authenticated
134
+ // query/debug surfaces.
131
135
  const cliSid = getAvailableCliSessionId()
132
- if (cliSid) checks.cli_session_id = cliSid
136
+ checks.cli_session_available = Boolean(cliSid)
133
137
 
134
138
  // Feature summary for client capability detection.
135
139
  // v5.9.5 — voice.hasKey reflects the centralized resolver (env > saved file >
@@ -140,6 +144,13 @@ healthRouter.get('/health', async (_req, res) => {
140
144
  const keyStatus = getKeyStatus()
141
145
  const durableJobs = durableQueryJobStatus()
142
146
  const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
147
+ const transcription = getTranscriptionPolicySnapshot()
148
+ const recovery = {
149
+ status: false,
150
+ restartWhisper: false,
151
+ restartServer: false,
152
+ managed: false,
153
+ }
143
154
  const features = {
144
155
  claude: claudeAvailable,
145
156
  codex: codexAvailable,
@@ -154,6 +165,7 @@ healthRouter.get('/health', async (_req, res) => {
154
165
  durableQueryJobs: durableJobs.enabled,
155
166
  durableQueryJobsProtocol: durableJobs.protocolVersion,
156
167
  localFirstMeetings: localFirstMeetings !== null,
168
+ transcriptionPolicy: transcription.mode,
157
169
  }
158
170
  const voice = {
159
171
  hasKey: keyStatus.hasKey,
@@ -173,7 +185,12 @@ healthRouter.get('/health', async (_req, res) => {
173
185
  whisper_health,
174
186
  openai_whisper_budget,
175
187
  codex_models,
176
- capabilities: localFirstMeetings ? { localFirstMeetings } : {},
188
+ capabilities: {
189
+ transcription,
190
+ recovery,
191
+ cliDebug: CLI_DEBUG_CAPABILITY,
192
+ ...(localFirstMeetings ? { localFirstMeetings } : {}),
193
+ },
177
194
  // /api/health is intentionally unauthenticated for setup diagnostics.
178
195
  // Publish capability only; job counts, retention identities, subscriber
179
196
  // counts, and the storage fingerprint remain internal.
@@ -192,6 +209,7 @@ healthRouter.get('/models', async (req, res) => {
192
209
  const catalog = await getCodexModelCatalog(req.query.refresh === '1')
193
210
  const durableJobs = durableQueryJobStatus()
194
211
  const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
212
+ const transcription = getTranscriptionPolicySnapshot()
195
213
  res.json({
196
214
  ...catalog,
197
215
  serverInstanceId: getServerInstanceId(),
@@ -200,6 +218,14 @@ healthRouter.get('/models', async (req, res) => {
200
218
  enabled: durableJobs.enabled,
201
219
  protocolVersion: durableJobs.protocolVersion,
202
220
  },
221
+ transcription,
222
+ cliDebug: CLI_DEBUG_CAPABILITY,
223
+ recovery: {
224
+ status: false,
225
+ restartWhisper: false,
226
+ restartServer: false,
227
+ managed: false,
228
+ },
203
229
  ...(localFirstMeetings ? { localFirstMeetings } : {}),
204
230
  },
205
231
  })
@@ -1,5 +1,6 @@
1
1
  // POST /api/transcribe-stream — Streaming transcription for continuous meeting capture
2
- // Uses local Whisper (50ms) with OpenAI API fallback.
2
+ // Uses local Whisper. OpenAI API fallback is disabled by default and requires
3
+ // both COS_OPENAI_WHISPER_FALLBACK=1 and a configured key.
3
4
  // Streams speaker-labeled transcript chunks for live meeting capture.
4
5
 
5
6
  import { Router } from 'express'
@@ -10,11 +11,13 @@ import { resolve } from 'node:path'
10
11
  import { fileURLToPath } from 'node:url'
11
12
  import { getVocabulary, getOwnerName } from '../lib/profile.js'
12
13
  import { getOpenAIKey } from '../lib/openai-key.js'
14
+ import { getTranscriptionPolicySnapshot, isOpenAIWhisperFallbackReady } from '../lib/transcription-policy.js'
15
+ import { TranscriptionUnavailableError } from '../lib/transcribe-audio.js'
13
16
 
14
17
  const __dirname = fileURLToPath(new URL('.', import.meta.url))
15
18
  import { emitDisplay } from '../lib/display-bus.js'
16
19
  import { errMsg } from '../lib/utils.js'
17
- import { transcribeLocal, isWhisperLocalAvailable, applyCorrections, type WhisperWord } from '../lib/whisper-local.js'
20
+ import { transcribeLocal, applyCorrections, type WhisperWord } from '../lib/whisper-local.js'
18
21
  import { enhanceAudio } from '../lib/audio-enhance.js'
19
22
  import { trimSilence, isSileroAvailable } from '../lib/vad-silero.js'
20
23
  import { identifySpeaker, isEmbeddingAvailable, autoEnroll, getEmbeddingCount } from '../lib/speaker-embeddings.js'
@@ -1044,18 +1047,26 @@ function canonicalChunkResponse(
1044
1047
  }
1045
1048
 
1046
1049
  async function transcribeWithServerWhisper(audioBuffer: Buffer, whisperAudio: Buffer, whisperContext: string, isQuiet: boolean): Promise<{ text: string; words?: WhisperWord[]; backend: string }> {
1047
- if (isWhisperLocalAvailable()) {
1048
- try {
1049
- const result = await transcribeLocal(whisperAudio, whisperContext || undefined, isQuiet)
1050
- return { text: result.text, words: result.words, backend: `local-${result.backend}` }
1051
- } catch (err: unknown) {
1052
- console.warn(`[transcribe-stream] Local Whisper failed, falling back to cloud: ${errMsg(err)}`)
1053
- const text = await transcribeViaCloud(whisperAudio)
1054
- return { text, words: undefined, backend: 'cloud' }
1050
+ // The worker owns reconciliation of stale health. Always attempt local ASR
1051
+ // once; an availability snapshot must not divert meeting audio to cloud.
1052
+ try {
1053
+ const result = await transcribeLocal(whisperAudio, whisperContext || undefined, isQuiet)
1054
+ return { text: result.text, words: result.words, backend: `local-${result.backend}` }
1055
+ } catch (err: unknown) {
1056
+ if (!isOpenAIWhisperFallbackReady()) {
1057
+ const fallback = getTranscriptionPolicySnapshot()
1058
+ console.warn(`[transcribe-stream] Local Whisper unavailable; preserving chunk for retry: ${errMsg(err)}`)
1059
+ throw new TranscriptionUnavailableError(
1060
+ fallback.openaiFallbackConfigured ? 'openai_key_missing' : 'local_asr_unavailable',
1061
+ fallback.openaiFallbackConfigured
1062
+ ? 'Local transcription is unavailable; audio is preserved for retry and the configured OpenAI fallback has no key'
1063
+ : 'Local transcription is unavailable; audio is preserved for retry',
1064
+ )
1055
1065
  }
1066
+ console.warn(`[transcribe-stream] Local Whisper failed; using explicitly enabled OpenAI fallback: ${errMsg(err)}`)
1067
+ const text = await transcribeViaCloud(whisperAudio)
1068
+ return { text, words: undefined, backend: 'cloud' }
1056
1069
  }
1057
- const text = await transcribeViaCloud(whisperAudio)
1058
- return { text, words: undefined, backend: 'cloud' }
1059
1070
  }
1060
1071
 
1061
1072
  function identifyChunkSpeaker(audioBuffer: Buffer, sessionId: string, chunkIndex: number, clientSpeaker: string): { speaker: string; similarity: number } {
@@ -1315,6 +1326,14 @@ async function processStreamChunk(opts: {
1315
1326
  }
1316
1327
 
1317
1328
  function sendStreamError(res: { status: (code: number) => { json: (body: unknown) => unknown } }, err: unknown): unknown {
1329
+ if (err instanceof TranscriptionUnavailableError) {
1330
+ console.warn(`[transcribe-stream] ${err.message}`)
1331
+ return res.status(err.status).json({
1332
+ error: err.message,
1333
+ reason: err.reason,
1334
+ retryable: true,
1335
+ })
1336
+ }
1318
1337
  if (err instanceof OpenAIWhisperBudgetExhaustedError) {
1319
1338
  console.error(`[transcribe-stream] ${err.message}`)
1320
1339
  return res.status(503).json({
@@ -1487,9 +1506,18 @@ transcribeStreamRouter.post('/transcribe-stream/candidates', async (req, res) =>
1487
1506
  * A hung whisper-server + long meeting is the exact scenario this guards against —
1488
1507
  * chunks stay empty on budget-exceeded instead of silently billing per chunk. */
1489
1508
  async function transcribeViaCloud(audioBuffer: Buffer): Promise<string> {
1490
- assertOpenAIWhisperBudget()
1509
+ if (!isOpenAIWhisperFallbackReady()) {
1510
+ const fallback = getTranscriptionPolicySnapshot()
1511
+ throw new TranscriptionUnavailableError(
1512
+ fallback.openaiFallbackConfigured ? 'openai_key_missing' : 'local_asr_unavailable',
1513
+ fallback.openaiFallbackConfigured
1514
+ ? 'Local transcription is unavailable; audio is preserved for retry and the configured OpenAI fallback has no key'
1515
+ : 'Local transcription is unavailable; audio is preserved for retry',
1516
+ )
1517
+ }
1491
1518
 
1492
1519
  const key = getOpenAIKey()
1520
+ assertOpenAIWhisperBudget()
1493
1521
  const audioSeconds = estimateAudioSeconds(audioBuffer)
1494
1522
 
1495
1523
  const isWav = audioBuffer.length >= 4 && audioBuffer.toString('ascii', 0, 4) === 'RIFF'
@@ -9,6 +9,7 @@ import {
9
9
  resolveTranscribeMode,
10
10
  NoSpeechDetectedError,
11
11
  OpenAIWhisperBudgetExhaustedError,
12
+ TranscriptionUnavailableError,
12
13
  } from '../lib/transcribe-audio.js'
13
14
 
14
15
  export const transcribeRouter = Router()
@@ -50,6 +51,14 @@ transcribeRouter.post('/transcribe', async (req, res) => {
50
51
  cap_usd: err.capUsd,
51
52
  })
52
53
  }
54
+ if (err instanceof TranscriptionUnavailableError) {
55
+ console.warn(`[transcribe] ${err.message}`)
56
+ return res.status(err.status).json({
57
+ error: err.message,
58
+ reason: err.reason,
59
+ retryable: true,
60
+ })
61
+ }
53
62
  res.status(500).json({ error: err.message })
54
63
  }
55
64
  })