@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 +5 -1
- package/CHANGELOG.md +57 -0
- package/README.md +30 -0
- package/package.json +1 -1
- package/server/index.ts +48 -23
- package/server/lib/api-auth.ts +5 -1
- package/server/lib/audio-enhance.ts +15 -8
- package/server/lib/claude-bridge.ts +48 -18
- package/server/lib/claude-tool-access.ts +73 -7
- package/server/lib/codex-bridge.ts +60 -27
- package/server/lib/maintenance-lifecycle.ts +40 -0
- package/server/lib/prompt-draft-store.ts +1 -0
- package/server/lib/provider-process-lifecycle.ts +139 -0
- package/server/lib/provider-proof.ts +28 -10
- package/server/lib/transcribe-audio.ts +20 -3
- package/server/lib/whisper-local.ts +170 -20
- package/server/routes/health.ts +27 -3
- package/server/routes/openai-compat.ts +54 -27
- package/server/routes/prompt-drafts.ts +122 -9
- package/server/routes/provider-proof.ts +40 -2
- package/server/routes/transcribe.ts +9 -1
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).
|
|
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
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
|
-
|
|
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
|
-
|
|
391
|
-
// last-known-good snapshot if Codex is temporarily unavailable.
|
|
392
|
-
if (startupAdmissionsOpen) {
|
|
393
|
-
startCodexModelCatalogRefresh()
|
|
381
|
+
reportClaudeExtraToolConfiguration()
|
|
394
382
|
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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)
|
package/server/lib/api-auth.ts
CHANGED
|
@@ -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({
|
|
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
|
|
6
|
-
// highpass=f=80
|
|
7
|
-
//
|
|
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
|
|
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(
|
|
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',
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
-
|
|
963
|
-
|
|
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
|
-
|
|
973
|
-
|
|
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
|
|
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
|
-
):
|
|
20
|
+
): ClaudeExtraToolConfiguration {
|
|
12
21
|
const raw = env.COS_EXTRA_TOOLS ?? ''
|
|
13
22
|
const seen = new Set<string>()
|
|
14
|
-
const
|
|
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
|
|
17
|
-
if (!
|
|
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
|
-
|
|
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
|
|
87
|
+
return parsed
|
|
22
88
|
}
|
|
23
89
|
|
|
24
90
|
export function buildClaudeToolList(input: {
|