@gotcos/glasses-server 6.2.1 → 6.5.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.
@@ -1,5 +1,5 @@
1
1
  // Daily archive system — persists conversation history beyond session TTL
2
- // Archives are stored as JSON files per day in server/data/archive/
2
+ // Archives are stored as JSON files per day in ~/.cos-glasses/data/archive/
3
3
  // Each day's archive contains one or more "chats" (split by context breaks)
4
4
  // Summaries are generated via `claude -p --model sonnet`, budget-capped per day.
5
5
 
@@ -11,11 +11,11 @@ import { promisify } from 'node:util'
11
11
  import { logTokenAudit } from './token-audit.js'
12
12
  import { atomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
13
13
  import { consumeArchiveLLMBudget } from './archive-budget.js'
14
+ import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
14
15
 
15
16
  const execAsync = promisify(exec)
16
17
  import type { Exchange } from './conversation.js'
17
18
 
18
- const __dirname = dirname(fileURLToPath(import.meta.url))
19
19
  import { dataPath } from './data-dir.js'
20
20
  const ARCHIVE_DIR = dataPath('archive')
21
21
 
@@ -87,7 +87,12 @@ export function loadArchive(date: string): DailyArchive | null {
87
87
  return null
88
88
  }
89
89
  if (result.status === 'missing') return null
90
- return result.data
90
+ // Defense: a valid-JSON but wrong-shape day file (no chats[]) would make the
91
+ // readers throw 500 AND drop listArchiveDates into its catch → the whole
92
+ // Message History list vanishes on one bad file. Coerce to an empty day.
93
+ const data = result.data
94
+ if (data && !Array.isArray(data.chats)) data.chats = []
95
+ return data
91
96
  }
92
97
 
93
98
  function saveArchive(archive: DailyArchive): void {
@@ -344,20 +349,30 @@ export function getArchiveChats(date: string): ArchiveChatSummary[] {
344
349
  }))
345
350
  }
346
351
 
347
- /** Get paired Q&A messages for a specific chat within a day */
348
- export function getArchiveChatMessages(date: string, chatIndex: number): Array<{ query: string; text: string; timestamp: number }> {
352
+ /** Get paired Q&A messages for a specific chat within a day. Request refs on
353
+ * the user turn and model-output refs on the assistant turn surface together. */
354
+ export function getArchiveChatMessages(
355
+ date: string,
356
+ chatIndex: number,
357
+ ): Array<{ query: string; text: string; timestamp: number; attachments?: MediaAttachmentRef[] }> {
349
358
  const archive = loadArchive(date)
350
359
  if (!archive) return []
351
360
  const chat = archive.chats.find(c => c.id === chatIndex)
352
361
  if (!chat) return []
353
362
 
354
- const messages: Array<{ query: string; text: string; timestamp: number }> = []
363
+ const messages: Array<{ query: string; text: string; timestamp: number; attachments?: MediaAttachmentRef[] }> = []
355
364
  for (let i = 0; i < chat.exchanges.length; i++) {
356
365
  const ex = chat.exchanges[i]
357
366
  if (ex.role === 'user') {
358
367
  const next = chat.exchanges[i + 1]
359
368
  if (next && next.role === 'assistant') {
360
- messages.push({ query: ex.content, text: next.content, timestamp: next.timestamp })
369
+ const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
370
+ messages.push({
371
+ query: ex.content,
372
+ text: next.content,
373
+ timestamp: next.timestamp,
374
+ ...(attachments.length > 0 ? { attachments } : {}),
375
+ })
361
376
  i++ // skip assistant
362
377
  }
363
378
  }
@@ -370,23 +385,26 @@ export function getArchiveChatMessages(date: string, chatIndex: number): Array<{
370
385
  * (sessionId, timestamp) instead of bare timestamp (collision-prone). */
371
386
  export function getArchiveDayMessages(
372
387
  date: string,
373
- ): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string }> {
388
+ ): Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; attachments?: MediaAttachmentRef[] }> {
374
389
  const archive = loadArchive(date)
375
390
  if (!archive) return []
376
391
 
377
- const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string }> = []
392
+ const messages: Array<{ query: string; text: string; timestamp: number; chatIndex: number; sessionId: string; no?: number; attachments?: MediaAttachmentRef[] }> = []
378
393
  for (const chat of archive.chats) {
379
394
  for (let i = 0; i < chat.exchanges.length; i++) {
380
395
  const ex = chat.exchanges[i]
381
396
  if (ex.role === 'user') {
382
397
  const next = chat.exchanges[i + 1]
383
398
  if (next && next.role === 'assistant') {
399
+ const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
384
400
  messages.push({
385
401
  query: ex.content,
386
402
  text: next.content,
387
403
  timestamp: next.timestamp,
388
404
  chatIndex: chat.id,
389
405
  sessionId: chat.sessionId,
406
+ ...((ex.globalMsgNum ?? next.globalMsgNum) != null ? { no: ex.globalMsgNum ?? next.globalMsgNum } : {}),
407
+ ...(attachments.length > 0 ? { attachments } : {}),
390
408
  })
391
409
  i++
392
410
  }
@@ -8,26 +8,49 @@
8
8
 
9
9
  import { spawn } from 'node:child_process'
10
10
  import { writeFileSync, readFileSync, unlinkSync, existsSync } from 'node:fs'
11
- import { join } from 'node:path'
12
11
  import { appendFileSync } from 'node:fs'
13
- import crypto from 'node:crypto'
14
12
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
13
+ import { cleanupModelImageInputs, type ModelImageInput } from './model-image-input.js'
15
14
  import { cosBrainDir } from './launch-dir.js'
16
15
  import { logTokenAudit } from './token-audit.js'
17
16
  import { buildSystemPrompt, buildLightweightSystemPrompt, buildPrewarmSystemPrompt, getCachedContextInstant } from './context-builder.js'
18
- import { getHistory, addExchange, formatHistoryForPrompt, getOrCreateSession, isNewSession, markSessionNotified, getSessionModel, getSessionRaw, replaceLastExchangeWithSummary, type ModelPreference, type PromptReference } from './conversation.js'
17
+ import { getHistory, addExchange, setExchangeAttachments, removeExchange, formatHistoryForPrompt, getOrCreateSession, isNewSession, markSessionNotified, getSessionModel, getSessionRaw, replaceLastExchangeWithSummary, type ModelPreference, type PromptReference } from './conversation.js'
19
18
  import { notifySessionStart, notifyExchange } from './telegram-notify.js'
20
- import { isClaudeModel, DEFAULT_MODEL, type ClaudeModelPreference } from '../../shared/model-preference.js'
19
+ import {
20
+ isClaudeModel,
21
+ DEFAULT_MODEL,
22
+ resolveClaudeCliModelId,
23
+ resolveCliEffortFlag,
24
+ ULTRACODE_KEYWORD,
25
+ type ClaudeModelPreference,
26
+ type EffortPreference,
27
+ } from '../../shared/model-preference.js'
21
28
  import {
22
29
  finishClaudeRun,
23
30
  getClaudeEffortLevel,
24
31
  startClaudeRun,
25
32
  updateClaudeRun,
33
+ type ClaudeRunStatus,
26
34
  } from './claude-run-ledger.js'
35
+ import {
36
+ claudeToolInputPreview,
37
+ claudeToolResultPreviewLines,
38
+ type ActivityPreviewLine,
39
+ } from './activity-preview.js'
40
+ import {
41
+ createRunOutputImagePublisher,
42
+ isRunOutputImagePublisherCommand,
43
+ type RunOutputImageCollectionStats,
44
+ } from './run-output-images.js'
45
+ import {
46
+ MAX_ATTACHMENTS_PER_PROMPT,
47
+ type MediaAttachmentRef,
48
+ } from '../../shared/media-attachment.js'
27
49
 
28
50
  // Inactivity = no stdout data for this long → kill (catches stalls)
29
51
  const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
30
52
  opus: 180_000, // 3 minutes — Opus can gap during tool use (WebSearch, reasoning)
53
+ fable: 180_000, // 3 minutes — premium thinker, same gap tolerance as Opus
31
54
  sonnet: 30_000, // 30 seconds
32
55
  haiku: 15_000, // 15 seconds
33
56
  }
@@ -35,10 +58,12 @@ const INACTIVITY_BY_MODEL: Record<ClaudeModelPreference, number> = {
35
58
  // Wall clock max = absolute cap even if actively streaming
36
59
  const WALL_MAX_BY_MODEL: Record<ClaudeModelPreference, number> = {
37
60
  opus: 600_000, // 10 minutes
61
+ fable: 900_000, // 15 minutes — deepest model, longest single-turn runs
38
62
  sonnet: 120_000, // 2 minutes
39
63
  haiku: 60_000, // 1 minute
40
64
  }
41
65
  const WALL_MAX_EXTENDED_MS = 900_000 // 15 minutes for slash commands / heavy queries
66
+ const WALL_MAX_DEEP_EFFORT_MS = 1_200_000 // 20 minutes for max/ultracode
42
67
 
43
68
  // Heartbeat = emit progress status during silence so client knows we're alive
44
69
  const HEARTBEAT_INTERVAL_MS = 6_000 // Every 6 seconds
@@ -166,7 +191,7 @@ export function logLatency(entry: LatencyEntry): void {
166
191
  }
167
192
 
168
193
  /**
169
- * Pre-warm the Claude CLI by running a minimal Haiku query at server boot.
194
+ * Pre-warm the Claude CLI by running a minimal default-model query at boot.
170
195
  * Captures the CLI session ID so the first real query can use --resume.
171
196
  * Called from index.ts alongside prewarmContext().
172
197
  */
@@ -183,7 +208,7 @@ export async function preWarmCLI(): Promise<void> {
183
208
 
184
209
  const proc = spawn('claude', [
185
210
  '-p',
186
- '--model', DEFAULT_MODEL, // Must match default query model — --resume inherits session model
211
+ '--model', resolveClaudeCliModelId(DEFAULT_MODEL),
187
212
  '--effort', getClaudeEffortLevel(),
188
213
  '--output-format', 'stream-json',
189
214
  '--verbose',
@@ -207,7 +232,7 @@ export async function preWarmCLI(): Promise<void> {
207
232
  if (!trimmed) continue
208
233
  try {
209
234
  const event = JSON.parse(trimmed)
210
- if (event.type === 'result' && event.session_id) {
235
+ if (event.type === 'result' && event.session_id && !claudeResultErrorMessage(event)) {
211
236
  preWarmedCliSessionId = event.session_id
212
237
  scheduleCliSessionSave()
213
238
  const elapsed = Date.now() - start
@@ -222,7 +247,7 @@ export async function preWarmCLI(): Promise<void> {
222
247
  const elapsed = Date.now() - start
223
248
  logTokenAudit({
224
249
  source: 'g2-prewarm',
225
- model: 'opus',
250
+ model: DEFAULT_MODEL,
226
251
  inputChars: 500, // system prompt + "ready"
227
252
  outputChars: 50,
228
253
  durationMs: elapsed,
@@ -268,6 +293,8 @@ function isExtendedQuery(query: string): boolean {
268
293
  export interface ModelRunMetadata {
269
294
  codexRunId?: string
270
295
  codexThreadId?: string
296
+ outputAttachments?: MediaAttachmentRef[]
297
+ outputImageStats?: RunOutputImageCollectionStats
271
298
  }
272
299
 
273
300
  export interface StreamCallbacks {
@@ -275,9 +302,23 @@ export interface StreamCallbacks {
275
302
  onDone: (fullText: string, model: ModelPreference, cliSessionId?: string, metadata?: ModelRunMetadata) => void
276
303
  onError: (error: string) => void
277
304
  onToolStatus?: (toolName: string) => void
305
+ onActivityLine?: (line: ActivityPreviewLine) => void
278
306
  onStart?: (model: ModelPreference, sessionId: string, cliSessionId?: string, metadata?: ModelRunMetadata) => void
279
307
  }
280
308
 
309
+ /** Claude CLI can emit `subtype: success` with `is_error: true`; the boolean
310
+ * is authoritative and must win before session ids or result text are saved. */
311
+ export function claudeResultErrorMessage(event: any): string | null {
312
+ if (event?.type !== 'result' || (event?.is_error !== true && event?.subtype !== 'error')) return null
313
+ const raw = typeof event?.result === 'string' ? event.result
314
+ : typeof event?.error === 'string' ? event.error
315
+ : typeof event?.error?.message === 'string' ? event.error.message
316
+ : typeof event?.message === 'string' ? event.message
317
+ : ''
318
+ const detail = raw.replace(/\s+/g, ' ').trim().slice(0, 240)
319
+ return detail ? `claude-bridge: ${detail}` : 'claude-bridge: Claude CLI returned an error result.'
320
+ }
321
+
281
322
  type Phase = 'context' | 'thinking' | 'searching' | 'generating'
282
323
 
283
324
  const PHASE_LABELS: Record<Phase, string> = {
@@ -294,6 +335,7 @@ const PHASE_LABELS: Record<Phase, string> = {
294
335
  export interface CallOptions {
295
336
  lightweight?: boolean // Skip async context fetch — use cached context instantly (G2 speed path)
296
337
  abortSignal?: AbortSignal
338
+ effort?: EffortPreference
297
339
  }
298
340
 
299
341
  export async function callClaudeStreaming(
@@ -301,7 +343,7 @@ export async function callClaudeStreaming(
301
343
  sessionId: string | undefined,
302
344
  callbacks: StreamCallbacks,
303
345
  model?: ClaudeModelPreference,
304
- images?: string[],
346
+ images?: ModelImageInput[],
305
347
  reference?: PromptReference,
306
348
  globalMsgNum?: number,
307
349
  options?: CallOptions,
@@ -314,9 +356,12 @@ export async function callClaudeStreaming(
314
356
  const historyPrompt = formatHistoryForPrompt(history, contextBreaks, reference)
315
357
  const contextPrompt = historyPrompt
316
358
 
317
- // Resolve model: per-message > session preference > opus default
359
+ // Resolve model: per-message > session preference > public-server default.
318
360
  const sessionModel = getSessionModel(sid)
319
- const resolvedModel: ClaudeModelPreference = model ?? (sessionModel && isClaudeModel(sessionModel) ? sessionModel : 'opus')
361
+ const resolvedModel: ClaudeModelPreference = model
362
+ ?? (sessionModel && isClaudeModel(sessionModel) ? sessionModel : DEFAULT_MODEL)
363
+ const resolvedEffort = options?.effort
364
+ const cliEffortFlag = resolvedEffort ? resolveCliEffortFlag(resolvedEffort) : getClaudeEffortLevel()
320
365
 
321
366
  // Notify client immediately — model is known before any async work
322
367
  // Pass existing CLI session ID if resuming (new sessions get it after first result)
@@ -327,41 +372,55 @@ export async function callClaudeStreaming(
327
372
  // Phase: context loading (skipped in lightweight mode)
328
373
  let phase: Phase = 'context'
329
374
 
375
+ const imageInputs: ModelImageInput[] = images ?? []
376
+ const imagePaths = imageInputs.map(input => input.path)
377
+ const outputImageBudget = Math.max(0, MAX_ATTACHMENTS_PER_PROMPT - imageInputs.length)
378
+ let outputImagePublisher: ReturnType<typeof createRunOutputImagePublisher> | null = null
379
+ if (!options?.lightweight && outputImageBudget > 0) {
380
+ try {
381
+ outputImagePublisher = createRunOutputImagePublisher({
382
+ sessionId: sid,
383
+ globalMsgNum,
384
+ maxImages: outputImageBudget,
385
+ })
386
+ } catch (err) {
387
+ console.error('[claude-bridge] output image publisher unavailable:', err)
388
+ }
389
+ }
390
+
330
391
  let systemPrompt: string
331
- if (options?.lightweight) {
332
- // G2 speed path — minimal system prompt, context only when needed
333
- systemPrompt = buildLightweightSystemPrompt(query, contextPrompt)
334
- } else {
335
- callbacks.onToolStatus?.('Loading context...')
336
- // Full COS path — async context with Python subprocess calls
337
- systemPrompt = await buildSystemPrompt(contextPrompt)
392
+ try {
393
+ if (options?.lightweight) {
394
+ // G2 speed path — minimal system prompt, context only when needed
395
+ systemPrompt = buildLightweightSystemPrompt(query, contextPrompt)
396
+ } else {
397
+ callbacks.onToolStatus?.('Loading context...')
398
+ // Full COS path — async context with Python subprocess calls
399
+ systemPrompt = await buildSystemPrompt(contextPrompt)
400
+ }
401
+ } catch (err) {
402
+ outputImagePublisher?.cleanup()
403
+ throw err
338
404
  }
405
+ if (outputImagePublisher) systemPrompt = `${systemPrompt}\n\n${outputImagePublisher.promptInstructions}`
339
406
 
340
407
  // Phase: thinking (waiting for Claude to start)
341
408
  phase = 'thinking'
342
409
  callbacks.onToolStatus?.('Thinking...')
343
410
 
344
- // ── Vision: save temp image files if provided ──
345
- const imagePaths: string[] = []
346
- if (images && images.length > 0) {
347
- for (const img of images) {
348
- const id = crypto.randomUUID().slice(0, 8)
349
- const p = join('/tmp', `cos-vision-${id}.jpg`)
350
- writeFileSync(p, Buffer.from(img, 'base64'))
351
- imagePaths.push(p)
352
- }
353
- }
354
-
355
411
  // Check if this is the first query in a new session (before adding exchange)
356
412
  const isFirstQuery = isNewSession(sid)
357
413
 
358
414
  // Record user message (with [Photo]/[N Photos] prefix for vision queries)
359
415
  const photoPrefix = imagePaths.length === 1 ? '[Photo]' : imagePaths.length > 1 ? `[${imagePaths.length} Photos]` : ''
360
416
  const historyQuery = photoPrefix ? `${photoPrefix} ${query || 'What do you see?'}` : query
361
- addExchange(sid, 'user', historyQuery, globalMsgNum)
417
+ const pendingUserExchange = addExchange(sid, 'user', historyQuery, globalMsgNum)
362
418
 
363
419
  // Vision queries need the Read tool to see the image files
364
- const tools = imagePaths.length > 0 ? 'WebSearch,WebFetch,Read' : 'WebSearch,WebFetch'
420
+ const baseTools = imagePaths.length > 0 ? 'WebSearch,WebFetch,Read' : 'WebSearch,WebFetch'
421
+ const tools = outputImagePublisher
422
+ ? `${baseTools},${outputImagePublisher.claudeAllowedTool}`
423
+ : baseTools
365
424
 
366
425
  // Prepend image instruction when photos are attached
367
426
  let fullQuery: string
@@ -376,8 +435,8 @@ export async function callClaudeStreaming(
376
435
 
377
436
  // Check if we have a prior CLI session for this COS session.
378
437
  // If not, use the pre-warmed session (eliminates 2-15s cold start on first query).
379
- if (!existingCliSession && preWarmedCliSessionId && resolvedModel === 'opus') {
380
- // Only Opus queries consume the pre-warmed session (pre-warmed with Opus).
438
+ if (!existingCliSession && preWarmedCliSessionId && resolvedModel === DEFAULT_MODEL) {
439
+ // Only the default model consumes the pre-warmed session.
381
440
  // Hey Even (Haiku) cold-starts its own session to avoid model contamination.
382
441
  existingCliSession = preWarmedCliSessionId
383
442
  // Consume the pre-warmed session — next new session will cold start
@@ -392,8 +451,8 @@ export async function callClaudeStreaming(
392
451
 
393
452
  const args = [
394
453
  '-p',
395
- '--model', resolvedModel,
396
- '--effort', getClaudeEffortLevel(),
454
+ '--model', resolveClaudeCliModelId(resolvedModel),
455
+ '--effort', cliEffortFlag,
397
456
  '--output-format', 'stream-json',
398
457
  '--verbose', // Required: stream-json requires --verbose
399
458
  '--dangerously-skip-permissions', // Required: headless CLI mode with no TTY for user prompts
@@ -420,10 +479,14 @@ export async function callClaudeStreaming(
420
479
  // Strip CLAUDECODE env var so claude -p doesn't think it's nested
421
480
  const env = { ...process.env }
422
481
  delete env.CLAUDECODE
482
+ if (outputImagePublisher) Object.assign(env, outputImagePublisher.env)
423
483
  const cliCwd = COS_SCRIPTS_DIR ?? cosBrainDir() ?? process.cwd()
424
484
  const inactivityMs = INACTIVITY_BY_MODEL[resolvedModel]
425
485
  const defaultWallMax = WALL_MAX_BY_MODEL[resolvedModel]
426
- const wallMax = isExtendedQuery(query) ? WALL_MAX_EXTENDED_MS : defaultWallMax
486
+ const effortWallMax = resolvedEffort === 'max' || resolvedEffort === 'ultracode'
487
+ ? Math.max(defaultWallMax, WALL_MAX_DEEP_EFFORT_MS)
488
+ : defaultWallMax
489
+ const wallMax = isExtendedQuery(query) ? Math.max(WALL_MAX_EXTENDED_MS, effortWallMax) : effortWallMax
427
490
  const startTime = Date.now()
428
491
  const run = startClaudeRun({
429
492
  cosSessionId: sid,
@@ -434,6 +497,8 @@ export async function callClaudeStreaming(
434
497
  timeoutMs: inactivityMs,
435
498
  wallMaxMs: wallMax,
436
499
  query: fullQuery,
500
+ effortLevel: resolvedEffort ?? getClaudeEffortLevel(),
501
+ cliModelId: resolveClaudeCliModelId(resolvedModel),
437
502
  })
438
503
 
439
504
  const proc = spawn('claude', args, {
@@ -448,19 +513,50 @@ export async function callClaudeStreaming(
448
513
  let finalized = false // Guard against double onDone/onError
449
514
  let lastActivity = Date.now() // Tracks last stdout data for inactivity timeout
450
515
  let receivedStreamEvents = false // Track if CLI emits stream_event (vs older assistant-only format)
516
+ const toolInputs = new Map<number, { name: string; json: string }>()
451
517
 
452
518
  function cleanupImages() {
453
- for (const p of imagePaths) {
454
- try { unlinkSync(p) } catch { /* ignore */ }
455
- }
519
+ cleanupModelImageInputs(imageInputs)
456
520
  }
457
521
 
458
- function finalize(text: string) {
522
+ async function finalize(text: string) {
459
523
  if (finalized) return
460
524
  finalized = true
461
525
  cleanup()
462
526
  cleanupImages()
463
527
 
528
+ // Persist text before output-image normalization. A daemon crash during
529
+ // finalization cannot erase an otherwise successful answer.
530
+ const assistantExchange = addExchange(sid, 'assistant', text, globalMsgNum)
531
+ if (imagePaths.length > 0) {
532
+ replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
533
+ }
534
+
535
+ let outputAttachments: MediaAttachmentRef[] = []
536
+ let outputImageStats: RunOutputImageCollectionStats | undefined
537
+ if (outputImagePublisher) {
538
+ callbacks.onToolStatus?.('Preparing images...')
539
+ const preparingHeartbeat = setInterval(() => callbacks.onToolStatus?.('Preparing images...'), HEARTBEAT_INTERVAL_MS)
540
+ preparingHeartbeat.unref?.()
541
+ try {
542
+ outputAttachments = await outputImagePublisher.collect()
543
+ } catch (err) {
544
+ console.error('[claude-bridge] output image collection failed:', err)
545
+ } finally {
546
+ clearInterval(preparingHeartbeat)
547
+ outputImageStats = outputImagePublisher.stats
548
+ outputImagePublisher.cleanup()
549
+ }
550
+ if (outputAttachments.length > 0) {
551
+ setExchangeAttachments(sid, assistantExchange, outputAttachments)
552
+ }
553
+ if (outputImageStats && outputImageStats.rejected > 0) {
554
+ callbacks.onToolStatus?.(outputImageStats.attached > 0
555
+ ? 'Some images could not be attached'
556
+ : 'Image attachment unavailable')
557
+ }
558
+ }
559
+
464
560
  // Token audit — log every completed claude -p call
465
561
  const totalMs = Date.now() - startTime
466
562
  const inputEstimate = systemPrompt.length + fullQuery.length + contextPrompt.length
@@ -473,14 +569,6 @@ export async function callClaudeStreaming(
473
569
  caller: options?.lightweight ? 'voice_query' : 'full_query',
474
570
  })
475
571
 
476
- addExchange(sid, 'assistant', text, globalMsgNum)
477
-
478
- // Replace photo exchanges with condensed summaries to prevent context rot
479
- // while preserving enough context for follow-up questions
480
- if (imagePaths.length > 0) {
481
- replaceLastExchangeWithSummary(sid, query, text, imagePaths.length)
482
- }
483
-
484
572
  finishClaudeRun(run.runId, {
485
573
  status: 'completed',
486
574
  startedAtMs: startTime,
@@ -488,7 +576,10 @@ export async function callClaudeStreaming(
488
576
  exitCode: 0,
489
577
  })
490
578
 
491
- callbacks.onDone(text, resolvedModel, cliSessionMap.get(resolvedCliKey))
579
+ callbacks.onDone(text, resolvedModel, cliSessionMap.get(resolvedCliKey), {
580
+ ...(outputAttachments.length > 0 ? { outputAttachments } : {}),
581
+ ...(outputImageStats && outputImageStats.published > 0 ? { outputImageStats } : {}),
582
+ })
492
583
 
493
584
  // Telegram notifications — fire and forget
494
585
  if (isFirstQuery) {
@@ -498,13 +589,23 @@ export async function callClaudeStreaming(
498
589
  notifyExchange(sid, query, text)
499
590
  }
500
591
 
501
- function finalizeError(msg: string, exitCode?: number | null) {
592
+ function finalizeError(
593
+ msg: string,
594
+ exitCode?: number | null,
595
+ status: Exclude<ClaudeRunStatus, 'running'> = 'failed',
596
+ ) {
502
597
  if (finalized) return
503
598
  finalized = true
504
599
  cleanup()
505
600
  cleanupImages()
601
+ outputImagePublisher?.cleanup()
602
+ removeExchange(sid, pendingUserExchange)
603
+ // A failed/cancelled resumed CLI turn may already exist in Claude's own
604
+ // session transcript even though COS history was rolled back. Never resume
605
+ // that potentially contaminated CLI session on retry.
606
+ if (cliSessionMap.delete(resolvedCliKey)) scheduleCliSessionSave()
506
607
  finishClaudeRun(run.runId, {
507
- status: 'failed',
608
+ status,
508
609
  startedAtMs: startTime,
509
610
  error: msg,
510
611
  exitCode,
@@ -512,6 +613,12 @@ export async function callClaudeStreaming(
512
613
  callbacks.onError(msg)
513
614
  }
514
615
 
616
+ function handleAbort() {
617
+ if (finalized) return
618
+ proc.kill('SIGTERM')
619
+ finalizeError('claude-bridge: client disconnected before Claude completed.', null, 'client_disconnected')
620
+ }
621
+
515
622
  // ─── Heartbeat: emit phase status during silence ───
516
623
 
517
624
  const heartbeat = setInterval(() => {
@@ -548,7 +655,7 @@ export async function callClaudeStreaming(
548
655
  proc.kill('SIGTERM')
549
656
  if (fullText) {
550
657
  // Got partial output — deliver what we have
551
- finalize(fullText)
658
+ void finalize(fullText)
552
659
  } else {
553
660
  finalizeError(`Wall clock limit reached (${wallMax / 1000}s). Process killed.`)
554
661
  }
@@ -558,6 +665,7 @@ export async function callClaudeStreaming(
558
665
  clearInterval(heartbeat)
559
666
  clearTimeout(inactivityTimer)
560
667
  clearTimeout(wallTimer)
668
+ options?.abortSignal?.removeEventListener('abort', handleAbort)
561
669
  }
562
670
 
563
671
  // ─── Process stdout ───
@@ -577,7 +685,9 @@ export async function callClaudeStreaming(
577
685
  try {
578
686
  const event = JSON.parse(trimmed)
579
687
 
580
- if (event.type === 'stream_event') {
688
+ if (event.type === 'system' && event.subtype === 'init' && typeof event.model === 'string') {
689
+ updateClaudeRun(run.runId, { resolvedModelId: event.model })
690
+ } else if (event.type === 'stream_event') {
581
691
  // Real-time token streaming — fires every few tokens during generation
582
692
  receivedStreamEvents = true
583
693
  const inner = event.event
@@ -590,6 +700,7 @@ export async function callClaudeStreaming(
590
700
  callbacks.onToolStatus?.('Reasoning...')
591
701
  } else if (inner?.type === 'content_block_start' && inner.content_block?.type === 'tool_use' && inner.content_block.name) {
592
702
  const toolName = inner.content_block.name
703
+ if (typeof inner.index === 'number') toolInputs.set(inner.index, { name: toolName, json: '' })
593
704
  if (toolName === 'WebSearch' || toolName === 'WebFetch') {
594
705
  phase = 'searching'
595
706
  }
@@ -597,6 +708,18 @@ export async function callClaudeStreaming(
597
708
  if (toolName === 'Read') {
598
709
  callbacks.onToolStatus?.('Analyzing photo...')
599
710
  }
711
+ } else if (inner?.type === 'content_block_delta' && inner.delta?.type === 'input_json_delta' && typeof inner.index === 'number') {
712
+ const current = toolInputs.get(inner.index)
713
+ if (current && typeof inner.delta.partial_json === 'string') current.json += inner.delta.partial_json
714
+ } else if (inner?.type === 'content_block_stop' && typeof inner.index === 'number') {
715
+ const current = toolInputs.get(inner.index)
716
+ if (current) {
717
+ if (!isRunOutputImagePublisherCommand(current.json)) {
718
+ const preview = claudeToolInputPreview(current.name, current.json)
719
+ if (preview) callbacks.onActivityLine?.(preview)
720
+ }
721
+ toolInputs.delete(inner.index)
722
+ }
600
723
  }
601
724
  } else if (event.type === 'assistant') {
602
725
  // Fallback: only used if CLI doesn't emit stream_events (older CLI compatibility)
@@ -626,7 +749,14 @@ export async function callClaudeStreaming(
626
749
  callbacks.onChunk(text)
627
750
  }
628
751
  }
752
+ } else if (event.type === 'user') {
753
+ for (const preview of claudeToolResultPreviewLines(event)) callbacks.onActivityLine?.(preview)
629
754
  } else if (event.type === 'result') {
755
+ const resultError = claudeResultErrorMessage(event)
756
+ if (resultError) {
757
+ finalizeError(resultError)
758
+ continue
759
+ }
630
760
  // Capture CLI session ID for future --resume (avoids cold start on next query)
631
761
  if (event.session_id) {
632
762
  cliSessionMap.set(resolvedCliKey, event.session_id)
@@ -634,7 +764,7 @@ export async function callClaudeStreaming(
634
764
  updateClaudeRun(run.runId, { cliSessionId: event.session_id })
635
765
  }
636
766
  // Final result — use accumulated text (more reliable than result.result)
637
- finalize(fullText || event.result || '')
767
+ void finalize(fullText || event.result || '')
638
768
  }
639
769
  // tool_use/tool_result/other events still reset inactivity (we got stdout data)
640
770
  } catch {
@@ -655,7 +785,12 @@ export async function callClaudeStreaming(
655
785
  try {
656
786
  const event = JSON.parse(buffer.trim())
657
787
  if (event.type === 'result') {
658
- finalize(fullText || event.result || '')
788
+ const resultError = claudeResultErrorMessage(event)
789
+ if (resultError) {
790
+ finalizeError(resultError, code)
791
+ return
792
+ }
793
+ void finalize(fullText || event.result || '')
659
794
  return
660
795
  }
661
796
  } catch { /* ignore */ }
@@ -665,19 +800,39 @@ export async function callClaudeStreaming(
665
800
  finalizeError(`claude-bridge: exit ${code} — ${stderr.trim().slice(0, 200)}`, code)
666
801
  } else if (fullText) {
667
802
  // If we got text but no explicit result event, still finalize
668
- finalize(fullText)
803
+ void finalize(fullText)
669
804
  } else {
670
- cleanup() // No output, no error — just clean up timers
805
+ finalizeError('claude-bridge: Claude completed without a response.', code)
671
806
  }
672
807
  })
673
808
 
674
809
  proc.on('error', (err) => {
675
810
  finalizeError(`claude-bridge: ${err.message}`, null)
676
811
  })
812
+ proc.stdin.on('error', (err) => {
813
+ finalizeError(`claude-bridge: stdin failed — ${err.message}`, null)
814
+ })
677
815
 
678
- // Send query via stdin (fullQuery includes image instruction when vision)
679
- proc.stdin.write(fullQuery)
680
- proc.stdin.end()
816
+ if (options?.abortSignal) {
817
+ if (options.abortSignal.aborted) {
818
+ handleAbort()
819
+ return sid
820
+ }
821
+ options.abortSignal.addEventListener('abort', handleAbort, { once: true })
822
+ }
823
+
824
+ // Ultracode is a CLI-only orchestration keyword; history/chat keeps the
825
+ // original user text so the keyword never appears on the lens.
826
+ const cliQuery = resolvedEffort === 'ultracode'
827
+ ? `${fullQuery}\n\n${ULTRACODE_KEYWORD}`
828
+ : fullQuery
829
+ try {
830
+ proc.stdin.write(cliQuery)
831
+ proc.stdin.end()
832
+ } catch (err) {
833
+ const message = err instanceof Error ? err.message : String(err)
834
+ finalizeError(`claude-bridge: stdin failed — ${message}`, null)
835
+ }
681
836
 
682
837
  return sid
683
838
  }
@@ -33,7 +33,9 @@ export interface ClaudeRunRecord {
33
33
  updatedAt: string
34
34
  model: ClaudeModelPreference
35
35
  cliCommand: string
36
- effortLevel: ClaudeEffortLevel
36
+ effortLevel: ClaudeEffortLevel | 'ultracode'
37
+ cliModelId?: string
38
+ resolvedModelId?: string
37
39
  cwd: string
38
40
  resumed: boolean
39
41
  trustMode: 'full-access'
@@ -211,6 +213,8 @@ export function startClaudeRun(input: {
211
213
  timeoutMs: number
212
214
  wallMaxMs: number
213
215
  query: string
216
+ effortLevel?: ClaudeEffortLevel | 'ultracode'
217
+ cliModelId?: string
214
218
  }): ClaudeRunRecord {
215
219
  const now = new Date().toISOString()
216
220
  const run: ClaudeRunRecord = {
@@ -222,7 +226,8 @@ export function startClaudeRun(input: {
222
226
  updatedAt: now,
223
227
  model: input.model,
224
228
  cliCommand: 'claude -p',
225
- effortLevel: getClaudeEffortLevel(),
229
+ effortLevel: input.effortLevel ?? getClaudeEffortLevel(),
230
+ cliModelId: input.cliModelId,
226
231
  cwd: input.cwd,
227
232
  resumed: input.resumed,
228
233
  trustMode: 'full-access',