@gotcos/glasses-server 6.15.3 → 6.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -70,11 +70,15 @@ BIND_HOST=0.0.0.0
70
70
 
71
71
  # ── VOICE (optional) ────────────────────────────────────────────────────
72
72
  # Local transcription is FREE via whisper.cpp (brew install whisper-cpp; the
73
- # model auto-downloads on first run). Voice is local-only by default. Merely
73
+ # real-time turbo model auto-downloads on first run). Full HQ dictation also
74
+ # needs ggml-large-v3.bin as documented in README. Voice is local-only by default. Merely
74
75
  # configuring a key never uploads audio. To allow OpenAI Whisper only after a
75
76
  # local failure, set BOTH the exact opt-in and a key:
76
77
  # COS_OPENAI_WHISPER_FALLBACK=1
77
78
  # OPENAI_API_KEY=sk-...
79
+ # COS_HQ_SPECULATIVE_WARM=0 # disable background HQ warm
80
+ # COS_BATCH_LARGE_V3=0 # explicitly use turbo instead of full HQ
81
+ # COS_HQ_BEAM_INTERACTIVE=2 # interactive only; meetings stay at beam 5
78
82
 
79
83
  # Spoken reply playback defaults to local Kokoro on Apple silicon Macs. The
80
84
  # first run creates a private venv and downloads the model. Local mode fails
package/CHANGELOG.md CHANGED
@@ -1,3 +1,60 @@
1
+ ## 6.16.0
2
+
3
+ - **Truthful HQ results.** An HQ request is reported as HQ only when the full
4
+ local large-v3 decoder actually ran. Turbo, real-time server, long-audio, and
5
+ decode-error fallbacks now retain the requested mode while returning their
6
+ actual quality, backend, degradation flag, and bounded reason code.
7
+ - **HQ capability health.** `/api/health` and `/api/models` add a path-free
8
+ `capabilities.transcription.hq` block with availability, model, backend, and
9
+ a user-safe missing-prerequisite reason. Generic Whisper liveness no longer
10
+ implies that large-v3 HQ is installed.
11
+ - **Phone-visible fallback telemetry.** One-shot transcription and prompt-draft
12
+ finalize responses expose the same additive quality fields. Draft finalize
13
+ aggregates the records it actually used and reuses a successful degraded
14
+ warm result instead of paying for an identical second turbo decode.
15
+ - **Default unchanged.** Absent an explicit Fast request, prompt dictation still
16
+ requests HQ. `COS_HQ_SPECULATIVE_WARM=0` remains the immediate warm-path
17
+ rollback, and meeting batch beam/isolation behavior is unchanged.
18
+
19
+ ## 6.15.5
20
+
21
+ - **Speculative HQ warm (no EHPK).** While a prompt-draft chunk is acknowledged,
22
+ Fast warm still paints the HUD and, when Settings HQ is active (default), a
23
+ background large-v3 warm overwrites `warmTranscripts` with `actualQuality=hq`
24
+ under `local-only` (never OpenAI mid-speak). Finalize reuses that cache so
25
+ Render is dominated by the last unfinished chunk, not a cold full re-decode.
26
+ Killswitch: `COS_HQ_SPECULATIVE_WARM=0`.
27
+ - **Finalize dedupe.** In-flight HQ warm and finalize share one decode via a
28
+ purpose-agnostic job key so Render cannot start a second large-v3 while warm
29
+ is still running.
30
+ - **Interactive HQ latency knobs.** Interactive beam defaults to 2 (meetings
31
+ keep beam 5); short clips (<15s) use light ffmpeg enhance (highpass only).
32
+ Env: `COS_HQ_BEAM_INTERACTIVE`, `COS_HQ_ENHANCE_LIGHT_MAX_SEC`.
33
+
34
+ ## 6.15.4
35
+
36
+ - Start Whisper, Kokoro, model discovery, and local audio prerequisites while
37
+ a managed successor remains behind the authenticated maintenance gate. Start
38
+ durable recovery, session warming, snapshots, and media GC exactly once after
39
+ the controller releases admissions, so routine restarts cannot strand local
40
+ services or prematurely mutate durable state.
41
+ - Bound Whisper process and port inspection to two seconds per probe, move it
42
+ off the Node event loop, expose startup phase/error diagnostics, and only reap
43
+ processes whose executable is actually `whisper-server` with the COS model
44
+ and port signature.
45
+ - Migrate legacy bare MCP server selectors to `mcp__server__*` and warn once for
46
+ rejected local/invalid selectors without relaxing the safe tool boundary.
47
+ - Terminate abandoned, timed-out, start-failed, and ownership-lost provider
48
+ runs without orphaning tool subprocesses. Termination targets the detached
49
+ process group, escalates from SIGTERM to SIGKILL, and releases lifecycle
50
+ ownership only after Node observes process close. Control's provider proof
51
+ uses the same process-owned cancellation boundary.
52
+ - Expose persistent Whisper prerequisite state separately from batch-only CLI
53
+ availability, plus an additive readiness summary so HTTP-200 liveness cannot
54
+ hide a configured local subsystem failure.
55
+ - Keep the stable `unauthorized` error code while adding non-secret guidance to
56
+ copy and paste the complete pairing token from COS Control.
57
+
1
58
  ## 6.15.3
2
59
 
3
60
  - Make `COS_WORKDIR` the authoritative Claude, Codex, and Cursor workspace.
package/README.md CHANGED
@@ -106,6 +106,10 @@ range is the exact Tailscale/CGNAT allocation (`100.64.0.0/10`), not all of
106
106
  Whisper fallback is optional and requires both the exact
107
107
  `COS_OPENAI_WHISPER_FALLBACK=1` opt-in and a configured key; a key alone never
108
108
  uploads audio.
109
+ - HQ prompt dictation is requested by default; the phone's **Fast mode** switch
110
+ opts into turbo. Server 6.16.0 reports whether full local large-v3 actually
111
+ ran, and compatible companions alert once if an HQ request used Fast or
112
+ Cloud instead of silently claiming HQ.
109
113
  - Local-first spoken reply playback through Kokoro on Apple silicon. The first
110
114
  use creates a private Python environment and downloads its model without
111
115
  blocking the API. Selecting Local fails closed; `local_first` can fall back
@@ -137,6 +141,32 @@ Telegram activity export is disabled by default even when a private COS
137
141
  pipeline contains `.telegram_config.json`; enable it only with the explicit
138
142
  `COS_TELEGRAM_NOTIFICATIONS=1` opt-in.
139
143
 
144
+ ## HQ dictation
145
+
146
+ Prompt dictation defaults to HQ. The phone owns the preference: **Fast mode
147
+ OFF** requests HQ, and **Fast mode ON** requests turbo. The Mac performs all
148
+ decoding; the phone does not run Whisper.
149
+
150
+ The first server start downloads the real-time turbo model. True HQ additionally
151
+ requires the full `ggml-large-v3.bin` model (about 3.1 GB):
152
+
153
+ ```bash
154
+ mkdir -p "$HOME/.local/share/whisper-models"
155
+ curl -fL --progress-bar \
156
+ https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin \
157
+ -o "$HOME/.local/share/whisper-models/ggml-large-v3.bin.partial"
158
+ mv "$HOME/.local/share/whisper-models/ggml-large-v3.bin.partial" \
159
+ "$HOME/.local/share/whisper-models/ggml-large-v3.bin"
160
+ ```
161
+
162
+ Restart the server, then confirm
163
+ `capabilities.transcription.hq.hqAvailable: true` at `/api/health`. The response
164
+ does not expose local paths. If the CLI or model is unavailable, dictation stays
165
+ usable on Fast and reports the downgrade truthfully. Set
166
+ `COS_HQ_SPECULATIVE_WARM=0` to disable background HQ warm immediately; set
167
+ `COS_BATCH_LARGE_V3=0` to explicitly use turbo. Interactive HQ uses beam 2 by
168
+ default (`COS_HQ_BEAM_INTERACTIVE`); meeting batch remains beam 5.
169
+
140
170
  ## Run from source
141
171
 
142
172
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.15.3",
3
+ "version": "6.16.0",
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
@@ -69,12 +69,14 @@ import {
69
69
  } from './lib/network-policy.js'
70
70
  import { requireApiToken } from './lib/api-auth.js'
71
71
  import { isManagedRuntime } from './lib/managed-runtime.js'
72
+ import { reportClaudeExtraToolConfiguration } from './lib/claude-tool-access.js'
72
73
  import {
73
74
  acquireMaintenanceWork,
74
75
  maintenanceOperationCredentialsValid,
75
76
  MaintenanceLifecycleError,
76
77
  maintenanceAdmissionsOpen,
77
78
  maintenanceErrorPayload,
79
+ onMaintenanceAdmissionsOpen,
78
80
  } from './lib/maintenance-lifecycle.js'
79
81
 
80
82
  const app = express()
@@ -153,6 +155,7 @@ app.use('/api', (req, res, next) => {
153
155
  if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next()
154
156
  const lifecycleOwned = (req.path === '/query-jobs' && req.method === 'POST')
155
157
  || req.path === '/query'
158
+ || req.path === '/diagnostics/provider-proof'
156
159
  || req.path === '/transcribe'
157
160
  || req.path.startsWith('/transcribe-stream')
158
161
  || req.path === '/meeting/save'
@@ -326,20 +329,8 @@ listenRequiredServers(listeners).then(() => {
326
329
  console.log(`[COS API] Server instance: ${serverInstanceId}`)
327
330
  console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
328
331
 
329
- if (startupAdmissionsOpen) {
330
- void initQueryJobRuntime().then(health => {
331
- if (process.env.COS_DURABLE_QUERY_JOBS === '1') {
332
- console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
333
- } else {
334
- console.log('[COS API] Durable query jobs: disabled (set COS_DURABLE_QUERY_JOBS=1 to enable)')
335
- }
336
- }).catch(error => {
337
- // The store remains degraded and rejects admission. Legacy /api/query is
338
- // still mounted, so disabling the feature flag is an immediate rollback.
339
- console.error('[COS API] Durable query-job store unavailable:', error)
340
- })
341
- } else {
342
- console.log('[COS API] Startup maintenance gate is closed — durable recovery waits for controller adoption')
332
+ if (!startupAdmissionsOpen) {
333
+ console.log('[COS API] Startup maintenance gate is closed — durable recovery waits for controller release')
343
334
  }
344
335
 
345
336
  // Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
@@ -387,16 +378,20 @@ listenRequiredServers(listeners).then(() => {
387
378
  console.log(`[COS API] Codex mode: ${codexConfig.persistenceEnabled ? 'persistent' : 'ephemeral'} · ${codexConfig.reasoningEffort} · ${codexConfig.trustMode}`)
388
379
  console.log(`[COS API] Codex models (${codexConfig.catalogSource}): ${codexConfig.availableModels.map(item => `${item.displayName}=${item.model}`).join(' · ')}`)
389
380
  console.log(`[COS API] Codex workdir: ${codexConfig.cwd}`)
390
- // Refresh immediately and then periodically. The catalog module retains the
391
- // last-known-good snapshot if Codex is temporarily unavailable.
392
- if (startupAdmissionsOpen) {
393
- startCodexModelCatalogRefresh()
381
+ reportClaudeExtraToolConfiguration()
394
382
 
395
- if (COS_MODE) {
396
- initSessionCache()
397
- // Pre-warm context cache so first query doesn't wait for the pipeline
398
- prewarmContext()
399
- }
383
+ // These services do not admit or mutate user work. They must start while a
384
+ // managed successor is still behind the cross-boot gate so COS Control can
385
+ // prove the candidate before opening admissions. Keeping this idempotent also
386
+ // prevents a release notification from creating duplicate sidecars.
387
+ let proofSafeServicesStarted = false
388
+ const startProofSafeServices = () => {
389
+ if (proofSafeServicesStarted) return
390
+ proofSafeServicesStarted = true
391
+
392
+ // Refresh immediately and then periodically. The catalog retains the last
393
+ // known-good snapshot if Codex is temporarily unavailable.
394
+ startCodexModelCatalogRefresh()
400
395
  // Start local whisper-server (model stays in RAM for ~50ms transcription)
401
396
  startWhisperServer().catch(err => console.error('[startup] Whisper server error:', err))
402
397
  startLocalTtsServer().catch(err => console.error('[startup] Local TTS server error:', err))
@@ -406,6 +401,33 @@ listenRequiredServers(listeners).then(() => {
406
401
  // Initialize Silero VAD (silence trimming before Whisper) — fails soft if model absent
407
402
  const vadOk = initSileroVAD()
408
403
  console.log(`[startup] Silero VAD: ${vadOk ? 'active' : 'disabled (model not found)'}`)
404
+ }
405
+
406
+ // Durable recovery and state-mutating background work remain closed until an
407
+ // authenticated controller release. The lifecycle callback is delivered for
408
+ // both an already-open fresh boot and a later cross-boot release.
409
+ let admittedRuntimeStarted = false
410
+ const startAdmittedRuntime = () => {
411
+ if (admittedRuntimeStarted) return
412
+ admittedRuntimeStarted = true
413
+
414
+ void initQueryJobRuntime().then(health => {
415
+ if (process.env.COS_DURABLE_QUERY_JOBS === '1') {
416
+ console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
417
+ } else {
418
+ console.log('[COS API] Durable query jobs: disabled (set COS_DURABLE_QUERY_JOBS=1 to enable)')
419
+ }
420
+ }).catch(error => {
421
+ // The store remains degraded and rejects admission. Legacy /api/query is
422
+ // still mounted, so disabling the feature flag is an immediate rollback.
423
+ console.error('[COS API] Durable query-job store unavailable:', error)
424
+ })
425
+
426
+ if (COS_MODE) {
427
+ initSessionCache()
428
+ // Pre-warm context cache so first query doesn't wait for the pipeline
429
+ prewarmContext()
430
+ }
409
431
 
410
432
  // Pre-warm Claude only when installed so Codex-only startup stays quiet.
411
433
  if (claudeAvailable) {
@@ -418,6 +440,9 @@ listenRequiredServers(listeners).then(() => {
418
440
  // Durable media GC (staged/reserved expiry + generated-image content TTL).
419
441
  getMediaStore().startGC()
420
442
  }
443
+
444
+ startProofSafeServices()
445
+ onMaintenanceAdmissionsOpen(startAdmittedRuntime)
421
446
  }).catch((error: NodeJS.ErrnoException) => {
422
447
  console.error(`[COS API] Fatal listener startup: ${error.message}`)
423
448
  process.exit(error.code === 'EADDRINUSE' ? 75 : 74)
@@ -27,7 +27,11 @@ export function requireApiToken(apiToken: string): RequestHandler {
27
27
  return (req, res, next) => {
28
28
  if (isPublicApiRequest(req.method, req.path)) return next()
29
29
  if (!timingSafeTokenEqual(req.headers['x-cos-token'], apiToken)) {
30
- return res.status(401).json({ error: 'unauthorized' })
30
+ return res.status(401).json({
31
+ error: 'unauthorized',
32
+ reason: 'pairing_token_rejected',
33
+ message: 'In COS Control choose Copy Pairing Token, then paste the complete value into COS Glasses.',
34
+ })
31
35
  }
32
36
  next()
33
37
  }
@@ -2,10 +2,9 @@
2
2
  // Extracted so both batch (post-meeting) and one-shot (message query HQ) paths
3
3
  // can use the same filter chain.
4
4
  //
5
- // Filter chain:
6
- // highpass=f=80 kills low-freq rumble (HVAC, body noise, table thumps)
7
- // afftdn=nt=w FFT-based denoiser (white noise, fan hum)
8
- // loudnorm — EBU R128 loudness normalization (fixes quiet speakers)
5
+ // Filter chains:
6
+ // light — highpass=f=80 only (short interactive clips; lower latency)
7
+ // full highpass + afftdn + loudnorm (meetings / longer outdoor audio)
9
8
  //
10
9
  // Graceful: returns the original buffer if ffmpeg is missing, fails, or times out.
11
10
  // Callers should never crash a user request because enhancement couldn't run.
@@ -16,7 +15,10 @@ import { join } from 'node:path'
16
15
  import { randomUUID } from 'node:crypto'
17
16
 
18
17
  const FFMPEG_TIMEOUT_MS = 30_000
19
- const FILTER_CHAIN = 'highpass=f=80,afftdn=nt=w,loudnorm=I=-16:LRA=11:TP=-1.5'
18
+ const FILTER_FULL = 'highpass=f=80,afftdn=nt=w,loudnorm=I=-16:LRA=11:TP=-1.5'
19
+ const FILTER_LIGHT = 'highpass=f=80'
20
+
21
+ export type EnhanceProfile = 'light' | 'full'
20
22
 
21
23
  /**
22
24
  * Enhance raw audio (WAV/webm/etc) and return a 16kHz mono WAV buffer suitable
@@ -25,7 +27,12 @@ const FILTER_CHAIN = 'highpass=f=80,afftdn=nt=w,loudnorm=I=-16:LRA=11:TP=-1.5'
25
27
  *
26
28
  * Returns the ORIGINAL buffer unchanged on any failure. Logs the reason.
27
29
  */
28
- export async function enhanceAudio(audioBuffer: Buffer): Promise<Buffer> {
30
+ export async function enhanceAudio(
31
+ audioBuffer: Buffer,
32
+ opts: { profile?: EnhanceProfile } = {},
33
+ ): Promise<Buffer> {
34
+ const profile: EnhanceProfile = opts.profile === 'light' ? 'light' : 'full'
35
+ const filterChain = profile === 'light' ? FILTER_LIGHT : FILTER_FULL
29
36
  const id = randomUUID().slice(0, 8)
30
37
  const inputPath = join('/tmp', `cos-enhance-in-${id}`)
31
38
  const outputPath = join('/tmp', `cos-enhance-out-${id}.wav`)
@@ -36,7 +43,7 @@ export async function enhanceAudio(audioBuffer: Buffer): Promise<Buffer> {
36
43
  const enhanced = await new Promise<Buffer>((resolve, reject) => {
37
44
  const proc = spawn('ffmpeg', [
38
45
  '-i', inputPath,
39
- '-af', FILTER_CHAIN,
46
+ '-af', filterChain,
40
47
  '-ar', '16000',
41
48
  '-ac', '1',
42
49
  '-f', 'wav',
@@ -78,7 +85,7 @@ export async function enhanceAudio(audioBuffer: Buffer): Promise<Buffer> {
78
85
  return enhanced
79
86
  } catch (err: unknown) {
80
87
  const msg = err instanceof Error ? err.message : String(err)
81
- console.warn(`[audio-enhance] ffmpeg failed, returning original buffer: ${msg}`)
88
+ console.warn(`[audio-enhance] ffmpeg failed (${profile}), returning original buffer: ${msg}`)
82
89
  return audioBuffer
83
90
  } finally {
84
91
  try { unlinkSync(inputPath) } catch { /* ignore */ }
@@ -54,6 +54,7 @@ import {
54
54
  } from './claude-tool-access.js'
55
55
  import { terminalProviderAuthFailure } from './provider-terminal-error.js'
56
56
  import { claudePermissionArgs, getClaudeTrustMode } from './claude-permissions.js'
57
+ import { terminateProviderProcess } from './provider-process-lifecycle.js'
57
58
 
58
59
  // Inactivity = no stdout data for this long → kill (catches stalls)
59
60
  const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
@@ -553,12 +554,14 @@ export async function callClaudeStreaming(
553
554
  stdio: ['pipe', 'pipe', 'pipe'],
554
555
  env,
555
556
  cwd: cliCwd,
557
+ detached: true,
556
558
  })
557
559
 
558
560
  let fullText = ''
559
561
  let stderr = ''
560
562
  let buffer = ''
561
563
  let finalized = false // Guard against double onDone/onError
564
+ let terminationRequested = false // Forced terminal callbacks wait for confirmed process close
562
565
  let terminalTextError: string | null = null
563
566
  let lastActivity = Date.now() // Tracks last stdout data for inactivity timeout
564
567
  let receivedStreamEvents = false // Track if CLI emits stream_event (vs older assistant-only format)
@@ -722,10 +725,27 @@ export async function callClaudeStreaming(
722
725
  await callbacks.onError(msg)
723
726
  }
724
727
 
728
+ async function terminateForTerminal(
729
+ reason: string,
730
+ onClosed: (result: Awaited<ReturnType<typeof terminateProviderProcess>>) => void | Promise<void>,
731
+ ) {
732
+ if (finalized || terminationRequested) return
733
+ terminationRequested = true
734
+ cleanup()
735
+ const result = await terminateProviderProcess(proc)
736
+ if (!result.closed) {
737
+ console.error(`[claude-bridge] provider did not close after SIGKILL (${reason}); retaining lifecycle ownership`)
738
+ return
739
+ }
740
+ await onClosed(result)
741
+ }
742
+
725
743
  function handleAbort() {
726
- if (finalized) return
727
- proc.kill('SIGTERM')
728
- finalizeError('claude-bridge: client disconnected before Claude completed.', null, 'client_disconnected')
744
+ void terminateForTerminal('client disconnect', result => finalizeError(
745
+ 'claude-bridge: client disconnected before Claude completed.',
746
+ result.code,
747
+ 'client_disconnected',
748
+ ))
729
749
  }
730
750
 
731
751
  // ─── Heartbeat: emit phase status during silence ───
@@ -743,31 +763,35 @@ export async function callClaudeStreaming(
743
763
  // ─── Inactivity timeout: resets on any stdout data ───
744
764
 
745
765
  let inactivityTimer = setTimeout(() => {
746
- proc.kill('SIGTERM')
747
766
  const elapsed = Math.round((Date.now() - startTime) / 1000)
748
- finalizeError(`No output for ${inactivityMs / 1000}s (${elapsed}s total). Process killed.`)
767
+ void terminateForTerminal('inactivity timeout', result => finalizeError(
768
+ `No output for ${inactivityMs / 1000}s (${elapsed}s total). Process killed.`,
769
+ result.code,
770
+ ))
749
771
  }, inactivityMs)
750
772
 
751
773
  function resetInactivity() {
752
774
  lastActivity = Date.now()
753
775
  clearTimeout(inactivityTimer)
754
776
  inactivityTimer = setTimeout(() => {
755
- proc.kill('SIGTERM')
756
777
  const elapsed = Math.round((Date.now() - startTime) / 1000)
757
- finalizeError(`No output for ${inactivityMs / 1000}s (${elapsed}s total). Process killed.`)
778
+ void terminateForTerminal('inactivity timeout', result => finalizeError(
779
+ `No output for ${inactivityMs / 1000}s (${elapsed}s total). Process killed.`,
780
+ result.code,
781
+ ))
758
782
  }, inactivityMs)
759
783
  }
760
784
 
761
785
  // ─── Wall clock max: absolute cap ───
762
786
 
763
787
  const wallTimer = setTimeout(() => {
764
- proc.kill('SIGTERM')
765
- if (fullText) {
766
- // Got partial output — deliver what we have
767
- void finalize(fullText)
768
- } else {
769
- finalizeError(`Wall clock limit reached (${wallMax / 1000}s). Process killed.`)
770
- }
788
+ void terminateForTerminal('wall timeout', result => {
789
+ if (fullText) {
790
+ // Got partial output — deliver only after the process tree is closed.
791
+ return finalize(fullText)
792
+ }
793
+ return finalizeError(`Wall clock limit reached (${wallMax / 1000}s). Process killed.`, result.code)
794
+ })
771
795
  }, wallMax)
772
796
 
773
797
  function cleanup() {
@@ -898,6 +922,7 @@ export async function callClaudeStreaming(
898
922
  })
899
923
 
900
924
  proc.on('close', (code) => {
925
+ if (terminationRequested) return
901
926
  // Process any remaining buffer
902
927
  if (buffer.trim()) {
903
928
  try {
@@ -931,9 +956,11 @@ export async function callClaudeStreaming(
931
956
  })
932
957
 
933
958
  proc.on('error', (err) => {
959
+ if (terminationRequested) return
934
960
  finalizeError(`claude-bridge: ${err.message}`, null)
935
961
  })
936
962
  proc.stdin.on('error', (err) => {
963
+ if (terminationRequested) return
937
964
  finalizeError(`claude-bridge: stdin failed — ${err.message}`, null)
938
965
  })
939
966
 
@@ -959,8 +986,9 @@ export async function callClaudeStreaming(
959
986
  generation: options?.generation,
960
987
  })
961
988
  if (providerOwned === false) {
962
- proc.kill('SIGTERM')
963
- abandonLostDurableOwnership('claude-bridge: durable provider ownership was lost.')
989
+ await terminateForTerminal('provider ownership lost', () => {
990
+ abandonLostDurableOwnership('claude-bridge: durable provider ownership was lost.')
991
+ })
964
992
  return sid
965
993
  }
966
994
  if (finalized) return sid
@@ -969,8 +997,10 @@ export async function callClaudeStreaming(
969
997
  } catch (err) {
970
998
  const message = err instanceof Error ? err.message : String(err)
971
999
  if (!finalized) {
972
- proc.kill('SIGTERM')
973
- await finalizeError(`claude-bridge: provider start failed — ${message}`, null)
1000
+ await terminateForTerminal('provider start failure', result => finalizeError(
1001
+ `claude-bridge: provider start failed — ${message}`,
1002
+ result.code,
1003
+ ))
974
1004
  }
975
1005
  }
976
1006
 
@@ -5,20 +5,86 @@ import { resolve } from 'node:path'
5
5
  // server's built-in Web/Read tools remain code-owned, so a remotely reachable
6
6
  // glasses query cannot turn a local env typo into Bash/Write access.
7
7
  const MCP_SELECTOR = /^mcp__[A-Za-z0-9][A-Za-z0-9_.:@/-]*__[A-Za-z0-9*][A-Za-z0-9_.*:@/-]*$/
8
+ const LEGACY_MCP_SERVER_SELECTOR = /^mcp__(?!.*__)[A-Za-z0-9][A-Za-z0-9_.:@/-]*$/
8
9
 
9
- export function configuredClaudeExtraTools(
10
+ export interface ClaudeExtraToolConfiguration {
11
+ accepted: string[]
12
+ rejected: string[]
13
+ migrated: Array<{ from: string; to: string }>
14
+ }
15
+
16
+ let reportedConfiguration: string | null = null
17
+
18
+ export function parseClaudeExtraToolConfiguration(
10
19
  env: NodeJS.ProcessEnv = process.env,
11
- ): string[] {
20
+ ): ClaudeExtraToolConfiguration {
12
21
  const raw = env.COS_EXTRA_TOOLS ?? ''
13
22
  const seen = new Set<string>()
14
- const tools: string[] = []
23
+ const rejectedSeen = new Set<string>()
24
+ const accepted: string[] = []
25
+ const rejected: string[] = []
26
+ const migrated: Array<{ from: string; to: string }> = []
27
+
15
28
  for (const value of raw.split(',')) {
16
- const tool = value.trim()
17
- if (!tool || !MCP_SELECTOR.test(tool) || seen.has(tool)) continue
29
+ const original = value.trim()
30
+ if (!original) continue
31
+ const tool = LEGACY_MCP_SERVER_SELECTOR.test(original)
32
+ ? `${original}__*`
33
+ : original
34
+ if (!MCP_SELECTOR.test(tool)) {
35
+ if (!rejectedSeen.has(original)) {
36
+ rejectedSeen.add(original)
37
+ rejected.push(original)
38
+ }
39
+ continue
40
+ }
41
+ if (seen.has(tool)) continue
18
42
  seen.add(tool)
19
- tools.push(tool)
43
+ accepted.push(tool)
44
+ if (tool !== original) migrated.push({ from: original, to: tool })
45
+ }
46
+
47
+ return { accepted, rejected, migrated }
48
+ }
49
+
50
+ export function configuredClaudeExtraTools(
51
+ env: NodeJS.ProcessEnv = process.env,
52
+ ): string[] {
53
+ return parseClaudeExtraToolConfiguration(env).accepted
54
+ }
55
+
56
+ function safeSelectorList(values: string[]): string {
57
+ const visible = values.slice(0, 8).map(value => {
58
+ const sanitized = value.replace(/[^A-Za-z0-9_.:@/*=>-]/g, '?')
59
+ return sanitized.length > 64 ? `${sanitized.slice(0, 61)}...` : sanitized
60
+ })
61
+ return `${visible.join(', ')}${values.length > visible.length ? ` (+${values.length - visible.length} more)` : ''}`
62
+ }
63
+
64
+ /** Log migration/rejection once per process configuration, never per query. */
65
+ export function reportClaudeExtraToolConfiguration(
66
+ env: NodeJS.ProcessEnv = process.env,
67
+ ): ClaudeExtraToolConfiguration {
68
+ const raw = env.COS_EXTRA_TOOLS ?? ''
69
+ const parsed = parseClaudeExtraToolConfiguration(env)
70
+ if (!raw.trim() || reportedConfiguration === raw) return parsed
71
+ reportedConfiguration = raw
72
+
73
+ if (parsed.migrated.length > 0) {
74
+ console.warn(
75
+ '[claude-tools] Migrated legacy COS_EXTRA_TOOLS server selector(s): ' +
76
+ safeSelectorList(parsed.migrated.map(item => `${item.from}->${item.to}`)) +
77
+ '. Persist the full mcp__server__* form.',
78
+ )
79
+ }
80
+ if (parsed.rejected.length > 0) {
81
+ console.warn(
82
+ '[claude-tools] Ignored unsafe or invalid COS_EXTRA_TOOLS selector(s): ' +
83
+ safeSelectorList(parsed.rejected) +
84
+ '. Use mcp__server__tool or mcp__server__*. Read/Glob/Grep/Bash/Write cannot be enabled through this setting.',
85
+ )
20
86
  }
21
- return tools
87
+ return parsed
22
88
  }
23
89
 
24
90
  export function buildClaudeToolList(input: {