@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.
- package/out/headless/index.mjs +13663 -385
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +11665 -286
- package/out/main/node-CUnQxFha.js +868 -0
- package/out/preload/index.js +17 -0
- package/out/renderer/assets/{cssMode-CsSLnKZ-.js → cssMode-7adWSORr.js} +3 -3
- package/out/renderer/assets/{freemarker2-C1XYRbOq.js → freemarker2-BkLLWnzk.js} +1 -1
- package/out/renderer/assets/{handlebars-BSdbbnMW.js → handlebars-Cinfm9fZ.js} +1 -1
- package/out/renderer/assets/{html-96igtyOz.js → html-vl9fkHKL.js} +1 -1
- package/out/renderer/assets/{htmlMode-BRnCcIzp.js → htmlMode-BtGqkTNI.js} +3 -3
- package/out/renderer/assets/{index-Cc-jDHm3.js → index-BhrV6ld-.js} +359 -23
- package/out/renderer/assets/{index-BEekQe3-.css → index-BpCORklm.css} +217 -0
- package/out/renderer/assets/{javascript-7MdRNHk9.js → javascript-B7CCdfuW.js} +2 -2
- package/out/renderer/assets/{jsonMode-oGam2go_.js → jsonMode-ut9LWBMJ.js} +3 -3
- package/out/renderer/assets/{liquid-DoKC7ba2.js → liquid-DVDJ0lKt.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-C2h5qGCv.js → lspLanguageFeatures-BbMj8Wsc.js} +1 -1
- package/out/renderer/assets/{mdx-0w8n1HGo.js → mdx-CcQzYB0J.js} +1 -1
- package/out/renderer/assets/{python-BfY4HssB.js → python-Byy7wKMv.js} +1 -1
- package/out/renderer/assets/{razor-BoDk9Ula.js → razor-D1xi_wMM.js} +1 -1
- package/out/renderer/assets/{tsMode-CX0oVKRt.js → tsMode-DJPzbn5i.js} +1 -1
- package/out/renderer/assets/{typescript-BqIU7abP.js → typescript-BuVaU7i3.js} +1 -1
- package/out/renderer/assets/{xml-BlSDnxFw.js → xml-rzc91O3d.js} +1 -1
- package/out/renderer/assets/{yaml-bVMmGUtU.js → yaml-fAOzQMge.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +2 -1
- package/src/main/index.ts +36 -2
- package/src/main/observer/anthropic.ts +48 -0
- package/src/main/observer/budget.ts +79 -0
- package/src/main/observer/ledger.ts +61 -0
- package/src/main/observer/observer.ts +566 -0
- package/src/main/observer/spans.ts +170 -0
- package/src/main/observer/store.ts +200 -0
- package/src/main/observer/tier0.ts +234 -0
- package/src/main/observer/tier1.ts +306 -0
- package/src/main/observer/tier2.ts +125 -0
- package/src/main/observer/types.ts +224 -0
- package/src/main/orchestrator.ts +74 -0
- package/src/main/transcripts.ts +18 -0
- package/src/preload/index.ts +36 -0
- package/src/renderer/App.tsx +22 -0
- package/src/renderer/components/ObserverPanel.tsx +289 -0
- package/src/renderer/components/SettingsDialog.tsx +104 -0
- package/src/renderer/styles.css +217 -0
package/src/main/orchestrator.ts
CHANGED
|
@@ -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()
|
package/src/main/transcripts.ts
CHANGED
|
@@ -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
|
|
package/src/preload/index.ts
CHANGED
|
@@ -144,6 +144,42 @@ const api = {
|
|
|
144
144
|
ipcRenderer.invoke('speak:get'),
|
|
145
145
|
setSpeakReplies: (enabled: boolean): Promise<{ enabled: boolean }> =>
|
|
146
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
|
+
},
|
|
147
183
|
onAgentMessage: (callback: (text: string) => void) => {
|
|
148
184
|
const listener = (_event: Electron.IpcRendererEvent, text: string) => callback(text)
|
|
149
185
|
ipcRenderer.on('agent:message', listener)
|
package/src/renderer/App.tsx
CHANGED
|
@@ -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
|
+
}
|