@phenx-inc/ctlsurf 0.13.5 → 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 (44) 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 +11677 -290
  4. package/out/main/node-CUnQxFha.js +868 -0
  5. package/out/preload/index.js +19 -0
  6. package/out/renderer/assets/{cssMode-CAEObGNt.js → cssMode-7adWSORr.js} +3 -3
  7. package/out/renderer/assets/{freemarker2-D63hnnDq.js → freemarker2-BkLLWnzk.js} +1 -1
  8. package/out/renderer/assets/{handlebars-UYo_IYpi.js → handlebars-Cinfm9fZ.js} +1 -1
  9. package/out/renderer/assets/{html-2dvYDi3g.js → html-vl9fkHKL.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-B6fxDDPF.js → htmlMode-BtGqkTNI.js} +3 -3
  11. package/out/renderer/assets/{index-CMb4AqkZ.js → index-BhrV6ld-.js} +381 -29
  12. package/out/renderer/assets/{index-BEekQe3-.css → index-BpCORklm.css} +217 -0
  13. package/out/renderer/assets/{javascript-8RyFDDdk.js → javascript-B7CCdfuW.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-CqspkWCb.js → jsonMode-ut9LWBMJ.js} +3 -3
  15. package/out/renderer/assets/{liquid-DaEnAsYY.js → liquid-DVDJ0lKt.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-Czkp5sZb.js → lspLanguageFeatures-BbMj8Wsc.js} +1 -1
  17. package/out/renderer/assets/{mdx-Q-qSZsFg.js → mdx-CcQzYB0J.js} +1 -1
  18. package/out/renderer/assets/{python-Sw0S0BJV.js → python-Byy7wKMv.js} +1 -1
  19. package/out/renderer/assets/{razor-C7zQmabz.js → razor-D1xi_wMM.js} +1 -1
  20. package/out/renderer/assets/{tsMode-BWdOxk6v.js → tsMode-DJPzbn5i.js} +1 -1
  21. package/out/renderer/assets/{typescript-BrdIiKZf.js → typescript-BuVaU7i3.js} +1 -1
  22. package/out/renderer/assets/{xml-DbKLNPOq.js → xml-rzc91O3d.js} +1 -1
  23. package/out/renderer/assets/{yaml-ehupJBsZ.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 +55 -8
  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 +40 -2
  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/components/TerminalPanel.tsx +28 -5
  44. package/src/renderer/styles.css +217 -0
@@ -12,6 +12,8 @@ import { AgentStateMonitor, type AgentState } from './agentState'
12
12
  import { WorkerWsClient, type WorkerWsStatus, type IncomingMessage } from './workerWs'
13
13
  import { TimeTracker } from './timeTracker'
14
14
  import { TicketStore, type TicketInput } from './ticketStore'
15
+ import { Observer } from './observer/observer'
16
+ import { OBSERVER_DEFAULTS, type ObserverSettings } from './observer/types'
15
17
  import { log } from './logger'
16
18
 
17
19
  // ─── Types ────────────────────────────────────────
@@ -37,6 +39,10 @@ export interface SettingsData {
37
39
  // Speak agent replies aloud (Electron desktop only). Drives the transcript
38
40
  // tailer the same way logChat does, independent of chat logging.
39
41
  speakReplies?: boolean
42
+ // Observer supervision layer (advisory; off by default). See
43
+ // src/main/observer/. Requires an Anthropic API key for Tiers 1–2;
44
+ // without one it still records the ledger, Tier-0 tags, and spans.
45
+ observer?: ObserverSettings
40
46
  }
41
47
 
42
48
  export interface OrchestratorEvents {
@@ -57,6 +63,12 @@ export interface OrchestratorEvents {
57
63
  // is true when any tab is waiting on the user, and drives the dock/taskbar
58
64
  // attention indicator. Optional — only the Electron entry wires it.
59
65
  onAgentStateChange?: (tabId: string, state: AgentState, anyAwaiting: boolean) => void
66
+ // Live Observer feed item (finding / digest / health / status) for the
67
+ // in-app Observer panel. Optional — only the Electron entry wires it.
68
+ onObserverUpdate?: (item: unknown) => void
69
+ // Observer model review in-flight (true while a Tier-1/Tier-2 call runs) —
70
+ // drives the panel's processing animation.
71
+ onObserverActivity?: (reviewing: boolean) => void
60
72
  }
61
73
 
62
74
  interface TabState {
@@ -97,6 +109,7 @@ export class Orchestrator {
97
109
  readonly workerWs: WorkerWsClient
98
110
  readonly timeTracker = new TimeTracker(this.ctlsurfApi)
99
111
  readonly ticketStore = new TicketStore(this.ctlsurfApi)
112
+ readonly observer: Observer
100
113
 
101
114
  // State
102
115
  private tabs = new Map<string, TabState>()
@@ -122,8 +135,17 @@ export class Orchestrator {
122
135
  this.settingsDir = settingsDir
123
136
  this.events = events
124
137
 
138
+ this.observer = new Observer({
139
+ settingsDir,
140
+ api: this.ctlsurfApi,
141
+ getSettings: () => this.settings.observer || {},
142
+ onUpdate: (item) => this.events.onObserverUpdate?.(item),
143
+ onActivity: (reviewing) => this.events.onObserverActivity?.(reviewing),
144
+ })
145
+
125
146
  this.stateMonitor = new AgentStateMonitor(({ tabId, state, anyAwaiting }) => {
126
147
  this.events.onAgentStateChange?.(tabId, state, anyAwaiting)
148
+ this.observer.noteAgentState(tabId, state)
127
149
  })
128
150
  this.stateMonitor.start()
129
151
 
@@ -205,6 +227,53 @@ export class Orchestrator {
205
227
  this.restartActiveChatLogging()
206
228
  }
207
229
 
230
+ // ─── Observer settings ──────────────────────────
231
+
232
+ /** Summary for the settings UI — never echoes the API key back. */
233
+ getObserverSettingsSummary(): { enabled: boolean; hasApiKey: boolean; keySource: 'settings' | 'env' | null; dailyCapUsd: number } {
234
+ const o = this.settings.observer || {}
235
+ const keySource = o.anthropicApiKey ? 'settings' : process.env.ANTHROPIC_API_KEY ? 'env' : null
236
+ return {
237
+ enabled: !!o.enabled,
238
+ hasApiKey: keySource !== null,
239
+ keySource,
240
+ dailyCapUsd: o.dailyCapUsd ?? OBSERVER_DEFAULTS.dailyCapUsd,
241
+ }
242
+ }
243
+
244
+ /** Persist observer settings. The Observer reads them lazily, so changes
245
+ * apply to new model calls immediately and to newly spawned agent tabs;
246
+ * an empty apiKey means "keep the stored one" (profile-edit convention). */
247
+ saveObserverSettings(data: { enabled: boolean; anthropicApiKey?: string; dailyCapUsd?: number; sensitivity?: ObserverSettings['sensitivity'] }): void {
248
+ const prev = this.settings.observer || {}
249
+ this.settings.observer = {
250
+ ...prev,
251
+ enabled: !!data.enabled,
252
+ ...(data.anthropicApiKey?.trim() ? { anthropicApiKey: data.anthropicApiKey.trim() } : {}),
253
+ ...(data.dailyCapUsd !== undefined && Number.isFinite(data.dailyCapUsd)
254
+ ? { dailyCapUsd: Math.max(0.05, data.dailyCapUsd) }
255
+ : {}),
256
+ ...(data.sensitivity && ['severe', 'balanced', 'all'].includes(data.sensitivity)
257
+ ? { sensitivity: data.sensitivity }
258
+ : {}),
259
+ }
260
+ this.saveSettings()
261
+ log(`[observer] Settings saved: enabled=${!!data.enabled}${data.sensitivity ? ` sensitivity=${data.sensitivity}` : ''}`)
262
+ }
263
+
264
+ /** Flip the Observer on/off live (panel toggle). Enabling attaches to all
265
+ * running agent tabs immediately; disabling detaches quietly (no digests). */
266
+ setObserverEnabled(enabled: boolean): void {
267
+ this.saveObserverSettings({ enabled })
268
+ if (enabled) {
269
+ for (const [tabId, tab] of this.tabs) {
270
+ this.observer.attachTab(tabId, tab.agent.id, tab.agent.name, tab.cwd)
271
+ }
272
+ } else {
273
+ this.observer.detachAll()
274
+ }
275
+ }
276
+
208
277
  // Re-evaluate the transcript tailer for the active tab against the current
209
278
  // logChat / speakReplies flags. Both setters funnel through here.
210
279
  private restartActiveChatLogging(): void {
@@ -557,6 +626,7 @@ export class Orchestrator {
557
626
  ptyManager.onExit(async (exitCode: number) => {
558
627
  this.events.onPtyExit(tabId, exitCode)
559
628
  await this.timeTracker.endSession(tabId)
629
+ void this.observer.endTab(tabId, this.tabs.get(tabId)?.projectRootPath)
560
630
  if (tabId === this.activeTabId) {
561
631
  this.stopChatLogging()
562
632
  if (this.currentAgent && isCodingAgent(this.currentAgent)) {
@@ -570,6 +640,7 @@ export class Orchestrator {
570
640
  })
571
641
 
572
642
  this.startChatLogging(agent, cwd)
643
+ this.observer.attachTab(tabId, agent.id, agent.name, cwd)
573
644
 
574
645
  const profile = this.getActiveProfile()
575
646
  const shouldTrack = opts?.trackTime !== undefined ? opts.trackTime : (profile.trackTime !== false)
@@ -594,6 +665,7 @@ export class Orchestrator {
594
665
  writePty(tabId: string, data: string): void {
595
666
  this.tabs.get(tabId)?.ptyManager.write(data)
596
667
  this.stateMonitor.noteInput(tabId, data)
668
+ this.observer.noteHumanInput(tabId)
597
669
  if (tabId === this.activeTabId) {
598
670
  this.bridge.feedInput(data)
599
671
  }
@@ -612,6 +684,7 @@ export class Orchestrator {
612
684
  if (!tab) return
613
685
  if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)
614
686
  await this.timeTracker.endSession(tabId)
687
+ void this.observer.endTab(tabId, tab.projectRootPath)
615
688
  tab.ptyManager.kill()
616
689
  this.tabs.delete(tabId)
617
690
  this.stateMonitor.removeTab(tabId)
@@ -831,6 +904,7 @@ export class Orchestrator {
831
904
  this.stopChatLogging()
832
905
  this.stateMonitor.stop()
833
906
  await this.timeTracker.endAll()
907
+ await this.observer.shutdown()
834
908
  for (const [, tab] of this.tabs) {
835
909
  if (tab.termStreamTimer) clearTimeout(tab.termStreamTimer)
836
910
  tab.ptyManager.kill()
@@ -40,6 +40,13 @@ export interface TranscriptTailerOptions {
40
40
  agentId: TranscriptAgentId
41
41
  cwd: string
42
42
  sink: ChatLogSink
43
+ /**
44
+ * Optional raw-line hook: receives every parsed transcript JSONL object
45
+ * (before chat filtering), minus replayed history and codex files excluded
46
+ * by cwd. Used by the Observer to see tool_use/tool_result records the
47
+ * chat sink discards. Must never throw.
48
+ */
49
+ rawSink?: (obj: unknown) => void
43
50
  /** Override transcript roots (for tests). */
44
51
  claudeProjectsDir?: string
45
52
  codexSessionsDir?: string
@@ -61,6 +68,7 @@ export class TranscriptTailer {
61
68
  private readonly agentId: TranscriptAgentId
62
69
  private readonly cwd: string
63
70
  private readonly sink: ChatLogSink
71
+ private readonly rawSink?: (obj: unknown) => void
64
72
  private readonly claudeProjectsDir: string
65
73
  private readonly codexSessionsDir: string
66
74
 
@@ -72,6 +80,7 @@ export class TranscriptTailer {
72
80
  this.agentId = options.agentId
73
81
  this.cwd = stripTrailingSep(options.cwd)
74
82
  this.sink = options.sink
83
+ this.rawSink = options.rawSink
75
84
  this.claudeProjectsDir = options.claudeProjectsDir || path.join(os.homedir(), '.claude', 'projects')
76
85
  this.codexSessionsDir = options.codexSessionsDir || path.join(os.homedir(), '.codex', 'sessions')
77
86
  }
@@ -209,6 +218,15 @@ export class TranscriptTailer {
209
218
  return // tolerate partial/corrupt lines
210
219
  }
211
220
 
221
+ // Raw hook (Observer): every parsed object except replayed history.
222
+ // Codex lines from excluded files never reach here (drainFile stops).
223
+ if (this.rawSink) {
224
+ const rawMs = typeof obj?.timestamp === 'string' ? Date.parse(obj.timestamp) : NaN
225
+ if (!Number.isFinite(rawMs) || rawMs >= this.sinceMs - DISCOVERY_SLACK_MS) {
226
+ try { this.rawSink(obj) } catch { /* observer errors must not break tailing */ }
227
+ }
228
+ }
229
+
212
230
  const entry = this.agentId === 'claude' ? this.parseClaudeLine(obj) : this.parseCodexLine(obj, tail)
213
231
  if (!entry) return
214
232
 
@@ -81,9 +81,11 @@ const api = {
81
81
  ipcRenderer.invoke('app:browseCwd'),
82
82
 
83
83
  // Clipboard — write a rich-HTML table (+ plain-text fallback) for email paste.
84
- copyEmailTable: (html: string, text: string): Promise<{ ok: boolean }> =>
84
+ // `ok` reflects a read-back verification in the main process, not just that the
85
+ // call was made; `error` is set when the write threw.
86
+ copyEmailTable: (html: string, text: string): Promise<{ ok: boolean; error?: string }> =>
85
87
  ipcRenderer.invoke('clipboard:writeTable', html, text),
86
- copyText: (text: string): Promise<{ ok: boolean }> =>
88
+ copyText: (text: string): Promise<{ ok: boolean; error?: string }> =>
87
89
  ipcRenderer.invoke('clipboard:writeText', text),
88
90
  getVersion: (): Promise<string> =>
89
91
  ipcRenderer.invoke('app:getVersion'),
@@ -142,6 +144,42 @@ const api = {
142
144
  ipcRenderer.invoke('speak:get'),
143
145
  setSpeakReplies: (enabled: boolean): Promise<{ enabled: boolean }> =>
144
146
  ipcRenderer.invoke('speak:set', enabled),
147
+
148
+ // Observer supervision layer (global). The API key is write-only over IPC:
149
+ // the getter reports only whether one is configured and from where.
150
+ getObserverSettings: (): Promise<{ enabled: boolean; hasApiKey: boolean; keySource: 'settings' | 'env' | null; dailyCapUsd: number }> =>
151
+ ipcRenderer.invoke('observer:get'),
152
+ setObserverSettings: (data: { enabled: boolean; anthropicApiKey?: string; dailyCapUsd?: number; sensitivity?: 'severe' | 'balanced' | 'all' }): Promise<{ enabled: boolean; hasApiKey: boolean; keySource: 'settings' | 'env' | null; dailyCapUsd: number }> =>
153
+ ipcRenderer.invoke('observer:set', data),
154
+ // Observer panel: feed snapshot, live on/off toggle, and push updates.
155
+ getObserverFeed: (): Promise<{
156
+ items: Array<Record<string, unknown>>
157
+ enabled: boolean
158
+ hasApiKey: boolean
159
+ observing: Array<{ tabId: string; agentName: string; session: string }>
160
+ spentTodayUsd: number
161
+ reviewing: boolean
162
+ sensitivity: 'severe' | 'balanced' | 'all'
163
+ }> => ipcRenderer.invoke('observer:feed'),
164
+ toggleObserver: (enabled: boolean): Promise<{
165
+ items: Array<Record<string, unknown>>
166
+ enabled: boolean
167
+ hasApiKey: boolean
168
+ observing: Array<{ tabId: string; agentName: string; session: string }>
169
+ spentTodayUsd: number
170
+ reviewing: boolean
171
+ sensitivity: 'severe' | 'balanced' | 'all'
172
+ }> => ipcRenderer.invoke('observer:toggle', enabled),
173
+ onObserverUpdate: (callback: (item: Record<string, unknown>) => void) => {
174
+ const listener = (_event: Electron.IpcRendererEvent, item: Record<string, unknown>) => callback(item)
175
+ ipcRenderer.on('observer:update', listener)
176
+ return () => ipcRenderer.removeListener('observer:update', listener)
177
+ },
178
+ onObserverActivity: (callback: (reviewing: boolean) => void) => {
179
+ const listener = (_event: Electron.IpcRendererEvent, reviewing: boolean) => callback(reviewing)
180
+ ipcRenderer.on('observer:activity', listener)
181
+ return () => ipcRenderer.removeListener('observer:activity', listener)
182
+ },
145
183
  onAgentMessage: (callback: (text: string) => void) => {
146
184
  const listener = (_event: Electron.IpcRendererEvent, text: string) => callback(text)
147
185
  ipcRenderer.on('agent:message', listener)
@@ -16,6 +16,7 @@ import {
16
16
  import { StatusBar } from './components/StatusBar'
17
17
  import { SettingsDialog } from './components/SettingsDialog'
18
18
  import { TicketPane } from './components/TicketPanel'
19
+ import { ObserverPane } from './components/ObserverPanel'
19
20
 
20
21
  interface AgentConfig {
21
22
  id: string
@@ -67,6 +68,12 @@ declare global {
67
68
  setLogChat: (enabled: boolean) => Promise<{ enabled: boolean }>
68
69
  getSpeakReplies: () => Promise<{ enabled: boolean }>
69
70
  setSpeakReplies: (enabled: boolean) => Promise<{ enabled: boolean }>
71
+ getObserverSettings: () => Promise<{ enabled: boolean; hasApiKey: boolean; keySource: 'settings' | 'env' | null; dailyCapUsd: number }>
72
+ setObserverSettings: (data: { enabled: boolean; anthropicApiKey?: string; dailyCapUsd?: number; sensitivity?: 'severe' | 'balanced' | 'all' }) => Promise<{ enabled: boolean; hasApiKey: boolean; keySource: 'settings' | 'env' | null; dailyCapUsd: number }>
73
+ getObserverFeed: () => Promise<{ items: Array<Record<string, unknown>>; enabled: boolean; hasApiKey: boolean; observing: Array<{ tabId: string; agentName: string; session: string }>; spentTodayUsd: number; reviewing: boolean; sensitivity: 'severe' | 'balanced' | 'all' }>
74
+ toggleObserver: (enabled: boolean) => Promise<{ items: Array<Record<string, unknown>>; enabled: boolean; hasApiKey: boolean; observing: Array<{ tabId: string; agentName: string; session: string }>; spentTodayUsd: number; reviewing: boolean; sensitivity: 'severe' | 'balanced' | 'all' }>
75
+ onObserverUpdate: (callback: (item: Record<string, unknown>) => void) => () => void
76
+ onObserverActivity: (callback: (reviewing: boolean) => void) => () => void
70
77
  onAgentMessage: (callback: (text: string) => void) => () => void
71
78
  onAgentState: (callback: (tabId: string, state: string, anyAwaiting: boolean) => void) => () => void
72
79
  getLastReply: () => Promise<string | null>
@@ -524,6 +531,7 @@ export default function App() {
524
531
  },
525
532
  { id: 'ctlsurf', label: 'ctlsurf', content: <CtlsurfPanel navigate={ctlsurfNav} /> },
526
533
  { id: 'tickets', label: 'Tickets', content: <TicketPane onSendToTerminal={handleSendToTerminal} /> },
534
+ { id: 'observer', label: 'Observer', content: <ObserverPane /> },
527
535
  ]
528
536
 
529
537
  return (
@@ -565,6 +573,20 @@ export default function App() {
565
573
  </svg>
566
574
  <span>Tickets</span>
567
575
  </button>
576
+ <button
577
+ className={`titlebar-btn titlebar-icon-btn ${visiblePaneIds.includes('observer') ? 'active' : ''}`}
578
+ onClick={() => togglePane('observer')}
579
+ title="Toggle Observer pane — live session review"
580
+ aria-label="Observer"
581
+ >
582
+ <svg viewBox="0 0 24 24" width="13" height="13"
583
+ fill="none" stroke="currentColor" strokeWidth="2"
584
+ strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
585
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
586
+ <circle cx="12" cy="12" r="3" />
587
+ </svg>
588
+ <span>Observer</span>
589
+ </button>
568
590
  <button
569
591
  className={`titlebar-btn titlebar-icon-btn ${showFloatingMic ? 'active' : ''}`}
570
592
  onClick={() => setFloatingMicVisible(!showFloatingMic)}
@@ -0,0 +1,289 @@
1
+ import { useState, useEffect, useCallback } from 'react'
2
+
3
+ /**
4
+ * Observer side pane — live supervision feed for the current session.
5
+ *
6
+ * Shares the drawer chrome with the Tickets pane but renders its own card
7
+ * language: severity-accented finding cards, a digest card per session, and
8
+ * muted one-line heartbeats so it's always visible that a review ran. The
9
+ * header switch turns the whole feature on/off, applying immediately to
10
+ * running agent tabs.
11
+ */
12
+
13
+ interface FeedItem {
14
+ id: string
15
+ ts: string
16
+ kind: 'finding' | 'digest' | 'health' | 'status'
17
+ tabId?: string
18
+ session?: string
19
+ title: string
20
+ detail?: string
21
+ category?: string
22
+ severity?: string
23
+ confidence?: number
24
+ receipts?: string[]
25
+ tier?: number
26
+ }
27
+
28
+ type Sensitivity = 'severe' | 'balanced' | 'all'
29
+
30
+ interface FeedSnapshot {
31
+ items: FeedItem[]
32
+ enabled: boolean
33
+ hasApiKey: boolean
34
+ observing: Array<{ tabId: string; agentName: string; session: string }>
35
+ spentTodayUsd: number
36
+ reviewing: boolean
37
+ sensitivity: Sensitivity
38
+ }
39
+
40
+ const SENSITIVITY_LEVELS: Array<{ value: Sensitivity; label: string; hint: string }> = [
41
+ { value: 'severe', label: 'Severe', hint: 'Only high/critical findings — near silence' },
42
+ { value: 'balanced', label: 'Balanced', hint: 'Warn and above — clear signals only' },
43
+ { value: 'all', label: 'All signals', hint: 'Everything, including low-confidence observations' },
44
+ ]
45
+
46
+ const SEVERITY_COLORS: Record<string, string> = {
47
+ info: '#7aa2f7',
48
+ warn: '#e0af68',
49
+ high: '#ff9e64',
50
+ critical: '#f7768e',
51
+ }
52
+ const DIGEST_COLOR = '#bb9af7'
53
+
54
+ function EyeIcon({ size = 16 }: { size?: number }) {
55
+ return (
56
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="none"
57
+ stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
58
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
59
+ <circle cx="12" cy="12" r="3" />
60
+ </svg>
61
+ )
62
+ }
63
+
64
+ function formatTime(iso: string): string {
65
+ const d = new Date(iso)
66
+ if (Number.isNaN(d.getTime())) return ''
67
+ return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
68
+ }
69
+
70
+ function Chip({ color, label }: { color: string; label: string }) {
71
+ return (
72
+ <span className="obs-chip" style={{ color, borderColor: `${color}55`, background: `${color}14` }}>
73
+ <span className="obs-chip-dot" style={{ background: color }} />
74
+ {label}
75
+ </span>
76
+ )
77
+ }
78
+
79
+ function FindingCard({ item }: { item: FeedItem }) {
80
+ const [expanded, setExpanded] = useState(false)
81
+ const color = SEVERITY_COLORS[item.severity || 'info'] || SEVERITY_COLORS.info
82
+ return (
83
+ <button
84
+ type="button"
85
+ className="obs-card"
86
+ style={{ borderLeftColor: color }}
87
+ onClick={() => setExpanded(v => !v)}
88
+ aria-expanded={expanded}
89
+ >
90
+ <div className="obs-card-top">
91
+ <Chip color={color} label={item.severity || 'info'} />
92
+ <span className="obs-card-cat">{(item.category || '').replace(/_/g, ' ')}</span>
93
+ <span className="obs-card-time">{formatTime(item.ts)}</span>
94
+ </div>
95
+ <div className="obs-card-title">{item.title}</div>
96
+ {expanded && (
97
+ <div className="obs-card-body">
98
+ {item.detail && <p>{item.detail}</p>}
99
+ <div className="obs-card-facts">
100
+ {typeof item.confidence === 'number' && (
101
+ <span className="obs-fact">{Math.round(item.confidence * 100)}% confidence</span>
102
+ )}
103
+ {typeof item.tier === 'number' && <span className="obs-fact">tier {item.tier}</span>}
104
+ {item.receipts && item.receipts.length > 0 && (
105
+ <span className="obs-fact">{item.receipts.join(' · ')}</span>
106
+ )}
107
+ </div>
108
+ </div>
109
+ )}
110
+ <span className={`obs-card-caret ${expanded ? 'open' : ''}`} aria-hidden="true">▸</span>
111
+ </button>
112
+ )
113
+ }
114
+
115
+ function DigestCard({ item }: { item: FeedItem }) {
116
+ const [expanded, setExpanded] = useState(true)
117
+ return (
118
+ <button
119
+ type="button"
120
+ className="obs-card"
121
+ style={{ borderLeftColor: DIGEST_COLOR }}
122
+ onClick={() => setExpanded(v => !v)}
123
+ aria-expanded={expanded}
124
+ >
125
+ <div className="obs-card-top">
126
+ <Chip color={DIGEST_COLOR} label="digest" />
127
+ <span className="obs-card-time">{formatTime(item.ts)}</span>
128
+ </div>
129
+ <div className="obs-card-title">{item.title}</div>
130
+ {expanded && item.detail && (
131
+ <div className="obs-card-body"><p>{item.detail}</p></div>
132
+ )}
133
+ <span className={`obs-card-caret ${expanded ? 'open' : ''}`} aria-hidden="true">▸</span>
134
+ </button>
135
+ )
136
+ }
137
+
138
+ export function ObserverPane() {
139
+ const [snapshot, setSnapshot] = useState<FeedSnapshot | null>(null)
140
+ const [toggling, setToggling] = useState(false)
141
+ const [reviewing, setReviewing] = useState(false)
142
+
143
+ const refresh = useCallback(async () => {
144
+ try {
145
+ const s = await window.worker.getObserverFeed()
146
+ setSnapshot(s as FeedSnapshot)
147
+ setReviewing((s as FeedSnapshot).reviewing)
148
+ } catch { /* main process without observer support */ }
149
+ }, [])
150
+
151
+ useEffect(() => {
152
+ void refresh()
153
+ const unsub = window.worker.onObserverUpdate((item) => {
154
+ setSnapshot(prev => prev
155
+ ? { ...prev, items: [...prev.items.filter(i => i.id !== (item as FeedItem).id), item as FeedItem].slice(-200) }
156
+ : prev)
157
+ })
158
+ const unsubActivity = window.worker.onObserverActivity(setReviewing)
159
+ // Status bits (observing list, spend) refresh with the project/tab context.
160
+ const unsubCwd = window.worker.onCwdChanged(() => void refresh())
161
+ return () => { unsub(); unsubActivity(); unsubCwd() }
162
+ }, [refresh])
163
+
164
+ const handleToggle = useCallback(async (enabled: boolean) => {
165
+ if (toggling) return
166
+ setToggling(true)
167
+ try {
168
+ const s = await window.worker.toggleObserver(enabled)
169
+ setSnapshot(s as FeedSnapshot)
170
+ } finally {
171
+ setToggling(false)
172
+ }
173
+ }, [toggling])
174
+
175
+ const handleSensitivity = useCallback(async (level: Sensitivity) => {
176
+ const current = snapshot
177
+ if (!current || current.sensitivity === level) return
178
+ setSnapshot({ ...current, sensitivity: level }) // optimistic
179
+ try {
180
+ await window.worker.setObserverSettings({ enabled: current.enabled, sensitivity: level })
181
+ } catch {
182
+ void refresh()
183
+ }
184
+ }, [snapshot, refresh])
185
+
186
+ const enabled = snapshot?.enabled ?? false
187
+ const items = [...(snapshot?.items || [])].reverse() // newest first
188
+ const findings = items.filter(i => i.kind === 'finding')
189
+
190
+ return (
191
+ <div className="ticket-pane observer-pane" role="region" aria-label="Observer">
192
+ <div className="ticket-drawer-header">
193
+ <div className="ticket-drawer-heading">
194
+ <span className={`ticket-drawer-badge obs-badge ${reviewing && enabled ? 'reviewing' : ''}`}>
195
+ <EyeIcon size={16} />
196
+ </span>
197
+ <div className="ticket-drawer-headtext">
198
+ <span className="ticket-drawer-title">Observer</span>
199
+ <span className="ticket-drawer-subtitle">
200
+ {!snapshot ? 'loading…'
201
+ : !enabled ? 'off'
202
+ : !snapshot.hasApiKey ? <span className="ticket-drawer-warn">no Anthropic API key — Tier 0 only (add in Settings)</span>
203
+ : snapshot.observing.length === 0 ? 'on — waiting for an agent tab'
204
+ : <>watching <strong>{snapshot.observing.map(o => o.agentName).join(', ')}</strong></>}
205
+ </span>
206
+ </div>
207
+ </div>
208
+ <label className="observer-switch" title={enabled ? 'Turn Observer off' : 'Turn Observer on'}>
209
+ <input
210
+ type="checkbox"
211
+ checked={enabled}
212
+ disabled={toggling || !snapshot}
213
+ onChange={e => void handleToggle(e.target.checked)}
214
+ />
215
+ <span className="observer-switch-track"><span className="observer-switch-thumb" /></span>
216
+ </label>
217
+ </div>
218
+
219
+ {enabled && snapshot && (
220
+ <div className="obs-toolbar">
221
+ <div className="obs-sensitivity" role="radiogroup" aria-label="Detection threshold">
222
+ {SENSITIVITY_LEVELS.map(l => (
223
+ <button
224
+ key={l.value}
225
+ type="button"
226
+ className={`obs-sens-btn ${snapshot.sensitivity === l.value ? 'active' : ''}`}
227
+ title={l.hint}
228
+ aria-pressed={snapshot.sensitivity === l.value}
229
+ onClick={() => void handleSensitivity(l.value)}
230
+ >
231
+ {l.label}
232
+ </button>
233
+ ))}
234
+ </div>
235
+ {reviewing && (
236
+ <span className="obs-reviewing" aria-live="polite">
237
+ <span className="obs-reviewing-dot" />
238
+ <span className="obs-reviewing-dot" />
239
+ <span className="obs-reviewing-dot" />
240
+ reviewing
241
+ </span>
242
+ )}
243
+ </div>
244
+ )}
245
+
246
+ <div className="ticket-list-body obs-feed">
247
+ {!enabled && (
248
+ <div className="ticket-empty">
249
+ <span className="ticket-empty-icon"><EyeIcon size={26} /></span>
250
+ <span className="ticket-empty-title">Observer is off</span>
251
+ <span className="ticket-empty-hint">
252
+ Flip the switch to review this session for assumptions, unverified
253
+ claims, and dependency/license risk each time the agent hands
254
+ control back to you.
255
+ </span>
256
+ </div>
257
+ )}
258
+ {enabled && items.length === 0 && (
259
+ <div className="ticket-empty">
260
+ <span className="ticket-empty-icon"><EyeIcon size={26} /></span>
261
+ <span className="ticket-empty-title">Nothing yet</span>
262
+ <span className="ticket-empty-hint">
263
+ Reviews run when the agent finishes a turn. Results land here.
264
+ </span>
265
+ </div>
266
+ )}
267
+ {enabled && items.map(item => {
268
+ if (item.kind === 'finding') return <FindingCard key={item.id} item={item} />
269
+ if (item.kind === 'digest') return <DigestCard key={item.id} item={item} />
270
+ return (
271
+ <div key={item.id} className={`obs-line ${item.kind}`}>
272
+ <span className="obs-line-dot" aria-hidden="true" />
273
+ <span className="obs-line-text">{item.title}</span>
274
+ <span className="obs-line-time">{formatTime(item.ts)}</span>
275
+ </div>
276
+ )
277
+ })}
278
+ </div>
279
+
280
+ {snapshot && enabled && (
281
+ <div className="ticket-drawer-footer">
282
+ <span className="ticket-drawer-hint">
283
+ {findings.length} finding{findings.length === 1 ? '' : 's'} · ${snapshot.spentTodayUsd.toFixed(2)} spent today
284
+ </span>
285
+ </div>
286
+ )}
287
+ </div>
288
+ )
289
+ }