@phenx-inc/ctlsurf 0.13.6 → 0.14.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.
Files changed (43) hide show
  1. package/out/headless/index.mjs +13663 -385
  2. package/out/headless/index.mjs.map +4 -4
  3. package/out/main/index.js +11665 -286
  4. package/out/main/node-CUnQxFha.js +868 -0
  5. package/out/preload/index.js +17 -0
  6. package/out/renderer/assets/{cssMode-CsSLnKZ-.js → cssMode-7adWSORr.js} +3 -3
  7. package/out/renderer/assets/{freemarker2-C1XYRbOq.js → freemarker2-BkLLWnzk.js} +1 -1
  8. package/out/renderer/assets/{handlebars-BSdbbnMW.js → handlebars-Cinfm9fZ.js} +1 -1
  9. package/out/renderer/assets/{html-96igtyOz.js → html-vl9fkHKL.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-BRnCcIzp.js → htmlMode-BtGqkTNI.js} +3 -3
  11. package/out/renderer/assets/{index-Cc-jDHm3.js → index-BhrV6ld-.js} +359 -23
  12. package/out/renderer/assets/{index-BEekQe3-.css → index-BpCORklm.css} +217 -0
  13. package/out/renderer/assets/{javascript-7MdRNHk9.js → javascript-B7CCdfuW.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-oGam2go_.js → jsonMode-ut9LWBMJ.js} +3 -3
  15. package/out/renderer/assets/{liquid-DoKC7ba2.js → liquid-DVDJ0lKt.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-C2h5qGCv.js → lspLanguageFeatures-BbMj8Wsc.js} +1 -1
  17. package/out/renderer/assets/{mdx-0w8n1HGo.js → mdx-CcQzYB0J.js} +1 -1
  18. package/out/renderer/assets/{python-BfY4HssB.js → python-Byy7wKMv.js} +1 -1
  19. package/out/renderer/assets/{razor-BoDk9Ula.js → razor-D1xi_wMM.js} +1 -1
  20. package/out/renderer/assets/{tsMode-CX0oVKRt.js → tsMode-DJPzbn5i.js} +1 -1
  21. package/out/renderer/assets/{typescript-BqIU7abP.js → typescript-BuVaU7i3.js} +1 -1
  22. package/out/renderer/assets/{xml-BlSDnxFw.js → xml-rzc91O3d.js} +1 -1
  23. package/out/renderer/assets/{yaml-bVMmGUtU.js → yaml-fAOzQMge.js} +1 -1
  24. package/out/renderer/index.html +2 -2
  25. package/package.json +2 -1
  26. package/src/main/index.ts +36 -2
  27. package/src/main/observer/anthropic.ts +48 -0
  28. package/src/main/observer/budget.ts +79 -0
  29. package/src/main/observer/ledger.ts +61 -0
  30. package/src/main/observer/observer.ts +566 -0
  31. package/src/main/observer/spans.ts +170 -0
  32. package/src/main/observer/store.ts +200 -0
  33. package/src/main/observer/tier0.ts +234 -0
  34. package/src/main/observer/tier1.ts +306 -0
  35. package/src/main/observer/tier2.ts +125 -0
  36. package/src/main/observer/types.ts +224 -0
  37. package/src/main/orchestrator.ts +74 -0
  38. package/src/main/transcripts.ts +18 -0
  39. package/src/preload/index.ts +36 -0
  40. package/src/renderer/App.tsx +22 -0
  41. package/src/renderer/components/ObserverPanel.tsx +289 -0
  42. package/src/renderer/components/SettingsDialog.tsx +104 -0
  43. package/src/renderer/styles.css +217 -0
@@ -0,0 +1,566 @@
1
+ import { randomUUID } from 'crypto'
2
+ import path from 'path'
3
+
4
+ import type { CtlsurfApi } from '../ctlsurfApi'
5
+ import { TranscriptTailer, supportsTranscriptLogging } from '../transcripts'
6
+ import { log as baseLog } from '../logger'
7
+
8
+ import type {
9
+ Finding,
10
+ LedgerEvent,
11
+ ModelCaller,
12
+ ObserverFeedItem,
13
+ ObserverSettings,
14
+ Sensitivity,
15
+ Span,
16
+ } from './types'
17
+ import { OBSERVER_DEFAULTS, SEVERITY_ORDER, severityFloor } from './types'
18
+ import { SessionLedger } from './ledger'
19
+ import { tagEvent, checkTestClaim, truncateForTier1 } from './tier0'
20
+ import { SpanTracker } from './spans'
21
+ import { Tier1Reviewer } from './tier1'
22
+ import { generateDigest } from './tier2'
23
+ import { ObserverBudget } from './budget'
24
+ import { ObserverStore } from './store'
25
+ import { createModelCaller } from './anthropic'
26
+
27
+ /**
28
+ * Observer — supervision layer over the session ledger (spec v0.1, Phase 1).
29
+ *
30
+ * Consumes the agent's native transcript (via its own TranscriptTailer) and
31
+ * pty-derived activity signals, normalizes them into a local append-only
32
+ * JSONL ledger, and produces two artifact streams:
33
+ * - findings (Tier 0 ground-truth + Tier 1 Haiku windowed review)
34
+ * - spans (deterministic time attribution + Tier 1 boundary detection)
35
+ * with a Tier 2 session-end digest delivered to the project's ctlsurf
36
+ * Agent Datastore page.
37
+ *
38
+ * Advisory only: nothing here can block or slow the acting agent, and every
39
+ * code path is wrapped so observation failures never break the session.
40
+ */
41
+
42
+ function log(...args: unknown[]): void {
43
+ baseLog('[observer]', ...args)
44
+ }
45
+
46
+ interface TabSession {
47
+ tabId: string
48
+ agentId: string
49
+ agentName: string
50
+ cwd: string
51
+ sessionId: string
52
+ startedAt: string
53
+ ledger: SessionLedger
54
+ tailer: TranscriptTailer | null
55
+ spans: SpanTracker
56
+ tier1: Tier1Reviewer | null
57
+ findings: Finding[]
58
+ closedSpans: Span[]
59
+ escalations: string[]
60
+ userMessages: string[]
61
+ /** Last AgentStateMonitor state, to detect busy → awaiting transitions. */
62
+ lastAgentState: string
63
+ ended: boolean
64
+ }
65
+
66
+ export interface ObserverOptions {
67
+ settingsDir: string
68
+ api: CtlsurfApi
69
+ getSettings: () => ObserverSettings
70
+ /** Live feed hook for the in-app Observer panel. */
71
+ onUpdate?: (item: ObserverFeedItem) => void
72
+ /** Model review in-flight signal — drives the panel's processing
73
+ * animation. Emitted on 0→1 and →0 transitions of concurrent reviews. */
74
+ onActivity?: (reviewing: boolean) => void
75
+ /** Override transcript roots (for tests). */
76
+ claudeProjectsDir?: string
77
+ codexSessionsDir?: string
78
+ }
79
+
80
+ const FEED_LIMIT = 200
81
+
82
+ export class Observer {
83
+ private readonly opts: ObserverOptions
84
+ private readonly store: ObserverStore
85
+ private tabs = new Map<string, TabSession>()
86
+ private budget: ObserverBudget | null = null
87
+ private budgetCapUsd = -1
88
+ private modelCaller: ModelCaller | null = null
89
+ private modelCallerKey = ''
90
+ /** Rolling feed for the in-app panel (survives tab close, not restart). */
91
+ private feed: ObserverFeedItem[] = []
92
+ private feedSeq = 0
93
+ /** Concurrent model reviews in flight (Tier 1 windows + Tier 2 digests). */
94
+ private activeReviews = 0
95
+
96
+ constructor(opts: ObserverOptions) {
97
+ this.opts = opts
98
+ this.store = new ObserverStore(opts.api)
99
+ }
100
+
101
+ isEnabled(): boolean {
102
+ return !!this.opts.getSettings().enabled
103
+ }
104
+
105
+ /** Feed + status snapshot for the in-app panel. */
106
+ getFeed(): {
107
+ items: ObserverFeedItem[]
108
+ enabled: boolean
109
+ hasApiKey: boolean
110
+ observing: Array<{ tabId: string; agentName: string; session: string }>
111
+ spentTodayUsd: number
112
+ reviewing: boolean
113
+ sensitivity: Sensitivity
114
+ } {
115
+ const settings = this.opts.getSettings()
116
+ return {
117
+ items: [...this.feed],
118
+ enabled: !!settings.enabled,
119
+ hasApiKey: !!(settings.anthropicApiKey || process.env.ANTHROPIC_API_KEY),
120
+ observing: [...this.tabs.values()].map((t) => ({ tabId: t.tabId, agentName: t.agentName, session: t.sessionId })),
121
+ spentTodayUsd: Math.round(this.ensureBudget().spent * 1000) / 1000,
122
+ reviewing: this.activeReviews > 0,
123
+ sensitivity: settings.sensitivity || OBSERVER_DEFAULTS.sensitivity,
124
+ }
125
+ }
126
+
127
+ /** Detach from all tabs without digests (used when the user flips the
128
+ * panel toggle off — quiet stop, not a session end). */
129
+ detachAll(): void {
130
+ for (const tabId of [...this.tabs.keys()]) this.detachSync(tabId)
131
+ this.pushFeed({ kind: 'status', title: 'Observer turned off' })
132
+ }
133
+
134
+ /** Begin observing a tab. No-op unless enabled and the agent has a native
135
+ * transcript (Claude Code / Codex) — Phase 1 scope. */
136
+ attachTab(tabId: string, agentId: string, agentName: string, cwd: string): void {
137
+ try {
138
+ if (!this.isEnabled()) return
139
+ if (this.tabs.has(tabId)) this.detachSync(tabId)
140
+ if (!supportsTranscriptLogging(agentId)) {
141
+ log(`Tab ${tabId}: agent "${agentId}" has no native transcript; observer idle for this tab`)
142
+ return
143
+ }
144
+
145
+ const settings = this.opts.getSettings()
146
+ const sessionId = `ses_${randomUUID().replace(/-/g, '').slice(0, 12)}`
147
+ const ledgerDir = path.join(this.opts.settingsDir, 'observer', 'ledger')
148
+ const ledger = new SessionLedger(ledgerDir, sessionId)
149
+
150
+ const tab: TabSession = {
151
+ tabId,
152
+ agentId,
153
+ agentName,
154
+ cwd,
155
+ sessionId,
156
+ startedAt: new Date().toISOString(),
157
+ ledger,
158
+ tailer: null,
159
+ spans: new SpanTracker(sessionId, (span) => this.onSpanClosed(tab, span)),
160
+ tier1: null,
161
+ findings: [],
162
+ closedSpans: [],
163
+ escalations: [],
164
+ userMessages: [],
165
+ lastAgentState: 'idle',
166
+ ended: false,
167
+ }
168
+
169
+ const caller = this.resolveModelCaller()
170
+ if (caller) {
171
+ tab.tier1 = new Tier1Reviewer({
172
+ session: sessionId,
173
+ model: settings.tier1Model || OBSERVER_DEFAULTS.tier1Model,
174
+ windowEvents: settings.windowEvents ?? OBSERVER_DEFAULTS.windowEvents,
175
+ debounceMs: (settings.debounceSec ?? OBSERVER_DEFAULTS.debounceSec) * 1000,
176
+ callModel: caller,
177
+ // Resolved lazily so a daily-cap change from the settings UI
178
+ // applies to sessions already running.
179
+ isOverBudget: () => this.ensureBudget().overCap(),
180
+ onUsage: (model, usage) => this.ensureBudget().addUsage(model, usage),
181
+ onFindings: (findings) => this.onFindings(tab, findings),
182
+ onSpanBoundary: (workRef, kind) => tab.spans.applyBoundary(workRef, kind),
183
+ onEscalate: (reason) => {
184
+ tab.escalations.push(reason)
185
+ log(`Tab ${tabId}: Tier 1 escalation — ${reason}`)
186
+ },
187
+ onHealth: (message) => this.emitHealth(tab, message),
188
+ onReviewState: (running) => this.noteReviewState(running),
189
+ getSensitivity: () => this.opts.getSettings().sensitivity || OBSERVER_DEFAULTS.sensitivity,
190
+ onReviewed: (eventCount, findingCount) =>
191
+ this.pushFeed({
192
+ kind: 'status',
193
+ tabId,
194
+ session: sessionId,
195
+ title: `Reviewed ${eventCount} event${eventCount === 1 ? '' : 's'} — ${findingCount === 0 ? 'nothing to flag' : `${findingCount} finding${findingCount === 1 ? '' : 's'}`}`,
196
+ }),
197
+ getSpanHypothesis: () => tab.spans.currentWorkRef(),
198
+ })
199
+ } else {
200
+ this.emitHealth(tab, 'No Anthropic API key configured (settings.observer.anthropicApiKey or ANTHROPIC_API_KEY); running Tier 0 + spans only.')
201
+ }
202
+
203
+ tab.spans.begin()
204
+ tab.tailer = new TranscriptTailer({
205
+ agentId: agentId as 'claude' | 'codex',
206
+ cwd,
207
+ sink: (entry) => this.onChatEntry(tab, entry.type === 'user_input' ? 'user_message' : 'agent_message', entry.content, entry.ts),
208
+ rawSink: (obj) => this.onRawTranscriptLine(tab, obj),
209
+ claudeProjectsDir: this.opts.claudeProjectsDir,
210
+ codexSessionsDir: this.opts.codexSessionsDir,
211
+ })
212
+ tab.tailer.start()
213
+ this.tabs.set(tabId, tab)
214
+ log(`Observing tab ${tabId} (${agentId}) → ledger ${ledger.filePath}`)
215
+ this.pushFeed({
216
+ kind: 'status',
217
+ tabId,
218
+ session: sessionId,
219
+ title: `Observing ${agentName} (${tab.tier1 ? 'reviews when the agent hands control back' : 'Tier 0 + spans only — no API key'})`,
220
+ })
221
+ } catch (err: any) {
222
+ log(`attachTab failed: ${err?.message || err}`)
223
+ }
224
+ }
225
+
226
+ /** Human keystrokes reached the tab's pty (activity attribution only —
227
+ * content is never recorded here; typed prompts arrive via the transcript). */
228
+ noteHumanInput(tabId: string): void {
229
+ this.tabs.get(tabId)?.spans.noteHumanInput()
230
+ }
231
+
232
+ /** Agent busy/awaiting/idle state changed (from AgentStateMonitor). */
233
+ noteAgentState(tabId: string, state: string): void {
234
+ const tab = this.tabs.get(tabId)
235
+ if (!tab || tab.ended) return
236
+ tab.spans.noteAgentState(state)
237
+ const event = tab.ledger.append({ type: 'agent_state', ts: new Date().toISOString(), state })
238
+ tab.spans.noteEvent(event)
239
+
240
+ // Turn boundary: the agent just handed control back to the human — the
241
+ // same signal that drives the dock notification. Review what's buffered
242
+ // now instead of waiting for the window/debounce.
243
+ const wasBusy = tab.lastAgentState === 'busy'
244
+ const awaiting = state === 'awaiting_input' || state === 'awaiting_decision'
245
+ tab.lastAgentState = state
246
+ if (wasBusy && awaiting) tab.tier1?.reviewNow()
247
+ }
248
+
249
+ /** Session over: flush Tier 1, close spans, generate + deliver the digest.
250
+ * `projectPath` overrides cwd for ctlsurf delivery (remote-bound tabs). */
251
+ async endTab(tabId: string, projectPath?: string): Promise<void> {
252
+ const tab = this.tabs.get(tabId)
253
+ if (!tab || tab.ended) return
254
+ tab.ended = true
255
+ this.tabs.delete(tabId)
256
+
257
+ try {
258
+ tab.tailer?.stop()
259
+ tab.tailer = null
260
+ await tab.tier1?.flush()
261
+ tab.tier1?.dispose()
262
+ tab.spans.end()
263
+
264
+ const settings = this.opts.getSettings()
265
+ const caller = this.resolveModelCaller()
266
+ const budget = this.ensureBudget()
267
+ this.noteReviewState(true)
268
+ let digest
269
+ try {
270
+ digest = await generateDigest(
271
+ {
272
+ session: tab.sessionId,
273
+ agentName: tab.agentName,
274
+ cwd: projectPath || tab.cwd,
275
+ startedAt: tab.startedAt,
276
+ endedAt: new Date().toISOString(),
277
+ findings: tab.findings,
278
+ spans: tab.closedSpans,
279
+ humanInstructionsSummary: tab.tier1?.instructionsSummary || '',
280
+ escalations: tab.escalations,
281
+ userMessages: tab.userMessages,
282
+ },
283
+ caller && !budget.overCap() ? caller : null,
284
+ settings.tier2Model || OBSERVER_DEFAULTS.tier2Model,
285
+ (model, usage) => budget.addUsage(model, usage),
286
+ )
287
+ } finally {
288
+ this.noteReviewState(false)
289
+ }
290
+
291
+ tab.ledger.append({
292
+ type: 'observer.digest',
293
+ ts: new Date().toISOString(),
294
+ text: digest.digest,
295
+ payload: { from_model: digest.fromModel, findings: tab.findings.length, spans: tab.closedSpans.length },
296
+ })
297
+ this.pushFeed({
298
+ kind: 'digest',
299
+ tabId: tab.tabId,
300
+ session: tab.sessionId,
301
+ title: `Session digest (${tab.findings.length} finding${tab.findings.length === 1 ? '' : 's'}, ${tab.closedSpans.length} span${tab.closedSpans.length === 1 ? '' : 's'})`,
302
+ detail: digest.digest,
303
+ })
304
+
305
+ const deliveryPath = projectPath || tab.cwd
306
+ await this.store.appendDigest(deliveryPath, digest.digest, {
307
+ session: tab.sessionId,
308
+ agent: tab.agentName,
309
+ findings: tab.findings.length,
310
+ spans: tab.closedSpans.length,
311
+ ledger: tab.ledger.filePath,
312
+ })
313
+ log(`Tab ${tabId}: session ${tab.sessionId} closed — ${tab.findings.length} finding(s), ${tab.closedSpans.length} span(s)`)
314
+ } catch (err: any) {
315
+ log(`endTab failed for ${tabId}: ${err?.message || err}`)
316
+ }
317
+ }
318
+
319
+ async shutdown(): Promise<void> {
320
+ const ids = [...this.tabs.keys()]
321
+ await Promise.all(ids.map((id) => this.endTab(id)))
322
+ }
323
+
324
+ // ─── Event intake ───────────────────────────────
325
+
326
+ /** Clean chat text from the tailer's filtered sink. */
327
+ private onChatEntry(tab: TabSession, type: 'user_message' | 'agent_message', text: string, ts: string): void {
328
+ if (tab.ended) return
329
+ try {
330
+ if (type === 'user_message') tab.userMessages.push(text)
331
+ this.ingest(tab, { type, ts, text })
332
+ } catch (err: any) {
333
+ log(`onChatEntry failed: ${err?.message || err}`)
334
+ }
335
+ }
336
+
337
+ /** Raw transcript objects — used only for tool_use / tool_result, which the
338
+ * chat sink filters out. Message text arrives via onChatEntry. */
339
+ private onRawTranscriptLine(tab: TabSession, obj: any): void {
340
+ if (tab.ended || !obj || typeof obj !== 'object') return
341
+ try {
342
+ if (tab.agentId !== 'claude') return // Codex tool events: later phase
343
+ if (obj.isMeta || obj.isSidechain) return
344
+ if (typeof obj.cwd === 'string' && stripTrailingSep(obj.cwd) !== stripTrailingSep(tab.cwd)) return
345
+ const content = obj.message?.content
346
+ if (!Array.isArray(content)) return
347
+ const ts = typeof obj.timestamp === 'string' ? obj.timestamp : new Date().toISOString()
348
+
349
+ if (obj.type === 'assistant') {
350
+ for (const block of content) {
351
+ if (block?.type !== 'tool_use') continue
352
+ this.ingest(tab, {
353
+ type: 'tool_use',
354
+ ts,
355
+ tool: typeof block.name === 'string' ? block.name : 'unknown',
356
+ input: summarizeToolInput(block.input),
357
+ toolUseId: typeof block.id === 'string' ? block.id : undefined,
358
+ })
359
+ }
360
+ } else if (obj.type === 'user') {
361
+ for (const block of content) {
362
+ if (block?.type !== 'tool_result') continue
363
+ this.ingest(tab, {
364
+ type: 'tool_result',
365
+ ts,
366
+ toolUseId: typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined,
367
+ isError: !!block.is_error,
368
+ text: truncateForTier1(extractToolResultText(block.content)),
369
+ })
370
+ }
371
+ }
372
+ } catch (err: any) {
373
+ log(`onRawTranscriptLine failed: ${err?.message || err}`)
374
+ }
375
+ }
376
+
377
+ /** Tier 0 → ledger → spans → ground truth → Tier 1. */
378
+ private ingest(tab: TabSession, partial: Omit<LedgerEvent, 'id' | 'session' | 'tags'>): void {
379
+ const tags = tagEvent({ ...partial, id: '', session: tab.sessionId }, tab.cwd)
380
+ const event = tab.ledger.append({ ...partial, tags: tags.length ? tags : undefined })
381
+ tab.spans.noteEvent(event)
382
+
383
+ // Log-only "would have gated" (Phase 1): deterministic Tier-0 finding.
384
+ for (const tag of tags) {
385
+ if (tag.rule.startsWith('would_gate:') || tag.rule === 'secret_material') {
386
+ this.onFindings(tab, [{
387
+ type: 'observer.finding',
388
+ ts: event.ts,
389
+ category: tag.rule === 'secret_material' ? 'security' : 'destructive',
390
+ severity: tag.severity,
391
+ confidence: 0.95,
392
+ title: tag.rule === 'secret_material'
393
+ ? `Secret-shaped string in session (${tag.note})`
394
+ : `Would have gated: ${tag.rule.slice('would_gate:'.length)}`,
395
+ detail: `${tag.note || ''} (gates are log-only in Phase 1)`.trim(),
396
+ receipts: [event.id],
397
+ tier: 0,
398
+ status: 'open',
399
+ delivery: ['digest'],
400
+ resolution: null,
401
+ }])
402
+ }
403
+ }
404
+
405
+ // Ground truth: "tests pass" claims are decidable from the ledger.
406
+ if (event.type === 'agent_message' && tags.some((t) => t.rule === 'claim_tests_pass')) {
407
+ const check = checkTestClaim(tab.ledger.recent(), event.id)
408
+ if (!check.verified) {
409
+ this.onFindings(tab, [{
410
+ type: 'observer.finding',
411
+ ts: event.ts,
412
+ category: 'unverified_claim',
413
+ severity: 'high',
414
+ confidence: 0.9,
415
+ title: 'Claimed tests pass; ledger disagrees',
416
+ detail: check.detail,
417
+ receipts: check.receipts,
418
+ tier: 0,
419
+ status: 'open',
420
+ delivery: ['digest'],
421
+ resolution: null,
422
+ }])
423
+ }
424
+ }
425
+
426
+ tab.tier1?.push(event)
427
+ }
428
+
429
+ // ─── Outputs ────────────────────────────────────
430
+
431
+ private onFindings(tab: TabSession, findings: Array<Omit<Finding, 'id' | 'session'>>): void {
432
+ const complete: Finding[] = findings.map((f) => {
433
+ const event = tab.ledger.append({
434
+ type: 'observer.finding',
435
+ ts: f.ts,
436
+ payload: f,
437
+ })
438
+ return { ...f, id: event.id, session: tab.sessionId }
439
+ })
440
+ tab.findings.push(...complete)
441
+
442
+ // Sensitivity lever: everything is in the ledger, but only findings at
443
+ // or above the floor surface in the panel and ctlsurf.
444
+ const floor = severityFloor(this.opts.getSettings().sensitivity || OBSERVER_DEFAULTS.sensitivity)
445
+ const surfaced = complete.filter((f) => SEVERITY_ORDER[f.severity] >= SEVERITY_ORDER[floor])
446
+
447
+ for (const f of surfaced) {
448
+ this.pushFeed({
449
+ kind: 'finding',
450
+ tabId: tab.tabId,
451
+ session: tab.sessionId,
452
+ title: f.title,
453
+ detail: f.detail,
454
+ category: f.category,
455
+ severity: f.severity,
456
+ confidence: f.confidence,
457
+ receipts: f.receipts,
458
+ tier: f.tier,
459
+ })
460
+ }
461
+ // Deliver as they happen so the supervisor surface stays near-real-time.
462
+ void this.store.addFindings(tab.cwd, surfaced)
463
+ }
464
+
465
+ private onSpanClosed(tab: TabSession, span: Span): void {
466
+ tab.closedSpans.push(span)
467
+ tab.ledger.append({ type: 'observer.span', ts: span.end, id: span.id, payload: span })
468
+ }
469
+
470
+ private emitHealth(tab: TabSession, message: string): void {
471
+ log(`[health] ${message}`)
472
+ tab.ledger.append({ type: 'observer.health', ts: new Date().toISOString(), text: message })
473
+ this.pushFeed({ kind: 'health', tabId: tab.tabId, session: tab.sessionId, title: message })
474
+ }
475
+
476
+ /** Track concurrent model calls; notify the panel on busy↔idle edges. */
477
+ private noteReviewState(running: boolean): void {
478
+ const wasActive = this.activeReviews > 0
479
+ this.activeReviews = Math.max(0, this.activeReviews + (running ? 1 : -1))
480
+ const isActive = this.activeReviews > 0
481
+ if (wasActive !== isActive) {
482
+ try { this.opts.onActivity?.(isActive) } catch { /* renderer gone */ }
483
+ }
484
+ }
485
+
486
+ private pushFeed(item: Omit<ObserverFeedItem, 'id' | 'ts'>): void {
487
+ const complete: ObserverFeedItem = {
488
+ ...item,
489
+ id: `feed_${++this.feedSeq}`,
490
+ ts: new Date().toISOString(),
491
+ }
492
+ this.feed.push(complete)
493
+ if (this.feed.length > FEED_LIMIT) this.feed.splice(0, this.feed.length - FEED_LIMIT)
494
+ try { this.opts.onUpdate?.(complete) } catch { /* renderer gone — ignore */ }
495
+ }
496
+
497
+ // ─── Config plumbing ────────────────────────────
498
+
499
+ private resolveModelCaller(): ModelCaller | null {
500
+ const key = this.opts.getSettings().anthropicApiKey || process.env.ANTHROPIC_API_KEY || ''
501
+ if (!key) return null
502
+ if (!this.modelCaller || this.modelCallerKey !== key) {
503
+ this.modelCaller = createModelCaller(key)
504
+ this.modelCallerKey = key
505
+ }
506
+ return this.modelCaller
507
+ }
508
+
509
+ private ensureBudget(): ObserverBudget {
510
+ const cap = this.opts.getSettings().dailyCapUsd ?? OBSERVER_DEFAULTS.dailyCapUsd
511
+ if (!this.budget || this.budgetCapUsd !== cap) {
512
+ // Recreating re-reads today's spend from disk, so a cap change (from
513
+ // the settings UI) applies without losing the day's accounting.
514
+ this.budget = new ObserverBudget(path.join(this.opts.settingsDir, 'observer'), cap)
515
+ this.budgetCapUsd = cap
516
+ }
517
+ return this.budget
518
+ }
519
+
520
+ private detachSync(tabId: string): void {
521
+ const tab = this.tabs.get(tabId)
522
+ if (!tab) return
523
+ tab.ended = true
524
+ tab.tailer?.stop()
525
+ tab.tier1?.dispose()
526
+ this.tabs.delete(tabId)
527
+ }
528
+ }
529
+
530
+ // ─── Helpers ──────────────────────────────────────
531
+
532
+ function stripTrailingSep(p: string): string {
533
+ return p.length > 1 ? p.replace(/[/\\]+$/, '') : p
534
+ }
535
+
536
+ /** Compact one-line summary of a tool_use input for the ledger. */
537
+ export function summarizeToolInput(input: unknown): string {
538
+ if (!input || typeof input !== 'object') return ''
539
+ const obj = input as Record<string, unknown>
540
+ if (typeof obj.command === 'string') return obj.command.slice(0, 600)
541
+ const parts: string[] = []
542
+ if (typeof obj.file_path === 'string') parts.push(obj.file_path)
543
+ if (typeof obj.pattern === 'string') parts.push(`pattern=${obj.pattern}`)
544
+ if (typeof obj.url === 'string') parts.push(obj.url)
545
+ if (typeof obj.description === 'string') parts.push(obj.description)
546
+ if (typeof obj.new_string === 'string' || typeof obj.old_string === 'string' || typeof obj.content === 'string') {
547
+ const body = `${obj.old_string || ''} -> ${obj.new_string || ''}${obj.content || ''}`
548
+ parts.push(body.slice(0, 400))
549
+ }
550
+ if (parts.length > 0) return parts.join(' ').slice(0, 600)
551
+ try {
552
+ return JSON.stringify(obj).slice(0, 400)
553
+ } catch {
554
+ return ''
555
+ }
556
+ }
557
+
558
+ /** Extract text from a tool_result content value (string or block array). */
559
+ export function extractToolResultText(content: unknown): string {
560
+ if (typeof content === 'string') return content
561
+ if (!Array.isArray(content)) return ''
562
+ return content
563
+ .filter((b: any) => b?.type === 'text' && typeof b.text === 'string')
564
+ .map((b: any) => b.text)
565
+ .join('\n')
566
+ }