@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.
@@ -0,0 +1,358 @@
1
+ import { createHash } from 'node:crypto'
2
+ import {
3
+ parseMediaAttachmentRefs,
4
+ parseMediaIdList,
5
+ type MediaAttachmentRef,
6
+ } from '../../shared/media-attachment.js'
7
+
8
+ export const QUERY_JOB_SCHEMA_VERSION = 1 as const
9
+ export const QUERY_JOB_PROTOCOL_VERSION = 1 as const
10
+
11
+ export const QUERY_JOB_LIMITS = Object.freeze({
12
+ promptChars: 48_000,
13
+ referenceQueryChars: 48_000,
14
+ referenceResponseChars: 128_000,
15
+ partialChars: 128_000,
16
+ terminalResponseChars: 128_000,
17
+ errorChars: 2_000,
18
+ activityChars: 2_000,
19
+ activityEntries: 64,
20
+ replayEvents: 256,
21
+ retainedDays: 7,
22
+ hydratedJobs: 500,
23
+ })
24
+
25
+ export type QueryJobStatus =
26
+ | 'accepted'
27
+ | 'starting'
28
+ | 'running'
29
+ | 'answer_ready'
30
+ | 'completed'
31
+ | 'failed'
32
+ | 'canceled'
33
+ | 'interrupted'
34
+
35
+ export type QueryJobTerminalStatus = Extract<
36
+ QueryJobStatus,
37
+ 'completed' | 'failed' | 'canceled' | 'interrupted'
38
+ >
39
+
40
+ export type QueryJobEventType =
41
+ | QueryJobStatus
42
+ | 'chunk'
43
+ | 'tool_status'
44
+ | 'activity_line'
45
+ | 'acknowledged'
46
+
47
+ export type QueryJobActivityMode = 'off' | 'status' | 'preview'
48
+ export type QueryJobActivityKind = 'status' | 'input' | 'output' | 'gap'
49
+
50
+ export interface QueryJobPromptReference {
51
+ query: string
52
+ response: string
53
+ }
54
+
55
+ /** Immutable, persistence-safe request. Provider-only objects (paths, image
56
+ * bytes, AbortControllers, handoff runtime state) deliberately do not fit. */
57
+ export interface QueryJobRequest {
58
+ clientJobId: string
59
+ generation: number
60
+ query: string
61
+ sessionId: string
62
+ model?: string
63
+ effort?: string
64
+ messageEra?: string
65
+ globalMsgNum?: number
66
+ reference?: QueryJobPromptReference
67
+ handoffCode?: string
68
+ handoffLatest?: boolean
69
+ clientQueueItemId?: string
70
+ attachmentIds: string[]
71
+ attachmentRefs: MediaAttachmentRef[]
72
+ activityToolMode: QueryJobActivityMode
73
+ }
74
+
75
+ export interface QueryJobProviderLinkage {
76
+ provider?: 'claude' | 'codex'
77
+ resolvedModel?: string
78
+ cliSessionId?: string
79
+ claudeRunId?: string
80
+ codexRunId?: string
81
+ codexThreadId?: string
82
+ }
83
+
84
+ /** Path/id-free aggregate from output-image finalization. Values are bounded
85
+ * before journal persistence so terminal replay cannot smuggle arbitrary
86
+ * provider metadata. */
87
+ export interface QueryJobOutputImageStats {
88
+ published: number
89
+ attached: number
90
+ rejected: number
91
+ }
92
+
93
+ export interface QueryJobError {
94
+ code: string
95
+ message: string
96
+ retryable?: boolean
97
+ retryAfterMs?: number
98
+ }
99
+
100
+ export interface QueryJobActivity {
101
+ eventSeq: number
102
+ at: string
103
+ kind: QueryJobActivityKind
104
+ text: string
105
+ repeatCount?: number
106
+ }
107
+
108
+ export interface QueryJobSnapshot extends QueryJobProviderLinkage {
109
+ schemaVersion: typeof QUERY_JOB_SCHEMA_VERSION
110
+ jobId: string
111
+ clientJobId: string
112
+ generation: number
113
+ turnId: string
114
+ requestFingerprint: string
115
+ status: QueryJobStatus
116
+ eventSeq: number
117
+ oldestEventSeq: number
118
+ sessionId: string
119
+ requestedModel?: string
120
+ effort?: string
121
+ messageEra?: string
122
+ globalMsgNum?: number
123
+ handoffCode?: string
124
+ attachments: MediaAttachmentRef[]
125
+ partialText: string
126
+ partialTruncated: boolean
127
+ response?: string
128
+ responseTruncated?: boolean
129
+ outputImageStats?: QueryJobOutputImageStats
130
+ error?: QueryJobError
131
+ activity: QueryJobActivity[]
132
+ acceptedAt: string
133
+ startedAt?: string
134
+ answerReadyAt?: string
135
+ /** Fsynced only after the provider child exists and before prompt bytes are
136
+ * written. Run ids alone can be allocated before a child owns the session. */
137
+ providerOwnershipConfirmedAt?: string
138
+ updatedAt: string
139
+ completedAt?: string
140
+ acknowledgedAt?: string
141
+ orphanFenceUntil?: string
142
+ retentionUntil: string
143
+ }
144
+
145
+ export interface QueryJobEvent {
146
+ type: QueryJobEventType
147
+ eventSeq: number
148
+ jobId: string
149
+ clientJobId: string
150
+ generation: number
151
+ status: QueryJobStatus
152
+ at: string
153
+ data: Record<string, unknown>
154
+ }
155
+
156
+ export interface QueryJobReplay {
157
+ events: QueryJobEvent[]
158
+ gap: boolean
159
+ reason?: 'cursor_ahead' | 'buffer_overflow'
160
+ oldestEventSeq: number
161
+ latestEventSeq: number
162
+ snapshot: QueryJobSnapshot
163
+ }
164
+
165
+ export interface QueryJobStoreHealth {
166
+ state: 'new' | 'ready' | 'degraded'
167
+ bootId: string
168
+ hydratedJobs: number
169
+ retainedIdentities: number
170
+ subscribers: number
171
+ malformedRows: number
172
+ journalFailures: number
173
+ interruptedOnBoot: number
174
+ evictedHydratedJobs: number
175
+ lastErrorCode: string | null
176
+ lastSuccessfulWriteAt: string | null
177
+ rootFingerprint: string
178
+ counts: Record<QueryJobStatus, number>
179
+ }
180
+
181
+ const CLIENT_JOB_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
182
+ const SAFE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:@/-]*$/
183
+ const CONTROL_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g
184
+ const SECRET_PATTERNS: RegExp[] = [
185
+ /\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}\b/gi,
186
+ /\b(?:sk|xox[baprs]|gh[pousr])[-_][A-Za-z0-9_-]{12,}\b/gi,
187
+ /\b(?:api[_ -]?key|access[_ -]?token|authorization)\s*[:=]\s*[^\s,;]+/gi,
188
+ ]
189
+ const PATH_PATTERNS: RegExp[] = [
190
+ /\/(?:Users|home|private|var|tmp|Volumes)\/[A-Za-z0-9_.@%+~/-]+/g,
191
+ /(?:[A-Za-z]:\\|\\\\)[^\s"']+/g,
192
+ /~\/[A-Za-z0-9_.@%+~/-]+/g,
193
+ ]
194
+
195
+ export class QueryJobValidationError extends Error {
196
+ constructor(readonly code: string, message = code) {
197
+ super(message)
198
+ this.name = 'QueryJobValidationError'
199
+ }
200
+ }
201
+
202
+ function requiredString(value: unknown, field: string, max: number): string {
203
+ if (typeof value !== 'string') throw new QueryJobValidationError(`invalid_${field}`)
204
+ const cleaned = value.replace(CONTROL_RE, '').trim()
205
+ if (!cleaned || cleaned.length > max) throw new QueryJobValidationError(`invalid_${field}`)
206
+ return cleaned
207
+ }
208
+
209
+ function optionalString(value: unknown, field: string, max: number): string | undefined {
210
+ if (value == null || value === '') return undefined
211
+ return requiredString(value, field, max)
212
+ }
213
+
214
+ function boundedContent(value: unknown, field: string, max: number, allowEmpty = false): string {
215
+ if (typeof value !== 'string') throw new QueryJobValidationError(`invalid_${field}`)
216
+ if (value.length > max) throw new QueryJobValidationError(`${field}_too_large`)
217
+ const cleaned = value.replace(CONTROL_RE, '')
218
+ if (!allowEmpty && !cleaned.trim()) throw new QueryJobValidationError(`invalid_${field}`)
219
+ return cleaned
220
+ }
221
+
222
+ /** Parse untrusted admission input into the only request shape allowed in the
223
+ * private journal. Unknown keys are dropped before fingerprinting. */
224
+ export function parseQueryJobRequest(raw: unknown): QueryJobRequest {
225
+ if (!raw || typeof raw !== 'object') throw new QueryJobValidationError('invalid_request')
226
+ const input = raw as Record<string, unknown>
227
+ const clientJobId = requiredString(input.clientJobId, 'client_job_id', 36).toLowerCase()
228
+ if (!CLIENT_JOB_ID_RE.test(clientJobId)) throw new QueryJobValidationError('invalid_client_job_id')
229
+
230
+ const generation = Number(input.generation)
231
+ if (!Number.isSafeInteger(generation) || generation < 1) {
232
+ throw new QueryJobValidationError('invalid_generation')
233
+ }
234
+
235
+ const query = boundedContent(input.query, 'query', QUERY_JOB_LIMITS.promptChars, true)
236
+ const sessionId = requiredString(input.sessionId, 'session_id', 128)
237
+ if (!SAFE_ID_RE.test(sessionId)) throw new QueryJobValidationError('invalid_session_id')
238
+
239
+ const model = optionalString(input.model, 'model', 64)
240
+ const effort = optionalString(input.effort, 'effort', 32)
241
+ const messageEra = optionalString(input.messageEra, 'message_era', 80)
242
+ const handoffCode = optionalString(input.handoffCode, 'handoff_code', 128)
243
+ const clientQueueItemId = optionalString(input.clientQueueItemId, 'client_queue_item_id', 120)
244
+ const globalMsgNum = input.globalMsgNum == null ? undefined : Number(input.globalMsgNum)
245
+ if (globalMsgNum != null && (!Number.isSafeInteger(globalMsgNum) || globalMsgNum < 1)) {
246
+ throw new QueryJobValidationError('invalid_global_msg_num')
247
+ }
248
+
249
+ let reference: QueryJobPromptReference | undefined
250
+ if (input.reference != null) {
251
+ if (!input.reference || typeof input.reference !== 'object') {
252
+ throw new QueryJobValidationError('invalid_reference')
253
+ }
254
+ const ref = input.reference as Record<string, unknown>
255
+ reference = {
256
+ query: boundedContent(ref.query, 'reference_query', QUERY_JOB_LIMITS.referenceQueryChars),
257
+ response: boundedContent(ref.response, 'reference_response', QUERY_JOB_LIMITS.referenceResponseChars),
258
+ }
259
+ }
260
+
261
+ const activityToolMode: QueryJobActivityMode = input.activityToolMode === 'off'
262
+ || input.activityToolMode === 'preview'
263
+ ? input.activityToolMode
264
+ : 'status'
265
+
266
+ const attachmentIds = parseMediaIdList(input.attachmentIds)
267
+ const attachmentRefs = parseMediaAttachmentRefs(input.attachmentRefs ?? input.attachments)
268
+ if (!query.trim() && attachmentIds.length === 0 && attachmentRefs.length === 0) {
269
+ throw new QueryJobValidationError('query_or_attachment_required')
270
+ }
271
+
272
+ return {
273
+ clientJobId,
274
+ generation,
275
+ query,
276
+ sessionId,
277
+ ...(model ? { model } : {}),
278
+ ...(effort ? { effort } : {}),
279
+ ...(messageEra ? { messageEra } : {}),
280
+ ...(globalMsgNum ? { globalMsgNum } : {}),
281
+ ...(reference ? { reference } : {}),
282
+ ...(handoffCode ? { handoffCode } : {}),
283
+ ...(input.handoffLatest === true ? { handoffLatest: true } : {}),
284
+ ...(clientQueueItemId ? { clientQueueItemId } : {}),
285
+ attachmentIds,
286
+ attachmentRefs,
287
+ activityToolMode,
288
+ }
289
+ }
290
+
291
+ function canonical(value: unknown): unknown {
292
+ if (Array.isArray(value)) return value.map(canonical)
293
+ if (!value || typeof value !== 'object') return value
294
+ const record = value as Record<string, unknown>
295
+ return Object.fromEntries(Object.keys(record).sort().map(key => [key, canonical(record[key])]))
296
+ }
297
+
298
+ export function requestFingerprint(request: QueryJobRequest): string {
299
+ return createHash('sha256').update(JSON.stringify(canonical(request))).digest('hex')
300
+ }
301
+
302
+ export function isTerminalQueryJobStatus(status: QueryJobStatus): status is QueryJobTerminalStatus {
303
+ return status === 'completed' || status === 'failed' || status === 'canceled' || status === 'interrupted'
304
+ }
305
+
306
+ export function boundedText(value: unknown, max: number): { text: string; truncated: boolean } {
307
+ const text = typeof value === 'string' ? value.replace(CONTROL_RE, '') : String(value ?? '')
308
+ return text.length <= max
309
+ ? { text, truncated: false }
310
+ : { text: text.slice(0, max), truncated: true }
311
+ }
312
+
313
+ export function parseQueryJobOutputImageStats(raw: unknown): QueryJobOutputImageStats | undefined {
314
+ if (!raw || typeof raw !== 'object') return undefined
315
+ const value = raw as Record<string, unknown>
316
+ const counts = [value.published, value.attached, value.rejected]
317
+ if (!counts.every(count => Number.isSafeInteger(count) && Number(count) >= 0 && Number(count) <= 1_000)) {
318
+ return undefined
319
+ }
320
+ const [published, attached, rejected] = counts as number[]
321
+ if (attached > published || rejected > published || attached + rejected > published) return undefined
322
+ return { published, attached, rejected }
323
+ }
324
+
325
+ /** Second-line redaction even for bridge-produced "safe" activity. This is
326
+ * intentionally conservative: replay never needs a credential or local path. */
327
+ export function sanitizeQueryJobActivity(value: unknown): { text: string; truncated: boolean } {
328
+ let text = typeof value === 'string' ? value : String(value ?? '')
329
+ text = text.replace(CONTROL_RE, ' ')
330
+ for (const pattern of SECRET_PATTERNS) text = text.replace(pattern, '[redacted]')
331
+ for (const pattern of PATH_PATTERNS) text = text.replace(pattern, '[path]')
332
+ text = text.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n').trim()
333
+ return boundedText(text || 'Processing...', QUERY_JOB_LIMITS.activityChars)
334
+ }
335
+
336
+ export function normalizeQueryJobError(error: unknown, fallbackCode = 'query_job_failed'): QueryJobError {
337
+ const candidate = error && typeof error === 'object' ? error as Record<string, unknown> : {}
338
+ const rawCode = typeof candidate.code === 'string' ? candidate.code : fallbackCode
339
+ const code = /^[a-z0-9_.-]{1,80}$/i.test(rawCode) ? rawCode : fallbackCode
340
+ const rawMessage = error instanceof Error ? error.message
341
+ : typeof candidate.message === 'string' ? candidate.message
342
+ : typeof error === 'string' ? error : fallbackCode
343
+ const safe = sanitizeQueryJobActivity(rawMessage)
344
+ return {
345
+ code,
346
+ message: safe.text,
347
+ ...(candidate.retryable === true ? { retryable: true } : {}),
348
+ ...(typeof candidate.retryAfterMs === 'number' && Number.isFinite(candidate.retryAfterMs)
349
+ ? { retryAfterMs: Math.max(0, Math.ceil(candidate.retryAfterMs)) } : {}),
350
+ }
351
+ }
352
+
353
+ export function parsePositiveInteger(value: unknown): number | undefined {
354
+ if (value == null || value === '') return undefined
355
+ const parsed = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value
356
+ return typeof parsed === 'number' && Number.isSafeInteger(parsed) && parsed >= 0
357
+ ? parsed : undefined
358
+ }
@@ -80,6 +80,7 @@ export interface RunOutputImageCollectionStats {
80
80
 
81
81
  export const RUN_OUTPUT_IMAGE_DIR_PREFIX = 'cos-glasses-output-images-'
82
82
  export const RUN_OUTPUT_IMAGE_STALE_MS = 2 * 60 * 60_000
83
+ export const RUN_OUTPUT_IMAGE_COLLECTION_TIMEOUT_MS = 2 * 60_000
83
84
  const MANIFEST_MAX_BYTES = 64 * 1024
84
85
  export const RUN_OUTPUT_IMAGE_COLLECTION_CONCURRENCY = 2
85
86
  const ASSOCIATION_ATTEMPTS = 2
@@ -92,6 +93,49 @@ const LABELS: Record<OutputImageProvenance, string> = {
92
93
  }
93
94
  const HELPER_PATH = resolve(import.meta.dirname, '..', 'bin', 'cos-output-image-publisher.mjs')
94
95
 
96
+ /**
97
+ * Keep answer post-processing bounded independently of provider generation.
98
+ * The answer is already durable when bridges call this helper, so an image
99
+ * collector stall must not retain the session lease or strand finalization.
100
+ */
101
+ export async function collectRunOutputImagesBounded(
102
+ publisher: Pick<RunOutputImagePublisher, 'collect'>,
103
+ options: { signal?: AbortSignal; timeoutMs?: number } = {},
104
+ ): Promise<MediaAttachmentRef[]> {
105
+ const timeoutMs = Math.max(1, options.timeoutMs ?? RUN_OUTPUT_IMAGE_COLLECTION_TIMEOUT_MS)
106
+ const signal = options.signal
107
+ if (signal?.aborted) {
108
+ throw signal.reason instanceof Error
109
+ ? signal.reason
110
+ : Object.assign(new Error('Output image collection aborted.'), { code: 'output_image_collection_aborted' })
111
+ }
112
+
113
+ let timer: NodeJS.Timeout | undefined
114
+ let abortHandler: (() => void) | undefined
115
+ const guard = new Promise<never>((_resolve, reject) => {
116
+ timer = setTimeout(() => {
117
+ reject(Object.assign(
118
+ new Error('Output image collection exceeded its post-answer deadline.'),
119
+ { code: 'output_image_collection_timeout' },
120
+ ))
121
+ }, timeoutMs)
122
+ timer.unref?.()
123
+ if (signal) {
124
+ abortHandler = () => reject(signal.reason instanceof Error
125
+ ? signal.reason
126
+ : Object.assign(new Error('Output image collection aborted.'), { code: 'output_image_collection_aborted' }))
127
+ signal.addEventListener('abort', abortHandler, { once: true })
128
+ }
129
+ })
130
+
131
+ try {
132
+ return await Promise.race([publisher.collect(), guard])
133
+ } finally {
134
+ if (timer) clearTimeout(timer)
135
+ if (signal && abortHandler) signal.removeEventListener('abort', abortHandler)
136
+ }
137
+ }
138
+
95
139
  interface ManifestPublishEntry {
96
140
  v: 1
97
141
  type: 'publish'
@@ -5,6 +5,7 @@ import { resolve } from 'node:path'
5
5
  import { COS_SCRIPTS_DIR, COS_MODE, PYTHON_BIN } from '../lib/python-bridge.js'
6
6
  import { serverMetrics } from '../lib/server-metrics.js'
7
7
  import { getServerInstanceId } from '../lib/server-instance-id.js'
8
+ import { localFirstMeetingsCapability } from '../lib/local-first-meetings-contract.js'
8
9
  import { isSileroAvailable } from '../lib/vad-silero.js'
9
10
  import { getAvailableCliSessionId } from '../lib/claude-bridge.js'
10
11
  import { isWhisperLocalAvailable, getWhisperHealth } from '../lib/whisper-local.js'
@@ -16,9 +17,40 @@ import {
16
17
  } from '../lib/codex-model-catalog.js'
17
18
  import { isMediaProcessingReady } from '../lib/image-safety.js'
18
19
  import { G2_LENS_VARIANT_CAPABILITY } from '../lib/media-store.js'
20
+ import { durableQueryJobsCapability } from '../lib/query-job-feature.js'
21
+ import { getQueryJobRuntimeHealth } from '../lib/query-job-runtime.js'
19
22
 
20
23
  export const healthRouter = Router()
21
24
 
25
+ function durableQueryJobStatus() {
26
+ const configured = durableQueryJobsCapability()
27
+ const runtime = getQueryJobRuntimeHealth()
28
+ return {
29
+ configured: configured.enabled,
30
+ enabled: configured.enabled
31
+ && runtime.store.state === 'ready'
32
+ && !runtime.shuttingDown,
33
+ protocolVersion: configured.protocolVersion,
34
+ activeRuns: runtime.activeRuns,
35
+ shuttingDown: runtime.shuttingDown,
36
+ callbackPersistenceFailures: runtime.callbackPersistenceFailures,
37
+ terminalProjectionFailures: runtime.terminalProjectionFailures,
38
+ store: {
39
+ state: runtime.store.state,
40
+ hydratedJobs: runtime.store.hydratedJobs,
41
+ retainedIdentities: runtime.store.retainedIdentities,
42
+ subscribers: runtime.store.subscribers,
43
+ malformedRows: runtime.store.malformedRows,
44
+ journalFailures: runtime.store.journalFailures,
45
+ interruptedOnBoot: runtime.store.interruptedOnBoot,
46
+ lastErrorCode: runtime.store.lastErrorCode,
47
+ lastSuccessfulWriteAt: runtime.store.lastSuccessfulWriteAt,
48
+ rootFingerprint: runtime.store.rootFingerprint,
49
+ counts: runtime.store.counts,
50
+ },
51
+ }
52
+ }
53
+
22
54
  healthRouter.get('/health', async (_req, res) => {
23
55
  const checks: Record<string, string | number> = {
24
56
  status: 'ok',
@@ -106,6 +138,8 @@ healthRouter.get('/health', async (_req, res) => {
106
138
  // restart. The nested voice block also exposes the source so future wizard
107
139
  // work can decide whether to prompt for a key.
108
140
  const keyStatus = getKeyStatus()
141
+ const durableJobs = durableQueryJobStatus()
142
+ const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
109
143
  const features = {
110
144
  claude: claudeAvailable,
111
145
  codex: codexAvailable,
@@ -117,6 +151,9 @@ healthRouter.get('/health', async (_req, res) => {
117
151
  iphoneAsrCandidates: process.env.COS_IOS_ASR_CANDIDATES === '1',
118
152
  mediaProcessingReady: await isMediaProcessingReady(),
119
153
  g2LensVariant: G2_LENS_VARIANT_CAPABILITY,
154
+ durableQueryJobs: durableJobs.enabled,
155
+ durableQueryJobsProtocol: durableJobs.protocolVersion,
156
+ localFirstMeetings: localFirstMeetings !== null,
120
157
  }
121
158
  const voice = {
122
159
  hasKey: keyStatus.hasKey,
@@ -129,14 +166,43 @@ healthRouter.get('/health', async (_req, res) => {
129
166
  const openai_whisper_budget = getOpenAIWhisperBudgetState()
130
167
 
131
168
  const codex_models = getCodexModelCatalogSnapshot()
132
- res.json({ ...checks, features, voice, whisper_health, openai_whisper_budget, codex_models })
169
+ res.json({
170
+ ...checks,
171
+ features,
172
+ voice,
173
+ whisper_health,
174
+ openai_whisper_budget,
175
+ codex_models,
176
+ capabilities: localFirstMeetings ? { localFirstMeetings } : {},
177
+ // /api/health is intentionally unauthenticated for setup diagnostics.
178
+ // Publish capability only; job counts, retention identities, subscriber
179
+ // counts, and the storage fingerprint remain internal.
180
+ durable_query_jobs: {
181
+ configured: durableJobs.configured,
182
+ enabled: durableJobs.enabled,
183
+ protocolVersion: durableJobs.protocolVersion,
184
+ state: durableJobs.store.state,
185
+ },
186
+ })
133
187
  })
134
188
 
135
189
  // Stable app slots backed by Codex's live model/list catalog. This route is
136
190
  // authenticated by the global /api middleware; ?refresh=1 forces discovery.
137
191
  healthRouter.get('/models', async (req, res) => {
138
192
  const catalog = await getCodexModelCatalog(req.query.refresh === '1')
139
- res.json({ ...catalog, serverInstanceId: getServerInstanceId() })
193
+ const durableJobs = durableQueryJobStatus()
194
+ const localFirstMeetings = localFirstMeetingsCapability(getServerInstanceId())
195
+ res.json({
196
+ ...catalog,
197
+ serverInstanceId: getServerInstanceId(),
198
+ capabilities: {
199
+ durableQueryJobs: {
200
+ enabled: durableJobs.enabled,
201
+ protocolVersion: durableJobs.protocolVersion,
202
+ },
203
+ ...(localFirstMeetings ? { localFirstMeetings } : {}),
204
+ },
205
+ })
140
206
  })
141
207
 
142
208
  // GET /api/cli-session — returns current CLI session ID for cross-device resume
@@ -31,6 +31,7 @@ import {
31
31
  getSessionProviderCandidates,
32
32
  getSessionStartTime,
33
33
  getSessionTranscript,
34
+ getMeetingSessionStatus,
34
35
  hasSessionAudio,
35
36
  moveSessionAudioToPending,
36
37
  type IndexedTranscriptChunk,
@@ -38,6 +39,7 @@ import {
38
39
  type TranscriptChunk,
39
40
  type TranscriptGapReport,
40
41
  } from './transcribe-stream.js'
42
+ import { getServerInstanceId } from '../lib/server-instance-id.js'
41
43
 
42
44
  interface MeetingSessionSource {
43
45
  getTranscript(sessionId: string): string | null
@@ -98,6 +100,8 @@ function publicSaveResponse(saved: SavedMeeting, replayed = false): Record<strin
98
100
  ? Math.floor(integrity.completeness * 1_000) / 10
99
101
  : 100
100
102
  return {
103
+ receiptVersion: 1,
104
+ serverInstanceId: getServerInstanceId(),
101
105
  saved: true,
102
106
  // Keep the build199 string field without leaking an absolute host path.
103
107
  filepath: `recordings/${saved.month}/${saved.filename}`,
@@ -125,6 +129,33 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
125
129
  const router = Router()
126
130
  const savingSessions = new Set<string>()
127
131
 
132
+ router.get('/meeting/sessions/:sessionId/status', (req, res) => {
133
+ const sessionId = String(req.params.sessionId ?? '')
134
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
135
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
136
+ return
137
+ }
138
+ const serverInstanceId = getServerInstanceId()
139
+ if (!serverInstanceId) {
140
+ res.status(503).json({ error: 'Server identity unavailable', reason: 'server_identity_unavailable' })
141
+ return
142
+ }
143
+ const saved = store.findBySessionId(sessionId)
144
+ const live = getMeetingSessionStatus(sessionId)
145
+ res.set('Cache-Control', 'private, no-store')
146
+ res.json({
147
+ sessionId,
148
+ state: saved ? 'saved' : live.state,
149
+ serverInstanceId,
150
+ receivedRanges: live.receivedRanges,
151
+ receivedCount: live.receivedCount,
152
+ maxChunkIndex: live.maxChunkIndex,
153
+ lastActivityAt: live.lastActivityAt,
154
+ retainedUntil: saved ? null : live.retainedUntil,
155
+ saveReceipt: saved ? publicSaveResponse(saved) : null,
156
+ })
157
+ })
158
+
128
159
  router.post('/meeting/save', async (req, res) => {
129
160
  let lockedSessionId: string | null = null
130
161
  try {