@gotcos/glasses-server 6.9.0 → 6.11.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 +8 -0
- package/CHANGELOG.md +50 -0
- package/README.md +19 -1
- package/package.json +1 -1
- package/server/index.ts +40 -11
- package/server/lib/claude-bridge.ts +115 -12
- package/server/lib/codex-bridge.ts +151 -28
- package/server/lib/conversation.ts +194 -1
- package/server/lib/local-first-meetings-contract.ts +53 -0
- package/server/lib/model-router.ts +6 -6
- package/server/lib/query-job-coordinator.ts +580 -0
- package/server/lib/query-job-feature.ts +20 -0
- package/server/lib/query-job-runtime.ts +255 -0
- package/server/lib/query-job-store.ts +1102 -0
- package/server/lib/query-job-types.ts +358 -0
- package/server/lib/run-output-images.ts +44 -0
- package/server/routes/health.ts +68 -2
- package/server/routes/meeting.ts +31 -0
- package/server/routes/query-jobs.ts +294 -0
- package/server/routes/transcribe-stream.ts +234 -72
package/.env.example
CHANGED
|
@@ -28,6 +28,14 @@ BIND_HOST=0.0.0.0
|
|
|
28
28
|
# server after Wi-Fi/Tailscale changes and process restarts.
|
|
29
29
|
# COS_SERVER_INSTANCE_ID_PATH=/path/to/server-instance-id
|
|
30
30
|
|
|
31
|
+
# Build 204+ can opt into server-owned durable query jobs. The server fsyncs an
|
|
32
|
+
# accepted prompt before returning 202, then keeps provider work running if the
|
|
33
|
+
# phone backgrounds, reloads, or changes networks. Reopening COS reattaches to
|
|
34
|
+
# the same job. Set to 0 (or remove) to send NEW prompts through the legacy
|
|
35
|
+
# streaming route; already accepted durable jobs remain readable/cancellable
|
|
36
|
+
# and drain safely.
|
|
37
|
+
# COS_DURABLE_QUERY_JOBS=1
|
|
38
|
+
|
|
31
39
|
# ── THE LLM (chat) ──────────────────────────────────────────────────────
|
|
32
40
|
# Chat runs through your LOCAL agent CLI — NOT an API key:
|
|
33
41
|
# Opus / Fable / Sonnet -> Claude Code CLI (https://claude.ai/download, then `claude login`)
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,55 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 6.11.0
|
|
4
|
+
|
|
5
|
+
Local-first meeting recovery for COS Glasses build 209+.
|
|
6
|
+
|
|
7
|
+
- **Record through network loss.** The server advertises a versioned
|
|
8
|
+
`localFirstMeetings` capability with its stable instance ID. Compatible
|
|
9
|
+
clients can keep audio locally, reconnect to the same server, and reconcile
|
|
10
|
+
the exact sparse set of chunks it durably received.
|
|
11
|
+
- **Durable means acknowledged.** Raw meeting WAVs and the received-index
|
|
12
|
+
ledger are committed atomically before a chunk receives success. Storage
|
|
13
|
+
failures return typed retryable errors; capacity exhaustion returns `507`
|
|
14
|
+
instead of silently discarding audio.
|
|
15
|
+
- **Long meetings stay alive.** Active-session retention is measured from the
|
|
16
|
+
last durable activity, not the meeting start time, so recordings longer than
|
|
17
|
+
four hours are not mistaken for abandoned sessions.
|
|
18
|
+
- **Safe reconnect and close.** Authenticated session-status responses expose
|
|
19
|
+
exact compressed receive ranges, retention, and closed/saved state. Durable
|
|
20
|
+
tombstones prevent a late or replaying client from recreating a completed
|
|
21
|
+
meeting after a restart.
|
|
22
|
+
- **Idempotent finalization.** Repeating `POST /api/meeting/save` for an already
|
|
23
|
+
saved session returns the original versioned receipt and filename without
|
|
24
|
+
creating a second meeting.
|
|
25
|
+
- **Backward compatible.** Existing live transcription, meeting save, prompt
|
|
26
|
+
recovery, durable queries, and older clients retain their prior routes and
|
|
27
|
+
fields. The new capability, receipt fields, and status route are additive.
|
|
28
|
+
|
|
29
|
+
## 6.10.0
|
|
30
|
+
|
|
31
|
+
Opt-in server-owned durable query jobs for COS Glasses build 204+.
|
|
32
|
+
|
|
33
|
+
- **Accepted means durable.** With `COS_DURABLE_QUERY_JOBS=1`, the server
|
|
34
|
+
appends and fsyncs an immutable job before returning 202. Provider execution
|
|
35
|
+
is no longer owned by the phone's current request, WebView, or SSE subscriber.
|
|
36
|
+
- **Reconnect without duplication.** The client can recover an ambiguous
|
|
37
|
+
admission by its stable client job ID, replay ordered bounded events, and
|
|
38
|
+
acknowledge one terminal projection idempotently after message, queue,
|
|
39
|
+
counter, and session state are durable on the phone.
|
|
40
|
+
- **Crash and cancellation fences.** Provider ownership is persisted before
|
|
41
|
+
input, session-scoped leases prevent overlapping orphan continuations after a
|
|
42
|
+
restart, cancellation is durable, and answer-ready ownership gates
|
|
43
|
+
conversation, image, notification, and Done side effects.
|
|
44
|
+
- **Private bounded storage.** The append-only journal uses private directory
|
|
45
|
+
and file modes, repairs torn tails, bounds progress/activity payloads, and
|
|
46
|
+
retains terminal jobs for exactly seven days.
|
|
47
|
+
- **Safe rollout and rollback.** The health capability advertises exact protocol
|
|
48
|
+
version 1 only when configured and the store is ready. Removing the flag
|
|
49
|
+
blocks new durable admissions but leaves GET/events/cancel/ack available so
|
|
50
|
+
accepted jobs drain; legacy queries, first turns, handoffs, and older clients
|
|
51
|
+
remain unchanged.
|
|
52
|
+
|
|
3
53
|
## 6.9.0
|
|
4
54
|
|
|
5
55
|
Live recoverable prompt transcription for COS Glasses builds 200+.
|
package/README.md
CHANGED
|
@@ -56,6 +56,10 @@ The built-in IP allowlist blocks public-internet traffic regardless.
|
|
|
56
56
|
## What it does
|
|
57
57
|
|
|
58
58
|
- Ask anything, get a streamed answer on the lens (`/api/query`, `/v1/chat/completions`)
|
|
59
|
+
- With COS Glasses build 204+, opt into server-owned durable queries with
|
|
60
|
+
`COS_DURABLE_QUERY_JOBS=1`: accepted work survives phone backgrounding,
|
|
61
|
+
WebView reloads, and network handoffs, then reattaches without duplicate work
|
|
62
|
+
or duplicate replies
|
|
59
63
|
- Choose Opus, Fable, Sonnet, GPT Frontier, or GPT Balanced plus High, Extra
|
|
60
64
|
High, Max, or Ultracode effort; optional redacted tool activity streams only
|
|
61
65
|
to the authenticated query that requested it
|
|
@@ -68,6 +72,10 @@ The built-in IP allowlist blocks public-internet traffic regardless.
|
|
|
68
72
|
compatible app builds, their warm transcript also appears live while speaking;
|
|
69
73
|
final HQ transcription remains authoritative.
|
|
70
74
|
- Live voice capture + transcription during meetings
|
|
75
|
+
- With COS Glasses build 209+ and server 6.11.0+, meetings continue recording
|
|
76
|
+
locally through a network interruption. Reconnecting reconciles the exact
|
|
77
|
+
chunks already stored by the Mac, uploads only missing audio, and finalizes
|
|
78
|
+
through an idempotent save receipt without duplicating the meeting.
|
|
71
79
|
- Local whisper.cpp transcription (free) with OpenAI fallback (optional)
|
|
72
80
|
- Tasks / calendar / people context **if** you run the
|
|
73
81
|
[COS Starter Kit](https://www.gotcos.com) (`COS_SCRIPTS_DIR`); otherwise it is
|
|
@@ -78,7 +86,8 @@ The built-in IP allowlist blocks public-internet traffic regardless.
|
|
|
78
86
|
Config lives at `~/.cos-glasses/.env` (created on first run). Every key is
|
|
79
87
|
optional except an installed CLI. Highlights: `BIND_HOST`, `PORT`,
|
|
80
88
|
`COS_API_TOKEN` (auto if unset), `OPENAI_API_KEY` (cloud voice fallback),
|
|
81
|
-
`COS_SCRIPTS_DIR` (full pipeline),
|
|
89
|
+
`COS_SCRIPTS_DIR` (full pipeline), `COS_DURABLE_QUERY_JOBS=1` (build 204+
|
|
90
|
+
server-owned query recovery), and `COS_MEDIA_ROOT` (optional image-store
|
|
82
91
|
location; default `~/.cos-glasses/data/media`). Your name + transcription vocabulary live in
|
|
83
92
|
`~/.cos-glasses/.cos-profile.json` (see `.cos-profile.example.json`).
|
|
84
93
|
|
|
@@ -99,6 +108,15 @@ BIND_HOST=0.0.0.0 npm run start:server
|
|
|
99
108
|
- *Voice getting billed?* — install `whisper-cpp` for free local transcription.
|
|
100
109
|
- *Photos unavailable?* — install `ffmpeg`, restart the server, and confirm `/api/health` reports `features.mediaProcessingReady: true`.
|
|
101
110
|
- *Prompt recovery unavailable?* — update with `npx @gotcos/glasses-server@latest`, then confirm `/api/health` reports `features.promptRecovery: true`.
|
|
111
|
+
- *Durable query recovery unavailable?* — build 204+ requires server 6.10.0+ and
|
|
112
|
+
`COS_DURABLE_QUERY_JOBS=1`. Restart once, then confirm `/api/health` reports
|
|
113
|
+
`features.durableQueryJobs: true`, protocol `1`, and state `ready`. To roll
|
|
114
|
+
back, remove the flag; accepted jobs still drain while new prompts use legacy streaming.
|
|
115
|
+
- *Offline meeting recovery unavailable?* — build 209+ requires server 6.11.0+.
|
|
116
|
+
Restart once, then confirm `/api/health` reports
|
|
117
|
+
`features.localFirstMeetings: true` and
|
|
118
|
+
`capabilities.localFirstMeetings.protocolVersion: 1`. Older app builds keep
|
|
119
|
+
using their existing live-transcription and meeting-save paths.
|
|
102
120
|
|
|
103
121
|
## License
|
|
104
122
|
|
package/package.json
CHANGED
package/server/index.ts
CHANGED
|
@@ -43,6 +43,13 @@ import { getMediaStore } from './lib/media-store.js'
|
|
|
43
43
|
import { listenRequiredServers, type RequiredListener } from './lib/listener-startup.js'
|
|
44
44
|
import { serverMetrics } from './lib/server-metrics.js'
|
|
45
45
|
import { initializeServerInstanceId } from './lib/server-instance-id.js'
|
|
46
|
+
import { createQueryJobsRouter } from './routes/query-jobs.js'
|
|
47
|
+
import {
|
|
48
|
+
initQueryJobRuntime,
|
|
49
|
+
preparePublicDurableQueryAdmission,
|
|
50
|
+
queryJobCoordinator,
|
|
51
|
+
shutdownQueryJobRuntime,
|
|
52
|
+
} from './lib/query-job-runtime.js'
|
|
46
53
|
|
|
47
54
|
const app = express()
|
|
48
55
|
const PORT = parseInt(process.env.PORT ?? '3141', 10)
|
|
@@ -139,6 +146,9 @@ app.use((_req, _res, next) => {
|
|
|
139
146
|
// API routes
|
|
140
147
|
app.use('/api', healthRouter)
|
|
141
148
|
app.use('/api', diagRouter)
|
|
149
|
+
app.use('/api', createQueryJobsRouter(queryJobCoordinator, {
|
|
150
|
+
prepareAdmission: preparePublicDurableQueryAdmission,
|
|
151
|
+
}))
|
|
142
152
|
app.use('/api', queryRouter)
|
|
143
153
|
app.use('/api', transcribeRouter)
|
|
144
154
|
app.use('/api', displayRouter)
|
|
@@ -173,21 +183,28 @@ app.get('/', (_req, res) => {
|
|
|
173
183
|
)
|
|
174
184
|
})
|
|
175
185
|
|
|
176
|
-
// Graceful shutdown
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
186
|
+
// Graceful shutdown persists an interrupted terminal before provider abort.
|
|
187
|
+
// The bounded force-exit keeps service managers from hanging forever on a
|
|
188
|
+
// broken disk while retaining the previous session/catalog/Whisper cleanup.
|
|
189
|
+
let gracefulShutdownStarted = false
|
|
190
|
+
async function gracefulShutdown(): Promise<void> {
|
|
191
|
+
if (gracefulShutdownStarted) return
|
|
192
|
+
gracefulShutdownStarted = true
|
|
193
|
+
const forceExit = setTimeout(() => process.exit(1), 8_000)
|
|
194
|
+
forceExit.unref?.()
|
|
195
|
+
try {
|
|
196
|
+
await shutdownQueryJobRuntime('server_shutdown')
|
|
197
|
+
} catch (error) {
|
|
198
|
+
console.error('[query-jobs] graceful interruption failed:', error)
|
|
199
|
+
}
|
|
180
200
|
try { logActiveSessionsOnShutdown() } catch { /* best-effort flush */ }
|
|
181
201
|
stopCodexModelCatalogRefresh()
|
|
182
202
|
stopWhisperServer()
|
|
203
|
+
clearTimeout(forceExit)
|
|
183
204
|
process.exit(0)
|
|
184
|
-
}
|
|
185
|
-
process.on('
|
|
186
|
-
|
|
187
|
-
stopCodexModelCatalogRefresh()
|
|
188
|
-
stopWhisperServer()
|
|
189
|
-
process.exit(0)
|
|
190
|
-
})
|
|
205
|
+
}
|
|
206
|
+
process.on('SIGTERM', () => { void gracefulShutdown() })
|
|
207
|
+
process.on('SIGINT', () => { void gracefulShutdown() })
|
|
191
208
|
|
|
192
209
|
// Crash protection for runtime work. Listener failures are handled separately
|
|
193
210
|
// and exit immediately so a supervisor can restart a clean, unified process.
|
|
@@ -228,6 +245,18 @@ listenRequiredServers(listeners).then(() => {
|
|
|
228
245
|
console.log(`[COS API] Server instance: ${serverInstanceId}`)
|
|
229
246
|
console.log(`[COS API] Mode: ${COS_MODE ? 'COS pipeline' : 'standalone'}`)
|
|
230
247
|
|
|
248
|
+
void initQueryJobRuntime().then(health => {
|
|
249
|
+
if (process.env.COS_DURABLE_QUERY_JOBS === '1') {
|
|
250
|
+
console.log(`[COS API] Durable query jobs: ${health.store.state} · ${health.store.retainedIdentities} retained`)
|
|
251
|
+
} else {
|
|
252
|
+
console.log('[COS API] Durable query jobs: disabled (set COS_DURABLE_QUERY_JOBS=1 to enable)')
|
|
253
|
+
}
|
|
254
|
+
}).catch(error => {
|
|
255
|
+
// The store remains degraded and rejects admission. Legacy /api/query is
|
|
256
|
+
// still mounted, so disabling the feature flag is an immediate rollback.
|
|
257
|
+
console.error('[COS API] Durable query-job store unavailable:', error)
|
|
258
|
+
})
|
|
259
|
+
|
|
231
260
|
// Print ADDRESSES THE PHONE CAN ACTUALLY REACH. The bind address (0.0.0.0) is
|
|
232
261
|
// not paste-able — enumerate real interfaces and label the Tailscale one.
|
|
233
262
|
try {
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
type ActivityPreviewLine,
|
|
39
39
|
} from './activity-preview.js'
|
|
40
40
|
import {
|
|
41
|
+
collectRunOutputImagesBounded,
|
|
41
42
|
createRunOutputImagePublisher,
|
|
42
43
|
isRunOutputImagePublisherCommand,
|
|
43
44
|
type RunOutputImageCollectionStats,
|
|
@@ -291,19 +292,34 @@ function isExtendedQuery(query: string): boolean {
|
|
|
291
292
|
}
|
|
292
293
|
|
|
293
294
|
export interface ModelRunMetadata {
|
|
295
|
+
claudeRunId?: string
|
|
296
|
+
clientJobId?: string
|
|
297
|
+
generation?: number
|
|
294
298
|
codexRunId?: string
|
|
295
299
|
codexThreadId?: string
|
|
296
300
|
outputAttachments?: MediaAttachmentRef[]
|
|
297
301
|
outputImageStats?: RunOutputImageCollectionStats
|
|
298
302
|
}
|
|
299
303
|
|
|
304
|
+
/** Public-safe provider launch metadata for durable job coordination. It
|
|
305
|
+
* deliberately exposes no ChildProcess object, kill handle, paths, or env. */
|
|
306
|
+
export interface ProviderProcessMetadata {
|
|
307
|
+
provider: 'claude' | 'codex'
|
|
308
|
+
runId: string
|
|
309
|
+
pid?: number
|
|
310
|
+
clientJobId?: string
|
|
311
|
+
generation?: number
|
|
312
|
+
}
|
|
313
|
+
|
|
300
314
|
export interface StreamCallbacks {
|
|
301
315
|
onChunk: (text: string) => void
|
|
302
|
-
|
|
303
|
-
|
|
316
|
+
onAnswerReady?: (fullText: string) => boolean | void | Promise<boolean | void>
|
|
317
|
+
onDone: (fullText: string, model: ModelPreference, cliSessionId?: string, metadata?: ModelRunMetadata) => boolean | void | Promise<boolean | void>
|
|
318
|
+
onError: (error: string) => void | Promise<void>
|
|
304
319
|
onToolStatus?: (toolName: string) => void
|
|
305
320
|
onActivityLine?: (line: ActivityPreviewLine) => void
|
|
306
321
|
onStart?: (model: ModelPreference, sessionId: string, cliSessionId?: string, metadata?: ModelRunMetadata) => void
|
|
322
|
+
onProviderProcess?: (metadata: ProviderProcessMetadata) => boolean | void | Promise<boolean | void>
|
|
307
323
|
}
|
|
308
324
|
|
|
309
325
|
/** Claude CLI can emit `subtype: success` with `is_error: true`; the boolean
|
|
@@ -336,6 +352,10 @@ export interface CallOptions {
|
|
|
336
352
|
lightweight?: boolean // Skip async context fetch — use cached context instantly (G2 speed path)
|
|
337
353
|
abortSignal?: AbortSignal
|
|
338
354
|
effort?: EffortPreference
|
|
355
|
+
clientJobId?: string
|
|
356
|
+
generation?: number
|
|
357
|
+
/** Durable coordinator already owns the per-session provider lease. */
|
|
358
|
+
sessionLockHeld?: boolean
|
|
339
359
|
}
|
|
340
360
|
|
|
341
361
|
export async function callClaudeStreaming(
|
|
@@ -367,7 +387,10 @@ export async function callClaudeStreaming(
|
|
|
367
387
|
// Pass existing CLI session ID if resuming (new sessions get it after first result)
|
|
368
388
|
const resolvedCliKey = cliSessionKey(sid, resolvedModel)
|
|
369
389
|
let existingCliSession = cliSessionMap.get(resolvedCliKey)
|
|
370
|
-
callbacks.onStart?.(resolvedModel, sid, existingCliSession
|
|
390
|
+
callbacks.onStart?.(resolvedModel, sid, existingCliSession, {
|
|
391
|
+
clientJobId: options?.clientJobId,
|
|
392
|
+
generation: options?.generation,
|
|
393
|
+
})
|
|
371
394
|
|
|
372
395
|
// Phase: context loading (skipped in lightweight mode)
|
|
373
396
|
let phase: Phase = 'context'
|
|
@@ -414,7 +437,18 @@ export async function callClaudeStreaming(
|
|
|
414
437
|
// Record user message (with [Photo]/[N Photos] prefix for vision queries)
|
|
415
438
|
const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
|
|
416
439
|
const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
|
|
417
|
-
const
|
|
440
|
+
const exchangeProvenance = {
|
|
441
|
+
clientJobId: options?.clientJobId,
|
|
442
|
+
generation: options?.generation,
|
|
443
|
+
}
|
|
444
|
+
const pendingUserExchange = addExchange(
|
|
445
|
+
sid,
|
|
446
|
+
'user',
|
|
447
|
+
historyQuery,
|
|
448
|
+
globalMsgNum,
|
|
449
|
+
undefined,
|
|
450
|
+
exchangeProvenance,
|
|
451
|
+
)
|
|
418
452
|
|
|
419
453
|
// Vision queries need the Read tool to see the image files
|
|
420
454
|
const baseTools = imagePaths.length > 0 ? 'WebSearch,WebFetch,Read' : 'WebSearch,WebFetch'
|
|
@@ -519,15 +553,62 @@ export async function callClaudeStreaming(
|
|
|
519
553
|
cleanupModelImageInputs(imageInputs)
|
|
520
554
|
}
|
|
521
555
|
|
|
556
|
+
function abandonLostDurableOwnership(message: string) {
|
|
557
|
+
finalized = true
|
|
558
|
+
cleanup()
|
|
559
|
+
cleanupImages()
|
|
560
|
+
outputImagePublisher?.cleanup()
|
|
561
|
+
removeExchange(sid, pendingUserExchange)
|
|
562
|
+
if (cliSessionMap.delete(resolvedCliKey)) scheduleCliSessionSave()
|
|
563
|
+
finishClaudeRun(run.runId, {
|
|
564
|
+
status: 'failed',
|
|
565
|
+
startedAtMs: startTime,
|
|
566
|
+
error: message,
|
|
567
|
+
exitCode: null,
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
|
|
522
571
|
async function finalize(text: string) {
|
|
523
572
|
if (finalized) return
|
|
524
573
|
finalized = true
|
|
525
574
|
cleanup()
|
|
526
575
|
cleanupImages()
|
|
527
576
|
|
|
528
|
-
//
|
|
529
|
-
//
|
|
530
|
-
|
|
577
|
+
// The coordinator persists the final provider text before conversation
|
|
578
|
+
// mutation, condensation, or output-image normalization can stall/crash.
|
|
579
|
+
try {
|
|
580
|
+
const answerOwned = await callbacks.onAnswerReady?.(text)
|
|
581
|
+
if (answerOwned === false) {
|
|
582
|
+
abandonLostDurableOwnership('claude-bridge: durable answer ownership was lost.')
|
|
583
|
+
return
|
|
584
|
+
}
|
|
585
|
+
} catch (error) {
|
|
586
|
+
console.error('[claude-bridge] durable answer barrier failed:', error)
|
|
587
|
+
outputImagePublisher?.cleanup()
|
|
588
|
+
removeExchange(sid, pendingUserExchange)
|
|
589
|
+
if (cliSessionMap.delete(resolvedCliKey)) scheduleCliSessionSave()
|
|
590
|
+
finishClaudeRun(run.runId, {
|
|
591
|
+
status: 'failed',
|
|
592
|
+
startedAtMs: startTime,
|
|
593
|
+
error: 'claude-bridge: durable answer persistence failed.',
|
|
594
|
+
exitCode: null,
|
|
595
|
+
})
|
|
596
|
+
try {
|
|
597
|
+
await callbacks.onError('claude-bridge: durable answer persistence failed.')
|
|
598
|
+
} catch (callbackError) {
|
|
599
|
+
console.error('[claude-bridge] durable barrier error callback failed:', callbackError)
|
|
600
|
+
}
|
|
601
|
+
return
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const assistantExchange = addExchange(
|
|
605
|
+
sid,
|
|
606
|
+
'assistant',
|
|
607
|
+
text,
|
|
608
|
+
globalMsgNum,
|
|
609
|
+
undefined,
|
|
610
|
+
exchangeProvenance,
|
|
611
|
+
)
|
|
531
612
|
if (imagePaths.length > 0) {
|
|
532
613
|
replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
|
|
533
614
|
}
|
|
@@ -539,7 +620,9 @@ export async function callClaudeStreaming(
|
|
|
539
620
|
const preparingHeartbeat = setInterval(() => callbacks.onToolStatus?.('Preparing images...'), HEARTBEAT_INTERVAL_MS)
|
|
540
621
|
preparingHeartbeat.unref?.()
|
|
541
622
|
try {
|
|
542
|
-
outputAttachments = await outputImagePublisher
|
|
623
|
+
outputAttachments = await collectRunOutputImagesBounded(outputImagePublisher, {
|
|
624
|
+
signal: options?.abortSignal,
|
|
625
|
+
})
|
|
543
626
|
} catch (err) {
|
|
544
627
|
console.error('[claude-bridge] output image collection failed:', err)
|
|
545
628
|
} finally {
|
|
@@ -576,10 +659,14 @@ export async function callClaudeStreaming(
|
|
|
576
659
|
exitCode: 0,
|
|
577
660
|
})
|
|
578
661
|
|
|
579
|
-
callbacks.onDone(text, resolvedModel, cliSessionMap.get(resolvedCliKey), {
|
|
662
|
+
const terminalOwned = await callbacks.onDone(text, resolvedModel, cliSessionMap.get(resolvedCliKey), {
|
|
663
|
+
claudeRunId: run.runId,
|
|
664
|
+
clientJobId: options?.clientJobId,
|
|
665
|
+
generation: options?.generation,
|
|
580
666
|
...(outputAttachments.length > 0 ? { outputAttachments } : {}),
|
|
581
667
|
...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
|
|
582
668
|
})
|
|
669
|
+
if (terminalOwned === false) return
|
|
583
670
|
|
|
584
671
|
// Telegram notifications — fire and forget
|
|
585
672
|
if (isFirstQuery) {
|
|
@@ -589,7 +676,7 @@ export async function callClaudeStreaming(
|
|
|
589
676
|
notifyExchange(sid, query, text)
|
|
590
677
|
}
|
|
591
678
|
|
|
592
|
-
function finalizeError(
|
|
679
|
+
async function finalizeError(
|
|
593
680
|
msg: string,
|
|
594
681
|
exitCode?: number | null,
|
|
595
682
|
status: Exclude<ClaudeRunStatus, 'running'> = 'failed',
|
|
@@ -610,7 +697,7 @@ export async function callClaudeStreaming(
|
|
|
610
697
|
error: msg,
|
|
611
698
|
exitCode,
|
|
612
699
|
})
|
|
613
|
-
callbacks.onError(msg)
|
|
700
|
+
await callbacks.onError(msg)
|
|
614
701
|
}
|
|
615
702
|
|
|
616
703
|
function handleAbort() {
|
|
@@ -827,11 +914,27 @@ export async function callClaudeStreaming(
|
|
|
827
914
|
? `${fullQuery}\n\n${ULTRACODE_KEYWORD}`
|
|
828
915
|
: fullQuery
|
|
829
916
|
try {
|
|
917
|
+
const providerOwned = await callbacks.onProviderProcess?.({
|
|
918
|
+
provider: 'claude',
|
|
919
|
+
runId: run.runId,
|
|
920
|
+
pid: proc.pid,
|
|
921
|
+
clientJobId: options?.clientJobId,
|
|
922
|
+
generation: options?.generation,
|
|
923
|
+
})
|
|
924
|
+
if (providerOwned === false) {
|
|
925
|
+
proc.kill('SIGTERM')
|
|
926
|
+
abandonLostDurableOwnership('claude-bridge: durable provider ownership was lost.')
|
|
927
|
+
return sid
|
|
928
|
+
}
|
|
929
|
+
if (finalized) return sid
|
|
830
930
|
proc.stdin.write(cliQuery)
|
|
831
931
|
proc.stdin.end()
|
|
832
932
|
} catch (err) {
|
|
833
933
|
const message = err instanceof Error ? err.message : String(err)
|
|
834
|
-
|
|
934
|
+
if (!finalized) {
|
|
935
|
+
proc.kill('SIGTERM')
|
|
936
|
+
await finalizeError(`claude-bridge: provider start failed — ${message}`, null)
|
|
937
|
+
}
|
|
835
938
|
}
|
|
836
939
|
|
|
837
940
|
return sid
|