@phenx-inc/ctlsurf 0.13.6 → 0.14.1

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 +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-bQYoCKra.js} +3 -3
  7. package/out/renderer/assets/{freemarker2-C1XYRbOq.js → freemarker2-BS1ERQl_.js} +1 -1
  8. package/out/renderer/assets/{handlebars-BSdbbnMW.js → handlebars-B-ZEHRLr.js} +1 -1
  9. package/out/renderer/assets/{html-96igtyOz.js → html-lk9e1Lkl.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-BRnCcIzp.js → htmlMode-Ds1eMYgM.js} +3 -3
  11. package/out/renderer/assets/{index-Cc-jDHm3.js → index-BJNR4hRK.js} +380 -23
  12. package/out/renderer/assets/{index-BEekQe3-.css → index-BpCORklm.css} +217 -0
  13. package/out/renderer/assets/{javascript-7MdRNHk9.js → javascript-D6OeziKX.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-oGam2go_.js → jsonMode-t5SESE75.js} +3 -3
  15. package/out/renderer/assets/{liquid-DoKC7ba2.js → liquid-B5Wjh0p_.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-C2h5qGCv.js → lspLanguageFeatures-BEgiDCIW.js} +1 -1
  17. package/out/renderer/assets/{mdx-0w8n1HGo.js → mdx-ByNAXh8t.js} +1 -1
  18. package/out/renderer/assets/{python-BfY4HssB.js → python-CIFO0XQW.js} +1 -1
  19. package/out/renderer/assets/{razor-BoDk9Ula.js → razor-LXHhGcNO.js} +1 -1
  20. package/out/renderer/assets/{tsMode-CX0oVKRt.js → tsMode-WiO8CDaI.js} +1 -1
  21. package/out/renderer/assets/{typescript-BqIU7abP.js → typescript-ZBQlq256.js} +1 -1
  22. package/out/renderer/assets/{xml-BlSDnxFw.js → xml-DBaLJlQU.js} +1 -1
  23. package/out/renderer/assets/{yaml-bVMmGUtU.js → yaml-CId9skah.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/components/TerminalPanel.tsx +29 -0
  44. package/src/renderer/styles.css +217 -0
@@ -35,21 +35,60 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
35
35
  const [saved, setSaved] = useState(false)
36
36
  const [version, setVersion] = useState('')
37
37
 
38
+ // Observer (global) — the key is write-only over IPC; we only learn
39
+ // whether one is configured (hasApiKey) and from where (keySource).
40
+ const [obsEnabled, setObsEnabled] = useState(false)
41
+ const [obsApiKey, setObsApiKey] = useState('')
42
+ const [obsHasKey, setObsHasKey] = useState(false)
43
+ const [obsKeySource, setObsKeySource] = useState<'settings' | 'env' | null>(null)
44
+ const [obsCapUsd, setObsCapUsd] = useState(1)
45
+ const [obsSaved, setObsSaved] = useState(false)
46
+
38
47
  const loadProfiles = async () => {
39
48
  const data = await window.worker.listProfiles() as ProfileListResponse
40
49
  setProfileList(data)
41
50
  setActiveProfileId(data.activeProfile)
42
51
  }
43
52
 
53
+ const loadObserver = async () => {
54
+ try {
55
+ const o = await window.worker.getObserverSettings()
56
+ setObsEnabled(o.enabled)
57
+ setObsHasKey(o.hasApiKey)
58
+ setObsKeySource(o.keySource)
59
+ setObsCapUsd(o.dailyCapUsd)
60
+ setObsApiKey('')
61
+ } catch { /* older main process without observer IPC */ }
62
+ }
63
+
44
64
  useEffect(() => {
45
65
  if (open) {
46
66
  loadProfiles()
67
+ loadObserver()
47
68
  setEditingId(null)
48
69
  setSaved(false)
70
+ setObsSaved(false)
49
71
  window.worker.getVersion().then(setVersion).catch(() => { /* ignore */ })
50
72
  }
51
73
  }, [open])
52
74
 
75
+ const handleObserverSave = async () => {
76
+ const data: { enabled: boolean; anthropicApiKey?: string; dailyCapUsd?: number } = {
77
+ enabled: obsEnabled,
78
+ dailyCapUsd: Math.max(0.05, obsCapUsd) || 1,
79
+ }
80
+ // Only send the key if the user typed a new one (blank = keep stored).
81
+ if (obsApiKey.trim()) {
82
+ data.anthropicApiKey = obsApiKey.trim()
83
+ }
84
+ const o = await window.worker.setObserverSettings(data)
85
+ setObsHasKey(o.hasApiKey)
86
+ setObsKeySource(o.keySource)
87
+ setObsApiKey('')
88
+ setObsSaved(true)
89
+ setTimeout(() => setObsSaved(false), 900)
90
+ }
91
+
53
92
  const startEditing = (profile: ProfileSummary) => {
54
93
  setEditingId(profile.id)
55
94
  setName(profile.name)
@@ -238,6 +277,71 @@ export function SettingsDialog({ open, onClose }: SettingsDialogProps) {
238
277
  </div>
239
278
  )}
240
279
 
280
+ {!editingId && (
281
+ // ─── Observer (global) ─────────────────
282
+ <div className="settings-section">
283
+ <h4>Observer</h4>
284
+ <p className="settings-hint">
285
+ Reviews agent sessions for assumptions, unverified claims, and
286
+ dependency/license risk, tracks time per work item, and posts a
287
+ session digest to the project's Agent Datastore page.
288
+ </p>
289
+
290
+ <label className="settings-checkbox">
291
+ <input
292
+ type="checkbox"
293
+ checked={obsEnabled}
294
+ onChange={e => setObsEnabled(e.target.checked)}
295
+ />
296
+ <span>Enable Observer</span>
297
+ <span className="settings-hint">
298
+ Applies to newly started agent tabs. Advisory only — never
299
+ blocks or slows the agent.
300
+ </span>
301
+ </label>
302
+
303
+ <label>
304
+ Anthropic API Key
305
+ <input
306
+ type="password"
307
+ value={obsApiKey}
308
+ onChange={e => setObsApiKey(e.target.value)}
309
+ placeholder={
310
+ obsKeySource === 'settings' ? '***configured*** (leave blank to keep)'
311
+ : obsKeySource === 'env' ? 'using ANTHROPIC_API_KEY from environment'
312
+ : 'sk-ant-…'
313
+ }
314
+ />
315
+ <span className="settings-hint">
316
+ Your own Anthropic key — separate from the ctlsurf profile key.
317
+ Without one, the Observer still records the session ledger,
318
+ Tier-0 tags, and spans, but skips model review and digests.
319
+ </span>
320
+ </label>
321
+
322
+ <label>
323
+ Daily budget cap (USD)
324
+ <input
325
+ type="number"
326
+ min={0.05}
327
+ step={0.25}
328
+ value={obsCapUsd}
329
+ onChange={e => setObsCapUsd(parseFloat(e.target.value) || 1)}
330
+ />
331
+ <span className="settings-hint">
332
+ Model review pauses for the day once spend reaches the cap;
333
+ Tier-0 tagging and spans continue.
334
+ </span>
335
+ </label>
336
+
337
+ <div className="settings-actions">
338
+ <button className="btn-primary" onClick={handleObserverSave}>
339
+ {obsSaved ? 'Saved!' : 'Save Observer Settings'}
340
+ </button>
341
+ </div>
342
+ </div>
343
+ )}
344
+
241
345
  <div className="settings-section settings-about">
242
346
  <h4>About</h4>
243
347
  <div className="about-name">ctlsurf{version ? ` · v${version}` : ''}</div>
@@ -189,6 +189,12 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
189
189
  // Off by default on macOS; without this a right-click would reselect the
190
190
  // word under the cursor and clobber a multi-line table selection.
191
191
  rightClickSelectsWord: false,
192
+ // A mouse-tracking TUI (notably tmux with `set -g mouse on`) swallows plain
193
+ // drags, so they yank into a remote tmux buffer instead of selecting here.
194
+ // Option+drag is the only way back to a local selection on macOS, and xterm
195
+ // gates it behind this flag. Elsewhere Shift+drag already does it. Costs us
196
+ // Option+drag column-select on macOS, which nothing here relies on.
197
+ macOptionClickForcesSelection: true,
192
198
  fontSize: 14,
193
199
  fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', monospace",
194
200
  theme: {
@@ -222,6 +228,29 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
222
228
  terminal.loadAddon(fitAddon)
223
229
  terminal.loadAddon(new WebLinksAddon(handleTerminalLink))
224
230
 
231
+ // OSC 52 — how a remote program (tmux's mouse-drag yank, vim, an agent TUI)
232
+ // asks the terminal it's attached to for a system-clipboard write. Real
233
+ // terminals honour it; xterm ships no handler, so those yanks used to vanish.
234
+ // Payload is `<targets>;<base64>`; `?` is a read-back request we decline
235
+ // rather than answer, so the remote can never exfiltrate the clipboard.
236
+ terminal.parser.registerOscHandler(52, (data) => {
237
+ const payload = data.slice(data.indexOf(';') + 1)
238
+ if (!payload || payload === '?') return false
239
+ // ~1MB of base64. A runaway program shouldn't be able to wedge the main
240
+ // process with an unbounded clipboard write.
241
+ if (payload.length > 1_400_000) return false
242
+ let text: string
243
+ try {
244
+ const bin = atob(payload)
245
+ text = new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)))
246
+ } catch (err) {
247
+ console.warn('[copy] OSC 52 payload was not valid base64', err)
248
+ return false
249
+ }
250
+ copyTextValue(text)
251
+ return true
252
+ })
253
+
225
254
  // Selection actions via ⌘/Ctrl+Shift+[C|E|S] → copy / convert-to-table /
226
255
  // read-aloud. Handled here (not as DOM listeners) so they work even while a
227
256
  // mouse-tracking TUI is running, where right-click and mouse selection are
@@ -2382,3 +2382,220 @@ html, body, #root {
2382
2382
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
2383
2383
  pointer-events: none;
2384
2384
  }
2385
+
2386
+ /* ─── Observer pane ─────────────────────────────── */
2387
+
2388
+ /* Header on/off switch */
2389
+ .observer-switch { display: inline-flex; align-items: center; cursor: pointer; padding-top: 2px; }
2390
+ .observer-switch input { position: absolute; opacity: 0; width: 0; height: 0; }
2391
+ .observer-switch-track {
2392
+ width: 34px; height: 19px; border-radius: 10px;
2393
+ background: #3b4261; position: relative; transition: background 0.15s ease;
2394
+ display: inline-block; flex-shrink: 0;
2395
+ }
2396
+ .observer-switch-thumb {
2397
+ position: absolute; top: 2px; left: 2px; width: 15px; height: 15px;
2398
+ border-radius: 50%; background: #c0caf5; transition: transform 0.15s ease;
2399
+ }
2400
+ .observer-switch input:checked + .observer-switch-track { background: #9ece6a; }
2401
+ .observer-switch input:checked + .observer-switch-track .observer-switch-thumb {
2402
+ transform: translateX(15px); background: #1a1b26;
2403
+ }
2404
+ .observer-switch input:disabled + .observer-switch-track { opacity: 0.5; }
2405
+
2406
+ .obs-feed { gap: 6px; }
2407
+
2408
+ /* Finding / digest cards */
2409
+ .obs-card {
2410
+ position: relative;
2411
+ display: flex;
2412
+ flex-direction: column;
2413
+ gap: 5px;
2414
+ width: 100%;
2415
+ text-align: left;
2416
+ appearance: none;
2417
+ font: inherit;
2418
+ color: inherit;
2419
+ cursor: pointer;
2420
+ background: #15161f;
2421
+ border: 1px solid #262a3f;
2422
+ border-left: 3px solid #565f89;
2423
+ border-radius: 9px;
2424
+ padding: 9px 26px 9px 12px;
2425
+ transition: border-color 0.12s ease, background 0.12s ease;
2426
+ }
2427
+ .obs-card:hover { background: #181a26; border-color: #323752; border-left-width: 3px; }
2428
+ .obs-card:active { transform: scale(0.995); }
2429
+
2430
+ .obs-card-top {
2431
+ display: flex;
2432
+ align-items: center;
2433
+ gap: 8px;
2434
+ min-width: 0;
2435
+ }
2436
+ .obs-chip {
2437
+ display: inline-flex;
2438
+ align-items: center;
2439
+ gap: 5px;
2440
+ flex-shrink: 0;
2441
+ font-size: 9.5px;
2442
+ font-weight: 600;
2443
+ letter-spacing: 0.06em;
2444
+ text-transform: uppercase;
2445
+ padding: 2px 7px 2px 6px;
2446
+ border: 1px solid;
2447
+ border-radius: 999px;
2448
+ line-height: 1.4;
2449
+ }
2450
+ .obs-chip-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
2451
+ .obs-card-cat {
2452
+ font-size: 10px;
2453
+ color: #565f89;
2454
+ letter-spacing: 0.05em;
2455
+ text-transform: uppercase;
2456
+ white-space: nowrap;
2457
+ overflow: hidden;
2458
+ text-overflow: ellipsis;
2459
+ }
2460
+ .obs-card-time {
2461
+ margin-left: auto;
2462
+ flex-shrink: 0;
2463
+ font-size: 10px;
2464
+ color: #414868;
2465
+ font-variant-numeric: tabular-nums;
2466
+ }
2467
+ .obs-card-title {
2468
+ font-size: 12.5px;
2469
+ font-weight: 600;
2470
+ color: #c0caf5;
2471
+ line-height: 1.4;
2472
+ word-break: break-word;
2473
+ }
2474
+ .obs-card-body {
2475
+ font-size: 11.5px;
2476
+ color: #9aa5ce;
2477
+ line-height: 1.5;
2478
+ word-break: break-word;
2479
+ }
2480
+ .obs-card-body p { margin: 0 0 6px; white-space: pre-wrap; }
2481
+ .obs-card-facts { display: flex; flex-wrap: wrap; gap: 6px; }
2482
+ .obs-fact {
2483
+ font-size: 10px;
2484
+ color: #565f89;
2485
+ background: #1c1e2b;
2486
+ border: 1px solid #262a3f;
2487
+ border-radius: 5px;
2488
+ padding: 1px 6px;
2489
+ font-variant-numeric: tabular-nums;
2490
+ }
2491
+ .obs-card-caret {
2492
+ position: absolute;
2493
+ right: 10px;
2494
+ top: 10px;
2495
+ font-size: 10px;
2496
+ color: #414868;
2497
+ transition: transform 0.12s ease;
2498
+ }
2499
+ .obs-card-caret.open { transform: rotate(90deg); }
2500
+
2501
+ /* One-line heartbeats (reviews ran, attach/detach) and health warnings */
2502
+ .obs-line {
2503
+ display: flex;
2504
+ align-items: baseline;
2505
+ gap: 8px;
2506
+ padding: 3px 12px 3px 14px;
2507
+ font-size: 11px;
2508
+ color: #565f89;
2509
+ line-height: 1.45;
2510
+ }
2511
+ .obs-line-dot {
2512
+ flex-shrink: 0;
2513
+ width: 4px; height: 4px;
2514
+ border-radius: 50%;
2515
+ background: #3b4261;
2516
+ align-self: center;
2517
+ }
2518
+ .obs-line.health { color: #e0af68; }
2519
+ .obs-line.health .obs-line-dot { background: #e0af68; }
2520
+ .obs-line-text { min-width: 0; word-break: break-word; }
2521
+ .obs-line-time {
2522
+ margin-left: auto;
2523
+ flex-shrink: 0;
2524
+ font-size: 10px;
2525
+ color: #414868;
2526
+ font-variant-numeric: tabular-nums;
2527
+ }
2528
+
2529
+ /* Sensitivity lever + processing indicator row */
2530
+ .obs-toolbar {
2531
+ display: flex;
2532
+ align-items: center;
2533
+ justify-content: space-between;
2534
+ gap: 10px;
2535
+ padding: 8px 14px;
2536
+ border-bottom: 1px solid #23263a;
2537
+ }
2538
+ .obs-sensitivity {
2539
+ display: inline-flex;
2540
+ background: #15161f;
2541
+ border: 1px solid #262a3f;
2542
+ border-radius: 7px;
2543
+ padding: 2px;
2544
+ gap: 2px;
2545
+ }
2546
+ .obs-sens-btn {
2547
+ appearance: none;
2548
+ border: none;
2549
+ background: transparent;
2550
+ color: #565f89;
2551
+ font: inherit;
2552
+ font-size: 10.5px;
2553
+ font-weight: 600;
2554
+ padding: 3px 9px;
2555
+ border-radius: 5px;
2556
+ cursor: pointer;
2557
+ transition: background 0.12s ease, color 0.12s ease;
2558
+ white-space: nowrap;
2559
+ }
2560
+ .obs-sens-btn:hover { color: #9aa5ce; }
2561
+ .obs-sens-btn.active { background: #2a2f4a; color: #c0caf5; }
2562
+
2563
+ /* "reviewing" bouncing dots */
2564
+ .obs-reviewing {
2565
+ display: inline-flex;
2566
+ align-items: center;
2567
+ gap: 3px;
2568
+ font-size: 10.5px;
2569
+ color: #7aa2f7;
2570
+ letter-spacing: 0.03em;
2571
+ }
2572
+ .obs-reviewing-dot {
2573
+ width: 4px; height: 4px;
2574
+ border-radius: 50%;
2575
+ background: #7aa2f7;
2576
+ animation: obs-dot-bounce 1.1s ease-in-out infinite;
2577
+ }
2578
+ .obs-reviewing-dot:nth-child(2) { animation-delay: 0.15s; }
2579
+ .obs-reviewing-dot:nth-child(3) { animation-delay: 0.3s; margin-right: 4px; }
2580
+ @keyframes obs-dot-bounce {
2581
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.5; }
2582
+ 30% { transform: translateY(-3px); opacity: 1; }
2583
+ }
2584
+
2585
+ /* Pulsing eye badge while a model review is in flight */
2586
+ .obs-badge { position: relative; }
2587
+ .obs-badge.reviewing { color: #7aa2f7; }
2588
+ .obs-badge.reviewing::after {
2589
+ content: '';
2590
+ position: absolute;
2591
+ inset: -1px;
2592
+ border-radius: inherit;
2593
+ border: 1px solid #7aa2f7;
2594
+ animation: obs-badge-pulse 1.6s ease-out infinite;
2595
+ pointer-events: none;
2596
+ }
2597
+ @keyframes obs-badge-pulse {
2598
+ 0% { opacity: 0.9; transform: scale(1); }
2599
+ 70% { opacity: 0; transform: scale(1.35); }
2600
+ 100% { opacity: 0; transform: scale(1.35); }
2601
+ }