Compare commits

..

259 commits

Author SHA1 Message Date
DexterFromLab
32f6d7eadf docs: update meta files for multi-agent orchestration
- v3-progress.md: full session log for agent orchestration work
- v3-task_plan.md: 7 new decisions (agent rendering, env passthrough,
  re-injection, shared DB, role tabs, PlantUML encoding)
- CLAUDE.md: updated overview, key paths, component list
- .claude/CLAUDE.md: updated workflow, ProjectBox tabs, orchestration docs
2026-03-11 15:25:53 +01:00
DexterFromLab
2ca7756a74 feat(agents): role-specific tabs + bttask Tauri backend
- TaskBoardTab: kanban board (5 columns, CRUD, comments, 5s poll) for Manager
- ArchitectureTab: PlantUML viewer/editor (4 templates, plantuml.com) for Architect
- TestingTab: Selenium screenshots + test file discovery for Tester
- bttask.rs: Rust backend (list, create, update_status, delete, comments)
- bttask-bridge.ts: TypeScript IPC adapter
- ProjectBox: conditional role tabs (isAgent && agentRole), PERSISTED-LAZY
2026-03-11 15:25:41 +01:00
DexterFromLab
0c28f204c7 feat(agents): custom context for Tier 2 + periodic system prompt re-injection
- SettingsTab: Custom Context textarea for Tier 2 project cards
- AgentSession passes systemPrompt for ALL projects (Tier 1 gets full
  generated prompt, Tier 2 gets custom context)
- Periodic re-injection: 1-hour timer checks if agent is idle, then
  auto-sends context refresh prompt with role/tools reminder
- AgentPane: autoPrompt prop consumed when session is done/error,
  resumes session with fresh system prompt
2026-03-11 15:02:28 +01:00
DexterFromLab
14808a97e9 feat(settings): add Tier 1 agent config panel with system prompt editor
- Agent cards in SettingsTab: name, enable/disable, CWD, model, wake interval
- Custom Context textarea for editable system prompt per agent
- Collapsible preview of full generated introductory prompt
- Agent cards styled with mauve left border accent and role badge
- Export AGENT_ROLE_ICONS from groups.ts, add updateAgent() to workspace store
2026-03-11 14:55:38 +01:00
DexterFromLab
a158ed9544 feat(orchestration): multi-agent communication, unified agents, env passthrough
- btmsg: admin role (tier 0), channel messaging (create/list/send/history),
  admin global feed, mark-read conversations
- Rust btmsg module: admin bypass, channels, feed, 8 new Tauri commands
- CommsTab: sidebar chat interface with activity feed, DMs, channels (Ctrl+M)
- Agent unification: Tier 1 agents rendered as ProjectBoxes via agentToProject()
  converter, getAllWorkItems() combines agents + projects in ProjectGrid
- GroupAgentsPanel: click-to-navigate agents to their ProjectBox
- Agent system prompts: generateAgentPrompt() builds comprehensive introductory
  context (role, environment, team, btmsg/bttask docs, workflow instructions)
- AgentSession passes group context to prompt generator via $derived.by()
- BTMSG_AGENT_ID env var passthrough: extra_env field flows through full chain
  (agent-bridge → Rust AgentQueryOptions → NDJSON → sidecar runners → cleanEnv)
- workspace store: updateAgent() for Tier 1 agent config persistence
2026-03-11 14:53:39 +01:00
DexterFromLab
1331d094b3 feat(GroupAgentsPanel): add Tier 1/2 division with project agents
Show Tier 1 (Management: Manager, Architect, Tester) and Tier 2
(Execution: project agents) separated by a divider line. Tier 2
cards show project icon and name, are slightly smaller, no start/stop
button. Header dots show all agents with a separator between tiers.
2026-03-11 14:05:09 +01:00
DexterFromLab
f2dcedc460 feat(orchestration): add bttask CLI + GroupAgentsPanel + btmsg Tauri bridge
Phase 2: bttask CLI (Python, SQLite) — task management with role-based
visibility. Kanban board view. Manager/Architect can create tasks,
Tier 2 agents receive tasks via btmsg only.

Phase 3: GroupAgentConfig in groups.json + Rust backend. GroupAgentsPanel
Svelte component above ProjectGrid with status dots, role icons,
unread badges, start/stop buttons.

Phase 4: btmsg Rust bridge (btmsg.rs) — read/write access to btmsg.db.
6 Tauri commands for agent status, messages, and history.
GroupAgentsPanel polls btmsg.db every 5s for live status updates.
2026-03-11 14:03:11 +01:00
DexterFromLab
485b279659 feat(btmsg): add graph command — visual agent hierarchy with status
Shows tier boxes, communication links, status dots (green/yellow/red),
unread message badges, and model assignments per agent.
2026-03-11 13:54:27 +01:00
DexterFromLab
e1025a0a8a feat(btmsg): add group agent messenger CLI
Python CLI tool for hierarchical multi-agent communication.
SQLite-backed (WAL mode), agent identity via BTMSG_AGENT_ID env var.

Features:
- inbox/read/send/reply — message CRUD with read tracking
- contacts — role-based communication hierarchy enforcement
- history — per-agent conversation view
- status — all agents with tier/role/model/unread counts
- register/allow — agent and contact management
- notify — single-line notification for agent injection
- Short ID prefix matching for convenience

Also: change default Claude model to opus-4-6
2026-03-11 13:51:40 +01:00
DexterFromLab
44610f3177 fix(workspace): docs discovery for doc/ dirs + SSH terminal tab args
- Add doc/ alongside docs/ in markdown file discovery (groups.rs)
- Add SETUP.md to priority root files
- Fix SSH terminal tabs: resolve session args via sshArgsCache derived
  from listSshSessions() instead of passing empty args
- Fix Agent Preview: only mount xterm when tab is active (prevents
  CanvasAddon crash on hidden elements)
- Separate tab type rendering (shell/ssh/agent-preview) with proper guards
2026-03-11 13:04:32 +01:00
Hibryda
dc0ffb6dbf docs: update meta files for branded type call-site fixes 2026-03-11 05:46:22 +01:00
Hibryda
af3cd45324 refactor(components): apply branded types at Svelte component call sites 2026-03-11 05:46:22 +01:00
Hibryda
c3d2e1daee docs: update meta files for SOLID Phase 3 branded types 2026-03-11 05:40:28 +01:00
Hibryda
889adcb004 refactor(agent-dispatcher): brand sessionId at sidecar boundary 2026-03-11 05:40:28 +01:00
Hibryda
a06b9d5053 refactor(utils): apply branded types to session-persistence and auto-anchoring 2026-03-11 05:40:28 +01:00
Hibryda
3f4f2d70af refactor(stores): apply branded types to conflicts and health Map keys 2026-03-11 05:40:28 +01:00
Hibryda
f2a7d385d6 feat(types): introduce SessionId/ProjectId branded types (SOLID Phase 3) 2026-03-11 05:40:28 +01:00
Hibryda
7ba63db101 refactor(agent-dispatcher): remove dead detectWorktreeFromCwd re-export 2026-03-11 05:29:28 +01:00
Hibryda
584a38d096 docs: update meta files for SOLID Phase 2 refactoring 2026-03-11 05:25:32 +01:00
Hibryda
9c94272ca7 refactor(session): split session.rs into 7 sub-modules (SOLID Phase 2) 2026-03-11 05:25:32 +01:00
Hibryda
450756f540 refactor(agent-dispatcher): split into 4 focused modules (SOLID Phase 2) 2026-03-11 05:25:32 +01:00
Hibryda
54b1c60810 docs: update meta files for SOLID Phase 1 refactoring 2026-03-11 05:09:15 +01:00
Hibryda
af369f30d2 test(attention-scorer): add 14 tests for extracted scorer function 2026-03-11 05:09:15 +01:00
Hibryda
4d93b77f6a refactor(frontend): extract attention scorer and shared type guards 2026-03-11 05:09:15 +01:00
Hibryda
30c21256bc refactor(backend): split lib.rs into 11 domain command modules 2026-03-11 05:09:15 +01:00
Hibryda
b1bc5d18a4 docs: update meta files for reconnect loop fix 2026-03-11 04:51:46 +01:00
Hibryda
fc7fe3180e fix(remote): cancel reconnect loop on machine removal 2026-03-11 04:51:46 +01:00
Hibryda
4ac0336e72 chore: broaden .audit gitignore to cover all subdirectories 2026-03-11 04:47:25 +01:00
Hibryda
6b420a6a1f feat(health): configurable per-project stall threshold 2026-03-11 04:20:28 +01:00
Hibryda
267087937f docs: update meta files for configurable stall threshold 2026-03-11 04:20:23 +01:00
Hibryda
0139f482b5 docs: update meta files for Memora adapter registration 2026-03-11 04:09:29 +01:00
Hibryda
be504cadcf test(memora): add Memora bridge and adapter tests 2026-03-11 04:09:29 +01:00
Hibryda
f3f740a8fe feat(memora): add Memora adapter with read-only SQLite backend 2026-03-11 04:09:29 +01:00
Hibryda
ad7e24e40d docs: update meta files for Codex/Ollama provider runners 2026-03-11 03:56:05 +01:00
Hibryda
8309896e7d test(providers): add Codex and Ollama message adapter tests 2026-03-11 03:56:05 +01:00
Hibryda
3e34fda59a feat(providers): add Codex and Ollama provider runners with message adapters 2026-03-11 03:56:05 +01:00
Hibryda
4ae7ca6634 docs: update meta files for S-1 Phase 3 worktree isolation 2026-03-11 03:23:58 +01:00
Hibryda
643ab0a6b6 test(worktree-isolation): add worktree detection tests 2026-03-11 03:23:58 +01:00
Hibryda
0da53e7390 docs: update meta files for configurable anchor budget 2026-03-11 03:03:53 +01:00
Hibryda
0d9c473a06 feat(session-anchors): configurable budget scale + research-backed truncation fix
Remove 500-char assistant text truncation in anchor serializer — research
consensus (JetBrains NeurIPS 2025, SWE-agent, OpenDev ACC) is that agent
reasoning must never be truncated; only tool outputs get observation-masked.

Add AnchorBudgetScale type with 4 presets (Small=2K, Medium=6K, Large=12K,
Full=20K) and per-project range slider in SettingsTab. Remove Ollama-specific
warning toast — budget slider handles context limits generically.
2026-03-11 03:03:53 +01:00
Hibryda
64e040ebfe docs: update meta files for S-2 session anchors 2026-03-11 02:43:06 +01:00
Hibryda
3e60516544 test(session-anchors): add anchor serializer tests 2026-03-11 02:43:06 +01:00
Hibryda
ccce2b6005 feat(session-anchors): add pin button, anchor re-injection, and ContextTab UI 2026-03-11 02:43:06 +01:00
Hibryda
a9e94fc154 feat(session-anchors): implement S-2 session anchor persistence and auto-anchoring 2026-03-11 02:43:06 +01:00
Hibryda
8f4faaafa3 docs: update meta files for provider adapter implementation 2026-03-11 02:08:45 +01:00
Hibryda
11b00f18f8 feat(settings): add provider config UI and sidecar routing (Phase 2+3)
Add Providers section to SettingsTab with collapsible per-provider config
panels and per-project provider dropdown. Implement provider-based sidecar
runner selection and multi-provider env var stripping in Rust.
2026-03-11 02:08:45 +01:00
Hibryda
1efcb13869 feat(provider-adapter): implement multi-provider abstraction layer (Phase 1)
Add provider types, registry, capabilities, and message adapter registry.
Rename sdk-messages→claude-messages, agent-runner→claude-runner,
ClaudeSession→AgentSession. Update Rust AgentQueryOptions with provider
and provider_config fields. Capability-driven AgentPane rendering.
2026-03-11 02:08:45 +01:00
Hibryda
d8d7ad16f3 docs: update meta files for provider adapter planning 2026-03-11 01:40:03 +01:00
Hibryda
ab4e8b7e06 docs(provider-adapter): add architecture plan for multi-provider agent support 2026-03-11 01:40:03 +01:00
Hibryda
a74d3a74d3 fix(pdf-viewer): use static worker + lazy page rendering
Worker copied to public/ via prebuild script (avoids Vite resolution issues).
IntersectionObserver renders only visible pages (+200px ahead) instead of all
at once, fixing performance for large PDFs.
2026-03-11 01:27:54 +01:00
Hibryda
199873781b docs: update meta files for PDF viewer and CSV table view 2026-03-11 01:23:49 +01:00
Hibryda
378c59bb97 feat(files-tab): add PDF viewer and CSV table view
pdfjs-dist for canvas-based multi-page PDF rendering with zoom controls.
RFC 4180 CSV parser with delimiter auto-detection and sortable columns.
FilesTab routes Binary+pdf to PdfViewer, Text+csv to CsvTable.
2026-03-11 01:23:49 +01:00
Hibryda
929f54e195 docs: update CHANGELOG for scanning toast and notify return type 2026-03-11 01:11:39 +01:00
Hibryda
64ad4d2e58 feat(fs-watcher): add 300ms delayed scanning toast for large project dirs 2026-03-11 01:11:39 +01:00
Hibryda
9d9cc75b28 docs: update meta files for inotify limit sensing 2026-03-11 01:07:46 +01:00
Hibryda
b19aa632c8 feat(fs-watcher): add inotify watch limit sensing with toast warning 2026-03-11 01:07:46 +01:00
Hibryda
d1ce031624 docs: update meta files for S-1 Phase 2 filesystem write detection 2026-03-11 00:56:27 +01:00
Hibryda
e5d9f51df7 feat(s1p2): add inotify-based filesystem write detection with external conflict tracking 2026-03-11 00:56:27 +01:00
Hibryda
6b239c5ce5 docs(todo): expand worktree isolation plan with research findings
Detail 3-part implementation: UI toggle, --worktree spawn, CWD-based
detection from .claude/worktrees/, $CODEX_HOME/worktrees/, and
~/.cursor/worktrees/ patterns.
2026-03-11 00:35:09 +01:00
Hibryda
13ac45c203 docs: update meta files for conflict detection enhancements session 2026-03-11 00:25:26 +01:00
Hibryda
38b8447ae6 feat(conflicts): add bash write detection, dismiss/acknowledge, and worktree suppression 2026-03-11 00:25:26 +01:00
Hibryda
05191127ea docs: update meta files for conflict detection session 2026-03-11 00:12:10 +01:00
Hibryda
82fb618c76 feat(conflicts): add file overlap conflict detection (S-1 Phase 1)
Detects when 2+ agent sessions write the same file within a project.
New conflicts.svelte.ts store, shared tool-files.ts utility, dispatcher
integration, health attention scoring (SCORE_FILE_CONFLICT=70), and UI
indicators in ProjectHeader + StatusBar. 170/170 tests pass.
2026-03-11 00:12:10 +01:00
Hibryda
8e00e0ef8c perf(health): auto-stop tick timer when no active sessions
Health tick now self-stops when no tracked project has a running/starting
agent session. Auto-restarts on next recordActivity() call. Eliminates
5-second polling overhead when all agents are idle/done.
2026-03-10 23:46:52 +01:00
Hibryda
1b61f10532 docs: update meta files for health dashboard session 2026-03-10 23:45:30 +01:00
Hibryda
42094eac2a feat(health): add project health store, Mission Control bar, and session metrics 2026-03-10 23:45:30 +01:00
Hibryda
072316d63f docs: update meta files for compaction tracking session 2026-03-10 04:20:32 +01:00
Hibryda
f4ec2f3762 feat(context): detect compaction events from SDK compact_boundary messages 2026-03-10 04:20:32 +01:00
Hibryda
d898586181 docs: update meta files for AST and graph views 2026-03-10 04:03:52 +01:00
Hibryda
979c8883f3 feat(context): add AST conversation tree and tool-file graph views 2026-03-10 04:03:52 +01:00
Hibryda
9c7618cad1 docs: update meta files for ContextTab and CodeMirror editor session 2026-03-10 03:52:16 +01:00
Hibryda
6e24adcd56 feat(context): add ContextTab with LLM context window visualization 2026-03-10 03:52:16 +01:00
Hibryda
86fa55bc3e fix(editor): use $state for prop tracking to fix Svelte state_referenced_locally warnings 2026-03-10 03:13:36 +01:00
Hibryda
61cd33f393 docs: update CHANGELOG with CodeMirror editor and save-on-blur 2026-03-10 03:11:32 +01:00
Hibryda
e54ea1dbbc feat(settings): add save-on-blur toggle for file editor 2026-03-10 03:11:32 +01:00
Hibryda
3bb972fc01 feat(files): add CodeMirror 6 editor with save, dirty tracking, and 15 language modes 2026-03-10 03:11:32 +01:00
Hibryda
0ffbd93b8b docs: update CHANGELOG with FilesTab reactivity fix 2026-03-10 02:55:38 +01:00
Hibryda
cfe3abcf00 fix(files): look up tab from reactive array before setting content 2026-03-10 02:55:38 +01:00
Hibryda
ea44719685 docs: update CHANGELOG with FilesTab HTML nesting fix 2026-03-10 02:54:10 +01:00
Hibryda
e2a406a167 fix(files): change file tab bar from nested buttons to div with role=tab 2026-03-10 02:54:10 +01:00
Hibryda
a111ba0b9d docs: update CHANGELOG with FilesTab improvements 2026-03-10 02:51:05 +01:00
Hibryda
59606e067f feat(files): add word wrap, collapsible sidebar, and preview/pinned tabs to FilesTab 2026-03-10 02:51:05 +01:00
Hibryda
18c62cc462 docs: update meta files for tab system overhaul session 2026-03-10 02:12:05 +01:00
Hibryda
260a21c66a feat(backend): add list_directory_children and read_file_content Rust commands 2026-03-10 02:12:05 +01:00
Hibryda
6744e1beaf feat(workspace): overhaul ProjectBox tab system with 6 tabs and lazy mount 2026-03-10 02:12:05 +01:00
Hibryda
f5f3e0d63e docs: update CHANGELOG with ClaudeSession type fix 2026-03-10 01:00:32 +01:00
Hibryda
0a51fc4b02 fix(session): resolve ClaudeSession type errors — UUID cast and missing timestamp on restored messages 2026-03-10 01:00:32 +01:00
Hibryda
5a9f67fcb6 docs: update CHANGELOG with markdown link navigation 2026-03-10 00:58:46 +01:00
Hibryda
cd438c2cf3 feat(markdown): intercept links in MarkdownPane — relative files navigate in Files tab, external URLs open in browser 2026-03-10 00:58:46 +01:00
Hibryda
91aa711ef3 docs: update CHANGELOG with cost accumulation fix 2026-03-09 17:40:35 +01:00
Hibryda
b6ca086371 fix(agent): accumulate cost across continued session turns instead of replacing 2026-03-09 17:40:35 +01:00
Hibryda
134a7bd8ff docs: update meta files for collapsibles and aspect ratio session 2026-03-09 17:27:09 +01:00
Hibryda
e92e54d6c2 chore: add no-implicit-push rule and StartupWMClass to desktop entry 2026-03-09 17:27:09 +01:00
Hibryda
92b513dd9d feat(settings): add project max aspect ratio setting with CSS constraint 2026-03-09 17:27:09 +01:00
Hibryda
c6fda19170 feat(agent): add collapsible text messages and cost summary in AgentPane 2026-03-09 17:27:09 +01:00
Hibryda
ac5b3c4adc chore: add .vscode/ and .local/ to root .gitignore 2026-03-09 15:05:13 +01:00
Hibryda
7bc4a70b06 docs: update meta files for AgentPane UI redesign session 2026-03-09 02:31:08 +01:00
Hibryda
4674a4779d style(agent): redesign AgentPane and MarkdownPane for polished readable UI
Tribunal-elected design (S-3-R4, 88% confidence). AgentPane: sans-serif root
font, tool call/result pairing via $derived.by, hook message collapsing,
context window meter, minimal cost bar, session summary styling, two-phase
scroll anchoring, tool-aware truncation, color-mix() softening, container
query responsive margins. MarkdownPane: container query wrapper. Shared
--bterminal-pane-padding-inline CSS variable in catppuccin.css.
2026-03-09 02:31:04 +01:00
Hibryda
28f7867dc6 docs: update meta files for tab switch fix session 2026-03-08 23:22:48 +01:00
Hibryda
fa9ca415be fix(workspace): preserve ClaudeSession across tab switches with CSS display 2026-03-08 23:22:48 +01:00
Hibryda
6585208233 docs: update CHANGELOG for env var whitelist fix 2026-03-08 22:49:02 +01:00
Hibryda
74ce1ee083 docs: update meta files for E2E test fix session 2026-03-08 22:42:48 +01:00
Hibryda
b9b5ef9cb3 fix(e2e): scope terminal tab selectors to .tab-bar for reliable matching 2026-03-08 22:42:48 +01:00
Hibryda
8bdd9d6fcc docs: update meta files for E2E expansion session 2026-03-08 22:27:51 +01:00
Hibryda
2eb323fba8 test(e2e): expand coverage from 25 to 48 tests across 8 describe blocks 2026-03-08 22:27:51 +01:00
Hibryda
4c02b87e33 chore: remove old individual E2E spec files
Consolidated into single bterminal.test.ts (Tauri single-session requirement).
2026-03-08 21:58:28 +01:00
Hibryda
50a9ad40fa docs: update meta files for E2E expansion session 2026-03-08 21:58:23 +01:00
Hibryda
d12cbffda7 fix(e2e): consolidate specs into single file and fix WebDriver click issues
Tauri creates one app session per spec file; multiple files caused
invalid session id on subsequent specs. WebDriver clicks on Svelte 5
components inside scrollable panels dont trigger onclick handlers
via WebKit2GTK/tauri-driver - use browser.execute() JS clicks.
Also removed tauri-plugin-log (redundant with telemetry::init()).
2026-03-08 21:58:23 +01:00
Hibryda
13fe598742 docs: update meta files for E2E fixes session 2026-03-08 21:32:16 +01:00
Hibryda
bfbdb2cc18 fix(e2e): resolve wdio v9 BiDi + tauri-driver compatibility issues 2026-03-08 21:32:16 +01:00
Hibryda
3059475ab7 docs: update meta files for E2E testing session 2026-03-08 21:13:38 +01:00
Hibryda
3c3a8ab54e test(e2e): scaffold WebdriverIO + tauri-driver E2E testing infrastructure 2026-03-08 21:13:38 +01:00
Hibryda
7fc87a9567 docs: update meta files for teardown race fix and px-to-rem session 2026-03-08 20:54:43 +01:00
Hibryda
9738776bae style: convert px layout values to rem across all components (rule 18) 2026-03-08 20:54:43 +01:00
Hibryda
dba6a88a28 fix: resolve workspace teardown race with persistence fence 2026-03-08 20:54:43 +01:00
Hibryda
a69022756a docs: update meta files for OTEL telemetry session 2026-03-08 20:34:19 +01:00
Hibryda
fd9f55faff feat(telemetry): add OpenTelemetry tracing with optional OTLP export to Tempo 2026-03-08 20:34:19 +01:00
Hibryda
3f1638c98b fix: resolve medium/low audit findings across backend and frontend
- ctx CLI: validate int() limit arg, wrap FTS5 MATCH in try/except
- ctx.rs: FTS5 error message clarity, Mutex::lock() returns Err not panic
- sdk-messages.ts: runtime type guards (str/num) replace bare `as` casts
- agent-runner.ts: strip ANTHROPIC_* env vars alongside CLAUDE*
- agent-dispatcher.ts: timestamps use seconds (match session.rs convention)
- remote.rs: disconnect handler uses lock().await not try_lock()
- session.rs: propagate pane_ids serialization error
- watcher.rs: reject root-level paths instead of silent no-op
- lib.rs: log warnings on profile.toml read failure and resource_dir error
- agent-bridge.ts: validate event payload is object before cast
2026-03-08 20:10:54 +01:00
Hibryda
044f891c3a chore: add v2/.audit/ to gitignore 2026-03-08 20:04:52 +01:00
Hibryda
9ec7e560ae docs: update meta files for audit fixes session 2026-03-08 20:03:50 +01:00
Hibryda
4bdb74721d fix(security): audit fixes — path traversal, race conditions, memory leaks, transaction safety
- lib.rs: claude_read_skill path traversal prevention (canonicalize + starts_with)
- agent-dispatcher.ts: re-entrancy guard on exit handler, clear maps in stop
- machines.svelte.ts: track UnlistenFn array + destroyMachineListeners()
- agent-runner.ts: controller.signal.aborted, async handleMessage + .catch()
- remote.rs: try_lock → async lock, abort tasks on remove
- session.rs: unchecked_transaction for save_agent_messages
- agent-bridge.ts: safe msg.event check (implicit in dispatcher changes)
2026-03-08 20:03:50 +01:00
Hibryda
73ca780b54 docs: update meta files for ctx dead code cleanup session 2026-03-08 19:37:17 +01:00
Hibryda
4f2b8b3183 refactor(ctx): remove dead code from ctx integration 2026-03-08 19:37:17 +01:00
Hibryda
f50811cfdb docs: update meta files for project-scoped ContextPane session 2026-03-08 04:13:41 +01:00
Hibryda
e37c85e294 refactor(v3): project-scoped ContextPane with auto-registration 2026-03-08 04:13:41 +01:00
Hibryda
0f0ea3fb59 docs: update meta files for ctx init fix session 2026-03-08 04:08:36 +01:00
Hibryda
957f4c20f6 fix: ctx init missing directory + add Initialize Database button to ContextPane 2026-03-08 04:08:36 +01:00
Hibryda
d903904d52 docs: update meta files for markdown typography redesign session 2026-03-08 04:00:20 +01:00
Hibryda
c008e2c5f2 style(v3): premium markdown typography with Inter font, Tailwind-inspired spacing, and dark mode refinements 2026-03-08 04:00:20 +01:00
Hibryda
684af68ed9 docs: update meta files for collapsible terminal session 2026-03-08 03:47:42 +01:00
Hibryda
d67ab7eeaf feat(v3): collapsible terminal panel with status bar toggle 2026-03-08 03:47:42 +01:00
Hibryda
85952346f8 docs: update meta files for sidebar simplification session 2026-03-08 03:44:33 +01:00
Hibryda
e677a6aa6a refactor(v3): simplify sidebar to Settings-only + fix MarkdownPane switching + restyle 2026-03-08 03:44:33 +01:00
Hibryda
d2fd9fb6e3 docs: update meta files for agent preview terminal session 2026-03-08 03:24:31 +01:00
Hibryda
90c1fb94e2 feat(v3): agent preview terminal — read-only xterm.js tracking agent activity 2026-03-08 03:24:31 +01:00
Hibryda
975f03e75d docs: update meta files for terminal tabs fix session 2026-03-08 03:13:02 +01:00
Hibryda
308664a4c9 fix(v3): terminal tabs close + naming — replace $state<Map> with Record for Svelte 5 reactivity 2026-03-08 03:13:02 +01:00
Hibryda
0e5fcd766b docs: update meta files for project settings redesign session 2026-03-08 03:06:23 +01:00
Hibryda
e8c43b002c style(v3): redesign project settings cards + account dropdown + icon picker fix 2026-03-08 03:06:23 +01:00
Hibryda
caeb450eca docs: update meta files for VSCode prompt + theme CSS session 2026-03-08 02:54:31 +01:00
Hibryda
be4df01302 feat(v3): VSCode-style prompt redesign + theme-aware CSS migration 2026-03-08 02:54:31 +01:00
Hibryda
319c92fc68 docs: update meta files for project tabs + clean AgentPane session 2026-03-08 02:32:00 +01:00
Hibryda
f2aa514845 feat(v3): project-level tabs + clean AgentPane + ProjectHeader info bar
- ProjectBox: Claude|Files|Context tab bar switching content area
- ProjectFiles.svelte: project-scoped markdown file viewer
- ProjectHeader: CWD (ellipsized from start) + profile as info text
- AgentPane: removed DIR/ACC toolbar, CWD+profile now props from parent
- ClaudeSession: passes project.profile to AgentPane
2026-03-08 02:32:00 +01:00
Hibryda
e2fda3f742 docs: update meta files for project workspace redesign session 2026-03-08 02:15:34 +01:00
Hibryda
5c657d0daa feat(v3): redesign project workspace layout + emoji icons
- ProjectBox: CSS grid layout (header|session|terminal zones)
- AgentPane: bottom-anchored prompt, full-width form
- Icons: emoji replacing Nerd Font codepoints (cross-platform)
- SettingsTab: emoji picker grid (24 icons, 8-column popup)
- CSS: px to rem conversions across ProjectGrid, TerminalTabs, ProjectBox
2026-03-08 02:15:34 +01:00
Hibryda
b8001dc56c docs: update meta files for modal dialog fix session 2026-03-08 02:05:09 +01:00
Hibryda
2a93574d1f fix(v3): modal dark-themed directory picker via custom rfd command 2026-03-08 02:05:09 +01:00
Hibryda
b1efe8e48d chore: gitignore playwright-mcp temp directory 2026-03-08 01:58:09 +01:00
Hibryda
a53d629b61 docs: update meta files for dialog capability fix session 2026-03-08 01:58:05 +01:00
Hibryda
c9115158c2 fix(v3): add dialog:default capability for native directory picker 2026-03-08 01:58:05 +01:00
Hibryda
642508e9ea docs: update meta files for directory picker session 2026-03-08 01:51:16 +01:00
Hibryda
a64ab2e55f feat(v3): add native directory picker for CWD fields via tauri-plugin-dialog 2026-03-08 01:51:16 +01:00
Hibryda
99282e833a docs: update meta files for sidebar drawer width fix session 2026-03-08 01:36:23 +01:00
Hibryda
50eef73429 fix(v3): remove leftover v2 grid layout on #app constraining sidebar to 260px 2026-03-08 01:36:23 +01:00
Hibryda
27cc50fb9c docs: update meta files for content-driven sidebar width session 2026-03-08 01:13:50 +01:00
Hibryda
97860c3db1 style(v3): content-driven sidebar width and remaining px-to-rem conversions
Sidebar panel now uses width: max-content with per-tab min-width (22em)
instead of fixed 28em. Changed overflow: hidden to overflow-y: auto on
panel + panel-content so content drives parent width. Converted remaining
px values in SettingsTab, DocsTab to rem/em per rule 18.
2026-03-08 01:13:43 +01:00
Hibryda
3ecc4f02d1 docs: update meta files for relative-units rule session
Add session entries to progress logs, update CLAUDE.md files with
rule 18 reference and rem units, archive multi-machine progress
entries, update CHANGELOG/TODO/v3-task_plan with CSS units decision.
2026-03-08 00:27:28 +01:00
Hibryda
906e967aa0 style(v3): add relative-units rule and convert sidebar CSS from px to rem
Add .claude/rules/18-relative-units.md enforcing rem/em for layout CSS
(px only for icons/borders). Convert GlobalTabBar.svelte and App.svelte
sidebar styles from px to rem. Change rail-btn color to --ctp-subtext0.
2026-03-08 00:27:20 +01:00
Hibryda
820467c029 docs: update meta files for VSCode-style sidebar redesign session 2026-03-08 00:10:25 +01:00
Hibryda
87dd8cb09d feat(v3): redesign UI from top tab bar to VSCode-style left sidebar
Replace horizontal tab bar + right-side settings drawer with vertical
icon rail (36px) + expandable drawer panel (28em) + always-visible
workspace. GlobalTabBar now renders 4 SVG icon buttons. Settings is a
regular sidebar tab. Keyboard: Alt+1..4, Ctrl+B toggle, Ctrl+, settings.
2026-03-08 00:10:16 +01:00
Hibryda
4424a90f89 docs: update meta files for settings drawer conversion session 2026-03-07 23:48:12 +01:00
Hibryda
3776a3ba65 feat(v3): convert Settings from tab to collapsible side drawer
Settings is now a right-side drawer (32em width, semi-transparent
backdrop) instead of a full-page tab. GlobalTabBar has 3 tabs
(Sessions/Docs/Context) + gear icon toggle. WorkspaceTab type
reduced to 'sessions' | 'docs' | 'context'. Close via Escape,
click-outside, or close button. Alt+1..3 for tabs, Ctrl+, toggles
drawer.
2026-03-07 23:48:03 +01:00
Hibryda
b1b34f8195 docs: update meta files for SettingsTab global settings redesign session 2026-03-07 23:23:53 +01:00
Hibryda
36af9dd1d2 feat(v3): redesign SettingsTab global settings with split font controls
Split single font setting into separate UI font (sans-serif options)
and Terminal font (monospace options), each with custom themed dropdown
and size stepper (8-24px). Single-column layout with Appearance and
Defaults subsections. All native <select> replaced with custom themed
dropdowns. Font previews render in their own typeface. New CSS vars:
--term-font-family, --term-font-size. Setting keys changed from
font_family/font_size to ui_font_family/ui_font_size +
term_font_family/term_font_size.
2026-03-07 23:23:33 +01:00
Hibryda
fa7d0bd915 docs: update meta files for SettingsTab global font controls session 2026-03-07 23:03:05 +01:00
Hibryda
47492aa637 feat(v3): add global font controls to SettingsTab
Add font family select (9 monospace fonts) and font size +/- stepper
(8-24px) to SettingsTab global settings. Both controls apply live
preview via CSS custom properties (--ui-font-family, --ui-font-size)
and persist to SQLite. Restructure global settings from inline rows
to 2-column grid with labels above controls. initTheme() now restores
saved font settings on startup.
2026-03-07 23:02:55 +01:00
Hibryda
1279981bf9 docs: update meta files for theme dropdown CSS polish session 2026-03-07 22:47:16 +01:00
Hibryda
9af7ac3f68 fix(v3): improve theme dropdown sizing and prevent label truncation 2026-03-07 22:47:06 +01:00
Hibryda
d38adc017a docs: update meta files for custom theme dropdown session 2026-03-07 22:33:37 +01:00
Hibryda
37d211e9a7 feat(v3): replace native select with custom themed dropdown for theme picker
Custom dropdown in SettingsTab uses --ctp-* CSS vars for full theming.
Shows color swatches (base color) and accent dot previews (red/green/
blue/yellow) per theme. Grouped sections (Catppuccin/Editor/Deep Dark)
with styled headers. Click-outside and Escape to close. Uses getPalette()
from themes.ts for live color rendering.
2026-03-07 22:33:31 +01:00
Hibryda
edaf5fcdb6 docs: update meta files for deep dark themes session 2026-03-07 22:19:12 +01:00
Hibryda
4a46ef42ab feat(v3): add 6 deep dark themes to multi-theme system
Add Tokyo Night, Gruvbox Dark, Ayu Dark, Poimandres, Vesper, and
Midnight themes. Now 17 total themes in 3 groups: Catppuccin (4),
Editor (7), Deep Dark (6). Midnight is pure OLED black (#000000).
2026-03-07 22:19:05 +01:00
Hibryda
54edb43e8b docs: update meta files for multi-theme system session 2026-03-07 22:07:25 +01:00
Hibryda
ff2d354219 feat(v3): add 7 editor themes to multi-theme system
Generalize theme system from Catppuccin-only (4 flavors) to 11 themes
across 2 groups. New editor themes: VSCode Dark+, Atom One Dark,
Monokai, Dracula, Nord, Solarized Dark, GitHub Dark.

All themes map to the same 26 --ctp-* CSS custom properties, so every
component works unchanged. ThemeId replaces CatppuccinFlavor as primary
type. Theme store uses getCurrentTheme()/setTheme() with deprecated
wrappers. SettingsTab uses optgroup-based theme selector.
2026-03-07 22:07:14 +01:00
Hibryda
1ba818e7a5 docs: update meta files for SettingsTab global settings session 2026-03-07 21:31:48 +01:00
Hibryda
3b2c1353fa feat(v3): add global settings section to SettingsTab
Add Global section with theme flavor dropdown, default shell input, and
default CWD input. All settings persisted via settings-bridge. Fix a11y
by using wrapping <label> elements for project fields. Clean up unused
CSS selectors.
2026-03-07 21:31:42 +01:00
Hibryda
6ea1ed1dfd chore(v2): remove dead update_ssh_session method and fix stale comment
- Remove unused SessionDb::update_ssh_session() and its test (SshDialog
  was deleted in P10, this method had no callers)
- Fix stale TilingGrid reference in AgentPane comment
- Eliminates the last Rust compiler warning
2026-03-07 16:51:15 +01:00
Hibryda
67c416dc10 docs: update meta files for v3 Phases 6-10 completion 2026-03-07 16:34:00 +01:00
Hibryda
ab0811ca2b docs(v3): update progress logs and task plan for Phases 6-10 completion 2026-03-07 16:33:54 +01:00
Hibryda
86da302aa6 test(v3): add clearAllAgentSessions mock to workspace store tests 2026-03-07 16:33:47 +01:00
Hibryda
160712de50 refactor(v3): remove dead v2 components and empty directories
Delete 7 components no longer used in v3 Mission Control (~1,836 lines):
TilingGrid, PaneContainer, PaneHeader, SessionList, SshSessionList,
SshDialog, SettingsDialog. Empty directories (Layout/, Sidebar/,
Settings/, SSH/) removed.
2026-03-07 16:33:39 +01:00
Hibryda
e0056f811f feat(v3): implement session continuity, workspace teardown, StatusBar rewrite, subagent routing fix
P6: persistSessionForProject() saves agent state + messages to SQLite on
session complete. registerSessionProject() maps sessionId -> projectId.
ClaudeSession restoreMessagesFromRecords() restores cached messages on mount.

P7: clearAllAgentSessions() clears sessions on group switch. switchGroup()
calls clearAllAgentSessions() + resets terminal tabs.

P10: StatusBar rewritten for workspace store (group name, project count,
agent count, "BTerminal v3"). Subagent routing fixed: project-scoped
sessions skip layout pane creation (render in TeamAgentsPanel instead).
2026-03-07 16:33:27 +01:00
Hibryda
9766a480ed docs: update meta files for v3 Mission Control MVP
- CLAUDE.md: add v3 key paths, update overview and test counts
- .claude/CLAUDE.md: add v3 constraints and Svelte 5 event syntax note
- README.md: update description for v3 MVP status
- TODO.md: move v3 planning to completed, add Phases 6-10 active items
- CHANGELOG.md: add v3 MVP entries under Unreleased
2026-03-07 16:06:39 +01:00
Hibryda
4f29582aac docs(v3): update architecture docs and progress for MVP completion
- v3-task_plan.md: mark Phases 1-5 complete, update adversarial review
  results with 12 resolved issues, finalize component tree and layout
- v3-findings.md: add adversarial review agent findings
- v3-progress.md: document Phases 1-5 implementation details
- progress.md: add v3 MVP session entry
- docs/README.md: reorganize with v2/v3 sections
2026-03-07 16:06:27 +01:00
Hibryda
a11e7f9d2c test(v3): add workspace store tests (24 tests)
Tests for loadGroups, setActiveGroup, setActiveTab, focusProject,
resetWorkspace, derived state (activeGroup, activeProjects), and
edge cases. All 138 vitest + 36 cargo tests pass.
2026-03-07 16:06:17 +01:00
Hibryda
ab79dac4b3 feat(v3): implement Mission Control MVP (Phases 1-5)
Phase 1: Data model - groups.rs (Rust structs + load/save groups.json),
groups.ts (TypeScript interfaces), groups-bridge.ts (IPC adapter),
workspace.svelte.ts (replaces layout store), SQLite migrations
(agent_messages, project_agent_state tables, project_id column),
--group CLI argument.

Phase 2: Project shell layout - GlobalTabBar, ProjectGrid, ProjectBox,
ProjectHeader, CommandPalette, DocsTab, ContextTab, SettingsTab,
App.svelte full rewrite (no sidebar/TilingGrid).

Phase 3: ClaudeSession.svelte wrapping AgentPane per-project.
Phase 4: TerminalTabs.svelte with shell/SSH/agent tab types.
Phase 5: TeamAgentsPanel + AgentCard for compact subagent view.

Also fixes AgentPane Svelte 5 event modifier (on:click -> onclick).
2026-03-07 16:06:07 +01:00
Hibryda
293bed6dc5 docs: update meta files for v3 Mission Control planning
Add v3 doc references to CLAUDE.md, .claude/CLAUDE.md, README.md, and
docs index. Add v3 planning TODOs. Update progress log with v3 session.
Update CHANGELOG with v3 planning entry. Trim oldest completed TODOs.
2026-03-07 15:23:03 +01:00
Hibryda
03e9f34e07 docs(v3): add Mission Control redesign planning docs
v3 architecture planning: task plan with core concept, user requirements,
and architecture questions for adversarial review. Findings doc with
codebase reuse analysis (keep/replace/drop). Progress log for v3 sessions.
2026-03-07 15:22:55 +01:00
Hibryda
761070251f fix(v2): default systemPrompt to claude_code preset for CLAUDE.md loading
Without the preset, settingSources loads files but the system prompt has
no slot to inject CLAUDE.md content. The claude_code preset enables the
full Claude Code system prompt including project instructions.
2026-03-07 01:59:51 +01:00
Hibryda
17c5f9b88a docs: update meta files for Claude profiles, skill discovery, and extended agent options 2026-03-07 01:58:37 +01:00
Hibryda
ff49e7e176 feat(v2): add Claude profile switching, skill discovery, and extended agent options
Add switcher-claude multi-account support with profile selector in AgentPane
toolbar, skill autocomplete menu (type / in prompt), and 5 new AgentQueryOptions
fields (setting_sources, system_prompt, model, claude_config_dir,
additional_directories) flowing through full stack from Rust to SDK.

New Tauri commands: claude_list_profiles, claude_list_skills, claude_read_skill,
pick_directory. New frontend adapter: claude-bridge.ts.
2026-03-07 01:58:29 +01:00
Hibryda
768db420d3 docs: update meta files for Claude CLI path detection and split progress log
- Added pathToClaudeCodeExecutable and findClaudeCli() docs to CLAUDE.md,
  .claude/CLAUDE.md, task_plan.md decisions log, and CHANGELOG.md
- Split docs/progress.md (425 lines) into progress.md (153 lines) +
  progress-archive.md (180 lines) to stay under 300-line threshold
- Updated docs/README.md, README.md with archive file reference
- Updated TODO.md with completed items from this session
2026-03-07 01:28:13 +01:00
Hibryda
d35b3dc7fc feat(v2): auto-detect Claude CLI path and pass to SDK via pathToClaudeCodeExecutable
Both sidecar runners (agent-runner.ts and agent-runner-deno.ts) now include
findClaudeCli() which checks common paths (~/.local/bin/claude,
~/.claude/local/claude, /usr/local/bin/claude, /usr/bin/claude) and falls
back to `which claude`. The resolved path is passed to the SDK query()
options as pathToClaudeCodeExecutable. If the CLI is not found, an
agent_error is emitted immediately instead of a cryptic SDK failure.
2026-03-07 01:28:04 +01:00
Hibryda
14b62da729 docs: update meta files for Rust-side CLAUDE* env var stripping
- .claude/CLAUDE.md: document dual-layer env var stripping (Rust + JS)
- docs/progress.md: add session entry for Rust-side stripping
- docs/task_plan.md: add CLAUDE* env var leak to errors table
- CHANGELOG.md: add fix entry under Unreleased
- .gitignore: exclude debug/, plugins/, projects/ (Claude Code working dirs)
2026-03-07 01:15:10 +01:00
Hibryda
a3d9933221 fix(v2): strip CLAUDE* env vars at Rust level in SidecarManager
Add env_clear() + envs(clean_env) to Command in SidecarManager to
filter all CLAUDE-prefixed environment variables before spawning the
sidecar process. This provides primary defense against SDK nesting
detection when BTerminal is launched from a Claude Code terminal.
The JS-side stripping via SDK env option is retained as defense-in-depth.
2026-03-07 01:15:01 +01:00
Hibryda
f97e7391a9 docs: update meta files for unified sidecar bundle
Updated all docs, CLAUDE.md files, README, and CHANGELOG to reflect
the consolidated sidecar approach (single agent-runner.mjs for both
Deno and Node.js). Removed references to agent-runner-deno.ts as
active runner. Added progress log entry for 2026-03-07 session.
2026-03-07 01:07:13 +01:00
Hibryda
2409642925 refactor(v2): unify sidecar to single agent-runner.mjs bundle
Consolidated from two separate runners (agent-runner-deno.ts +
agent-runner.ts) to a single pre-built agent-runner.mjs that runs
under both Deno and Node.js. resolve_sidecar_command() now checks
runtime availability upfront before path search, with improved
error messages. Removed agent-runner-deno.ts from tauri.conf.json
bundled resources.
2026-03-07 01:07:06 +01:00
Hibryda
658dc4715e docs: update meta files for permission mode, agent stop-on-close fix, SDK bundling 2026-03-06 23:34:09 +01:00
Hibryda
e7c957d650 docs: update docs for permission mode, agent stop-on-close fix, SDK bundling 2026-03-06 23:34:00 +01:00
Hibryda
d5eb08ed42 feat(v2): add permission mode passthrough and fix agent stop-on-close
- Add permission_mode field to AgentQueryOptions (Rust, sidecar, bridge)
  flowing from controller through sidecar to SDK; defaults to
  bypassPermissions, supports default mode
- Fix AgentPane onDestroy bug: remove stopAgent() from onDestroy (fires
  on layout remounts), move stop-on-close to TilingGrid onClose handler
- Bundle SDK into sidecar via esbuild (remove --external flag)
2026-03-06 23:33:51 +01:00
Hibryda
af0eb362e6 docs: update meta files for SDK migration and AgentPane onDestroy bug 2026-03-06 22:57:55 +01:00
Hibryda
bb530edd28 docs: update docs for sidecar SDK migration and AgentPane bug discovery 2026-03-06 22:57:47 +01:00
Hibryda
323703caba feat(v2): migrate sidecar from raw CLI spawning to @anthropic-ai/claude-agent-sdk
Claude CLI v2.1.69 hangs silently when spawned via child_process.spawn()
with piped stdio (known bug github.com/anthropics/claude-code/issues/6775).

Replace raw CLI spawning in both sidecar runners with the SDK's query()
function, which handles subprocess management internally. SDK message
format matches CLI stream-json, so the sdk-messages.ts adapter is
unchanged.

- agent-runner.ts: use SDK query() with AbortController for stop
- agent-runner-deno.ts: use npm:@anthropic-ai/claude-agent-sdk import
- sidecar.rs: add --allow-write and --allow-net Deno permissions
- package.json: add @anthropic-ai/claude-agent-sdk ^0.2.70, build:sidecar script
2026-03-06 22:57:36 +01:00
Hibryda
fdd1884015 docs: update meta files for CLAUDE* env var fix and sidecar constraint 2026-03-06 22:12:23 +01:00
Hibryda
ce79ae671a fix(v2): strip all CLAUDE* env vars in sidecar to prevent CLI nesting detection
When BTerminal is launched from a Claude Code terminal, ~8 CLAUDE*
env vars leak into the sidecar child processes. The claude CLI detects
these as nesting indicators and silently hangs. Previously only
CLAUDECODE was removed; now all CLAUDE-prefixed vars are stripped
in both Node.js and Deno sidecar runners.
2026-03-06 22:12:16 +01:00
Hibryda
4c06b5f121 docs: update docs for TCP probe refactor and frontend reconnection listeners
Replace stale attempt_ws_connect() references with attempt_tcp_probe()
across all docs. Add progress entry for reconnection hardening session.
Update CHANGELOG with new entries and probe refactor change.
2026-03-06 21:50:54 +01:00
Hibryda
71100da125 feat(v2): refactor reconnection probe to TCP-only and add frontend listeners
Replace attempt_ws_connect() with attempt_tcp_probe() in RemoteManager to
avoid allocating per-connection resources (PtyManager, SidecarManager) on
the relay during reconnection probes. Add onRemoteMachineReconnecting and
onRemoteMachineReconnectReady event listeners in remote-bridge.ts. Wire
machines store to auto-reconnect when relay becomes reachable.
2026-03-06 21:50:45 +01:00
Hibryda
218570ac35 docs: update docs for relay hardening, reconnection, and session wrap
Update multi-machine docs with reconnection implementation details,
command response propagation, and pty_created confirmation flow.
Mark reconnection as complete in phases.md, progress.md, TODO.md.
Update CLAUDE.md files with reconnection and relay response info.
Add CHANGELOG entries for new features.
2026-03-06 19:49:28 +01:00
Hibryda
b0cce7ae4f feat(v2): add relay response propagation and reconnection with exponential backoff
Relay (bterminal-relay): command handlers now send structured responses
(pty_created, pong, error) back via shared event channel with commandId
for correlation. New send_error() helper replaces log-only error
reporting.

RemoteManager (remote.rs): exponential backoff reconnection on
disconnect (1s/2s/4s/8s/16s/30s cap). Uses attempt_ws_connect() probe
with 5s timeout. Emits remote-machine-reconnecting and
remote-machine-reconnect-ready events. Handles pty_created relay event
as remote-pty-created Tauri event.
2026-03-06 19:49:19 +01:00
Hibryda
0a17c09a46 docs: update docs for multi-machine implementation (Phases A-D)
Update phases.md with completed multi-machine phases A-D. Add session
entry to progress.md. Update task_plan.md decisions log and open
questions. Update multi-machine.md status to implemented. Update
CLAUDE.md files with new paths and deps. Add multi-machine section to
README.md. Mark multi-machine as done in TODO.md with new follow-up
items. Add changelog entries for all multi-machine components.
2026-03-06 19:06:00 +01:00
Hibryda
5503340e87 feat(v2): add frontend remote machine integration
remote-bridge.ts adapter for machine management IPC. machines.svelte.ts
store for remote machine state. Layout store extended with
remoteMachineId on Pane interface. agent-bridge.ts and pty-bridge.ts
route to remote commands when remoteMachineId is set. SettingsDialog
gains Remote Machines section. Sidebar auto-groups remote panes by
machine label.
2026-03-06 19:05:53 +01:00
Hibryda
0b39133d66 feat(v2): add RemoteManager for multi-machine WebSocket connections
New remote.rs module in src-tauri with WebSocket client connections to
bterminal-relay instances. Machine lifecycle: add/remove/connect/
disconnect. 12 new Tauri commands for remote operations. Heartbeat
ping every 15s. lib.rs updated with remote commands and AppState.
2026-03-06 19:05:47 +01:00
Hibryda
cf37b572cf feat(v2): add bterminal-relay WebSocket server binary
Standalone Rust binary for remote machine management. WebSocket server
with token auth (--port, --token, --insecure CLI flags via clap).
Routes RelayCommand to PtyManager/SidecarManager from bterminal-core,
forwards RelayEvent over WebSocket. Rate limiting on auth failures
(10 attempts, 5min lockout). Per-connection isolated managers.
2026-03-06 19:05:42 +01:00
Hibryda
f894c2862c refactor(v2): extract bterminal-core crate with EventSink trait
Create Cargo workspace at v2/ level with members: src-tauri,
bterminal-core, bterminal-relay. Extract PtyManager and SidecarManager
into shared bterminal-core crate with EventSink trait for abstracting
event emission. TauriEventSink wraps AppHandle. src-tauri pty.rs and
sidecar.rs become thin re-exports. Move Cargo.lock to workspace root.
Add v2/target/ to .gitignore.
2026-03-06 19:05:35 +01:00
Hibryda
250ea17d3e chore: update meta files for multi-machine architecture session 2026-03-06 18:46:03 +01:00
Hibryda
04a7a4bb94 docs: add multi-machine support architecture design
Full WebSocket architecture spec for remote agent/terminal management:
bterminal-relay binary, RemoteManager, NDJSON protocol, pre-shared
token + TLS auth, 4-phase implementation plan (A-D).
2026-03-06 18:45:56 +01:00
Hibryda
86fbe3e762 chore: update meta files for subagent cost and dispatcher tests session 2026-03-06 17:12:58 +01:00
Hibryda
fc429a5095 docs: update docs for subagent cost aggregation, dispatcher tests, Phase 7 complete 2026-03-06 17:12:52 +01:00
Hibryda
097b4b2ee7 test(v2): add 10 subagent routing tests for agent dispatcher
Tests cover: spawn on Agent/Task tool_call, skip non-subagent tools,
deduplicate panes for same toolUseId, reuse existing child sessions,
route child messages/init/cost by parentId, fallback titles and groups.
Total: 28 dispatcher tests, 114 vitest tests overall.
2026-03-06 17:12:43 +01:00
Hibryda
90efeea507 feat(v2): add recursive subagent cost aggregation in agent store and pane
getTotalCost() recursively aggregates costUsd, inputTokens, outputTokens
across parent and all child sessions. AgentPane done-bar displays total
cost in yellow when children are present and total exceeds parent cost.
2026-03-06 17:12:31 +01:00
Hibryda
b7f77d8f60 chore: update meta files for agent teams session work 2026-03-06 16:54:46 +01:00
Hibryda
a34687f844 docs: update docs for Phase 7 agent teams and subagent support 2026-03-06 16:54:35 +01:00
Hibryda
07fc52b958 feat(v2): add agent teams support with subagent pane spawning and routing
Detect subagent tool_call events (Agent/Task/dispatch_agent), auto-spawn
child agent panes with parent/child navigation. Messages with parentId
are routed to child panes; parent session keeps its own messages.

- agents.svelte.ts: parent/child hierarchy fields, findChildByToolUseId,
  getChildSessions, parent-aware createAgentSession/removeAgentSession
- agent-dispatcher.ts: SUBAGENT_TOOL_NAMES detection, toolUseToChildPane
  routing map, spawnSubagentPane with auto-grouping under parent title
- AgentPane.svelte: parent link bar (SUB badge), children bar (chips
  with status colors), clickable navigation between parent/child
- SessionList.svelte: subagent panes show arrow icon instead of asterisk
2026-03-06 16:54:27 +01:00
Hibryda
d021061b8a docs: update all docs for session groups, Deno sidecar, signing key, and tests
Update progress log, phases, task_plan decisions, CLAUDE.md files,
README, TODO, and CHANGELOG to reflect session groups, Deno-first
sidecar integration, auto-update signing key, and 104-test suite.
2026-03-06 15:42:44 +01:00
Hibryda
020dc20d4f test(v2): add integration tests for layout, agent-bridge, and dispatcher
Add 59 new vitest tests: layout.test.ts (30), agent-bridge.test.ts (11),
agent-dispatcher.test.ts (18). Fix unused import in sdk-messages.test.ts.
Add WebDriver E2E scaffold README. Total: 104 vitest + 29 cargo tests.
2026-03-06 15:42:34 +01:00
Hibryda
a2bc8838b4 feat(v2): Deno-first sidecar with Node.js fallback and signing key
Refactor SidecarManager to use SidecarCommand struct abstracting
runtime choice. resolve_sidecar_command() prefers Deno (runs TS
directly, no build step), falls back to Node.js if deno not in PATH.
Both runners bundled in tauri.conf.json resources. Set auto-update
signing pubkey in updater config.
2026-03-06 15:42:26 +01:00
Hibryda
035d4186fa feat(v2): add session groups with collapsible sidebar headers
Add group_name column to sessions table with ALTER TABLE migration,
setPaneGroup in layout store, grouped sidebar rendering with Svelte 5
snippets, and right-click to assign group via prompt dialog.
2026-03-06 15:42:16 +01:00
Hibryda
f349f3bb14 docs: update all docs for polish session — copy/paste, theme hot-swap, tests, drag-resize
- progress.md: add session log for copy/paste, theme hot-swap, tree enhancements,
  session resume, drag-resize, testing, CI, Deno PoC
- phases.md: mark completed items (copy/paste, drag-resize, tree click, subtree cost,
  session resume, CI signing)
- task_plan.md: update theme decision (hot-swap works), add new decisions
- CLAUDE.md: add test paths, test commands, vitest dep
- .claude/CLAUDE.md: fix stale deferred items and theme limitation
- README.md: update feature summary
- TODO.md: move 7 completed items, update active list
- CHANGELOG.md: add session entries under [Unreleased]
2026-03-06 15:10:32 +01:00
Hibryda
c15fe7d912 ci: add auto-update signing and latest.json to release workflow
- Pass TAURI_SIGNING_PRIVATE_KEY env vars from secrets to build step
- Generate latest.json with version, pub_date, platform URL, and .sig signature
- Upload latest.json alongside .deb and .AppImage as release artifacts
2026-03-06 15:10:21 +01:00
Hibryda
35a515db25 test(v2): add vitest and cargo tests for sdk-messages, agent-tree, session, ctx
Frontend (vitest):
- sdk-messages.test.ts: adaptSDKMessage() for all 9 message types
- agent-tree.test.ts: buildAgentTree(), countTreeNodes(), subtreeCost()
- vite.config.ts: vitest test config (src/**/*.test.ts)
- package.json: vitest ^4.0.18 dev dep, "test" script

Backend (cargo):
- session.rs: SessionDb CRUD tests (sessions, SSH, settings, layout) with tempfile
- ctx.rs: CtxDb error handling tests with missing database
- Cargo.toml: tempfile 3 dev dependency
2026-03-06 15:10:12 +01:00
Hibryda
7e6e777713 feat(v2): add Deno sidecar proof-of-concept
Experimental agent-runner-deno.ts as drop-in replacement for Node.js sidecar.
Uses Deno.Command for claude CLI subprocess, TextLineStream for NDJSON parsing.
Same stdio NDJSON protocol. Compiles to single binary via deno compile.
Not yet integrated with Rust SidecarManager.
2026-03-06 15:10:01 +01:00
Hibryda
f27543d8d8 feat(v2): add copy/paste, theme hot-swap, tree enhancements, session resume, drag-resize
- TerminalPane: Ctrl+Shift+C/V copy/paste via attachCustomKeyEventHandler
- TerminalPane: subscribe to onThemeChange() for live theme hot-swap
- theme.svelte.ts: callback registry (onThemeChange) notifies listeners on setFlavor()
- AgentPane: session resume with follow-up prompt and resume_session_id
- AgentPane: tree node click scrolls to corresponding message (scrollIntoView)
- AgentTree: subtree cost display below each node, NODE_H 32->40
- TilingGrid: pane drag-resize via splitter overlays with mouse drag (10-90% clamping)
2026-03-06 15:09:52 +01:00
Hibryda
1d028c67f7 docs: update all docs for Phase 5 completion with SSH, ctx, themes, detached mode
- phases.md: Phase 5 status -> complete, added SSH/ctx/themes/detached/shiki/updater items, updated file structure
- progress.md: added Phase 5 continued session log, updated next steps
- task_plan.md: added 6 new decisions (ctx read-only, SSH via PTY, themes, detached, shiki), status Rev 3
- CLAUDE.md: added new key paths (ctx.rs, adapters, utils, stores, components), updated deps
- .claude/CLAUDE.md: updated phase status, added new technical constraints
- README.md: updated v2 feature summary
- TODO.md: resolved completed items, added new active items
- CHANGELOG.md: added SSH, ctx, themes, detached, shiki, updater entries
2026-03-06 14:50:14 +01:00
Hibryda
4db7ccff60 feat(v2): add SSH management, ctx integration, themes, detached mode, auto-updater
SSH session management:
- SshSession struct + ssh_sessions SQLite table in session.rs
- CRUD Tauri commands (ssh_session_list/save/delete) in lib.rs
- SshDialog.svelte (create/edit modal), SshSessionList.svelte (sidebar)
- SSH pane routes to TerminalPane with shell=/usr/bin/ssh + args

ctx context database integration:
- ctx.rs: read-only CtxDb (SQLITE_OPEN_READ_ONLY for ~/.claude-context/context.db)
- 5 Tauri commands (ctx_list_projects/get_context/get_shared/get_summaries/search)
- ContextPane.svelte with project selector, tabs, search
- ctx-bridge.ts adapter

Catppuccin theme flavors (Latte/Frappe/Macchiato/Mocha):
- themes.ts: all 4 palette definitions + buildXtermTheme/applyCssVariables
- theme.svelte.ts: reactive store with SQLite persistence
- SettingsDialog flavor dropdown, TerminalPane theme-aware

Detached pane mode (pop-out windows):
- detach.ts: isDetachedMode/getDetachedConfig from URL params
- App.svelte: conditional rendering of single pane without chrome

Other additions:
- Shiki syntax highlighting (highlight.ts, lazy singleton, 13 languages)
- Tauri auto-updater plugin (tauri-plugin-updater + updater.ts)
- AgentPane markdown rendering with Shiki code highlighting
- New deps: shiki, @tauri-apps/plugin-updater, tauri-plugin-updater
2026-03-06 14:50:00 +01:00
Hibryda
4f2614186d chore: update README, TODO, and CHANGELOG for Phase 6 completion 2026-03-06 14:23:24 +01:00
Hibryda
173c55cb2b docs: update all docs for Phase 6 packaging completion 2026-03-06 14:23:16 +01:00
Hibryda
67875a1f70 feat(v2): add packaging, installer, and CI release workflow (Phase 6)
Build-from-source installer (install-v2.sh) with dependency checks for
Node.js 20+, Rust 1.77+, and system libraries. Tauri bundle config for
.deb and AppImage targets. GitHub Actions workflow builds and uploads
release artifacts on version tags. Icons regenerated as RGBA PNGs.
2026-03-06 14:23:09 +01:00
Hibryda
643eb15697 chore: update README, TODO, and CHANGELOG for Phase 5 progress 2026-03-06 13:46:44 +01:00
Hibryda
d7a1dca40d docs: update all docs for Phase 5 agent tree, status bar, notifications, settings 2026-03-06 13:46:37 +01:00
Hibryda
be24d07c65 feat(v2): add agent tree, status bar, notifications, settings dialog (Phase 5)
Agent tree visualization (SVG) with horizontal layout and bezier edges.
Global status bar with pane counts, active agents pulse, token/cost totals.
Toast notification system with auto-dismiss and agent dispatcher integration.
Settings dialog with SQLite persistence for shell, cwd, and max panes.
Keyboard shortcuts: Ctrl+W close pane, Ctrl+, open settings.
2026-03-06 13:46:21 +01:00
Hibryda
cd1271adf0 docs: update all docs for Phase 4 completion and MVP status
- phases.md: Phase 3 polish + Phase 4 items checked off as complete
- progress.md: Phase 4 session details added
- task_plan.md: status updated to "MVP COMPLETE"
- README.md: v2 status updated to "MVP complete (Phase 4 done)"
- TODO.md: Phase 3/4 moved to Completed, new post-MVP items added
- CHANGELOG.md: Phase 4 entries added under [Unreleased]
- .claude/CLAUDE.md: workflow status and constraints updated
2026-03-06 12:20:10 +01:00
Hibryda
bdb87978a9 feat(v2): implement session persistence, file watcher, and markdown viewer
Phase 4 complete (MVP ship):
- SessionDb (rusqlite, WAL mode): sessions + layout_state tables, CRUD
- FileWatcherManager (notify v6): watch files, emit Tauri change events
- MarkdownPane: marked.js rendering with Catppuccin styles, live reload
- Layout store wired to persistence (addPane/removePane/setPreset persist)
- restoreFromDb() on startup restores panes in layout order
- Sidebar "M" button opens file picker for markdown files
- New adapters: session-bridge.ts, file-bridge.ts
- Deps: rusqlite (bundled), dirs 5, notify 6, marked
2026-03-06 12:19:56 +01:00
Hibryda
5ca035d438 feat(v2): add sidecar crash detection, restart UI, and auto-scroll lock
Phase 3 polish: dispatcher listens for sidecar-exited events and marks
running sessions as error. AgentPane shows "Restart Sidecar" button on
error. Auto-scroll disables when user scrolls >50px from bottom with
"Scroll to bottom" button. Added agent_restart Tauri command.
2026-03-06 12:19:35 +01:00
Hibryda
da6d7272ee docs: update docs for .svelte.ts rune store fix and Phase 3 status
- progress.md: add bug fix section for rune file extension issue
- phases.md: update store file names to .svelte.ts in file structure
- task_plan.md: add error entry and .svelte.ts decision to decisions log
- .claude/CLAUDE.md: add .svelte.ts constraint, update Phase 3 status
- CHANGELOG.md: add Fixed entries for rune store rename
2026-03-06 01:11:58 +01:00
Hibryda
af1516ed2b fix(v2): rename rune stores to .svelte.ts to fix rune_outside_svelte error
Svelte 5 $state/$derived runes only work in .svelte and .svelte.ts
files. The stores had plain .ts extensions, causing a blank screen with
"rune_outside_svelte" runtime error. Renamed all three store files and
updated import paths across 5 consuming files.
2026-03-06 01:11:51 +01:00
Hibryda
c24e540080 docs: reflect Phase 3 agent SDK integration progress
Update phases.md with Phase 3 checklist (in_progress), add session
progress log entry, record claude CLI architecture decision in
task_plan.md, update README/TODO/CHANGELOG with Phase 3 additions,
and sync .claude/CLAUDE.md with current constraints.
2026-03-06 01:02:13 +01:00
Hibryda
314c6d77aa feat(v2): add agent pane with SDK message adapter and dispatcher
Implement full agent session frontend: SDK message adapter parsing
stream-json into 9 typed message types, agent bridge for Tauri IPC,
dispatcher routing sidecar events to store, agent session store with
cost tracking, and AgentPane component with prompt input, message
rendering (text, thinking, tool calls, results, cost), and stop
button. Add Ctrl+Shift+N shortcut and sidebar agent button.
2026-03-06 01:01:56 +01:00
Hibryda
f928501075 feat(v2): implement agent-runner sidecar with claude CLI subprocess
Replace Agent SDK stub with working implementation that spawns
claude CLI with --output-format stream-json, manages multiple
sessions via Map<sessionId, ChildProcess>, and forwards NDJSON
events to Rust backend. Supports query, stop, and graceful shutdown.
2026-03-06 01:01:43 +01:00
Hibryda
f0ec44f6a6 feat(v2): add SidecarManager and agent Tauri commands
Implement Rust SidecarManager that spawns Node.js sidecar process,
communicates via stdio NDJSON, and manages agent session lifecycle.
Add agent_query, agent_stop, agent_ready Tauri commands. Sidecar
auto-starts on app launch.
2026-03-06 01:01:35 +01:00
Hibryda
54b0d44bc1 docs: reflect Phase 2 completion across all project documentation
Update progress log with Phase 2 deliverables, mark Phase 2 complete
in TODO/CHANGELOG/README, fix stale Solid.js reference in findings,
update task_plan status to BUILDING, add v2 key paths to CLAUDE.md.
2026-03-05 23:50:16 +01:00
Hibryda
05ad0db092 docs: mark Phase 2 terminal pane + layout as complete 2026-03-05 23:43:00 +01:00
Hibryda
bfd4021909 feat(v2): add tiling layout, sidebar controls, and keyboard shortcuts
- TilingGrid: dynamic CSS Grid with auto-preset based on pane count
- Layout presets: 1-col, 2-col, 3-col, 2x2, master-stack
- PaneContainer: close button, status indicator, focus highlight
- SessionList: new terminal button, layout preset selector, pane list
- Layout store: pane CRUD, focus management, grid template generation
- Keyboard: Ctrl+N new terminal, Ctrl+1-4 focus pane by index
2026-03-05 23:42:41 +01:00
Hibryda
bb0e9283fc feat(v2): add xterm.js terminal pane with Canvas addon
- Install @xterm/xterm, @xterm/addon-canvas, @xterm/addon-fit, @tauri-apps/api
- TerminalPane: xterm.js with Catppuccin Mocha theme, auto-fit, resize observer
- PTY bridge: Tauri invoke wrappers + event listeners for data/exit
- Bidirectional streaming: keyboard input -> PTY write, PTY output -> xterm write
- 100ms debounced resize propagation to PTY backend
2026-03-05 23:42:31 +01:00
Hibryda
f15e60be60 feat(v2): add PTY backend with portable-pty
- Implement PtyManager: spawn, write, resize, kill operations
- Wire Tauri commands: pty_spawn, pty_write, pty_resize, pty_kill
- Reader thread streams PTY output via Tauri events (pty-data-{id})
- Process exit detection emits pty-exit-{id} event
- Add portable-pty 0.8 and uuid 1.0 dependencies
2026-03-05 23:42:20 +01:00
Hibryda
406683799b docs: reflect Phase 1 completion in README, TODO, and CHANGELOG
Move Phase 1 to completed in TODO.md, update README.md v2 status,
and add scaffolding entries to CHANGELOG.md.
2026-03-05 23:32:19 +01:00
Hibryda
9bd15b359b docs: update progress log and documentation index
Add Phase 1 completion details to progress.md and document index
table to docs/README.md linking all planning documents.
2026-03-05 23:32:11 +01:00
Hibryda
bb93bd1c9a chore(v2): add Cargo.lock and VS Code extensions config
Include Cargo.lock for reproducible Rust builds and recommended
Svelte extension for VS Code.
2026-03-05 23:32:00 +01:00
Hibryda
89a98adb61 docs: mark Phase 1 scaffolding as complete 2026-03-05 23:26:40 +01:00
Hibryda
758d626fab feat(v2): scaffold Tauri 2.x + Svelte 5 project (Phase 1)
- Tauri 2.10 + Svelte 5.45 + TypeScript + Vite 7
- Catppuccin Mocha theme with CSS variables and semantic aliases
- CSS Grid layout: sidebar (260px) + workspace, responsive breakpoints
  for ultrawide (3440px+) and narrow (<1200px)
- Component structure: Layout/, Terminal/, Agent/, Markdown/, Sidebar/
- Svelte 5 stores with $state runes: sessions, agents, layout
- SDK message adapter (abstracts Agent SDK wire format)
- PTY bridge (Tauri IPC wrapper, stubbed for Phase 2)
- Node.js sidecar entry point (stdio NDJSON, stubbed for Phase 3)
- Rust modules: pty, sidecar, watcher, session (stubbed)
- Vite dev server on port 9700
- Build verified: binary + .deb + .rpm + AppImage all produced
2026-03-05 23:26:27 +01:00
225 changed files with 53914 additions and 160 deletions

View file

@ -3,7 +3,10 @@
## Workflow
- v1 is a single-file Python app (`bterminal.py`). Changes are localized.
- v2 planning docs are in `docs/`. Architecture decisions are in `docs/task_plan.md`.
- v2 docs are in `docs/`. Architecture decisions are in `docs/task_plan.md`.
- v2 Phases 1-7 + multi-machine (A-D) + profiles/skills complete. Extras: SSH, ctx, themes, detached mode, auto-updater, shiki, copy/paste, session resume, drag-resize, session groups, Deno sidecar, Claude profiles, skill discovery.
- v3 Mission Control (All Phases 1-10 Complete + S-1 Phase 1/1.5/2/3 + S-2 Session Anchors + Provider Adapter Pattern + Provider Runners + Memora Adapter + SOLID Phase 3 + Multi-Agent Orchestration): project groups, workspace store, 15 Workspace components, session continuity, workspace teardown, file overlap conflict detection, inotify-based external write detection, multi-provider adapter pattern (3 phases + Codex/Ollama runners), worktree isolation, session anchors, Memora adapter (read-only SQLite), SOLID refactoring (agent-dispatcher split → 4 utils, session.rs split → 7 sub-modules, branded types), multi-agent orchestration (btmsg inter-agent messaging, bttask kanban task board, agent prompt generator, BTMSG_AGENT_ID env passthrough, periodic re-injection, role-specific tabs: Manager=Tasks, Architect=Arch, Tester=Selenium+Tests), dead v2 component cleanup. 286 vitest + 49 cargo tests.
- v3 docs: `docs/v3-task_plan.md`, `docs/v3-findings.md`, `docs/v3-progress.md`.
- Consult Memora (tag: `bterminal`) before making architectural changes.
## Documentation References
@ -12,21 +15,78 @@
- Implementation phases: [docs/phases.md](../docs/phases.md)
- Research findings: [docs/findings.md](../docs/findings.md)
- Progress log: [docs/progress.md](../docs/progress.md)
- v3 architecture: [docs/v3-task_plan.md](../docs/v3-task_plan.md)
- v3 findings: [docs/v3-findings.md](../docs/v3-findings.md)
- v3 progress: [docs/v3-progress.md](../docs/v3-progress.md)
## Rules
- Do not modify v1 code (`bterminal.py`) unless explicitly asked — it is production-stable.
- v2 work goes on a feature branch (`v2-mission-control`), not master.
- All v2 architecture decisions must reference `docs/task_plan.md` Decisions Log.
- v2/v3 work goes on the `v2-mission-control` branch, not master.
- v2 architecture decisions must reference `docs/task_plan.md` Decisions Log.
- v3 architecture decisions must reference `docs/v3-task_plan.md` Decisions Log.
- When adding new decisions, append to the Decisions Log table with date.
- Update `docs/progress.md` after each significant work session.
## Key Technical Constraints
- WebKit2GTK has no WebGL — xterm.js must use Canvas addon explicitly.
- Claude Agent SDK is 0.2.x (pre-1.0) — all SDK interactions go through the adapter layer (`src/lib/adapters/sdk-messages.ts`).
- Node.js sidecar communicates via stdio NDJSON, not sockets.
- Maximum 4 active xterm.js instances to avoid WebKit2GTK memory issues.
- Agent sessions use `@anthropic-ai/claude-agent-sdk` query() function (migrated from raw CLI spawning due to piped stdio hang bug). SDK handles subprocess management internally. All output goes through the adapter layer (`src/lib/adapters/claude-messages.ts` via `message-adapters.ts` registry) — SDK message format matches CLI stream-json. Multi-provider support: message-adapters.ts routes by ProviderId to provider-specific parsers (claude-messages.ts, codex-messages.ts, ollama-messages.ts — all 3 registered).
- Sidecar uses per-provider runner bundles (`sidecar/dist/{provider}-runner.mjs`). Currently only `claude-runner.mjs` exists. SidecarManager.resolve_sidecar_for_provider(provider) finds the right runner file. Deno preferred (faster startup), Node.js fallback. Communicates with Rust via stdio NDJSON. Claude CLI auto-detected at startup via `findClaudeCli()` — checks ~/.local/bin/claude, ~/.claude/local/claude, /usr/local/bin/claude, /usr/bin/claude, then `which claude`. Path passed to SDK via `pathToClaudeCodeExecutable` option. Agents error immediately if CLI not found. Provider env var stripping: strip_provider_env_var() strips CLAUDE*/CODEX*/OLLAMA* vars (whitelists CLAUDE_CODE_EXPERIMENTAL_*). Dual-layer: (1) Rust env_clear() + clean_env, (2) JS runner SDK `env` option. Session stop uses AbortController.abort(). `agent-runner-deno.ts` exists as standalone alternative runner but is NOT used by SidecarManager.
- AgentPane does NOT stop agents in onDestroy — onDestroy fires on layout remounts, not just explicit close. Stop-on-close is handled externally (was TilingGrid in v2, now workspace teardown in v3).
- Agent dispatcher (`src/lib/agent-dispatcher.ts`) is a thin coordinator (260 lines) routing sidecar events to the agent store. Delegates to extracted modules: `utils/session-persistence.ts` (session-project maps, persistSessionForProject), `utils/subagent-router.ts` (spawn + route subagent panes), `utils/auto-anchoring.ts` (triggerAutoAnchor on compaction), `utils/worktree-detection.ts` (detectWorktreeFromCwd pure function). Provider-aware via message-adapters.ts.
- AgentQueryOptions supports `provider` field (defaults to 'claude', flows Rust -> sidecar), `provider_config` blob (Rust passes through as serde_json::Value), `permission_mode` (defaults to 'bypassPermissions'), `setting_sources` (defaults to ['user', 'project']), `system_prompt`, `model`, `claude_config_dir` (for multi-account), `additional_directories`, `worktree_name` (when set, passed as `extraArgs: { worktree: name }` to SDK → `--worktree <name>` CLI flag), `extra_env` (HashMap<String,String>, injected into sidecar process env; used for BTMSG_AGENT_ID).
- Multi-agent orchestration: Tier 1 (management agents: Manager, Architect, Tester, Reviewer) defined in groups.json `agents[]`, converted to ProjectConfig via `agentToProject()`, rendered as full ProjectBoxes. Tier 2 (project agents) are regular ProjectConfig entries. Both tiers get system prompts. Tier 1 prompt built by `generateAgentPrompt()` (utils/agent-prompts.ts): 7 sections (Identity, Environment, Team, btmsg docs, bttask docs, Custom context, Workflow). Tier 2 gets optional `project.systemPrompt` as custom context. BTMSG_AGENT_ID env var injected for Tier 1 agents only (enables btmsg/bttask CLI usage). Periodic re-injection: AgentSession runs 1-hour timer, sends context refresh prompt when agent is idle (autoPrompt → AgentPane → startQuery with resume=true).
- bttask kanban: Rust bttask.rs module reads/writes tasks table in shared btmsg.db (~/. local/share/bterminal/btmsg.db). 6 operations: list_tasks, create_task, update_task_status, delete_task, add_comment, task_comments. Frontend: TaskBoardTab.svelte (kanban 5 columns, 5s poll). CLI `bttask` tool gives agents direct access; Manager has full CRUD, other roles have read-only + comments.
- ArchitectureTab: PlantUML diagram viewer/editor. Stores .puml files in `.architecture/` project dir. Renders via plantuml.com server using ~h hex encoding (no Java dependency). 4 templates: Class, Sequence, State, Component. Editor + SVG preview toggle.
- TestingTab: Dual-mode component (mode='selenium'|'tests'). Selenium: watches `.selenium/screenshots/` for PNG/JPG, displays in gallery with session log, 3s poll. Tests: discovers files in standard dirs (tests/, test/, spec/, __tests__/, e2e/), shows content.
- Worktree isolation (S-1 Phase 3): Per-project `useWorktrees` toggle in SettingsTab. When enabled, AgentPane passes `worktree_name=sessionId` in queryAgent(). Agent runs in `<repo>/.claude/worktrees/<sessionId>/`. CWD-based detection: `utils/worktree-detection.ts` `detectWorktreeFromCwd()` matches `.claude/worktrees/`, `.codex/worktrees/`, `.cursor/worktrees/` patterns on init events → calls `setSessionWorktree()` for conflict suppression. Dual detection: CWD-based (primary, from init event) + tool_call-based `extractWorktreePath()` (subagent fallback).
- Claude profiles: claude_list_profiles() reads ~/.config/switcher/profiles/ with profile.toml metadata. Profile set per-project in Settings (project.profile field), passed through AgentSession -> AgentPane `profile` prop -> resolved to config_dir for SDK. Profile name shown as info-only in ProjectHeader.
- ProjectBox has project-level tab bar: Model | Docs | Context | Files | SSH | Memory + role-specific tabs. Three mount strategies: PERSISTED-EAGER (Model, Docs, Context — always mounted, display:flex/none), PERSISTED-LAZY (Files, SSH, Memory, Tasks, Architecture, Selenium, Tests — mount on first activation via {#if everActivated} + display:flex/none). Tab type: `'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories' | 'tasks' | 'architecture' | 'selenium' | 'tests'`. Role-specific tabs: Manager gets Tasks (kanban), Architect gets Arch (PlantUML), Tester gets Selenium+Tests. Conditional on `isAgent && agentRole`. Model tab = AgentSession+TeamAgentsPanel. Docs tab = ProjectFiles (markdown viewer). Context tab = ContextTab.svelte (LLM context window visualization: stats bar, segmented token meter, file references, turn breakdown; reads from agent store via sessionId prop; replaced old ContextPane ctx database viewer). Files tab = FilesTab.svelte (VSCode-style directory tree + CodeMirror 6 editor with 15 language modes, dirty tracking, Ctrl+S save, save-on-blur setting, image display via convertFileSrc, 10MB gate; CodeEditor.svelte wrapper; PdfViewer.svelte for PDF files via pdfjs-dist with canvas multi-page rendering + zoom 0.5x3x; CsvTable.svelte for CSV with RFC 4180 parser, delimiter auto-detect, sortable columns). SSH tab = SshTab.svelte (CRUD for SSH connections, launch spawns terminal tab in Model tab). Memory tab = MemoriesTab.svelte (pluggable via MemoryAdapter interface in memory-adapter.ts; MemoraAdapter registered at startup, reads ~/.local/share/memora/memories.db via Rust memora.rs). Tasks tab = TaskBoardTab.svelte (kanban board, 5 columns, 5s poll, Manager only). Arch tab = ArchitectureTab.svelte (PlantUML viewer/editor, .architecture/ dir, plantuml.com ~h hex encoding, Architect only). Selenium tab = TestingTab.svelte mode=selenium (screenshot gallery, session log, 3s poll, Tester only). Tests tab = TestingTab.svelte mode=tests (test file discovery, content viewer, Tester only). Rust backend: list_directory_children + read_file_content + write_file_content (FileContent tagged union: Text/Binary/TooLarge). Frontend bridge: files-bridge.ts.
- ProjectHeader shows CWD (ellipsized from START via `direction: rtl`) + profile name as info-only text on right side. AgentPane no longer has DIR/ACC toolbar — CWD and profile are props from parent.
- Skill discovery: claude_list_skills() reads ~/.claude/skills/ (dirs with SKILL.md or .md files). claude_read_skill() reads content. AgentPane `/` prefix triggers autocomplete menu. Skill content injected as prompt via expandSkillPrompt().
- claude-bridge.ts adapter wraps profile/skill Tauri commands (ClaudeProfile, ClaudeSkill interfaces). provider-bridge.ts wraps claude-bridge as generic provider bridge (delegates by ProviderId).
- Provider adapter pattern: ProviderId = 'claude' | 'codex' | 'ollama'. ProviderCapabilities flags gate UI (hasProfiles, hasSkills, hasModelSelection, hasSandbox, supportsSubagents, supportsCost, supportsResume). ProviderMeta registered via registerProvider() in App.svelte onMount. AgentPane receives provider + capabilities props. SettingsTab has Providers section with collapsible per-provider config panels. ProjectConfig.provider field for per-project selection. Settings persisted as `provider_settings` JSON blob.
- Sidecar build: `npm run build:sidecar` builds all 3 runners via esbuild (claude-runner.mjs, codex-runner.mjs, ollama-runner.mjs). Each is a standalone ESM bundle. Codex runner dynamically imports @openai/codex-sdk (graceful failure if not installed). Ollama runner uses native fetch (zero deps).
- Agent preview terminal: `AgentPreviewPane.svelte` is a read-only xterm.js terminal (disableStdin:true) that subscribes to an agent session's messages via `$derived(getAgentSession(sessionId))` and renders tool calls/results in real-time. Bash commands shown as cyan ` cmd`, file ops as yellow `[Read] path`, results as plain text (80-line truncation), errors in red. Spawned via 👁 button in TerminalTabs (appears when agentSessionId prop is set). TerminalTab type: `'agent-preview'` with `agentSessionId` field. Deduplicates — won't create two previews for the same session. ProjectBox passes mainSessionId to TerminalTabs.
- Maximum 4 active xterm.js instances to avoid WebKit2GTK memory issues. Agent preview uses disableStdin and no PTY so is lighter, but still counts.
- Store files using Svelte 5 runes (`$state`, `$derived`) MUST have `.svelte.ts` extension (not `.ts`). Import with `.svelte` suffix. Plain `.ts` compiles but fails at runtime with "rune_outside_svelte".
- Session persistence uses rusqlite (bundled) with WAL mode. Data dir: `dirs::data_dir()/bterminal/sessions.db`.
- Layout store persists to SQLite on every addPane/removePane/setPreset/setPaneGroup change (fire-and-forget). Restores on app startup via `restoreFromDb()`.
- Session groups: Pane.group? field in layout store, group_name column in sessions table, collapsible group headers in sidebar. Right-click pane to set group.
- File watcher uses notify crate v6, watches parent directory (NonRecursive), emits `file-changed` Tauri events.
- Settings use key-value `settings` table in SQLite (session/settings.rs). Frontend: `settings-bridge.ts` adapter. v3 uses SettingsTab.svelte rendered in sidebar drawer panel (v2 SettingsDialog.svelte deleted in P10). SettingsTab has two sections: Global (single-column layout, split into Appearance [theme dropdown, UI font dropdown with sans-serif options + size stepper, Terminal font dropdown with monospace options + size stepper] and Defaults [shell, CWD] — all custom themed dropdowns, no native `<select>`, all persisted via settings-bridge with keys: theme, ui_font_family, ui_font_size, term_font_family, term_font_size, default_shell, default_cwd) and Group/Project CRUD.
- Notifications use ephemeral toast system: `notifications.svelte.ts` store (max 5, 4s auto-dismiss), `ToastContainer.svelte` display. Agent dispatcher emits toasts on agent complete/error/crash.
- StatusBar → Mission Control bar: running/idle/stalled agent counts (color-coded), total $/hr burn rate, "needs attention" dropdown priority queue (up to 5 cards sorted by urgency score, click-to-focus), total tokens + cost. Uses health.svelte.ts store (not workspace store for health signals).
- health.svelte.ts store: per-project health tracking via ProjectTracker map. ActivityState = inactive|running|idle|stalled (configurable per-project via stallThresholdMin in ProjectConfig, default 15 min, range 560 min step 5, synced via setStallThreshold() API). Burn rate from 5-min EMA costSnapshots. Context pressure = tokens/model limit. File conflict count from conflicts.svelte.ts. Attention scoring: stalled=100, error=90, ctx>90%=80, file_conflict=70, ctx>75%=40. 5-second tick timer (auto-stop/start). API: trackProject(), recordActivity(), recordToolDone(), recordTokenSnapshot(), getProjectHealth(), getAttentionQueue(), getHealthAggregates().
- conflicts.svelte.ts store: per-project file overlap + external write detection. Records Write/Edit/Bash-write tool_call file paths per session. Detects when 2+ sessions in same worktree write same file. S-1 Phase 2: inotify-based external write detection via fs_watcher.rs — uses 2s timing heuristic (AGENT_WRITE_GRACE_MS) to distinguish agent writes from external. EXTERNAL_SESSION_ID='__external__' sentinel. Worktree-aware. Dismissible. recordExternalWrite() for inotify events. FileConflict.isExternal flag, ProjectConflicts.externalConflictCount. Session-scoped, no persistence.
- tool-files.ts utility: shared extractFilePaths(tc) → ToolFileRef[], extractWritePaths(tc) → string[], extractWorktreePath(tc) → string|null. Bash write detection via regex (>, >>, sed -i, tee, cp, mv). Used by ContextTab (all ops) and agent-dispatcher (writes + worktree tracking for conflict detection).
- ProjectHeader shows status dot (green pulse=running, gray=idle, orange pulse=stalled, dim=inactive) + external write badge (orange ⚡ clickable, shown when externalConflictCount > 0) + agent conflict badge (red ⚠ clickable with ✕) + context pressure badge (>90% red, >75% orange, >50% yellow) + burn rate badge ($/hr). Health prop from ProjectBox via getProjectHealth(). ProjectBox starts/stops fs watcher per project CWD via $effect.
- session_metrics SQLite table: per-project historical session data (project_id, session_id, timestamps, peak_tokens, turn_count, tool_call_count, cost_usd, model, status, error_message). 100-row retention per project. Tauri commands: session_metric_save, session_metrics_load. Persisted on agent completion via agent-dispatcher.
- Session anchors (S-2): Preserves important turns through compaction chains. Types: auto (on first compaction, 3 turns, observation-masked — reasoning preserved in full, only tool outputs compacted), pinned (user-created via pin button in AgentPane), promoted (user-promoted from pinned, re-injectable). Configurable budget via AnchorBudgetScale ('small'=2K|'medium'=6K|'large'=12K|'full'=20K) — per-project slider in SettingsTab, stored as ProjectConfig.anchorBudgetScale in groups.json. Re-injection: anchors.svelte.ts → AgentPane.startQuery() → system_prompt field → sidecar → SDK. ContextTab shows anchor section with budget meter (derived from scale) + promote/demote. SQLite: session_anchors table. Files: types/anchors.ts, adapters/anchors-bridge.ts, stores/anchors.svelte.ts, utils/anchor-serializer.ts.
- Agent tree (AgentTree.svelte) uses SVG with recursive layout. Tree data built by `agent-tree.ts` utility from agent messages.
- ctx integration opens `~/.claude-context/context.db` as SQLITE_OPEN_READ_ONLY — never writes. CtxDb uses Option<Connection> for graceful absence if DB doesn't exist.
- SSH sessions spawn TerminalPane with shell=/usr/bin/ssh and args array. No SSH library needed — PTY handles it natively.
- Theme system: 17 themes in 3 groups — 4 Catppuccin + 7 Editor (VSCode Dark+, Atom One Dark, Monokai, Dracula, Nord, Solarized Dark, GitHub Dark) + 6 Deep Dark (Tokyo Night, Gruvbox Dark, Ayu Dark, Poimandres, Vesper, Midnight). All map to same 26 --ctp-* CSS custom properties — zero component changes needed. ThemeId replaces CatppuccinFlavor. getCurrentTheme()/setTheme() are primary API (deprecated wrappers exist). THEME_LIST has ThemeMeta with group metadata for custom dropdown UI. Open terminals hot-swap via onThemeChange() callback registry in theme.svelte.ts. Typography uses --ui-font-family/--ui-font-size (UI elements, sans-serif fallback) and --term-font-family/--term-font-size (terminal, monospace fallback) CSS custom properties (defined in catppuccin.css). initTheme() restores all 4 font settings (ui_font_family, ui_font_size, term_font_family, term_font_size) from SQLite on startup.
- Detached pane mode: App.svelte checks URL param `?detached=1` and renders a single pane without sidebar/grid chrome. Used for pop-out windows.
- Shiki syntax highlighting uses lazy singleton pattern (avoid repeated WASM init). 13 languages preloaded. Used in MarkdownPane and AgentPane text messages.
- Cargo workspace at v2/ level: members = [src-tauri, bterminal-core, bterminal-relay]. Cargo.lock is at workspace root (v2/), not in src-tauri/.
- EventSink trait (bterminal-core/src/event.rs) abstracts event emission. PtyManager and SidecarManager are in bterminal-core, not src-tauri. src-tauri has thin re-exports.
- RemoteManager (src-tauri/src/remote.rs) manages WebSocket client connections to bterminal-relay instances. 12 Tauri commands prefixed with `remote_`.
- remote-bridge.ts adapter wraps remote machine management IPC. machines.svelte.ts store tracks remote machine state.
- Pane.remoteMachineId?: string routes operations through RemoteManager instead of local managers. Bridge adapters (pty-bridge, agent-bridge) check this field.
- bterminal-relay binary (v2/bterminal-relay/) is a standalone WebSocket server with token auth, rate limiting, and per-connection isolated managers. Commands return structured responses (pty_created, pong, error) with commandId for correlation via send_error() helper.
- RemoteManager reconnection: exponential backoff (1s-30s cap) on disconnect, attempt_tcp_probe() (TCP-only, no WS upgrade), emits remote-machine-reconnecting and remote-machine-reconnect-ready events. Frontend listeners in remote-bridge.ts; machines store auto-reconnects on ready.
- v3 workspace store (`workspace.svelte.ts`) replaces layout store for v3. Groups loaded from `~/.config/bterminal/groups.json` via `groups-bridge.ts`. State: groups, activeGroupId, activeTab, focusedProjectId. Derived: activeGroup, activeProjects.
- v3 groups backend (`groups.rs`): load_groups(), save_groups(), default_groups(). Tauri commands: groups_load, groups_save.
- Telemetry (`telemetry.rs`): tracing + optional OTLP export to Tempo. `BTERMINAL_OTLP_ENDPOINT` env var controls (absent = console-only). TelemetryGuard in AppState with Drop-based shutdown. Frontend events route through `frontend_log` Tauri command → Rust tracing (no browser OTEL SDK — WebKit2GTK incompatible). `telemetry-bridge.ts` provides `tel.info/warn/error()` convenience API. Docker stack at `docker/tempo/` (Grafana port 9715).
- v3 SQLite additions: agent_messages table (per-project message persistence), project_agent_state table (sdkSessionId, cost, status per project), sessions.project_id column.
- v3 App.svelte: VSCode-style sidebar layout. Horizontal: left icon rail (GlobalTabBar, 2.75rem, single Settings gear icon) + expandable drawer panel (Settings only, content-driven width, max 50%) + main workspace (ProjectGrid always visible) + StatusBar. Sidebar has Settings only — Sessions/Docs/Context are project-specific (in ProjectBox tabs). Keyboard: Ctrl+B (toggle sidebar), Ctrl+, (settings), Escape (close).
- v3 component tree: App -> GlobalTabBar (settings icon) + sidebar-panel? (SettingsTab) + workspace (ProjectGrid) + StatusBar. See `docs/v3-task_plan.md` for full tree.
- MarkdownPane reactively watches filePath changes via $effect (not onMount-only). Uses sans-serif font (Inter, system-ui), all --ctp-* theme vars. Styled blockquotes with translucent backgrounds, table row hover, link hover underlines. Inner `.markdown-pane-scroll` wrapper with `container-type: inline-size` for responsive padding via `--bterminal-pane-padding-inline`.
- AgentPane UI (redesigned 2026-03-09): sans-serif root font (`system-ui, -apple-system, sans-serif`), monospace only on code/tool names. Tool calls paired with results in collapsible `<details>` groups via `$derived.by` toolResultMap (cache-guarded by tool_result count). Hook messages collapsed into compact `<details>` with gear icon. Context window meter inline in status strip. Cost bar minimal (no background, subtle border-top). Session summary with translucent surface background. Two-phase scroll anchoring (`$effect.pre` + `$effect`). Tool-aware output truncation (Bash 500 lines, Read/Write 50, Glob/Grep 20, default 30). Colors softened via `color-mix()`. Inner `.agent-pane-scroll` wrapper with `container-type: inline-size` for responsive padding via shared `--bterminal-pane-padding-inline` variable.
- ProjectBox uses CSS `style:display` (flex/none) instead of `{#if}` for tab content panes — keeps AgentSession mounted across tab switches (prevents session ID reset and message loss). Terminal section also uses `style:display`. Grid rows: auto auto 1fr auto.
- Svelte 5 event syntax: use `onclick` not `on:click`. Svelte 5 requires lowercase event handler attributes (no colon syntax).
## Memora Tags
@ -58,3 +118,6 @@ All operational rules live in `.claude/rules/`. Every `.md` file in that directo
| 15 | `memora.md` | Persistent memory across sessions |
| 16 | `sub-agents.md` | When to use sub-agents and team agents |
| 17 | `document-imports.md` | Resolve @ imports in CLAUDE.md before acting |
| 18 | `relative-units.md` | Use rem/em for layout, px only for icons/borders |
| 51 | `theme-integration.md` | All colors via --ctp-* CSS vars, never hardcode |
| 52 | `no-implicit-push.md` | Never push unless explicitly asked |

View file

@ -18,6 +18,21 @@ Assume nothing about correctness — prove it with tests.
- Critical user journeys only (~10% of suite). Test API endpoints with integration tests, not E2E.
## Browser Automation
Choose the right tool for the job:
| Tool | Use When |
|------|----------|
| **Claude in Chrome** | Authenticated sites, user's logged-in session needed |
| **Playwright MCP** | Cross-browser testing, E2E test suites, CI-style validation |
| **Puppeteer MCP** | Quick DOM scripting, page scraping, lightweight checks |
| **Chrome DevTools MCP** | Deep debugging (performance traces, network waterfall, memory) |
- Prefer Playwright for repeatable E2E tests (deterministic, headless-capable).
- Use Claude in Chrome when the test requires an existing authenticated session.
- Use DevTools MCP for performance profiling and network analysis, not functional tests.
## After Every Change
- Run the test suite, report results, fix failures before continuing.

View file

@ -0,0 +1,17 @@
# Relative Units (CSS)
Use relative units (`em`, `rem`, `%`, `vh`, `vw`) for layout and spacing. Pixels are acceptable only for:
- Icon sizes (`width`/`height` on `<svg>` or icon containers)
- Borders and outlines (`1px solid ...`)
- Box shadows
## Rules
- **Layout dimensions** (width, height, max-width, min-width): use `em`, `rem`, `%`, or viewport units.
- **Padding and margin**: use `em` or `rem`.
- **Font sizes**: use `rem` or `em`, never `px`.
- **Gap, border-radius**: use `em` or `rem`.
- **Media queries**: use `em`.
- When existing code uses `px` for layout elements, convert to relative units as part of the change.
- CSS custom properties for typography (`--ui-font-size`, `--term-font-size`) store `px` values because they feed into JS APIs (xterm.js) that require pixels. This is the only exception beyond icons/borders.

View file

@ -0,0 +1,17 @@
# Theme Integration (CSS)
All UI components MUST use the project's CSS custom properties for colors. Never hardcode color values.
## Rules
- **Backgrounds**: Use `var(--ctp-base)`, `var(--ctp-mantle)`, `var(--ctp-crust)`, `var(--ctp-surface0)`, `var(--ctp-surface1)`, `var(--ctp-surface2)`.
- **Text**: Use `var(--ctp-text)`, `var(--ctp-subtext0)`, `var(--ctp-subtext1)`.
- **Muted/overlay text**: Use `var(--ctp-overlay0)`, `var(--ctp-overlay1)`, `var(--ctp-overlay2)`.
- **Accents**: Use `var(--ctp-blue)`, `var(--ctp-green)`, `var(--ctp-mauve)`, `var(--ctp-peach)`, `var(--ctp-pink)`, `var(--ctp-red)`, `var(--ctp-yellow)`, `var(--ctp-teal)`, `var(--ctp-sapphire)`, `var(--ctp-lavender)`, `var(--ctp-flamingo)`, `var(--ctp-rosewater)`, `var(--ctp-maroon)`, `var(--ctp-sky)`.
- **Per-project accent**: Use `var(--accent)` which is set per ProjectBox slot.
- **Borders**: Use `var(--ctp-surface0)` or `var(--ctp-surface1)`.
- Never use raw hex/rgb/hsl color values in component CSS. All colors must go through `--ctp-*` variables.
- Hover states: typically lighten by stepping up one surface level (e.g., surface0 -> surface1) or change text from subtext0 to text.
- Active/selected states: use `var(--accent)` or a specific accent color with `var(--ctp-base)` background distinction.
- Disabled states: reduce opacity (0.4-0.5) rather than introducing gray colors.
- Use `color-mix()` for semi-transparent overlays: `color-mix(in srgb, var(--ctp-blue) 10%, transparent)`.

View file

@ -0,0 +1,10 @@
# No Implicit Push
Never push to a remote repository unless the user explicitly asks for it.
## Rules
- Commits are local-only by default. Do not follow a commit with `git push`.
- Only push when the user says "push", "push it", "push to remote", or similar explicit instruction.
- When the user asks to "commit and push" in the same request, both are explicitly authorized.
- Creating a PR (via `gh pr create`) implies pushing — that is acceptable.

143
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,143 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
env:
CARGO_TERM_COLOR: always
jobs:
build-linux:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libssl-dev \
build-essential \
pkg-config \
curl \
wget \
libfuse2
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: v2/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
v2/src-tauri/target
key: ${{ runner.os }}-cargo-${{ hashFiles('v2/src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Install npm dependencies
working-directory: v2
run: npm ci --legacy-peer-deps
- name: Build Tauri app
working-directory: v2
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npx tauri build
- name: List build artifacts
run: |
find v2/src-tauri/target/release/bundle -type f \( -name "*.deb" -o -name "*.AppImage" -o -name "*.sig" \) | head -20
- name: Generate updater latest.json
run: |
VERSION="${GITHUB_REF_NAME#v}"
DEB_NAME=$(basename v2/src-tauri/target/release/bundle/deb/*.deb)
APPIMAGE_NAME=$(basename v2/src-tauri/target/release/bundle/appimage/*.AppImage)
SIG=""
if [ -f "v2/src-tauri/target/release/bundle/appimage/${APPIMAGE_NAME}.sig" ]; then
SIG=$(cat "v2/src-tauri/target/release/bundle/appimage/${APPIMAGE_NAME}.sig")
fi
cat > latest.json << EOF
{
"version": "${VERSION}",
"notes": "Release ${VERSION}",
"pub_date": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"platforms": {
"linux-x86_64": {
"signature": "${SIG}",
"url": "https://github.com/DexterFromLab/BTerminal/releases/download/${GITHUB_REF_NAME}/${APPIMAGE_NAME}"
}
}
}
EOF
- name: Upload .deb
uses: actions/upload-artifact@v4
with:
name: bterminal-deb
path: v2/src-tauri/target/release/bundle/deb/*.deb
- name: Upload AppImage
uses: actions/upload-artifact@v4
with:
name: bterminal-appimage
path: v2/src-tauri/target/release/bundle/appimage/*.AppImage
- name: Upload latest.json
uses: actions/upload-artifact@v4
with:
name: updater-json
path: latest.json
release:
needs: build-linux
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Download .deb
uses: actions/download-artifact@v4
with:
name: bterminal-deb
path: artifacts/
- name: Download AppImage
uses: actions/download-artifact@v4
with:
name: bterminal-appimage
path: artifacts/
- name: Download latest.json
uses: actions/download-artifact@v4
with:
name: updater-json
path: artifacts/
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: |
artifacts/*.deb
artifacts/*.AppImage
artifacts/latest.json

9
.gitignore vendored
View file

@ -2,3 +2,12 @@ __pycache__/
*.pyc
*.pyo
/CLAUDE.md
v2/target/
debug/
plugins/
projects/
.playwright-mcp/
.audit/
.tribunal/
.vscode/
.local/

View file

@ -7,11 +7,438 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Security
- `claude_read_skill` path traversal: added `canonicalize()` + `starts_with()` validation to prevent reading arbitrary files via crafted skill paths (commands/claude.rs)
### Fixed
- **Reconnect loop race in RemoteManager** — orphaned reconnect tasks continued running after `remove_machine()` or `disconnect()`. Added `cancelled: Arc<AtomicBool>` flag to `RemoteMachine`; set on removal/disconnect, checked each reconnect iteration. `connect()` resets flag for new connections (remote.rs)
### Changed
- **Branded types for SessionId/ProjectId (SOLID Phase 3)**`types/ids.ts` with compile-time branded types (`string & { __brand }`) and factory functions. Applied to ~140 sites across 11 files: Map/Set keys in conflicts.svelte.ts (4 maps), health.svelte.ts (2 maps), session-persistence.ts (3 maps), function signatures across 6 files, boundary branding at sidecar entry in agent-dispatcher.ts, Svelte component call sites in AgentSession/ProjectBox/ProjectHeader. 293 vitest + 49 cargo total
- **agent-dispatcher.ts split (SOLID Phase 2)** — 496→260 lines. Extracted 4 modules: `utils/worktree-detection.ts` (pure function), `utils/session-persistence.ts` (session maps + persist), `utils/auto-anchoring.ts` (compaction anchor), `utils/subagent-router.ts` (spawn + route). Dispatcher is now a thin coordinator
- **session.rs split (SOLID Phase 2)** — 1008-line monolith split into 7 sub-modules under `session/` directory: mod.rs (struct + migrate), sessions.rs, layout.rs, settings.rs, ssh.rs, agents.rs, metrics.rs, anchors.rs. `pub(in crate::session)` conn visibility. 21 new cargo tests
- **lib.rs command module split** — 976-line monolith with 48 Tauri commands split into 11 domain modules under `src-tauri/src/commands/` (pty, agent, watcher, session, persistence, knowledge, claude, groups, files, remote, misc). lib.rs reduced to ~170 lines (AppState + setup + handler registration)
- **Attention scorer extraction**`scoreAttention()` pure function extracted from inline health store code to `utils/attention-scorer.ts` with 14 tests. Priority chain: stalled > error > context critical > file conflict > context high
- **Shared type guards** — deduplicated `str()`/`num()` runtime guards from claude-messages.ts, codex-messages.ts, ollama-messages.ts into shared `utils/type-guards.ts`
### Added
- **Configurable stall threshold** — per-project range slider (560 min, step 5) in SettingsTab. `stallThresholdMin` in `ProjectConfig` (groups.json), `setStallThreshold()` API in health store with `stallThresholds` Map and `DEFAULT_STALL_THRESHOLD_MS` fallback. ProjectBox `$effect` syncs config → store on mount/change
- **Memora adapter**`MemoraAdapter` (memora-bridge.ts) implements `MemoryAdapter` interface, bridging to Memora's SQLite database (`~/.local/share/memora/memories.db`) via read-only Rust backend (`memora.rs`). FTS5 text search, tag filtering via `json_each()`. 4 Tauri commands (memora_available, memora_list, memora_search, memora_get). Registered in App.svelte onMount. 16 vitest + 7 cargo tests. MemoriesTab now shows Memora memories on startup
- **Codex provider runner**`sidecar/codex-runner.ts` wraps `@openai/codex-sdk` (dynamic import, graceful failure if not installed). Maps Codex ThreadEvents (agent_message, reasoning, command_execution, file_change, mcp_tool_call, web_search) to common AgentMessage format via `codex-messages.ts` adapter. Sandbox/approval mode mapping from BTerminal permission modes. Session resume via thread ID. `providers/codex.ts` ProviderMeta (gpt-5.4 default, hasSandbox, supportsResume). 19 adapter tests
- **Ollama provider runner**`sidecar/ollama-runner.ts` uses direct HTTP to `localhost:11434/api/chat` with NDJSON streaming (zero external dependencies). Health check before session start. Configurable host/model/num_ctx/think via providerConfig. Supports Qwen3 extended thinking. `ollama-messages.ts` adapter maps streaming chunks to AgentMessage (text, thinking, cost with token counts). `providers/ollama.ts` ProviderMeta (qwen3:8b default, modelSelection only). 11 adapter tests
- All 3 providers registered in App.svelte onMount + message-adapters.ts. `build:sidecar` builds all 3 runners
- **S-1 Phase 3: Worktree isolation per project** — per-project `useWorktrees` toggle in SettingsTab. When enabled, agents run in git worktrees at `<repo>/.claude/worktrees/<sessionId>/` via SDK `extraArgs: { worktree: sessionId }`. CWD-based worktree detection in agent-dispatcher (`detectWorktreeFromCwd()`) matches `.claude/`, `.codex/`, `.cursor/` worktree patterns on init events. Dual detection: CWD-based (primary) + tool_call-based (subagent fallback). 8 files, +125 lines, 7 new tests. 226 vitest + 42 cargo tests
- **S-2 Session Anchors** — preserves important conversation turns through context compaction chains. Auto-anchors first 3 turns with observation masking (reasoning preserved in full per research). Manual pin button on AgentPane text messages. Three anchor types: auto (re-injectable), pinned (display-only), promoted (user-promoted, re-injectable). Re-injection via `system_prompt` field. ContextTab anchor section with budget meter bar, per-anchor promote/demote/remove actions. SQLite `session_anchors` table with 5 CRUD commands. 5 new files, 7 modified. 219 vitest + 42 cargo tests
- **Configurable anchor budget scale**`AnchorBudgetScale` type with 4 presets: Small (2K), Medium (6K, default), Large (12K), Full (20K). Per-project 4-stop range slider in SettingsTab. `ProjectConfig.anchorBudgetScale` persisted in groups.json. ContextTab budget meter derives from project setting. agent-dispatcher resolves scale on auto-anchor
- **Agent provider adapter pattern** — full implementation (3 phases complete): core abstraction layer (provider types/registry/capabilities, message adapter registry, 4 file renames), Settings UI (collapsible per-provider config panels, per-project provider dropdown, settings persistence), sidecar routing (provider-based runner selection, env var stripping for CLAUDE*/CODEX*/OLLAMA*). 5 new files, 4 renames, 20+ modified. 6 architecture decisions (PA-1PA-6). Docs at `docs/provider-adapter/`
- **PDF viewer** in Files tab: `PdfViewer.svelte` using pdfjs-dist (v5.5.207). Canvas-based multi-page rendering, zoom controls (0.5x3x, 25% steps), HiDPI-aware via devicePixelRatio. Reads PDF via `convertFileSrc()` — no new Rust commands needed
- **CSV table view** in Files tab: `CsvTable.svelte` with RFC 4180 CSV parser (no external dependency). Auto-detects delimiter (comma, semicolon, tab). Sortable columns (numeric-aware), sticky header, row numbers, text truncation at 20rem
- FilesTab routing update: Binary+pdf → PdfViewer, Text+csv → CsvTable. Updated file icons (📕 PDF, 📊 CSV)
- **S-1 Phase 2: Filesystem write detection** — inotify-based real-time file change detection via `ProjectFsWatcher` (fs_watcher.rs). Watches project CWDs recursively, filters .git/node_modules/target, debounces 100ms per-file (fs_watcher.rs, lib.rs)
- External write conflict detection: timing heuristic (2s grace window) distinguishes agent writes from external edits. `EXTERNAL_SESSION_ID` sentinel, `recordExternalWrite()`, `getExternalConflictCount()`, `FileConflict.isExternal` flag (conflicts.svelte.ts)
- Separate external write badge (orange ⚡) and agent conflict badge (red ⚠) in ProjectHeader (ProjectHeader.svelte)
- `externalConflictCount` in ProjectHealth interface with attention scoring integration (health.svelte.ts)
- Frontend bridge for filesystem watcher: `fsWatchProject()`, `fsUnwatchProject()`, `onFsWriteDetected()`, `fsWatcherStatus()` (fs-watcher-bridge.ts)
- Inotify watch limit sensing: `FsWatcherStatus` reads `/proc/sys/fs/inotify/max_user_watches`, counts watched directories per project, warns at >75% usage with shell command to increase limit (fs_watcher.rs, lib.rs, ProjectBox.svelte)
- Delayed scanning toast: "Scanning project directories…" info toast shown only when inotify status check takes >300ms, auto-dismissed on completion (ProjectBox.svelte)
- `notify()` returns toast ID (was void) to enable dismissing specific toasts via `dismissNotification(id)` (notifications.svelte.ts)
- ProjectBox `$effect` starts/stops fs watcher per project CWD on mount/unmount with toast on new external conflict + inotify capacity check (ProjectBox.svelte)
- Collapsible text messages in AgentPane: model responses wrapped in `<details open>` (open by default, user-collapsible with first-line preview) (AgentPane.svelte)
- Collapsible cost summary in AgentPane: `cost.result` wrapped in `<details>` (collapsed by default, expandable with 80-char preview) (AgentPane.svelte)
- Project max aspect ratio setting: `project_max_aspect` (float 0.33.0, default 1.0) limits project box width via CSS `max-width: calc(100vh * var(--project-max-aspect))` (SettingsTab.svelte, ProjectGrid.svelte, App.svelte)
- No-implicit-push rule: `.claude/rules/52-no-implicit-push.md` — never push unless user explicitly asks
- `StartupWMClass=bterminal` in install-v2.sh .desktop template for GNOME auto-move extension compatibility
- MarkdownPane link navigation: relative file links open in Files tab, external URLs open in system browser via `xdg-open`, anchor links scroll in-page (MarkdownPane.svelte, ProjectFiles.svelte, lib.rs)
- `open_url` Tauri command for opening http/https URLs in system browser (lib.rs)
- Tab system overhaul: renamed Claude→Model, Files→Docs, added 3 new tabs (Files, SSH, Memory) with PERSISTED-EAGER/LAZY mount strategies (ProjectBox.svelte)
- FilesTab: VSCode-style directory tree sidebar + tabbed content viewer with shiki syntax highlighting, word wrap, image display via convertFileSrc, 10MB file size gate, collapsible/resizable sidebar, preview vs pinned tabs (FilesTab.svelte)
- CodeEditor: CodeMirror 6 editor component with Catppuccin theme (reads --ctp-* CSS vars), 15 lazy-loaded language modes, auto-close brackets, bracket matching, code folding, line numbers, search, line wrapping, Ctrl+S save binding, blur event (CodeEditor.svelte)
- FilesTab editor mode: files are now editable with dirty dot indicator on tabs, (unsaved) label in path bar, Ctrl+S save, auto-save dirty tabs on close (FilesTab.svelte)
- Rust `write_file_content` command: writes content to existing files only — safety check prevents creating new files (lib.rs)
- Save-on-blur setting: `files_save_on_blur` toggle in Settings → Defaults → Editor, auto-saves files when editor loses focus (SettingsTab.svelte, FilesTab.svelte)
- SshTab: SSH connection CRUD panel with launch-to-terminal button, reuses existing ssh-bridge.ts model (SshTab.svelte)
- MemoriesTab: pluggable knowledge explorer with MemoryAdapter interface, adapter registry, search, tag display, expandable cards (MemoriesTab.svelte, memory-adapter.ts)
- Rust `list_directory_children` command: lazy tree expansion, hidden files skipped, dirs-first alphabetical sort (lib.rs)
- Rust `read_file_content` command: FileContent tagged union (Text/Binary/TooLarge), 30+ language mappings (lib.rs)
- Frontend `files-bridge.ts` adapter: DirEntry and FileContent TypeScript types + IPC wrappers
- ContextTab: LLM context window visualization with stats bar (tokens, cost, turns, duration), segmented token meter (color-coded by message type), file references tree (extracted from tool calls), and collapsible turn breakdown — replaces old ContextPane ctx database viewer (ContextTab.svelte)
- ContextTab AST view: per-turn SVG conversation trees showing hierarchical message flow (Turn → Thinking/Response/Tool Calls → File operations), with bezier edges, color-coded nodes, token counts, and detail tooltips (ContextTab.svelte)
- ContextTab Graph view: bipartite tool→file DAG with tools on left (color-coded by type) and files on right, curved SVG edges showing which tools touched which files, count badges on both sides (ContextTab.svelte)
- Compaction event detection: `compact_boundary` SDK messages adapted to `CompactionContent` type in sdk-messages.ts, ContextTab shows yellow compaction count pill in stats bar and red boundary nodes in AST view
- Project health store: per-project activity state (running/idle/stalled), burn rate ($/hr EMA), context pressure (% of model limit), attention scoring with urgency weights (health.svelte.ts)
- Mission Control status bar: running/idle/stalled agent counts, total $/hr burn rate, "needs attention" dropdown priority queue with click-to-focus cards (StatusBar.svelte)
- ProjectHeader health indicators: color-coded status dot (green=running, orange=stalled), context pressure badge, burn rate badge (ProjectHeader.svelte)
- Session metrics SQLite table: per-project historical metrics with 100-row retention, `session_metric_save` and `session_metrics_load` Tauri commands (session.rs, lib.rs)
- Session metric persistence on agent completion: records peak tokens, turn count, tool call count, cost, model, status (agent-dispatcher.ts)
- File overlap conflict detection store: per-project tracking of Write/Edit tool file paths across agent sessions, detects when 2+ sessions write same file, SCORE_FILE_CONFLICT=70 attention signal (conflicts.svelte.ts)
- Shared tool-files utility: extractFilePaths() and extractWritePaths() extracted from ContextTab to reusable module (tool-files.ts)
- File conflict indicators: red "⚠ N conflicts" badge in ProjectHeader, conflict count in StatusBar, toast notification on new conflict, conflict cards in attention queue (ProjectHeader.svelte, StatusBar.svelte)
- Health tick auto-stop/auto-start: tick timer self-stops when no running/starting sessions, auto-restarts on recordActivity() (health.svelte.ts)
- Bash write detection in tool-files.ts: BASH_WRITE_PATTERNS regex array covering >, >>, sed -i, tee, cp, mv, chmod/chown — conflict detection now catches shell-based file writes (tool-files.ts)
- Worktree-aware conflict suppression: sessions in different git worktrees don't trigger conflicts, sessionWorktrees tracking map, setSessionWorktree() API, extractWorktreePath() detects Agent/Task isolation:"worktree" and EnterWorktree tool calls (conflicts.svelte.ts, tool-files.ts, agent-dispatcher.ts)
- Acknowledge/dismiss conflicts: acknowledgeConflicts(projectId) suppresses badge until new session writes, acknowledgedFiles state map, auto-clear on new session write to acknowledged file (conflicts.svelte.ts)
- Clickable conflict badge in ProjectHeader: red button with ✕ calls acknowledgeConflicts() on click with stopPropagation, hover darkens background (ProjectHeader.svelte)
- `useWorktrees` optional boolean field on ProjectConfig for future per-project worktree spawning setting (groups.ts)
### Changed
- Anchor observation masking no longer truncates assistant reasoning text (was 500 chars) — reasoning is preserved in full per research consensus (JetBrains NeurIPS 2025, SWE-agent, OpenDev ACC); only tool outputs are compacted (anchor-serializer.ts)
- `getAnchorSettings()` now accepts optional `AnchorBudgetScale` parameter to resolve budget from per-project scale setting (anchors.svelte.ts)
- ContextTab now derives anchor budget from `anchorBudgetScale` prop via `ANCHOR_BUDGET_SCALE_MAP` instead of hardcoded `DEFAULT_ANCHOR_SETTINGS` (ContextTab.svelte)
- Renamed `sdk-messages.ts``claude-messages.ts`, `agent-runner.ts``claude-runner.ts`, `ClaudeSession.svelte``AgentSession.svelte` — provider-neutral naming for multi-provider support
- `agent-dispatcher.ts` now uses `adaptMessage(provider, event)` from message-adapters.ts registry instead of directly calling `adaptSDKMessage` — enables per-provider message parsing
- Rust `AgentQueryOptions` gained `provider` (String, defaults "claude") and `provider_config` (serde_json::Value) fields with serde defaults for backward compatibility
- Rust `SidecarManager.resolve_sidecar_for_provider(provider)` looks for `{provider}-runner.mjs` instead of hardcoded `claude-runner.mjs`
- Rust `strip_provider_env_var()` strips CLAUDE*/CODEX*/OLLAMA* env vars (whitelists CLAUDE_CODE_EXPERIMENTAL_*)
- SettingsTab: added Providers section with collapsible per-provider config panels (enabled toggle, default model, capabilities display) and per-project provider dropdown
- AgentPane: capability-driven rendering via ProviderCapabilities props (hasProfiles, hasSkills, supportsResume gates)
- AgentPane UI redesign: sans-serif root font (system-ui), tool calls paired with results in collapsible `<details>` groups, hook messages collapsed into compact labels, context window usage meter in status strip, cost bar made minimal (no background), session summary with translucent background, two-phase scroll anchoring, tool-aware output truncation (Bash 500/Read 50/Glob 20 lines), colors softened via `color-mix()`, responsive margins via container queries (AgentPane.svelte)
- MarkdownPane: added inner scroll wrapper with `container-type: inline-size`, responsive padding via shared `--bterminal-pane-padding-inline` variable (MarkdownPane.svelte)
- Added `--bterminal-pane-padding-inline: clamp(0.75rem, 3.5cqi, 2rem)` shared CSS variable for responsive pane padding (catppuccin.css)
### Fixed
- FilesTab invalid HTML nesting: file tab bar used `<button>` inside `<button>` which Svelte/browser rejects — changed outer element to `<div role="tab">` (FilesTab.svelte)
- FilesTab file content not rendering: after inserting a FileTab into the `$state` array, the local plain-object reference lost Svelte 5 proxy reactivity — content mutations were invisible. Fixed by looking up from the reactive array before setting content (FilesTab.svelte)
- ClaudeSession type errors: cast `last_session_id` to UUID template literal type, add missing `timestamp` field (from `created_at`) to restored AgentMessage records (ClaudeSession.svelte)
- Cost bar shows only last turn's cost instead of cumulative session total: `updateAgentCost()` changed from assignment to accumulation (`+=`) so continued sessions properly sum costs across all turns (agents.svelte.ts)
- ProjectBox tab switch destroys running agent sessions: changed `{#if activeTab}` conditional rendering to CSS `style:display` (flex/none) for all three content panes and terminal section — ClaudeSession now stays mounted across tab switches, preserving session ID, message history, and running agents (ProjectBox.svelte)
- Sidecar env var stripping now whitelists `CLAUDE_CODE_EXPERIMENTAL_*` vars (both Rust sidecar.rs and JS agent-runner.ts) — previously all `CLAUDE*` vars were stripped, blocking feature flags like agent teams from reaching the SDK (sidecar.rs, agent-runner.ts)
- E2E terminal tab tests: scoped selectors to `.tab-bar .tab-title` (was `.tab-title` which matched project tabs), used `browser.execute()` for DOM text reads to avoid stale element issues (bterminal.test.ts)
- E2E wdio.conf.js: added `wdio:enforceWebDriverClassic: true` to disable BiDi negotiation (wdio v9 injects `webSocketUrl:true` which tauri-driver rejects), removed unnecessary `browserName: 'wry'`, fixed binary path to Cargo workspace target dir (`v2/target/debug/` not `v2/src-tauri/target/debug/`)
- E2E consolidated to single spec file: Tauri creates one app session per spec file; multiple files caused "invalid session id" on 2nd+ file (wdio.conf.js, bterminal.test.ts)
- E2E WebDriver clicks on Svelte 5 components: `element.click()` doesn't reliably trigger onclick handlers inside complex components via WebKit2GTK/tauri-driver; replaced with `browser.execute()` JS-level clicks for .ptab, .dropdown-trigger, .panel-close (bterminal.test.ts)
- Removed `tauri-plugin-log` entirely — `telemetry::init()` already registers tracing-subscriber which bridges the `log` crate; adding plugin-log after panics with "attempted to set a logger after the logging system was already initialized" (lib.rs, Cargo.toml)
### Changed
- E2E tests expanded from 6 smoke tests to 48 tests across 8 describe blocks: Smoke (6), Workspace & Projects (8), Settings Panel (6), Keyboard Shortcuts (5), Command Palette (5), Terminal Tabs (7), Theme Switching (3), Settings Interaction (8) — all in single bterminal.test.ts file
- wdio.conf.js: added SKIP_BUILD env var to skip cargo tauri build when debug binary already exists
### Removed
- Ollama-specific warning toast from AgentPane when injecting anchors — replaced by generic configurable budget scale slider (AgentPane.svelte)
- Unused `notify` import from AgentPane (AgentPane.svelte)
- `tauri-plugin-log` dependency from Cargo.toml — redundant with telemetry::init() tracing-subscriber setup
- Individual E2E spec files (smoke.test.ts, keyboard.test.ts, settings.test.ts, workspace.test.ts) — consolidated into bterminal.test.ts
- Workspace teardown race: `switchGroup()` now awaits `waitForPendingPersistence()` before clearing agent state, preventing data loss when agents complete during group switch (agent-dispatcher.ts, workspace.svelte.ts)
- SettingsTab switchGroup click handler made async with await to properly handle the async switchGroup() flow (SettingsTab.svelte)
- Re-entrant sidecar exit handler race condition: added `restarting` guard flag preventing double-restart on rapid disconnect/reconnect (agent-dispatcher.ts)
- Memory leak: `toolUseToChildPane` and `sessionProjectMap` maps now cleared in `stopAgentDispatcher()` (agent-dispatcher.ts)
- Listener leak: 5 Tauri event listeners in machines store now tracked via `UnlistenFn[]` array with `destroyMachineListeners()` cleanup function (machines.svelte.ts)
- Fragile abort detection: replaced `errMsg.includes('aborted')` with `controller.signal.aborted` for authoritative abort state check (agent-runner.ts)
- Unhandled rejection: `handleMessage` made async with `.catch()` on `rl.on('line')` handler preventing sidecar crash on malformed input (agent-runner.ts)
- Remote machine `add_machine`/`list_machines`/`remove_machine` converted from `try_lock()` (silent failure on contention) to async `.lock().await` (remote.rs)
- `remove_machine` now aborts `WsConnection` tasks before removal, preventing resource leak (remote.rs)
- `save_agent_messages` wrapped in `unchecked_transaction()` for atomic DELETE+INSERT, preventing partial writes on crash (session.rs)
- Non-null assertion `msg.event!` replaced with safe check `if (msg.event)` in agent bridge event handler (agent-bridge.ts)
- Runtime type guards (`str()`, `num()`) replace bare `as` casts on untrusted SDK wire format in sdk-messages.ts
- ANTHROPIC_* environment variables now stripped alongside CLAUDE* in sidecar agent-runner.ts
- Frontend persistence timestamps use `Math.floor(Date.now() / 1000)` matching Rust seconds convention (agent-dispatcher.ts)
- Remote disconnect handler converted from `try_lock()` to async `.lock().await` (remote.rs)
- `save_layout` pane_ids serialization error now propagated instead of silent fallback (session.rs)
- ctx.rs Mutex::lock() returns Err instead of panicking on poisoned lock (5 occurrences)
- ctx CLI: `int()` limit argument validated with try/except (ctx)
- ctx CLI: FTS5 MATCH query wrapped in try/except for syntax errors (ctx)
- File watcher: explicit error for root-level path instead of silent fallback (watcher.rs)
- Agent bridge payload validated before cast to SidecarMessage (agent-bridge.ts)
- Profile.toml and resource_dir failures now log::warn instead of silent empty fallback (lib.rs)
### Changed
- All ~100 px layout values converted to rem across 10 components per rule 18: AgentPane, ToastContainer, CommandPalette, SettingsTab, TeamAgentsPanel, AgentCard, StatusBar, AgentTree, TerminalPane, AgentPreviewPane (1rem = 16px base, icon/dot dimensions kept as px)
### Added
- E2E testing infrastructure: WebdriverIO v9.24 + tauri-driver setup with `wdio.conf.js` (lifecycle hooks for tauri-driver spawn/kill, debug binary build), 6 smoke tests (`smoke.test.ts`), TypeScript config, `test:e2e` npm script, 4 new devDeps (@wdio/cli, @wdio/local-runner, @wdio/mocha-framework, @wdio/spec-reporter)
- `waitForPendingPersistence()` export in agent-dispatcher.ts: counter-based fence that resolves when all in-flight `persistSessionForProject()` calls complete
- OpenTelemetry instrumentation: `telemetry.rs` module with TelemetryGuard (Drop-based shutdown), tracing + optional OTLP/HTTP export to Tempo, controlled by `BTERMINAL_OTLP_ENDPOINT` env var (absent = console-only fallback)
- `#[tracing::instrument]` on 10 key Tauri commands: pty_spawn, pty_kill, agent_query, agent_stop, agent_restart, remote_connect, remote_disconnect, remote_agent_query, remote_agent_stop, remote_pty_spawn
- `frontend_log` Tauri command: routes frontend telemetry events (level + message + context JSON) to Rust tracing layer with `source="frontend"` field
- `telemetry-bridge.ts` adapter: `tel.info/warn/error/debug/trace()` convenience wrappers for frontend → Rust tracing bridge via IPC
- Agent dispatcher telemetry: structured events for agent_started, agent_stopped, agent_error, sidecar_crashed, and agent_cost (with full metrics: costUsd, tokens, turns, duration)
- Docker Tempo + Grafana stack (`docker/tempo/`): Tempo (OTLP gRPC 4317, HTTP 4318, query 3200) + Grafana (port 9715) with auto-provisioned Tempo datasource
- 6 new Rust dependencies: tracing 0.1, tracing-subscriber 0.3, opentelemetry 0.28, opentelemetry_sdk 0.28, opentelemetry-otlp 0.28, tracing-opentelemetry 0.29
- `ctx_register_project` Tauri command and `ctxRegisterProject()` bridge function: registers a project in the ctx database via `INSERT OR IGNORE` into sessions table; opens DB read-write briefly then closes
- Agent preview terminal (`AgentPreviewPane.svelte`): read-only xterm.js terminal that subscribes to agent session messages in real-time; renders Bash commands as cyan ` command`, file operations as yellow `[Read/Write/Edit] path`, tool results (80-line truncation), text summaries, errors in red, session start/complete with cost; uses `disableStdin: true`, Canvas addon, theme hot-swap; spawned via 👁 button in TerminalTabs tab bar (appears when agent session is active); deduplicates — only one preview per session
- `TerminalTab.type` extended with `'agent-preview'` variant and `agentSessionId?: string` field in workspace store
- `ProjectBox` passes `mainSessionId` to `TerminalTabs` for agent preview tab creation
- SettingsTab project settings card redesign: each project rendered as a polished card with icon picker (Svelte state-driven emoji grid popup), inline-editable name input, CWD with left-ellipsis (`direction: rtl`), account/profile dropdown (via `listProfiles()` from claude-bridge.ts), custom toggle switch (green track/thumb), and subtle remove footer with trash icon
- Account/profile dropdown per project in SettingsTab: uses `listProfiles()` to fetch Claude profiles, displays display_name + email in dropdown, blue badge styling; falls back to static label when single profile
- ProjectHeader profile badge: account name styled as blue pill with translucent background (`color-mix(in srgb, var(--ctp-blue) 10%, transparent)`), font-weight 600, expanded max-width to 8rem
- Theme integration rule (`.claude/rules/51-theme-integration.md`): mandates all colors via `--ctp-*` CSS custom properties, never hardcode hex/rgb/hsl values
- AgentPane VSCode-style prompt: unified input always at bottom with auto-resizing textarea, send icon button (arrow SVG) inside rounded container, welcome state with chat icon when no session
- AgentPane session controls: New Session and Continue buttons shown after session completes, enabling explicit session management
- ClaudeSession `handleNewSession()`: resets sessionId for fresh agent sessions, wired via `onExit` prop to AgentPane
- ContextPane "Initialize Database" button: when ctx database doesn't exist, shows a prominent button to create `~/.claude-context/context.db` with full schema (sessions, contexts, shared, summaries + FTS5 + sync triggers) directly from the UI; replaces old "run ctx init" hint text; auto-loads data after successful init
- Project-level tab bar in ProjectBox: Claude | Files | Context tabs switch the content area between ClaudeSession, ProjectFiles, and ContextPane
- ProjectFiles.svelte: project-scoped markdown file viewer (file picker sidebar + MarkdownPane), accepts cwd/projectName props
- ProjectHeader info bar: CWD path (ellipsized from start via `direction: rtl`) + profile name displayed as read-only info alongside project icon/name
- Emoji icon picker in SettingsTab: 24 project-relevant emoji in 8-column grid popup, replaces plain text icon input
- Native directory picker for CWD fields: custom `pick_directory` Tauri command using `rfd` crate with `set_parent(&window)` for modal behavior on Linux; browse buttons added to Default CWD, existing project CWD, and Add Project path inputs in SettingsTab
- `rfd = { version = "0.16", default-features = false, features = ["gtk3"] }` direct dependency for modal file dialogs (zero extra compile — already built transitively via tauri-plugin-dialog)
- CSS relative units rule (`.claude/rules/18-relative-units.md`): enforces rem/em for layout CSS, px only for icons/borders/shadows
### Changed
- ContextPane redesigned as project-scoped: now receives `projectName` + `projectCwd` props from ProjectBox; auto-registers project in ctx database on mount (`INSERT OR IGNORE`); removed project selector list — directly shows context entries, shared context, and session summaries for the current project; empty state shows `ctx set <project> <key> <value>` usage hint; all CSS converted to rem; header shows project name in accent color
- Sidebar simplified to Settings-only: removed Sessions, Docs, Context icons from GlobalTabBar (project-specific tabs already in ProjectBox); removed DocsTab/ContextTab imports from App.svelte; removed Alt+1..4 keyboard shortcuts; drawer always renders SettingsTab
- MarkdownPane file switching: replaced onMount-only `watchFile()` with reactive `$effect` that unwatches previous file and watches new one when `filePath` prop changes; added `highlighterReady` gate to prevent premature watches
- MarkdownPane premium typography overhaul: font changed from `var(--ui-font-family)` (resolved to JetBrains Mono) to hardcoded `'Inter', system-ui, sans-serif` for proper prose rendering; added `text-rendering: optimizeLegibility`, `-webkit-font-smoothing: antialiased`, `font-feature-settings: 'cv01', 'cv02', 'cv03', 'cv04', 'ss01'` (Inter alternates); body color softened from `--ctp-text` to `--ctp-subtext1` for reduced dark-mode contrast; Tailwind-prose-inspired spacing (1.15-1.75em paragraph/heading margins); heading line-height tightened to 1.2-1.4 with negative letter-spacing on h1/h2; gradient HR (`linear-gradient` fading to transparent edges); link underlines use `text-decoration-color` transition (30% opacity → full on hover, VitePress pattern); blockquotes now italic with translucent bg; code blocks have inset `box-shadow` for depth; added h5 (uppercase small) and h6 styles; all colors via `--ctp-*` vars for 17-theme compatibility
- ProjectBox terminal area: only visible on Claude tab, now collapsible — collapsed shows a status bar with chevron toggle, "Terminal" label, and tab count badge; expanded shows full 16rem TerminalTabs area. Default: collapsed. Grid rows: `auto auto 1fr auto`
- SettingsTab project settings: flat row layout replaced with stacked card layout; icon picker rewritten from DOM `classList.toggle('visible')` to Svelte `$state` (iconPickerOpenFor); checkbox replaced with custom toggle switch component
- SettingsTab CSS: all remaining px values in project section converted to rem; add-project form uses dashed border container
- AgentPane prompt: replaced separate initial prompt + follow-up input with single unified prompt area; removed `followUpPrompt` state, `handleSubmit` function; follow-up handled via `isResume` detection in `handleUnifiedSubmit()`
- AgentPane CSS: migrated all legacy CSS vars (`--bg-primary`, `--bg-surface`, `--text-primary`, `--text-secondary`, `--text-muted`, `--border`, `--accent`, `--font-mono`, `--border-radius`) to `--ctp-*` theme vars + rem units
- ContextPane CSS: same legacy-to-theme var migration as AgentPane
- ProjectBox tab CSS: polished with `margin-bottom: -1px` active tab trick (merges with content), `scrollbar-width: none`, `focus-visible` outline, hover with `var(--ctp-surface0)` background
- ProjectBox layout: CSS grid with 4 rows (`auto auto 1fr auto`) — header | tab bar | content | terminal; content area switches by tab
- AgentPane: removed DIR/ACC toolbar entirely — CWD and profile now passed as props from parent (set in Settings, shown in ProjectHeader); clean chat window with prompt + send button only
- AgentPane prompt area: anchored to bottom (`justify-content: flex-end`) instead of vertical center, removed `max-width: 600px` constraint — uses full panel width
- ClaudeSession passes `project.profile` to AgentPane for automatic profile resolution
- ProjectGrid.svelte CSS converted from px to rem: gap 0.25rem, padding 0.25rem, min-width 30rem
- TerminalTabs.svelte CSS converted from px to rem: tab bar, tabs, close/add buttons, empty state
### Removed
- Dead ctx code: `ContextTab.svelte` wrapper component, `CtxProject` struct (Rust), `list_projects()` method, `ctx_list_projects` Tauri command, `ctxListProjects()` bridge function, `CtxProject` TypeScript interface — all unused after ContextPane project-scoped redesign
- Unused Python imports in `ctx` CLI: `os`, `datetime`/`timezone` modules
- AgentPane session toolbar (DIR/ACC inputs) — CWD and profile are now props, not interactive inputs
- Nerd Font codepoints for project icons — replaced with emoji (`📁` default) for cross-platform compatibility
- Nerd Font `font-family` declarations from ProjectHeader and TerminalTabs
- Stub `pick_directory` Tauri command (replaced by `tauri-plugin-dialog` frontend API)
### Fixed
- `ctx init` fails when `~/.claude-context/` directory doesn't exist: `get_db()` called `sqlite3.connect()` without creating the parent directory; added `DB_PATH.parent.mkdir(parents=True, exist_ok=True)` before connect
- Terminal tabs cannot be closed and all named "Shell 1": `$state<Map<string, TerminalTab[]>>` in workspace store didn't trigger reactive updates for `$derived` consumers when `Map.set()` was called; changed `projectTerminals` from `Map` to `Record<string, TerminalTab[]>` (plain object property access is Svelte 5's strongest reactivity path)
- SettingsTab icon picker not opening: replaced broken DOM `classList.toggle('visible')` approach with Svelte `$state` (`iconPickerOpenFor` keyed by project ID); icon picker now reliably opens/closes and dismisses on click-outside or Escape
- SettingsTab CWD path truncated from right: added `direction: rtl; text-align: left; unicode-bidi: plaintext` on CWD input so path shows the end (project directory) instead of the beginning when truncated
- Project icons showing "?" — Nerd Font codepoint `\uf120` not rendering without font installed; switched to emoji
- Native directory picker not opening: added missing `"dialog:default"` permission to `v2/src-tauri/capabilities/default.json` — Tauri's IPC security layer silently blocked `invoke()` calls without this capability
- Native directory picker not modal on Linux: replaced `@tauri-apps/plugin-dialog` `open()` with custom `pick_directory` Tauri command using `rfd::AsyncFileDialog::set_parent(&window)` — the plugin skips `set_parent` on Linux via `cfg(any(windows, target_os = "macos"))` gate
- Native directory picker not dark-themed: set `GTK_THEME=Adwaita:dark` via `std::env::set_var` at Tauri startup to force dark theme on native GTK dialogs
- Sidebar drawer not scaling to content width: removed leftover v2 grid layout on `#app` in `app.css` (`display: grid; grid-template-columns: var(--sidebar-width) 1fr` + media queries) that constrained `.app-shell` to 260px first column; v3 `.app-shell` manages its own flexbox layout internally
- ContextPane.svelte CSS converted from px to rem: font-size, padding, margin, gap; added `white-space: nowrap` on `.ctx-header`/`.ctx-error` for intrinsic width measurement
### Changed
- GlobalTabBar.svelte CSS converted from px to rem: rail width 2.75rem, button 2rem, gap 0.25rem, padding 0.5rem 0.375rem, border-radius 0.375rem; rail-btn color changed from --ctp-overlay1 to --ctp-subtext0 for better contrast
- App.svelte sidebar header CSS converted from px to rem: padding 0.5rem 0.75rem, close button 1.375rem, border-radius 0.25rem
- App.svelte sidebar drawer: JS `$effect` measures content width via `requestAnimationFrame` + `querySelectorAll` for nowrap elements, headings, inputs, and tab-specific selectors; `panelWidth` state drives inline `style:width` on `aside.sidebar-panel`
- Sidebar panel changed from fixed width (28em) to content-driven sizing with `min-width: 16em` and `max-width: 50%`; each tab component defines its own `min-width: 22em`
- Sidebar panel and panel-content overflow changed from `hidden` to `overflow-y: auto` to allow content to drive parent width
- SettingsTab.svelte padding converted from px to rem (0.75rem 1rem)
- DocsTab.svelte converted from px to rem: file-picker 14em, picker-title/file-btn/empty padding in rem
- ContextTab.svelte, DocsTab.svelte, SettingsTab.svelte all now set `min-width: 22em` for content-driven drawer sizing
- UI redesigned from top tab bar + right-side settings drawer to VSCode-style left sidebar: vertical icon rail (GlobalTabBar, 2.75rem, 4 SVG icons) + expandable drawer panel (content-driven width) + always-visible main workspace (ProjectGrid)
- GlobalTabBar rewritten from horizontal text tabs + gear icon to vertical icon rail with SVG icons for Sessions, Docs, Context, Settings; Props: `expanded`/`ontoggle` (was `settingsOpen`/`ontoggleSettings`)
- Settings is now a regular sidebar tab (not a special right-side drawer); `WorkspaceTab` type: `'sessions' | 'docs' | 'context' | 'settings'`
- App.svelte layout: `.main-row` flex container with icon rail + optional sidebar panel + workspace; state renamed `settingsOpen` -> `drawerOpen`
- Keyboard shortcuts: Alt+1..4 (switch tabs + open drawer), Ctrl+B (toggle sidebar), Ctrl+, (toggle settings), Escape (close drawer)
- SettingsTab CSS: `height: 100%` (was `flex: 1`) for sidebar panel context
### Added
- SettingsTab split font controls: separate UI font (sans-serif options: System Sans-Serif, Inter, Roboto, Open Sans, Lato, Noto Sans, Source Sans 3, IBM Plex Sans, Ubuntu) and Terminal font (monospace options: JetBrains Mono, Fira Code, Cascadia Code, Source Code Pro, IBM Plex Mono, Hack, Inconsolata, Ubuntu Mono, monospace), each with custom themed dropdown + size stepper (8-24px), font previews in own typeface
- `--term-font-family` and `--term-font-size` CSS custom properties in catppuccin.css (defaults: JetBrains Mono fallback chain, 13px)
- Deep Dark theme group: 6 new themes (Tokyo Night, Gruvbox Dark, Ayu Dark, Poimandres, Vesper, Midnight) — total 17 themes across 3 groups (Catppuccin, Editor, Deep Dark). Midnight is pure OLED black (#000000), Ayu Dark near-black (#0b0e14), Vesper warm dark (#101010)
- Multi-theme system: 7 new editor themes (VSCode Dark+, Atom One Dark, Monokai, Dracula, Nord, Solarized Dark, GitHub Dark) alongside 4 Catppuccin flavors
- `ThemeId` union type, `ThemePalette` (26-color interface), `ThemeMeta` (id/label/group/isDark), `THEME_LIST` registry with group metadata, `ALL_THEME_IDS` for validation
- Theme store `getCurrentTheme()`/`setTheme()` as primary API; deprecated `getCurrentFlavor()`/`setFlavor()` wrappers for backwards compat
- SettingsTab custom themed dropdown for theme selection: color swatches (base color per theme), 4 accent color dots (red/green/blue/yellow), grouped sections (Catppuccin/Editor/Deep Dark) with styled headers, click-outside and Escape to close
- SettingsTab global settings section: theme selector, UI font dropdown (sans-serif options), Terminal font dropdown (monospace options), each with size stepper (8-24px), default shell input, default CWD input — all custom themed dropdowns (no native `<select>`), all persisted via settings-bridge
- Typography CSS custom properties (`--ui-font-family`, `--ui-font-size`, `--term-font-family`, `--term-font-size`) in catppuccin.css with defaults; consumed by app.css body rule
- `initTheme()` now restores 4 saved font settings (ui_font_family, ui_font_size, term_font_family, term_font_size) from SQLite on startup alongside theme restoration
- v3 Mission Control (All Phases 1-10 complete): multi-project dashboard with project groups, per-project Claude sessions, team agents panel, terminal tabs, 3 workspace tabs (Sessions/Docs/Context) + settings drawer
- v3 session continuity (P6): `persistSessionForProject()` saves agent state + messages to SQLite on session complete; `registerSessionProject()` maps session to project; `ClaudeSession.restoreMessagesFromRecords()` restores cached messages on mount
- v3 workspace teardown (P7): `clearAllAgentSessions()` clears agent sessions on group switch; terminal tabs reset via `switchGroup()`
- v3 data model: `groups.rs` (Rust structs + load/save `~/.config/bterminal/groups.json`), `groups.ts` (TypeScript interfaces), `groups-bridge.ts` (IPC adapter), `--group` CLI argument
- v3 workspace store (`workspace.svelte.ts`): replaces `layout.svelte.ts`, manages groups/activeGroupId/activeTab/focusedProjectId with Svelte 5 runes
- v3 SQLite migrations: `agent_messages` table (per-project message persistence), `project_agent_state` table (sdkSessionId/cost/status per project), `project_id` column on sessions
- 12 new Workspace components: GlobalTabBar, ProjectGrid, ProjectBox, ProjectHeader, ClaudeSession, TeamAgentsPanel, AgentCard, TerminalTabs, CommandPalette, DocsTab, ContextTab, SettingsTab
- v3 App.svelte full rewrite: GlobalTabBar + tab content area + StatusBar (no sidebar, no TilingGrid)
- 24 new vitest tests for workspace store, 7 new cargo tests for groups (total: 138 vitest + 36 cargo)
- v3 adversarial architecture review: 3 agents (Architect, Devil's Advocate, UX+Performance Specialist), 12 issues identified and resolved
- v3 Mission Control redesign planning: architecture docs (`docs/v3-task_plan.md`, `docs/v3-findings.md`, `docs/v3-progress.md`), codebase reuse analysis
- Claude profile/account switching: `claude_list_profiles()` reads `~/.config/switcher/profiles/` directories with `profile.toml` metadata (email, subscription_type, display_name); profile selector dropdown in AgentPane toolbar when multiple profiles available; selected profile's `config_dir` passed as `CLAUDE_CONFIG_DIR` env override to SDK
- Skill discovery and autocomplete: `claude_list_skills()` reads `~/.claude/skills/` (directories with `SKILL.md` or standalone `.md` files); type `/` in agent prompt textarea to trigger autocomplete menu with arrow key navigation, Tab/Enter selection, Escape dismiss; `expandSkillPrompt()` reads skill content and injects as prompt
- New frontend adapter `claude-bridge.ts`: `ClaudeProfile` and `ClaudeSkill` interfaces, `listProfiles()`, `listSkills()`, `readSkill()` IPC wrappers
- AgentPane session toolbar: editable working directory input, profile/account selector (shown when >1 profile), all rendered above prompt form
- Extended `AgentQueryOptions` with 5 new fields across full stack (Rust struct, sidecar JSON, SDK options): `setting_sources` (defaults to `['user', 'project']`), `system_prompt`, `model`, `claude_config_dir`, `additional_directories`
- 4 new Tauri commands: `claude_list_profiles`, `claude_list_skills`, `claude_read_skill`, `pick_directory`
- Claude CLI path auto-detection: `findClaudeCli()` in both sidecar runners checks common paths (~/.local/bin/claude, ~/.claude/local/claude, /usr/local/bin/claude, /usr/bin/claude) then falls back to `which`/`where`; resolved path passed to SDK via `pathToClaudeCodeExecutable` option
- Early error reporting when Claude CLI is not found — sidecar emits `agent_error` immediately instead of cryptic SDK failure
### Changed
- SettingsTab global settings restructured to single-column layout with labels above controls, split into "Appearance" (theme, UI font, terminal font) and "Defaults" (shell, CWD) subsections; all native `<select>` replaced with custom themed dropdowns
- Font setting keys changed from `font_family`/`font_size` to `ui_font_family`/`ui_font_size` + `term_font_family`/`term_font_size`; UI font fallback changed from monospace to sans-serif
- `app.css` body font-family and font-size now use CSS custom properties (`var(--ui-font-family)`, `var(--ui-font-size)`) instead of hardcoded values
- Theme system generalized from Catppuccin-only to multi-theme: all 17 themes map to same `--ctp-*` CSS custom properties (26 vars) — zero component-level changes needed
- `CatppuccinFlavor` type deprecated in favor of `ThemeId`; `CatppuccinPalette` deprecated in favor of `ThemePalette`; `FLAVOR_LABELS` and `ALL_FLAVORS` deprecated in favor of `THEME_LIST` and `ALL_THEME_IDS`
### Fixed
- SettingsTab theme dropdown sizing: set `min-width: 180px` on trigger container, `min-width: 280px` and `max-height: 400px` on dropdown menu, `white-space: nowrap` on option labels to prevent text truncation
- SettingsTab input overflow: added `min-width: 0` on `.setting-row` to prevent flex children from overflowing container
- SettingsTab a11y: project field labels changed from `<div><label>` to wrapping `<label><span class="field-label">` pattern for proper label/input association
- SettingsTab CSS: removed unused `.project-field label` selector, simplified input selector to `.project-field input:not([type="checkbox"])`
### Removed
- Dead `update_ssh_session()` method from session.rs and its unit test (method was unused after SSH CRUD refactoring)
- Stale TilingGrid reference in AgentPane.svelte comment (TilingGrid was deleted in v3 P10)
### Changed
- StatusBar rewritten for v3 workspace store: shows active group name, project count, agent count instead of pane counts; version label updated to "BTerminal v3"
- Agent dispatcher subagent routing: project-scoped sessions skip layout pane creation (subagents render in TeamAgentsPanel instead); detached mode still creates layout pane
- AgentPane `cwd` prop renamed to `initialCwd` — now editable via text input in session toolbar instead of fixed prop
### Removed
- Dead v2 components deleted in P10 (~1,836 lines): `TilingGrid.svelte` (328), `PaneContainer.svelte` (113), `PaneHeader.svelte` (44), `SessionList.svelte` (374), `SshSessionList.svelte` (263), `SshDialog.svelte` (281), `SettingsDialog.svelte` (433)
- Empty component directories removed: `Layout/`, `Sidebar/`, `Settings/`, `SSH/`
- Sidecar runners now pass `settingSources` (defaults to `['user', 'project']`), `systemPrompt`, `model`, and `additionalDirectories` to SDK `query()` options
- Sidecar runners inject `CLAUDE_CONFIG_DIR` into clean env when `claudeConfigDir` provided in query message (multi-account support)
### Fixed
- AgentPane Svelte 5 event modifier syntax: `on:click` changed to `onclick` (Svelte 5 requires lowercase event handler attributes, not colon syntax)
- CLAUDE* env var stripping now applied at Rust level in SidecarManager (bterminal-core/src/sidecar.rs): `env_clear()` + `envs(clean_env)` strips all CLAUDE-prefixed vars before spawning sidecar process, providing primary defense against nesting detection (JS-side stripping retained as defense-in-depth)
### Changed
- Sidecar resolution unified: single pre-built `agent-runner.mjs` bundle replaces separate `agent-runner-deno.ts` + `agent-runner.ts` lookup; same `.mjs` file runs under both Deno and Node.js
- `resolve_sidecar_command()` in sidecar.rs now checks deno/node availability upfront before searching paths, improved error message with runtime availability note
- Removed `agent-runner-deno.ts` from tauri.conf.json bundled resources (only `dist/agent-runner.mjs` shipped)
### Added
- `@anthropic-ai/claude-agent-sdk` ^0.2.70 npm dependency for sidecar agent session management
- `build:sidecar` npm script for esbuild bundling of agent-runner.ts (SDK bundled in, no external dependency at runtime)
- `permission_mode` field in AgentQueryOptions (Rust, TypeScript) — flows from controller through sidecar to SDK, defaults to 'bypassPermissions', supports 'default' mode
### Changed
- Sidecar agent runners migrated from raw `claude` CLI spawning (`child_process.spawn`/`Deno.Command`) to `@anthropic-ai/claude-agent-sdk` query() function — fixes silent hang when CLI spawned with piped stdio (known bug github.com/anthropics/claude-code/issues/6775)
- agent-runner.ts: sessions now use `{ query: Query, controller: AbortController }` map instead of `ChildProcess` map; stop uses `controller.abort()` instead of `child.kill()`
- agent-runner-deno.ts: sessions now use `AbortController` map; uses `npm:@anthropic-ai/claude-agent-sdk` import specifier
- Deno sidecar permissions expanded: added `--allow-write` and `--allow-net` flags in sidecar.rs (required by SDK)
- CLAUDE* env var stripping now passes clean env via SDK's `env` option in query() instead of filtering process.env before spawn
- SDK permissionMode and allowDangerouslySkipPermissions now dynamically set based on permission_mode option (was hardcoded to bypassPermissions)
- build:sidecar esbuild command no longer uses --external for SDK (SDK bundled into output)
### Fixed
- AgentPane onDestroy no longer kills running agent sessions on component remount — stopAgent() moved from AgentPane.svelte onDestroy to TilingGrid.svelte onClose handler, ensuring agents only stop on explicit user close action
### Previously Added
- Exponential backoff reconnection in RemoteManager: on disconnect, spawns async task with 1s/2s/4s/8s/16s/30s-cap backoff, uses attempt_tcp_probe() (TCP-only, no WS upgrade, 5s timeout, default port 9750), emits remote-machine-reconnecting and remote-machine-reconnect-ready events
- Frontend reconnection listeners: onRemoteMachineReconnecting and onRemoteMachineReconnectReady in remote-bridge.ts; machines store sets status to 'reconnecting' and auto-calls connectMachine() on ready
- Relay command response propagation: bterminal-relay now sends structured responses (pty_created, pong, error) back to client via shared event channel with commandId correlation
- send_error() helper in bterminal-relay for consistent error reporting across all command handlers
- PTY creation confirmation flow: pty_create command returns pty_created event with session ID and commandId; RemoteManager emits remote-pty-created Tauri event
- bterminal-core shared crate with EventSink trait: extracted PtyManager and SidecarManager into reusable crate at v2/bterminal-core/, EventSink trait abstracts event emission for both Tauri and WebSocket contexts
- bterminal-relay WebSocket server binary: standalone Rust binary at v2/bterminal-relay/ with token auth (--port, --token, --insecure CLI flags), rate limiting (10 attempts, 5min lockout), per-connection isolated PTY + sidecar managers
- RemoteManager for multi-machine WebSocket connections: v2/src-tauri/src/remote.rs manages WebSocket client connections to relay instances, 12 new Tauri commands for remote operations, heartbeat ping every 15s
- Remote machine management UI in settings: SettingsDialog "Remote Machines" section for add/remove/connect/disconnect
- Auto-grouping of remote panes in sidebar: remote panes auto-grouped by machine label in SessionList
- remote-bridge.ts adapter for remote machine IPC operations
- machines.svelte.ts store for remote machine state management (Svelte 5 runes)
- Pane.remoteMachineId field in layout store for local vs remote routing
- TauriEventSink (event_sink.rs) implementing EventSink trait for Tauri AppHandle
- Multi-machine support architecture design (`docs/multi-machine.md`): WebSocket NDJSON protocol, pre-shared token + TLS auth, autonomous relay model
- Subagent cost aggregation: getTotalCost() recursive helper in agents store aggregates cost across parent + all child sessions; total cost displayed in parent pane done-bar when children present
- 10 new subagent routing tests in agent-dispatcher.test.ts: spawn, dedup, child message routing, init/cost forwarding, fallbacks (28 total dispatcher tests, 114 vitest tests overall)
- TAURI_SIGNING_PRIVATE_KEY secret set in GitHub repo for auto-update signing
- Agent teams/subagent support (Phase 7): auto-detects subagent tool calls ('Agent', 'Task', 'dispatch_agent'), spawns child agent panes with parent/child navigation, routes messages via parentId field
- Agent store parent/child hierarchy: AgentSession extended with parentSessionId, parentToolUseId, childSessionIds; findChildByToolUseId() and getChildSessions() query functions
- AgentPane parent link bar: SUB badge with navigate-to-parent button for subagent panes
- AgentPane children bar: clickable chips per child subagent with status-colored indicators (running/done/error)
- SessionList subagent icon: subagent panes show '↳' instead of '*' in sidebar
- Session groups/folders: group_name column in sessions table, setPaneGroup in layout store, collapsible group headers in sidebar with arrow/count, right-click pane to set group
- Auto-update signing key: generated minisign keypair, pubkey configured in tauri.conf.json updater section
- Deno-first sidecar: SidecarCommand struct in sidecar.rs, resolve_sidecar_command() prefers Deno (runs TS directly) with Node.js fallback, both runners bundled via tauri.conf.json resources
- Vitest integration tests: layout.test.ts (30 tests), agent-bridge.test.ts (11 tests), agent-dispatcher.test.ts (28 tests) — total 114 vitest tests passing
- E2E test scaffold: v2/tests/e2e/README.md documenting WebDriver approach
- Terminal copy/paste: Ctrl+Shift+C copies selection, Ctrl+Shift+V pastes from clipboard to PTY (TerminalPane.svelte)
- Terminal theme hot-swap: onThemeChange() callback registry in theme.svelte.ts, open terminals update immediately when flavor changes
- Agent tree node click: clicking a tree node scrolls to the corresponding message in the agent pane (scrollIntoView smooth)
- Agent tree subtree cost: cumulative cost displayed in yellow below each tree node label (subtreeCost utility)
- Agent session resume: follow-up prompt input after session completes or errors, passes resume_session_id to SDK
- Pane drag-resize handles: splitter overlays in TilingGrid with mouse drag, supports 2-col/3-col/2-row layouts with 10-90% ratio clamping
- Auto-update CI workflow: release.yml generates latest.json with version, platform URL, and signature from .sig file; uploads as release artifact
- Deno sidecar proof-of-concept: agent-runner-deno.ts with same NDJSON protocol, compiles to single binary via deno compile
- Vitest test suite: sdk-messages.test.ts (SDK message adapter) and agent-tree.test.ts (tree builder/cost), vite.config.ts test config, npm run test script
- Cargo test suite: session.rs tests (SessionDb CRUD for sessions, SSH sessions, settings, layout) and ctx.rs tests (CtxDb error handling with missing database)
- tempfile dev dependency for Rust test isolation
### Fixed
- Sidecar env var leak: both agent-runner.ts and agent-runner-deno.ts now strip ALL `CLAUDE*` prefixed env vars before spawning the claude CLI, preventing silent hangs when BTerminal is launched from within a Claude Code terminal session (previously only CLAUDECODE was removed)
### Changed
- RemoteManager reconnection probe refactored from attempt_ws_connect() (full WS handshake + auth) to attempt_tcp_probe() (TCP-only connect, no resource allocation on relay)
- bterminal-relay command handlers refactored: all error paths now use send_error() helper instead of log::error!() only; pong response sent via event channel instead of no-op
- RemoteManager disconnect handler: scoped mutex release before event emission to prevent deadlocks; spawns reconnection task
- PtyManager and SidecarManager extracted from src-tauri to bterminal-core shared crate (src-tauri now has thin re-export wrappers)
- Cargo workspace structure at v2/ level: members = [src-tauri, bterminal-core, bterminal-relay], Cargo.lock moved from src-tauri/ to workspace root
- agent-bridge.ts and pty-bridge.ts extended with remote routing (check remoteMachineId, route to remote_* commands)
- Agent dispatcher refactored to split messages: parentId-bearing messages routed to child panes via toolUseToChildPane Map, main session messages stay in parent
- Agent store createAgentSession() now accepts optional parent parameter for registering bidirectional parent/child links
- Agent store removeAgentSession() cleans up parent's childSessionIds on removal
- Sidecar manager refactored from Node.js-only to Deno-first with Node.js fallback (SidecarCommand abstraction)
- Session struct: added group_name field with serde default
- SessionDb: added update_group method, list/save queries updated for group_name column
- SessionList sidebar: uses Svelte 5 snippets for grouped pane rendering with collapsible headers
- Agent tree NODE_H increased from 32 to 40 to accommodate subtree cost display
- release.yml build step now passes TAURI_SIGNING_PRIVATE_KEY and PASSWORD env vars from secrets
- release.yml uploads latest.json alongside .deb and .AppImage artifacts
- vitest ^4.0.18 added as npm dev dependency
### Previously Added
- SSH session management: SshSession CRUD in SQLite, SshDialog create/edit modal, SshSessionList grouped by folder with color dots, SSH pane type routing to TerminalPane with shell=/usr/bin/ssh (Phase 5)
- ctx context database integration: read-only CtxDb (Rust, SQLITE_OPEN_READ_ONLY), ContextPane with project selector, tabs for entries/summaries/search, ctx-bridge adapter (Phase 5)
- Catppuccin theme flavors: all 4 palettes (Latte/Frappe/Macchiato/Mocha) selectable via Settings dialog, theme.svelte.ts reactive store with SQLite persistence, TerminalPane theme-aware (Phase 5)
- Detached pane mode: pop-out terminal/agent panes into standalone windows via URL params (?detached=1), detach.ts utility, App.svelte conditional rendering (Phase 5)
- Shiki syntax highlighting: lazy singleton highlighter with catppuccin-mocha theme, 13 preloaded languages, integrated in MarkdownPane and AgentPane text messages (Phase 5)
- Tauri auto-updater plugin: tauri-plugin-updater (Rust + npm) + updater.ts frontend utility (Phase 6)
- Markdown rendering in agent text messages with Shiki code highlighting (Phase 5)
- Build-from-source installer `install-v2.sh` with 6-step dependency checking (Node.js 20+, Rust 1.77+, WebKit2GTK, GTK3, and 8 other system libraries), auto-install via apt, binary install to `~/.local/bin/bterminal-v2` with desktop entry (Phase 6)
- Tauri bundle configuration for .deb and AppImage targets with category, descriptions, and deb dependencies (Phase 6)
- GitHub Actions release workflow (`.github/workflows/release.yml`): triggered on `v*` tags, builds on Ubuntu 22.04 with Rust/npm caching, uploads .deb + AppImage as GitHub Release artifacts (Phase 6)
- Regenerated application icons from `bterminal.svg` as RGBA PNGs (32x32, 128x128, 256x256, 512x512, .ico) (Phase 6)
- Agent tree visualization: SVG tree of tool calls with horizontal layout, bezier edges, status-colored nodes (AgentTree.svelte + agent-tree.ts) (Phase 5)
- Global status bar showing terminal/agent pane counts, active agents with pulse animation, total tokens and cost (StatusBar.svelte) (Phase 5)
- Toast notification system with auto-dismiss (4s), max 5 visible, color-coded by type (notifications.svelte.ts + ToastContainer.svelte) (Phase 5)
- Agent dispatcher toast integration: notifications on agent complete, error, and sidecar crash (Phase 5)
- Settings dialog with default shell, working directory, and max panes configuration (SettingsDialog.svelte) (Phase 5)
- Settings persistence: key-value settings table in SQLite, Tauri commands settings_get/set/list, settings-bridge.ts adapter (Phase 5)
- Keyboard shortcuts: Ctrl+W close focused pane, Ctrl+, open settings dialog (Phase 5)
- SQLite session persistence with rusqlite (bundled, WAL mode) — sessions table + layout_state singleton (Phase 4)
- Session CRUD: save, delete, update_title, touch with 7 Tauri commands (Phase 4)
- Layout restore on app startup — panes and preset restored from database (Phase 4)
- File watcher backend using notify crate v6 — watches files, emits Tauri events on change (Phase 4)
- MarkdownPane component with marked.js rendering, Catppuccin-themed styles, and live reload (Phase 4)
- Sidebar "M" button for opening markdown/text files via file picker (Phase 4)
- Session bridge adapter for Tauri IPC (session + layout persistence wrappers) (Phase 4)
- File bridge adapter for Tauri IPC (watch, unwatch, read, onChange wrappers) (Phase 4)
- Sidecar crash detection — dispatcher listens for process exit, marks running sessions as error (Phase 3 polish)
- Sidecar restart UI — "Restart Sidecar" button in AgentPane error bar (Phase 3 polish)
- Auto-scroll lock — disables auto-scroll when user scrolls up, shows "Scroll to bottom" button (Phase 3 polish)
- Agent restart Tauri command (agent_restart) (Phase 3 polish)
- Agent pane with prompt input, structured message rendering, stop button, and cost display (Phase 3)
### Fixed
- Svelte 5 rune stores (layout, agents, sessions) renamed from `.ts` to `.svelte.ts` — runes only work in `.svelte` and `.svelte.ts` files, plain `.ts` caused "rune_outside_svelte" runtime error (blank screen)
- Updated all import paths to use `.svelte` suffix for store modules
- Node.js sidecar manager (Rust) for spawning and communicating with agent-runner via stdio NDJSON (Phase 3)
- Agent-runner sidecar: spawns `claude` CLI with `--output-format stream-json` for structured agent output (Phase 3)
- SDK message adapter parsing stream-json into 9 typed message types: init, text, thinking, tool_call, tool_result, status, cost, error, unknown (Phase 3)
- Agent bridge adapter for Tauri IPC (invoke + event listeners) (Phase 3)
- Agent dispatcher routing sidecar events to agent session store (Phase 3)
- Agent session store with message history, cost tracking, and lifecycle management (Phase 3)
- Keyboard shortcut: Ctrl+Shift+N to open new agent pane (Phase 3)
- Sidebar button for creating new agent sessions (Phase 3)
- Rust PTY backend with portable-pty: spawn, write, resize, kill with Tauri event streaming (Phase 2)
- xterm.js terminal pane with Canvas addon, FitAddon, and Catppuccin Mocha theme (Phase 2)
- CSS Grid tiling layout with 5 presets: 1-col, 2-col, 3-col, 2x2, master-stack (Phase 2)
- Layout store with Svelte 5 $state runes and auto-preset selection (Phase 2)
- Sidebar with session list, layout preset selector, and new terminal button (Phase 2)
- Keyboard shortcuts: Ctrl+N new terminal, Ctrl+1-4 focus pane (Phase 2)
- PTY bridge adapter for Tauri IPC (invoke + event listeners) (Phase 2)
- PaneContainer component with header bar, status indicator, and close button (Phase 2)
- Terminal resize handling with ResizeObserver and 100ms debounce (Phase 2)
- v2 project scaffolding: Tauri 2.x + Svelte 5 in `v2/` directory (Phase 1)
- Rust backend stubs: main.rs, lib.rs, pty.rs, sidecar.rs, watcher.rs, session.rs (Phase 1)
- Svelte frontend with Catppuccin Mocha CSS variables and component structure (Phase 1)
- Node.js sidecar scaffold with NDJSON communication pattern (Phase 1)
- v2 architecture planning: Tauri 2.x + Svelte 5 + Claude Agent SDK via Node.js sidecar
- Research documentation covering Agent SDK, xterm.js performance, Tauri ecosystem, and ultrawide layout patterns
- Phased implementation plan (6 phases, MVP = Phases 1-4)
- Error handling and testing strategy for v2
- Documentation structure in `docs/` (task_plan, phases, findings, progress)
- 17 operational rules in `.claude/rules/`
- TODO.md for tracking active work
- `.claude/CLAUDE.md` behavioral guide for Claude sessions
- VS Code workspace configuration with Peacock color

192
CLAUDE.md Normal file
View file

@ -0,0 +1,192 @@
# BTerminal — Project Guide for Claude
## Project Overview
Terminal emulator with SSH and Claude Code session management. v1 (GTK3+VTE Python) is production-stable. v2 redesign (Tauri 2.x + Svelte 5 + Claude Agent SDK) Phases 1-7 + multi-machine (A-D) + profiles/skills complete. Packaging: .deb + AppImage via GitHub Actions CI. v3 Mission Control (All Phases 1-10 Complete + Sidebar Redesign + Multi-Agent Orchestration): multi-project dashboard with project groups, per-project Claude sessions with session continuity, team agents panel, terminal tabs, VSCode-style left sidebar, multi-agent orchestration (Tier 1 management agents: Manager/Architect/Tester with role-specific tabs, btmsg inter-agent messaging, bttask kanban task board, BTMSG_AGENT_ID env passthrough, periodic system prompt re-injection, custom context for all tiers).
- **Repository:** github.com/DexterFromLab/BTerminal
- **License:** MIT
- **Primary target:** Linux x86_64
## Documentation (SOURCE OF TRUTH)
**All project documentation lives in [`docs/`](docs/README.md). This is the single source of truth for this project.** Before making changes, consult the docs. After making changes, update the docs. No exceptions.
## Key Paths
| Path | Description |
|------|-------------|
| `bterminal.py` | v1 main application (2092 lines, GTK3+VTE) |
| `ctx` | Context manager CLI tool (SQLite-based) |
| `install.sh` | v1 system installer |
| `install-v2.sh` | v2 build-from-source installer (Node.js 20+, Rust 1.77+, system libs) |
| `.github/workflows/release.yml` | CI: builds .deb + AppImage on v* tags, uploads to GitHub Releases |
| `docs/task_plan.md` | v2 architecture decisions and strategies |
| `docs/phases.md` | v2 implementation phases (1-7 + multi-machine A-D) |
| `docs/findings.md` | v2 research findings |
| `docs/progress.md` | Session progress log (recent) |
| `docs/progress-archive.md` | Archived progress log (2026-03-05 to 2026-03-06 early) |
| `docs/multi-machine.md` | Multi-machine architecture (implemented, Phases A-D) |
| `docs/v3-task_plan.md` | v3 Mission Control redesign: architecture decisions and strategies |
| `docs/v3-findings.md` | v3 research findings and codebase reuse analysis |
| `docs/v3-progress.md` | v3 session progress log |
| `v2/Cargo.toml` | Cargo workspace root (members: src-tauri, bterminal-core, bterminal-relay) |
| `v2/bterminal-core/` | Shared crate: EventSink trait, PtyManager, SidecarManager |
| `v2/bterminal-relay/` | Standalone relay binary (WebSocket server, token auth, CLI) |
| `v2/src-tauri/src/pty.rs` | PTY backend (thin re-export from bterminal-core) |
| `v2/src-tauri/src/groups.rs` | Groups config (load/save ~/.config/bterminal/groups.json) |
| `v2/src-tauri/src/fs_watcher.rs` | ProjectFsWatcher (inotify per-project recursive file change detection, S-1 Phase 2) |
| `v2/src-tauri/src/lib.rs` | AppState + setup + handler registration (~170 lines) |
| `v2/src-tauri/src/commands/` | 12 domain command modules (pty, agent, watcher, session, persistence, knowledge, claude, groups, files, remote, misc, bttask) |
| `v2/src-tauri/src/bttask.rs` | Task board backend (list, create, update status, delete, comments; shared btmsg.db) |
| `v2/src-tauri/src/sidecar.rs` | SidecarManager (thin re-export from bterminal-core) |
| `v2/src-tauri/src/event_sink.rs` | TauriEventSink (implements EventSink for AppHandle) |
| `v2/src-tauri/src/remote.rs` | RemoteManager (WebSocket client connections to relays) |
| `v2/src-tauri/src/session/` | SessionDb module: mod.rs (struct + migrate), sessions.rs, layout.rs, settings.rs, ssh.rs, agents.rs, metrics.rs, anchors.rs |
| `v2/src-tauri/src/watcher.rs` | FileWatcherManager (notify crate, file change events) |
| `v2/src-tauri/src/ctx.rs` | CtxDb (read-only access to ~/.claude-context/context.db) |
| `v2/src-tauri/src/memora.rs` | MemoraDb (read-only access to ~/.local/share/memora/memories.db, FTS5 search) |
| `v2/src-tauri/src/telemetry.rs` | OTEL telemetry (TelemetryGuard, tracing + OTLP export, BTERMINAL_OTLP_ENDPOINT) |
| `v2/src/lib/stores/workspace.svelte.ts` | v3 workspace store (project groups, tabs, focus, replaces layout store) |
| `v2/src/lib/stores/layout.svelte.ts` | v2 layout store (panes, presets, groups, persistence, Svelte 5 runes) |
| `v2/src/lib/stores/agents.svelte.ts` | Agent session store (messages, cost, parent/child hierarchy) |
| `v2/src/lib/components/Terminal/TerminalPane.svelte` | xterm.js terminal pane |
| `v2/src/lib/components/Terminal/AgentPreviewPane.svelte` | Read-only xterm.js showing agent activity (Bash commands, tool results, errors) |
| `v2/src/lib/components/Agent/AgentPane.svelte` | Agent session pane (sans-serif font, tool call/result pairing, hook collapsing, context meter, prompt, cost, profile selector, skill autocomplete) |
| `v2/src/lib/adapters/pty-bridge.ts` | PTY IPC wrapper (Tauri invoke/listen) |
| `v2/src/lib/adapters/agent-bridge.ts` | Agent IPC wrapper (Tauri invoke/listen) |
| `v2/src/lib/adapters/claude-messages.ts` | Claude message adapter (stream-json parser, renamed from sdk-messages.ts) |
| `v2/src/lib/adapters/message-adapters.ts` | Provider message adapter registry (per-provider routing to common AgentMessage) |
| `v2/src/lib/adapters/provider-bridge.ts` | Generic provider bridge (delegates to provider-specific bridges) |
| `v2/src/lib/providers/types.ts` | Provider abstraction types (ProviderId, ProviderCapabilities, ProviderMeta, ProviderSettings) |
| `v2/src/lib/providers/registry.svelte.ts` | Svelte 5 rune-based provider registry (registerProvider, getProviders) |
| `v2/src/lib/providers/claude.ts` | Claude provider metadata constant (CLAUDE_PROVIDER) |
| `v2/src/lib/providers/codex.ts` | Codex provider metadata constant (CODEX_PROVIDER, gpt-5.4 default) |
| `v2/src/lib/providers/ollama.ts` | Ollama provider metadata constant (OLLAMA_PROVIDER, qwen3:8b default) |
| `v2/src/lib/adapters/codex-messages.ts` | Codex message adapter (ThreadEvent parser) |
| `v2/src/lib/adapters/ollama-messages.ts` | Ollama message adapter (streaming chunk parser) |
| `v2/src/lib/agent-dispatcher.ts` | Thin coordinator: routes sidecar events to agent store, delegates to extracted modules |
| `v2/src/lib/utils/session-persistence.ts` | Session-project maps + persistSessionForProject + waitForPendingPersistence |
| `v2/src/lib/utils/auto-anchoring.ts` | triggerAutoAnchor on first compaction event |
| `v2/src/lib/utils/subagent-router.ts` | Subagent pane creation + toolUseToChildPane routing |
| `v2/src/lib/utils/worktree-detection.ts` | detectWorktreeFromCwd pure function (3 provider patterns) |
| `v2/src/lib/adapters/file-bridge.ts` | File watcher IPC wrapper |
| `v2/src/lib/adapters/settings-bridge.ts` | Settings IPC wrapper (get/set/list) |
| `v2/src/lib/adapters/ctx-bridge.ts` | ctx database IPC wrapper |
| `v2/src/lib/adapters/ssh-bridge.ts` | SSH session IPC wrapper |
| `v2/src/lib/adapters/claude-bridge.ts` | Claude profiles + skills IPC wrapper |
| `v2/src/lib/adapters/groups-bridge.ts` | Groups config IPC wrapper (load/save) |
| `v2/src/lib/adapters/remote-bridge.ts` | Remote machine management IPC wrapper |
| `v2/src/lib/adapters/files-bridge.ts` | File browser IPC wrapper (list_directory_children, read_file_content) |
| `v2/src/lib/adapters/memory-adapter.ts` | Pluggable memory adapter interface (MemoryAdapter, registry) |
| `v2/src/lib/adapters/memora-bridge.ts` | Memora IPC bridge + MemoraAdapter (read-only SQLite via Tauri commands) |
| `v2/src/lib/adapters/fs-watcher-bridge.ts` | Filesystem watcher IPC wrapper (project CWD write detection) |
| `v2/src/lib/adapters/anchors-bridge.ts` | Session anchors IPC wrapper (save, load, delete, clear, updateType) |
| `v2/src/lib/adapters/bttask-bridge.ts` | Task board IPC adapter (listTasks, createTask, updateTaskStatus, deleteTask, comments) |
| `v2/src/lib/adapters/telemetry-bridge.ts` | Frontend telemetry bridge (routes events to Rust tracing via IPC) |
| `v2/src/lib/utils/agent-prompts.ts` | Agent prompt generator (generateAgentPrompt: identity, env, team, btmsg/bttask docs, workflow) |
| `docker/tempo/` | Docker compose: Tempo + Grafana for trace visualization (port 9715) |
| `v2/src/lib/stores/machines.svelte.ts` | Remote machine state store (Svelte 5 runes) |
| `v2/src/lib/utils/attention-scorer.ts` | Pure attention scoring function (extracted from health store, 14 tests) |
| `v2/src/lib/utils/type-guards.ts` | Shared runtime guards: str(), num() for untyped wire format parsing |
| `v2/src/lib/utils/agent-tree.ts` | Agent tree builder (hierarchy from messages) |
| `v2/src/lib/utils/highlight.ts` | Shiki syntax highlighter (lazy singleton, 13 languages) |
| `v2/src/lib/utils/detach.ts` | Detached pane mode (pop-out windows via URL params) |
| `v2/src/lib/utils/updater.ts` | Tauri auto-updater utility |
| `v2/src/lib/stores/notifications.svelte.ts` | Toast notification store (notify, dismiss) |
| `v2/src/lib/stores/theme.svelte.ts` | Theme store (17 themes: 4 Catppuccin + 7 Editor + 6 Deep Dark, UI + terminal font restoration on startup) |
| `v2/src/lib/styles/themes.ts` | Theme palette definitions (17 themes), ThemeId/ThemePalette/ThemeMeta types, THEME_LIST |
| `v2/src/lib/styles/catppuccin.css` | CSS custom properties: 26 --ctp-* color vars + --ui-font-* + --term-font-* |
| `v2/src/lib/components/Agent/AgentTree.svelte` | SVG agent tree visualization |
| `v2/src/lib/components/Context/ContextPane.svelte` | ctx database viewer (projects, entries, search) — replaced by ContextTab in ProjectBox |
| `v2/src/lib/components/Workspace/ContextTab.svelte` | LLM context window visualization (stats, token meter, file refs, turn breakdown) |
| `v2/src/lib/components/Workspace/CodeEditor.svelte` | CodeMirror 6 wrapper (15 languages, Catppuccin theme, save/blur callbacks) |
| `v2/src/lib/components/Workspace/PdfViewer.svelte` | PDF viewer (pdfjs-dist, canvas multi-page, zoom 0.5x3x, HiDPI) |
| `v2/src/lib/components/Workspace/CsvTable.svelte` | CSV table viewer (RFC 4180 parser, delimiter auto-detect, sortable columns) |
| `v2/src/lib/stores/health.svelte.ts` | Project health store (activity state, burn rate, context pressure, file conflicts, attention scoring) |
| `v2/src/lib/stores/conflicts.svelte.ts` | File overlap + external write conflict detection (per-project, session-scoped, worktree-aware, dismissible, inotify-backed) |
| `v2/src/lib/stores/anchors.svelte.ts` | Session anchor store (per-project anchors, auto-anchor tracking, re-injection support) |
| `v2/src/lib/types/anchors.ts` | Anchor types (AnchorType, SessionAnchor, AnchorSettings, AnchorBudgetScale, SessionAnchorRecord) |
| `v2/src/lib/utils/anchor-serializer.ts` | Anchor serialization (turn grouping, observation masking, token estimation) |
| `v2/src/lib/utils/tool-files.ts` | Shared file path extraction from tool_call inputs (extractFilePaths, extractWritePaths, extractWorktreePath) |
| `v2/src/lib/components/StatusBar/StatusBar.svelte` | Mission Control bar (agent states, $/hr burn rate, attention queue, cost) |
| `v2/src/lib/components/Notifications/ToastContainer.svelte` | Toast notification display |
| `v2/src/lib/components/Workspace/` | v3 components: GlobalTabBar, ProjectGrid, ProjectBox, ProjectHeader, AgentSession, TeamAgentsPanel, AgentCard, TerminalTabs, ProjectFiles, FilesTab, SshTab, MemoriesTab, CommandPalette, DocsTab, SettingsTab, TaskBoardTab, ArchitectureTab, TestingTab |
| `v2/src/lib/types/groups.ts` | TypeScript interfaces (ProjectConfig, GroupConfig, GroupsFile) |
| `v2/src/lib/adapters/session-bridge.ts` | Session/layout/group persistence IPC wrapper |
| `v2/src/lib/components/Markdown/MarkdownPane.svelte` | Markdown file viewer (marked.js + shiki, live reload) |
| `v2/sidecar/claude-runner.ts` | Claude sidecar source (compiled to .mjs by esbuild, includes findClaudeCli()) |
| `v2/sidecar/codex-runner.ts` | Codex sidecar source (@openai/codex-sdk dynamic import, sandbox/approval mapping) |
| `v2/sidecar/ollama-runner.ts` | Ollama sidecar source (direct HTTP to localhost:11434, zero external deps) |
| `v2/sidecar/agent-runner-deno.ts` | Standalone Deno sidecar runner (not used by SidecarManager, alternative) |
| `v2/sidecar/dist/claude-runner.mjs` | Bundled Claude sidecar (runs on both Deno and Node.js) |
| `v2/src/lib/adapters/claude-messages.test.ts` | Vitest tests for Claude message adapter (25 tests) |
| `v2/src/lib/adapters/codex-messages.test.ts` | Vitest tests for Codex message adapter (19 tests) |
| `v2/src/lib/adapters/ollama-messages.test.ts` | Vitest tests for Ollama message adapter (11 tests) |
| `v2/src/lib/adapters/memora-bridge.test.ts` | Vitest tests for Memora bridge + adapter (16 tests) |
| `v2/src/lib/adapters/agent-bridge.test.ts` | Vitest tests for agent IPC bridge (11 tests) |
| `v2/src/lib/agent-dispatcher.test.ts` | Vitest tests for agent dispatcher (29 tests) |
| `v2/src/lib/stores/conflicts.test.ts` | Vitest tests for conflict detection (28 tests) |
| `v2/src/lib/utils/tool-files.test.ts` | Vitest tests for tool file extraction (27 tests) |
| `v2/src/lib/stores/layout.test.ts` | Vitest tests for layout store (30 tests) |
| `v2/src/lib/utils/agent-tree.test.ts` | Vitest tests for agent tree builder (20 tests) |
| `v2/src/lib/stores/workspace.test.ts` | Vitest tests for workspace store (24 tests) |
## v1 Stack
- Python 3, GTK3 (PyGObject), VTE 2.91
- Config: `~/.config/bterminal/` (sessions.json, claude_sessions.json)
- Context DB: `~/.claude-context/context.db`
- Theme: Catppuccin Mocha
## v2/v3 Stack (v2 complete, v3 All Phases 1-10 complete, branch: v2-mission-control)
- Tauri 2.x (Rust backend) + Svelte 5 (frontend)
- Cargo workspace: bterminal-core (shared), bterminal-relay (remote binary), src-tauri (Tauri app)
- xterm.js with Canvas addon (no WebGL on WebKit2GTK)
- Agent sessions via `@anthropic-ai/claude-agent-sdk` query() function (migrated from raw CLI spawning)
- Sidecar uses SDK internally (single .mjs bundle, Deno-first + Node.js fallback, stdio NDJSON to Rust, auto-detects Claude CLI path via findClaudeCli(), supports CLAUDE_CONFIG_DIR override for multi-account)
- portable-pty for terminal management (in bterminal-core)
- Multi-machine: bterminal-relay WebSocket server + RemoteManager WebSocket client
- SQLite session persistence (rusqlite, WAL mode) + layout restore on startup
- File watcher (notify crate) for live markdown viewer
- OpenTelemetry: tracing + tracing-subscriber + opentelemetry 0.28 + tracing-opentelemetry 0.29, OTLP/HTTP to Tempo, BTERMINAL_OTLP_ENDPOINT env var
- Rust deps (src-tauri): tauri, bterminal-core (path), rusqlite (bundled), dirs, notify, serde, tokio, tokio-tungstenite, futures-util, tracing, tracing-subscriber, opentelemetry, opentelemetry_sdk, opentelemetry-otlp, tracing-opentelemetry, tauri-plugin-updater, tauri-plugin-dialog
- Rust deps (bterminal-core): portable-pty, uuid, serde, serde_json, log
- Rust deps (bterminal-relay): bterminal-core, tokio, tokio-tungstenite, clap, env_logger, futures-util
- npm deps: @anthropic-ai/claude-agent-sdk, @xterm/xterm, @xterm/addon-canvas, @xterm/addon-fit, @tauri-apps/api, @tauri-apps/plugin-updater, @tauri-apps/plugin-dialog, marked, shiki, pdfjs-dist, vitest (dev)
- Source: `v2/` directory
## Build / Run
```bash
# v1 (current production)
./install.sh # Install system-wide
bterminal # Run
# v1 Dependencies (Debian/Ubuntu)
sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-vte-2.91
# v2 (development, branch v2-mission-control)
cd v2 && npm install && npm run tauri dev # Dev mode
cd v2 && npm run tauri build # Release build
# v2 tests
cd v2 && npm run test # Vitest (frontend)
cd v2/src-tauri && cargo test # Cargo tests (backend)
# v2 install from source (builds + installs to ~/.local/bin/bterminal-v2)
./install-v2.sh
# Telemetry stack (Tempo + Grafana)
cd docker/tempo && docker compose up -d # Grafana at http://localhost:9715
BTERMINAL_OTLP_ENDPOINT=http://localhost:4318 npm run tauri dev # Enable OTLP export
```
## Conventions
- 17 themes in 3 groups: 4 Catppuccin (Mocha default) + 7 Editor + 6 Deep Dark (Tokyo Night, Gruvbox Dark, Ayu Dark, Poimandres, Vesper, Midnight)
- CSS uses rem/em for layout; px only for icons/borders (see `.claude/rules/18-relative-units.md`)
- Session configs stored as JSON
- Single-file Python app (v1) — will change to multi-file Rust+Svelte (v2)
- Polish language in some code comments (v1 legacy)

View file

@ -2,7 +2,7 @@
Terminal with session panel (MobaXterm-style), built with GTK 3 + VTE. Catppuccin Mocha theme.
> **v2 in planning:** Redesign as a multi-session Claude agent dashboard using Tauri 2.x + Svelte 5 + Claude Agent SDK. See [docs/task_plan.md](docs/task_plan.md) for architecture and [docs/phases.md](docs/phases.md) for implementation plan.
> **v2 complete, v3 all phases complete.** v2: Multi-session Claude agent dashboard using Tauri 2.x + Svelte 5. v3: Multi-project mission control dashboard (All Phases 1-10 complete + sidebar redesign) -- project groups with per-project Claude sessions, session continuity (persist/restore agent messages), team agents panel, terminal tabs, **VSCode-style left sidebar** (vertical icon rail + expandable drawer panel + always-visible workspace), command palette with group switching. Features: **project groups** (up to 5 projects per group, horizontal layout, adaptive viewport count), **per-project Claude sessions** with session continuity, **team agents panel** (compact subagent cards), **terminal tabs** (shell/SSH/agent per project), agent panes with structured output, tree visualization with subtree cost and session resume, **subagent/agent-teams support**, **multi-machine support** (bterminal-relay WebSocket server + RemoteManager), **Claude profile/account switching** (switcher-claude integration), **skill discovery and autocomplete** (type `/` in agent prompt), SSH session management, ctx context database viewer, SQLite session persistence with layout restore, live markdown file viewer with Shiki syntax highlighting, 17 themes in 3 groups (4 Catppuccin + 7 Editor + 6 Deep Dark: Tokyo Night, Gruvbox Dark, Ayu Dark, Poimandres, Vesper, Midnight), **global font controls** (separate UI font [sans-serif] + terminal font [monospace] with live preview), .deb + AppImage packaging, GitHub Actions CI, 138 vitest + 36 cargo tests. Branch `v2-mission-control`. See [docs/v3-task_plan.md](docs/v3-task_plan.md) for v3 architecture.
![BTerminal](screenshot.png)
@ -32,7 +32,21 @@ The installer will:
4. Initialize context database at `~/.claude-context/context.db`
5. Add desktop entry to application menu
### Manual dependency install (Debian/Ubuntu/Pop!_OS)
### v2 Installation (Tauri — build from source)
Requires Node.js 20+, Rust 1.77+, and system libraries (WebKit2GTK 4.1, GTK3, etc.).
```bash
git clone https://github.com/DexterFromLab/BTerminal.git
cd BTerminal
./install-v2.sh
```
The installer checks all dependencies, offers to install missing system packages via apt, builds the Tauri app, and installs the binary as `bterminal-v2` in `~/.local/bin/`.
Pre-built .deb and AppImage packages are available from [GitHub Releases](https://github.com/DexterFromLab/BTerminal/releases) (built via CI on version tags).
### v1 Manual dependency install (Debian/Ubuntu/Pop!_OS)
```bash
sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-vte-2.91
@ -95,14 +109,65 @@ Context database: `~/.claude-context/context.db`
| `Ctrl+Shift+V` | Paste |
| `Ctrl+PageUp/Down` | Previous/next tab |
## Multi-Machine Support (v2)
BTerminal v2 can manage agents and terminals running on remote machines via the `bterminal-relay` binary.
### Architecture
```
BTerminal (Controller) --WebSocket--> bterminal-relay (Remote Machine)
├── PtyManager (remote terminals)
└── SidecarManager (remote agents)
```
### Running the Relay
On each remote machine:
```bash
# Build the relay binary
cd v2 && cargo build --release -p bterminal-relay
# Start with token auth
./target/release/bterminal-relay --port 9750 --token <secret>
# Dev mode (allow unencrypted ws://)
./target/release/bterminal-relay --port 9750 --token <secret> --insecure
```
Add remote machines in BTerminal Settings > Remote Machines (label, URL, token). Remote panes auto-group by machine label in the sidebar. Connections automatically reconnect with exponential backoff (1s-30s cap) on disconnect.
See [docs/multi-machine.md](docs/multi-machine.md) for full architecture details.
## Telemetry (v2)
BTerminal supports OpenTelemetry tracing with optional export to Tempo + Grafana.
```bash
# Start the tracing stack
cd docker/tempo && docker compose up -d
# Grafana at http://localhost:9715
# Run BTerminal with OTLP export enabled
BTERMINAL_OTLP_ENDPOINT=http://localhost:4318 npm run tauri dev
```
Without `BTERMINAL_OTLP_ENDPOINT`, telemetry falls back to console-only tracing (no network calls). Key Tauri commands (PTY, agent, remote) are instrumented with `#[tracing::instrument]`. Frontend events (agent lifecycle, errors, cost) route to Rust tracing via IPC bridge.
## Documentation
| Document | Description |
|----------|-------------|
| [docs/task_plan.md](docs/task_plan.md) | v2 architecture decisions, error handling, testing strategy |
| [docs/phases.md](docs/phases.md) | v2 implementation phases (1-6) with checklists |
| [docs/phases.md](docs/phases.md) | v2 implementation phases (1-7 + multi-machine A-D) with checklists |
| [docs/findings.md](docs/findings.md) | Research findings (Agent SDK, Tauri, xterm.js, performance) |
| [docs/progress.md](docs/progress.md) | Session-by-session progress log |
| [docs/progress.md](docs/progress.md) | Session-by-session progress log (recent) |
| [docs/progress-archive.md](docs/progress-archive.md) | Archived progress log (2026-03-05 to 2026-03-06 early) |
| [docs/multi-machine.md](docs/multi-machine.md) | Multi-machine architecture (implemented, WebSocket relay, reconnection) |
| [docs/v3-task_plan.md](docs/v3-task_plan.md) | v3 Mission Control redesign: architecture decisions and strategies |
| [docs/v3-findings.md](docs/v3-findings.md) | v3 research findings and codebase reuse analysis |
| [docs/v3-progress.md](docs/v3-progress.md) | v3 session progress log (All Phases 1-10 complete) |
## License

26
TODO.md
View file

@ -1,14 +1,22 @@
# BTerminal TODO
# BTerminal -- TODO
## Active
- [ ] **Phase 1: Project Scaffolding** — Create feature branch, init Tauri 2.x + Svelte 5, basic window with Catppuccin theme. See [docs/phases.md](docs/phases.md).
- [ ] **Phase 2: Terminal Pane + Layout** — CSS Grid tiling, xterm.js with Canvas addon, PTY via portable-pty, SSH/shell/Claude CLI support.
- [ ] **Phase 3: Agent SDK Integration** — Node.js sidecar, SDK message adapter, structured agent panes with tool call cards.
- [ ] **Phase 4: Session Management + Markdown** — SQLite persistence, session CRUD, file watcher, markdown rendering. MVP ship after this phase.
- [ ] **Benchmark Canvas xterm.js** — Verify <50ms latency with 4 panes on target hardware (Phase 2 gate for Electron escape hatch).
- [ ] **Evaluate Deno as sidecar runtime** — Single binary, better packaging than Node.js. Test SDK compatibility.
### v2/v3 Remaining
- [ ] **E2E testing — expand coverage** -- 48 tests passing across 8 describe blocks (WebdriverIO v9.24 + tauri-driver, single spec file, ~23s). Add tests for agent sessions, terminal interaction.
- [ ] **Multi-machine real-world testing** -- Test bterminal-relay with 2 machines.
- [ ] **Multi-machine TLS/certificate pinning** -- TLS support for bterminal-relay + certificate pinning in RemoteManager.
- [ ] **Agent Teams real-world testing** -- Env var whitelist fix done. 3 test sessions ran ($1.10, $0.69, $1.70) but model didn't spawn subagents — needs complex multi-part prompts to trigger delegation. Test with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1.
## Completed
(none yet)
- [x] **SOLID Phase 3 — Primitive obsession** -- Branded types SessionId/ProjectId in types/ids.ts. Applied to ~130 sites: Map/Set keys in conflicts.svelte.ts (4 maps, 12 functions), health.svelte.ts (2 maps, 10 functions), session-persistence.ts (3 maps, 6 functions), auto-anchoring.ts, agent-dispatcher.ts. Boundary branding at sidecar entry. Deferred: Svelte props (75), IPC interfaces, Rust newtypes. 293 vitest + 49 cargo tests. | Done: 2026-03-11
- [x] **SOLID Phase 2 — agent-dispatcher.ts split** -- 496→260 lines. Extracted 4 modules: utils/worktree-detection.ts (pure function, 5 tests), utils/session-persistence.ts (session maps + persist), utils/auto-anchoring.ts (compaction anchor), utils/subagent-router.ts (spawn + route). Dispatcher is thin coordinator. 286 vitest + 49 cargo tests. | Done: 2026-03-11
- [x] **SOLID Phase 2 — session.rs split** -- 1008→7 sub-modules under session/ directory (mod.rs, sessions.rs, layout.rs, settings.rs, ssh.rs, agents.rs, metrics.rs, anchors.rs). pub(in crate::session) conn visibility. 21 new cargo tests. 49 cargo tests total. | Done: 2026-03-11
- [x] **SOLID Phase 1 Refactoring** -- Extracted AttentionScorer pure function (14 tests), shared str()/num() type guards, split lib.rs (976→170 lines, 11 command modules). 286 vitest + 49 cargo tests. | Done: 2026-03-11
- [x] **Configurable stall threshold** -- Per-project range slider (560 min, step 5) in SettingsTab. `stallThresholdMin` in ProjectConfig, `setStallThreshold()` API in health store, ProjectBox $effect sync. Adaptive suggestions deferred (needs 50+ sessions in session_metrics). | Done: 2026-03-11
- [x] **Register Memora adapter** -- MemoraAdapter (memora-bridge.ts) implements MemoryAdapter, reads ~/.local/share/memora/memories.db via Rust memora.rs (read-only SQLite, FTS5 search). 4 Tauri commands, 16 vitest + 7 cargo tests. 272 vitest + 49 cargo total. | Done: 2026-03-11
- [x] **Add Codex/Ollama provider runners** -- Full provider stack for both: ProviderMeta constants, message adapters (codex-messages.ts, ollama-messages.ts), sidecar runners (codex-runner.ts uses @openai/codex-sdk dynamic import, ollama-runner.ts uses direct HTTP). 30 new tests, 256 vitest total. | Done: 2026-03-11
- [x] **Worktree isolation per project (S-1 Phase 3)** -- UI toggle in SettingsTab, spawn with --worktree via sidecar extraArgs, CWD-based worktree detection in agent-dispatcher (matches .claude/.codex/.cursor patterns). 8 files, +125 lines. 226 vitest + 42 cargo tests. | Done: 2026-03-11
- [x] **S-2 — Session Anchors + Configurable Budget** -- Preserves important turns through compaction chains. Auto-anchors first 3 turns (observation-masked — reasoning preserved in full per research). Configurable budget via AnchorBudgetScale slider (Small=2K, Medium=6K, Large=12K, Full=20K) in SettingsTab per-project. Manual pin, promote/demote in ContextTab. Re-injection via system_prompt. 219 vitest + 42 cargo tests. | Done: 2026-03-11
- [x] **Agent provider adapter pattern** -- Multi-provider support (Claude, Codex, Ollama) via 3-phase adapter pattern. Core abstraction, Settings UI, Sidecar routing. 5 new files, 4 renames, 20+ modified. 202 vitest + 42 cargo tests. | Done: 2026-03-11
- [x] **Files tab PDF viewer + CSV table** -- PdfViewer.svelte (pdfjs-dist 5.5.207, canvas multi-page, zoom 0.5x3x, HiDPI). CsvTable.svelte (RFC 4180 parser, delimiter auto-detect, sortable columns, sticky header). | Done: 2026-03-11

1183
btmsg Executable file

File diff suppressed because it is too large Load diff

710
bttask Executable file
View file

@ -0,0 +1,710 @@
#!/usr/bin/env python3
"""
bttask — Group Task Manager for BTerminal Mission Control.
Hierarchical task management for multi-agent orchestration.
Tasks stored in SQLite, role-based visibility.
Agent identity set via BTMSG_AGENT_ID environment variable.
Usage: bttask <command> [args]
Commands:
list [--all] Show tasks (filtered by role visibility)
add <title> Create task (Manager/Architect only)
assign <id> <agent> Assign task to agent (Manager only)
status <id> <state> Set task status (todo/progress/review/done/blocked)
comment <id> <text> Add comment to task
show <id> Show task details with comments
board Kanban board view
delete <id> Delete task (Manager only)
priorities Reorder tasks by priority
"""
import sqlite3
import sys
import os
import uuid
from pathlib import Path
from datetime import datetime
DB_PATH = Path.home() / ".local" / "share" / "bterminal" / "btmsg.db"
TASK_STATES = ['todo', 'progress', 'review', 'done', 'blocked']
# Roles that can create tasks
CREATOR_ROLES = {'manager', 'architect'}
# Roles that can assign tasks
ASSIGNER_ROLES = {'manager'}
# Roles that can see full task list
VIEWER_ROLES = {'manager', 'architect', 'tester'}
# Colors
C_RESET = "\033[0m"
C_BOLD = "\033[1m"
C_DIM = "\033[2m"
C_RED = "\033[31m"
C_GREEN = "\033[32m"
C_YELLOW = "\033[33m"
C_BLUE = "\033[34m"
C_MAGENTA = "\033[35m"
C_CYAN = "\033[36m"
C_WHITE = "\033[37m"
STATE_COLORS = {
'todo': C_WHITE,
'progress': C_CYAN,
'review': C_YELLOW,
'done': C_GREEN,
'blocked': C_RED,
}
STATE_ICONS = {
'todo': '○',
'progress': '◐',
'review': '◑',
'done': '●',
'blocked': '✗',
}
PRIORITY_COLORS = {
'critical': C_RED,
'high': C_YELLOW,
'medium': C_WHITE,
'low': C_DIM,
}
def get_db():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(str(DB_PATH))
db.row_factory = sqlite3.Row
db.execute("PRAGMA journal_mode=WAL")
return db
def init_db():
db = get_db()
db.executescript("""
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT DEFAULT 'todo',
priority TEXT DEFAULT 'medium',
assigned_to TEXT,
created_by TEXT NOT NULL,
group_id TEXT NOT NULL,
parent_task_id TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (assigned_to) REFERENCES agents(id),
FOREIGN KEY (created_by) REFERENCES agents(id)
);
CREATE TABLE IF NOT EXISTS task_comments (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
agent_id TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (task_id) REFERENCES tasks(id),
FOREIGN KEY (agent_id) REFERENCES agents(id)
);
CREATE INDEX IF NOT EXISTS idx_tasks_group ON tasks(group_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to);
CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments(task_id);
""")
db.commit()
db.close()
def get_agent_id():
agent_id = os.environ.get("BTMSG_AGENT_ID")
if not agent_id:
print(f"{C_RED}Error: BTMSG_AGENT_ID not set.{C_RESET}")
sys.exit(1)
return agent_id
def get_agent(db, agent_id):
return db.execute("SELECT * FROM agents WHERE id = ?", (agent_id,)).fetchone()
def short_id(task_id):
return task_id[:8] if task_id else "?"
def format_time(ts_str):
if not ts_str:
return "?"
try:
dt = datetime.fromisoformat(ts_str)
return dt.strftime("%m-%d %H:%M")
except (ValueError, TypeError):
return ts_str[:16]
def format_state(state):
icon = STATE_ICONS.get(state, '?')
color = STATE_COLORS.get(state, C_RESET)
return f"{color}{icon} {state}{C_RESET}"
def format_priority(priority):
color = PRIORITY_COLORS.get(priority, C_RESET)
return f"{color}{priority}{C_RESET}"
def check_role(db, agent_id, allowed_roles, action="do this"):
agent = get_agent(db, agent_id)
if not agent:
print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}")
return None
if agent['role'] not in allowed_roles:
print(f"{C_RED}Permission denied: {agent['role']} cannot {action}.{C_RESET}")
print(f"{C_DIM}Required roles: {', '.join(allowed_roles)}{C_RESET}")
return None
return agent
def find_task(db, task_id_prefix, group_id=None):
"""Find task by ID prefix, optionally filtered by group."""
if group_id:
return db.execute(
"SELECT * FROM tasks WHERE id LIKE ? AND group_id = ?",
(task_id_prefix + "%", group_id)
).fetchone()
return db.execute(
"SELECT * FROM tasks WHERE id LIKE ?", (task_id_prefix + "%",)
).fetchone()
# ─── Commands ────────────────────────────────────────────────
def cmd_list(args):
"""List tasks visible to current agent."""
agent_id = get_agent_id()
show_all = "--all" in args
db = get_db()
agent = get_agent(db, agent_id)
if not agent:
print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}")
db.close()
return
# Tier 2 agents cannot see task list
if agent['role'] not in VIEWER_ROLES:
print(f"{C_RED}Access denied: project agents don't see the task list.{C_RESET}")
print(f"{C_DIM}Tasks are assigned to you via btmsg messages.{C_RESET}")
db.close()
return
group_id = agent['group_id']
if show_all:
rows = db.execute(
"SELECT t.*, a.name as assignee_name FROM tasks t "
"LEFT JOIN agents a ON t.assigned_to = a.id "
"WHERE t.group_id = ? ORDER BY t.sort_order, t.created_at",
(group_id,)
).fetchall()
else:
rows = db.execute(
"SELECT t.*, a.name as assignee_name FROM tasks t "
"LEFT JOIN agents a ON t.assigned_to = a.id "
"WHERE t.group_id = ? AND t.status != 'done' "
"ORDER BY t.sort_order, t.created_at",
(group_id,)
).fetchall()
if not rows:
print(f"{C_DIM}No tasks.{C_RESET}")
db.close()
return
label = "All tasks" if show_all else "Active tasks"
print(f"\n{C_BOLD}📋 {label} ({len(rows)}):{C_RESET}\n")
for row in rows:
state_str = format_state(row['status'])
priority_str = format_priority(row['priority'])
assignee = row['assignee_name'] or f"{C_DIM}unassigned{C_RESET}"
print(f" {state_str} [{short_id(row['id'])}] {C_BOLD}{row['title']}{C_RESET}")
print(f" {priority_str} → {assignee} {C_DIM}{format_time(row['updated_at'])}{C_RESET}")
# Show comment count
count = db.execute(
"SELECT COUNT(*) FROM task_comments WHERE task_id = ?", (row['id'],)
).fetchone()[0]
if count > 0:
print(f" {C_DIM}💬 {count} comment{'s' if count != 1 else ''}{C_RESET}")
print()
db.close()
def cmd_add(args):
"""Create a new task."""
if not args:
print(f"{C_RED}Usage: bttask add <title> [--desc TEXT] [--priority critical|high|medium|low] [--assign AGENT] [--parent TASK_ID]{C_RESET}")
return
agent_id = get_agent_id()
db = get_db()
agent = check_role(db, agent_id, CREATOR_ROLES, "create tasks")
if not agent:
db.close()
return
# Parse args
title_parts = []
description = ""
priority = "medium"
assign_to = None
parent_id = None
i = 0
while i < len(args):
if args[i] == "--desc" and i + 1 < len(args):
description = args[i + 1]
i += 2
elif args[i] == "--priority" and i + 1 < len(args):
priority = args[i + 1]
if priority not in PRIORITY_COLORS:
print(f"{C_RED}Invalid priority: {priority}. Use: critical, high, medium, low{C_RESET}")
db.close()
return
i += 2
elif args[i] == "--assign" and i + 1 < len(args):
assign_to = args[i + 1]
i += 2
elif args[i] == "--parent" and i + 1 < len(args):
parent_id = args[i + 1]
i += 2
else:
title_parts.append(args[i])
i += 1
title = " ".join(title_parts)
if not title:
print(f"{C_RED}Title is required.{C_RESET}")
db.close()
return
# Verify assignee if specified
if assign_to:
assignee = get_agent(db, assign_to)
if not assignee:
# prefix match
row = db.execute("SELECT * FROM agents WHERE id LIKE ?", (assign_to + "%",)).fetchone()
if row:
assign_to = row['id']
else:
print(f"{C_RED}Agent '{assign_to}' not found.{C_RESET}")
db.close()
return
# Resolve parent task
if parent_id:
parent = find_task(db, parent_id, agent['group_id'])
if not parent:
print(f"{C_RED}Parent task '{parent_id}' not found.{C_RESET}")
db.close()
return
parent_id = parent['id']
# Get max sort_order
max_order = db.execute(
"SELECT COALESCE(MAX(sort_order), 0) FROM tasks WHERE group_id = ?",
(agent['group_id'],)
).fetchone()[0]
task_id = str(uuid.uuid4())
db.execute(
"INSERT INTO tasks (id, title, description, priority, assigned_to, created_by, "
"group_id, parent_task_id, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(task_id, title, description, priority, assign_to, agent_id,
agent['group_id'], parent_id, max_order + 1)
)
db.commit()
db.close()
print(f"{C_GREEN}✓ Created: {title}{C_RESET} [{short_id(task_id)}]")
if assign_to:
print(f" {C_DIM}Assigned to: {assign_to}{C_RESET}")
def cmd_assign(args):
"""Assign task to an agent."""
if len(args) < 2:
print(f"{C_RED}Usage: bttask assign <task-id> <agent-id>{C_RESET}")
return
agent_id = get_agent_id()
db = get_db()
agent = check_role(db, agent_id, ASSIGNER_ROLES, "assign tasks")
if not agent:
db.close()
return
task = find_task(db, args[0], agent['group_id'])
if not task:
print(f"{C_RED}Task not found.{C_RESET}")
db.close()
return
assignee_id = args[1]
assignee = get_agent(db, assignee_id)
if not assignee:
row = db.execute("SELECT * FROM agents WHERE id LIKE ?", (assignee_id + "%",)).fetchone()
if row:
assignee = row
assignee_id = row['id']
else:
print(f"{C_RED}Agent '{assignee_id}' not found.{C_RESET}")
db.close()
return
db.execute(
"UPDATE tasks SET assigned_to = ?, updated_at = datetime('now') WHERE id = ?",
(assignee_id, task['id'])
)
db.commit()
db.close()
print(f"{C_GREEN}✓ Assigned [{short_id(task['id'])}] to {assignee['name']}{C_RESET}")
def cmd_status(args):
"""Change task status."""
if len(args) < 2:
print(f"{C_RED}Usage: bttask status <task-id> <{'/'.join(TASK_STATES)}>{C_RESET}")
return
agent_id = get_agent_id()
new_status = args[1]
if new_status not in TASK_STATES:
print(f"{C_RED}Invalid status: {new_status}. Use: {', '.join(TASK_STATES)}{C_RESET}")
return
db = get_db()
agent = get_agent(db, agent_id)
if not agent:
print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}")
db.close()
return
task = find_task(db, args[0], agent['group_id'])
if not task:
print(f"{C_RED}Task not found.{C_RESET}")
db.close()
return
# Tier 2 can only update tasks assigned to them
if agent['role'] not in VIEWER_ROLES and task['assigned_to'] != agent_id:
print(f"{C_RED}Cannot update task not assigned to you.{C_RESET}")
db.close()
return
old_status = task['status']
db.execute(
"UPDATE tasks SET status = ?, updated_at = datetime('now') WHERE id = ?",
(new_status, task['id'])
)
# Auto-add comment for status change
comment_id = str(uuid.uuid4())
db.execute(
"INSERT INTO task_comments (id, task_id, agent_id, content) VALUES (?, ?, ?, ?)",
(comment_id, task['id'], agent_id, f"Status: {old_status} → {new_status}")
)
db.commit()
db.close()
print(f"{C_GREEN}✓ [{short_id(task['id'])}] {format_state(old_status)} → {format_state(new_status)}{C_RESET}")
def cmd_comment(args):
"""Add comment to a task."""
if len(args) < 2:
print(f"{C_RED}Usage: bttask comment <task-id> <text>{C_RESET}")
return
agent_id = get_agent_id()
db = get_db()
agent = get_agent(db, agent_id)
if not agent:
print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}")
db.close()
return
task = find_task(db, args[0], agent['group_id'])
if not task:
print(f"{C_RED}Task not found.{C_RESET}")
db.close()
return
content = " ".join(args[1:])
comment_id = str(uuid.uuid4())
db.execute(
"INSERT INTO task_comments (id, task_id, agent_id, content) VALUES (?, ?, ?, ?)",
(comment_id, task['id'], agent_id, content)
)
db.execute(
"UPDATE tasks SET updated_at = datetime('now') WHERE id = ?", (task['id'],)
)
db.commit()
db.close()
print(f"{C_GREEN}✓ Comment added to [{short_id(task['id'])}]{C_RESET}")
def cmd_show(args):
"""Show task details with comments."""
if not args:
print(f"{C_RED}Usage: bttask show <task-id>{C_RESET}")
return
agent_id = get_agent_id()
db = get_db()
agent = get_agent(db, agent_id)
if not agent:
print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}")
db.close()
return
task = find_task(db, args[0], agent['group_id'])
if not task:
print(f"{C_RED}Task not found.{C_RESET}")
db.close()
return
# Get assignee name
assignee_name = "unassigned"
if task['assigned_to']:
assignee = get_agent(db, task['assigned_to'])
if assignee:
assignee_name = assignee['name']
# Get creator name
creator = get_agent(db, task['created_by'])
creator_name = creator['name'] if creator else task['created_by']
print(f"\n{C_BOLD}{'─' * 60}{C_RESET}")
print(f" {format_state(task['status'])} {C_BOLD}{task['title']}{C_RESET}")
print(f"{C_BOLD}{'─' * 60}{C_RESET}")
print(f" {C_DIM}ID:{C_RESET} {task['id']}")
print(f" {C_DIM}Priority:{C_RESET} {format_priority(task['priority'])}")
print(f" {C_DIM}Assigned:{C_RESET} {assignee_name}")
print(f" {C_DIM}Created:{C_RESET} {creator_name} @ {format_time(task['created_at'])}")
print(f" {C_DIM}Updated:{C_RESET} {format_time(task['updated_at'])}")
if task['description']:
print(f"\n {task['description']}")
if task['parent_task_id']:
parent = find_task(db, task['parent_task_id'])
if parent:
print(f" {C_DIM}Parent:{C_RESET} [{short_id(parent['id'])}] {parent['title']}")
# Subtasks
subtasks = db.execute(
"SELECT * FROM tasks WHERE parent_task_id = ? ORDER BY sort_order",
(task['id'],)
).fetchall()
if subtasks:
print(f"\n {C_BOLD}Subtasks:{C_RESET}")
for st in subtasks:
print(f" {format_state(st['status'])} [{short_id(st['id'])}] {st['title']}")
# Comments
comments = db.execute(
"SELECT c.*, a.name as agent_name, a.role as agent_role "
"FROM task_comments c JOIN agents a ON c.agent_id = a.id "
"WHERE c.task_id = ? ORDER BY c.created_at ASC",
(task['id'],)
).fetchall()
if comments:
print(f"\n {C_BOLD}Comments ({len(comments)}):{C_RESET}")
for c in comments:
time_str = format_time(c['created_at'])
print(f" {C_DIM}{time_str}{C_RESET} {C_BOLD}{c['agent_name']}{C_RESET}: {c['content']}")
print(f"\n{C_BOLD}{'─' * 60}{C_RESET}\n")
db.close()
def cmd_board(args):
"""Kanban board view."""
agent_id = get_agent_id()
db = get_db()
agent = get_agent(db, agent_id)
if not agent:
print(f"{C_RED}Agent '{agent_id}' not registered.{C_RESET}")
db.close()
return
if agent['role'] not in VIEWER_ROLES:
print(f"{C_RED}Access denied: project agents don't see the task board.{C_RESET}")
db.close()
return
group_id = agent['group_id']
# Get all tasks grouped by status
all_tasks = db.execute(
"SELECT t.*, a.name as assignee_name FROM tasks t "
"LEFT JOIN agents a ON t.assigned_to = a.id "
"WHERE t.group_id = ? ORDER BY t.sort_order, t.created_at",
(group_id,)
).fetchall()
columns = {}
for state in TASK_STATES:
columns[state] = [t for t in all_tasks if t['status'] == state]
# Calculate column width
col_width = 20
# Header
print(f"\n{C_BOLD} 📋 Task Board{C_RESET}\n")
# Column headers
header_line = " "
for state in TASK_STATES:
icon = STATE_ICONS[state]
color = STATE_COLORS[state]
count = len(columns[state])
col_header = f"{color}{icon} {state.upper()} ({count}){C_RESET}"
header_line += col_header.ljust(col_width + len(color) + len(C_RESET) + 5)
print(header_line)
print(f" {'─' * (col_width * len(TASK_STATES) + 10)}")
# Find max rows
max_rows = max(len(columns[s]) for s in TASK_STATES) if all_tasks else 0
for row_idx in range(max_rows):
line = " "
for state in TASK_STATES:
tasks_in_col = columns[state]
if row_idx < len(tasks_in_col):
t = tasks_in_col[row_idx]
title = t['title'][:col_width - 2]
assignee = (t['assignee_name'] or "?")[:8]
priority_c = PRIORITY_COLORS.get(t['priority'], C_RESET)
cell = f"{priority_c}{short_id(t['id'])}{C_RESET} {title}"
# Pad to column width (accounting for color codes)
visible_len = len(short_id(t['id'])) + 1 + len(title)
padding = max(0, col_width - visible_len)
line += cell + " " * padding + " "
else:
line += " " * (col_width + 2)
print(line)
# Second line with assignee
line2 = " "
for state in TASK_STATES:
tasks_in_col = columns[state]
if row_idx < len(tasks_in_col):
t = tasks_in_col[row_idx]
assignee = (t['assignee_name'] or "unassigned")[:col_width - 2]
cell = f"{C_DIM} → {assignee}{C_RESET}"
visible_len = 4 + len(assignee)
padding = max(0, col_width - visible_len)
line2 += cell + " " * padding + " "
else:
line2 += " " * (col_width + 2)
print(line2)
print()
if not all_tasks:
print(f" {C_DIM}No tasks. Create one: bttask add \"Task title\"{C_RESET}")
print()
db.close()
def cmd_delete(args):
"""Delete a task."""
if not args:
print(f"{C_RED}Usage: bttask delete <task-id>{C_RESET}")
return
agent_id = get_agent_id()
db = get_db()
agent = check_role(db, agent_id, ASSIGNER_ROLES, "delete tasks")
if not agent:
db.close()
return
task = find_task(db, args[0], agent['group_id'])
if not task:
print(f"{C_RED}Task not found.{C_RESET}")
db.close()
return
title = task['title']
db.execute("DELETE FROM task_comments WHERE task_id = ?", (task['id'],))
db.execute("DELETE FROM tasks WHERE id = ?", (task['id'],))
db.commit()
db.close()
print(f"{C_GREEN}✓ Deleted: {title}{C_RESET}")
def cmd_help(args=None):
"""Show help."""
print(__doc__)
# ─── Main dispatch ───────────────────────────────────────────
COMMANDS = {
'list': cmd_list,
'add': cmd_add,
'assign': cmd_assign,
'status': cmd_status,
'comment': cmd_comment,
'show': cmd_show,
'board': cmd_board,
'delete': cmd_delete,
'help': cmd_help,
'--help': cmd_help,
'-h': cmd_help,
}
def main():
init_db()
if len(sys.argv) < 2:
cmd_help()
sys.exit(0)
command = sys.argv[1]
args = sys.argv[2:]
handler = COMMANDS.get(command)
if not handler:
print(f"{C_RED}Unknown command: {command}{C_RESET}")
cmd_help()
sys.exit(1)
handler(args)
if __name__ == "__main__":
main()

33
ctx
View file

@ -8,15 +8,14 @@ Usage: ctx <command> [args]
import sqlite3
import sys
import os
import json
from datetime import datetime, timezone
from pathlib import Path
DB_PATH = Path.home() / ".claude-context" / "context.db"
def get_db():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(str(DB_PATH))
db.row_factory = sqlite3.Row
db.execute("PRAGMA journal_mode=WAL")
@ -279,7 +278,11 @@ def cmd_history(args):
print("Usage: ctx history <project> [limit]")
sys.exit(1)
project = args[0]
limit = int(args[1]) if len(args) > 1 else 10
try:
limit = int(args[1]) if len(args) > 1 else 10
except ValueError:
print(f"Error: limit must be an integer, got '{args[1]}'")
sys.exit(1)
db = get_db()
rows = db.execute(
"SELECT summary, created_at FROM summaries WHERE project = ? ORDER BY created_at DESC LIMIT ?",
@ -302,16 +305,24 @@ def cmd_search(args):
query = " ".join(args)
db = get_db()
# Search project contexts
results_ctx = db.execute(
"SELECT project, key, value FROM contexts_fts WHERE contexts_fts MATCH ?",
(query,),
).fetchall()
# Search project contexts (FTS5 MATCH can fail on malformed query syntax)
try:
results_ctx = db.execute(
"SELECT project, key, value FROM contexts_fts WHERE contexts_fts MATCH ?",
(query,),
).fetchall()
except sqlite3.OperationalError:
print(f"Invalid search query: '{query}' (FTS5 syntax error)")
db.close()
sys.exit(1)
# Search shared contexts
results_shared = db.execute(
"SELECT key, value FROM shared_fts WHERE shared_fts MATCH ?", (query,)
).fetchall()
try:
results_shared = db.execute(
"SELECT key, value FROM shared_fts WHERE shared_fts MATCH ?", (query,)
).fetchall()
except sqlite3.OperationalError:
results_shared = []
# Search summaries (simple LIKE since no FTS on summaries)
results_sum = db.execute(

View file

@ -0,0 +1,27 @@
services:
tempo:
image: grafana/tempo:latest
command: ["-config.file=/etc/tempo.yaml"]
volumes:
- ./tempo.yaml:/etc/tempo.yaml:ro
- tempo-data:/var/tempo
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
- "3200:3200" # Tempo query API
grafana:
image: grafana/grafana:latest
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_AUTH_DISABLE_LOGIN_FORM=true
volumes:
- ./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml:ro
ports:
- "9715:3000" # Grafana UI (project port convention)
depends_on:
- tempo
volumes:
tempo-data:

View file

@ -0,0 +1,11 @@
apiVersion: 1
datasources:
- name: Tempo
type: tempo
access: proxy
url: http://tempo:3200
isDefault: true
jsonData:
tracesToLogsV2:
datasourceUid: ''

19
docker/tempo/tempo.yaml Normal file
View file

@ -0,0 +1,19 @@
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
storage:
trace:
backend: local
local:
path: /var/tempo/traces
wal:
path: /var/tempo/wal

View file

@ -11,3 +11,26 @@ description: "Project documentation index"
Project documentation lives here.
> This directory is maintained automatically. When features are added or changed, corresponding documentation is updated.
## Index
### v2 Documentation
| Document | Description |
|----------|-------------|
| [task_plan.md](task_plan.md) | v2 architecture decisions, error handling, testing strategy |
| [phases.md](phases.md) | v2 implementation phases (1-7 + multi-machine A-D + profiles/skills) with checklists |
| [findings.md](findings.md) | Research findings (Agent SDK, Tauri, xterm.js, performance) |
| [multi-machine.md](multi-machine.md) | Multi-machine support architecture (implemented, WebSocket relay, reconnection) |
### v3 Mission Control Documentation
| Document | Description |
|----------|-------------|
| [v3-task_plan.md](v3-task_plan.md) | v3 Mission Control architecture: adversarial review, data model, component tree, layout system, 10-phase plan |
| [v3-findings.md](v3-findings.md) | v3 adversarial review results and codebase reuse analysis |
| [v3-progress.md](v3-progress.md) | v3 session progress log (All Phases 1-10 complete) |
### Progress Logs
| Document | Description |
|----------|-------------|
| [progress.md](progress.md) | Session-by-session progress log (recent sessions, v2 + v3) |
| [progress-archive.md](progress-archive.md) | Archived progress log (2026-03-05 to 2026-03-06 early) |

View file

@ -132,17 +132,17 @@ Zellij uses WASM plugins for extensibility:
## 6. Frontend Framework Choice
### Why Solid.js
- **Fine-grained reactivity**updates only the DOM nodes that changed, not the component tree
### Why Svelte 5 (revised from initial Solid.js choice)
- **Fine-grained reactivity**$state/$derived runes match Solid's signals model
- **No VDOM** — critical when we have 4-8 panes each streaming data
- **Small bundle** — ~7KB vs React's ~40KB
- **JSX familiar** — easy for anyone who knows React
- **Signals** — perfect for streaming agent state
- **Small bundle** — ~5KB runtime vs React's ~40KB
- **Larger ecosystem** — more component libraries, xterm.js wrappers, better tooling
- **Better TypeScript support** — improved in Svelte 5
### Alternative: Svelte
- Also no VDOM, also reactive, slightly larger community
- Slightly more ceremony for stores/state management
- Would also work, personal preference
### Why NOT Solid.js (initial choice, revised)
- Ecosystem too small for production use
- Fewer component libraries and integrations
- Svelte 5 runes eliminated the ceremony gap
### NOT React
- VDOM reconciliation across 4-8 simultaneously updating panes = CPU waste

323
docs/multi-machine.md Normal file
View file

@ -0,0 +1,323 @@
# Multi-Machine Support — Architecture & Implementation
**Status: Implemented (Phases A-D complete, 2026-03-06)**
## Overview
Extend BTerminal to manage Claude agent sessions and terminal panes running on **remote machines** over WebSocket, while keeping the local sidecar path unchanged.
## Problem
Current architecture is local-only:
```
WebView ←→ Rust (Tauri IPC) ←→ Local Sidecar (stdio NDJSON)
←→ Local PTY (portable-pty)
```
Target state: BTerminal acts as a **mission control** that observes agents and terminals running on multiple machines (dev servers, cloud VMs, CI runners).
## Design Constraints
1. **Zero changes to local path** — local sidecar/PTY must work identically
2. **Same NDJSON protocol** — remote and local agents speak the same message format
3. **No new runtime dependencies** — use Rust's `tokio-tungstenite` (already available via Tauri)
4. **Graceful degradation** — remote machine goes offline → pane shows disconnected state, reconnects automatically
5. **Security** — all remote connections authenticated and encrypted (TLS + token)
## Architecture
### Three-Layer Model
```
┌──────────────────────────────────────────────────────────────────┐
│ BTerminal (Controller) │
│ │
│ ┌──────────┐ Tauri IPC ┌──────────────────────────────┐ │
│ │ WebView │ ←────────────→ │ Rust Backend │ │
│ │ (Svelte) │ │ │ │
│ └──────────┘ │ ├── PtyManager (local) │ │
│ │ ├── SidecarManager (local) │ │
│ │ └── RemoteManager ──────────┼──┤
│ └──────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────────┘
│ │
│ (local stdio) │ (WebSocket wss://)
▼ ▼
┌───────────┐ ┌──────────────────────┐
│ Local │ │ Remote Machine │
│ Sidecar │ │ │
│ (Deno/ │ │ ┌────────────────┐ │
│ Node.js) │ │ │ bterminal-relay│ │
│ │ │ │ (Rust binary) │ │
└───────────┘ │ │ │ │
│ │ ├── PTY mgr │ │
│ │ ├── Sidecar mgr│ │
│ │ └── WS server │ │
│ └────────────────┘ │
└──────────────────────┘
```
### Components
#### 1. `bterminal-relay` — Remote Agent (Rust binary)
A standalone Rust binary that runs on each remote machine. It:
- Listens on a WebSocket port (default: 9750)
- Manages local PTYs and claude sidecar processes
- Forwards NDJSON events to the controller over WebSocket
- Receives commands (query, stop, resize, write) from the controller
**Why a Rust binary?** Reuses existing `PtyManager` and `SidecarManager` code from `src-tauri/src/`. Extracted into a shared crate.
```
bterminal-relay/
├── Cargo.toml # depends on bterminal-core
├── src/
│ └── main.rs # WebSocket server + auth
bterminal-core/ # shared crate (extracted from src-tauri)
├── Cargo.toml
├── src/
│ ├── pty.rs # PtyManager (from v2/src-tauri/src/pty.rs)
│ ├── sidecar.rs # SidecarManager (from v2/src-tauri/src/sidecar.rs)
│ └── lib.rs
```
#### 2. `RemoteManager` — Controller-Side (in Rust backend)
New module in `v2/src-tauri/src/remote.rs`. Manages WebSocket connections to multiple relays.
```rust
pub struct RemoteMachine {
pub id: String,
pub label: String,
pub url: String, // wss://host:9750
pub token: String, // auth token
pub status: RemoteStatus, // connected | connecting | disconnected | error
}
pub enum RemoteStatus {
Connected,
Connecting,
Disconnected,
Error(String),
}
pub struct RemoteManager {
machines: Arc<Mutex<Vec<RemoteMachine>>>,
connections: Arc<Mutex<HashMap<String, WsConnection>>>,
}
```
#### 3. Frontend Adapters — Unified Interface
The frontend doesn't care whether a pane is local or remote. The bridge layer abstracts this:
```typescript
// adapters/agent-bridge.ts — extended
export async function queryAgent(options: AgentQueryOptions): Promise<void> {
if (options.remote_machine_id) {
return invoke('remote_agent_query', { machineId: options.remote_machine_id, options });
}
return invoke('agent_query', { options });
}
```
Same pattern for `pty-bridge.ts` — add optional `remote_machine_id` to all operations.
## Protocol
### WebSocket Wire Format
Same NDJSON as local sidecar, wrapped in an envelope for multiplexing:
```typescript
// Controller → Relay (commands)
interface RelayCommand {
id: string; // request correlation ID
type: 'pty_create' | 'pty_write' | 'pty_resize' | 'pty_close'
| 'agent_query' | 'agent_stop' | 'sidecar_restart'
| 'ping';
payload: Record<string, unknown>;
}
// Relay → Controller (events)
interface RelayEvent {
type: 'pty_data' | 'pty_exit' | 'pty_created'
| 'sidecar_message' | 'sidecar_exited'
| 'error' | 'pong' | 'ready';
sessionId?: string;
payload: unknown;
}
```
### Authentication
1. **Pre-shared token** — relay starts with `--token <secret>`. Controller sends token in WebSocket upgrade headers (`Authorization: Bearer <token>`).
2. **TLS required** — relay rejects non-TLS connections in production mode. Dev mode allows `ws://` with `--insecure` flag.
3. **Token rotation** — future: relay exposes endpoint to rotate token. Controller stores tokens in SQLite settings table.
### Connection Lifecycle
```
Controller Relay
│ │
│── WSS connect ─────────────────→│
│── Authorization: Bearer token ──→│
│ │
│←── { type: "ready", ...} ───────│
│ │
│── { type: "ping" } ────────────→│
│←── { type: "pong" } ────────────│ (every 15s)
│ │
│── { type: "agent_query", ... }──→│
│←── { type: "sidecar_message" }──│ (streaming)
│←── { type: "sidecar_message" }──│
│ │
│ (disconnect) │
│── reconnect (exp backoff) ─────→│ (1s, 2s, 4s, 8s, max 30s)
```
### Reconnection (Implemented)
- Controller reconnects with exponential backoff (1s, 2s, 4s, 8s, 16s, 30s cap)
- Reconnection runs as an async tokio task spawned on disconnect
- Uses `attempt_tcp_probe()`: TCP connect only (no WS upgrade), 5s timeout, default port 9750. Avoids allocating per-connection resources (PtyManager, SidecarManager) on the relay during probes.
- Emits `remote-machine-reconnecting` event (with backoff duration) and `remote-machine-reconnect-ready` when probe succeeds
- Frontend listens via `onRemoteMachineReconnecting` and `onRemoteMachineReconnectReady` in remote-bridge.ts; machines store sets status to 'reconnecting' and auto-calls `connectMachine()` on ready
- Cancels if machine is removed or manually reconnected (checks status == "disconnected" && connection == None)
- On reconnect, relay sends current state snapshot (active sessions, PTY list)
- Controller reconciles: updates pane states, re-subscribes to streams
- Active agent sessions continue on relay regardless of controller connection
## Session Persistence Across Reconnects
Key insight: **remote agents keep running even when the controller disconnects**. The relay is autonomous — it doesn't need the controller to operate.
On reconnect:
1. Relay sends `{ type: "state_sync", activeSessions: [...], activePtys: [...] }`
2. Controller matches against known panes, updates status
3. Missed messages are NOT replayed (too complex, marginal value). Agent panes show "reconnected — some messages may be missing" notice
## Frontend Integration
### Pane Model Changes
```typescript
// stores/layout.svelte.ts
export interface Pane {
id: string;
type: 'terminal' | 'agent';
title: string;
group?: string;
remoteMachineId?: string; // NEW: undefined = local
}
```
### Sidebar — Machine Groups
Remote panes auto-group by machine label in the sidebar:
```
▾ Local
├── Terminal 1
└── Agent: fix bug
▾ devbox (192.168.1.50) ← remote machine
├── SSH session
└── Agent: deploy
▾ ci-runner (10.0.0.5) ← remote machine (disconnected)
└── Agent: test suite ⚠️
```
### Settings Panel
New "Machines" section in settings:
| Field | Type | Notes |
|-------|------|-------|
| Label | string | Human-readable name |
| URL | string | `wss://host:9750` |
| Token | password | Pre-shared auth token |
| Auto-connect | boolean | Connect on app launch |
Stored in SQLite `settings` table as JSON: `remote_machines` key.
## Implementation (All Phases Complete)
### Phase A: Extract `bterminal-core` crate [DONE]
- Cargo workspace at v2/ level (v2/Cargo.toml with members: src-tauri, bterminal-core, bterminal-relay)
- PtyManager and SidecarManager extracted to v2/bterminal-core/
- EventSink trait (bterminal-core/src/event.rs) abstracts event emission
- TauriEventSink (src-tauri/src/event_sink.rs) implements EventSink for AppHandle
- src-tauri pty.rs and sidecar.rs are thin re-export wrappers
### Phase B: Build `bterminal-relay` binary [DONE]
- v2/bterminal-relay/src/main.rs — WebSocket server (tokio-tungstenite)
- Token auth on WebSocket upgrade (Authorization: Bearer header)
- CLI: --port (default 9750), --token (required), --insecure (allow ws://)
- Routes RelayCommand to bterminal-core managers, forwards RelayEvent over WebSocket
- Rate limiting: 10 failed auth attempts triggers 5-minute lockout
- Per-connection isolated PtyManager + SidecarManager instances
- Command response propagation: structured responses (pty_created, pong, error) sent back via shared event channel
- send_error() helper: all command failures emit RelayEvent with commandId + error message
- PTY creation confirmation: pty_create command returns pty_created event with session ID and commandId for correlation
### Phase C: Add `RemoteManager` to controller [DONE]
- v2/src-tauri/src/remote.rs — RemoteManager struct with WebSocket client connections
- 12 Tauri commands: remote_add_machine, remote_remove_machine, remote_connect, remote_disconnect, remote_list_machines, remote_pty_spawn/write/resize/kill, remote_agent_query/stop, remote_sidecar_restart
- Heartbeat ping every 15s
- PTY creation event: emits `remote-pty-created` Tauri event with machineId, ptyId, commandId
- Exponential backoff reconnection on disconnect (1s/2s/4s/8s/16s/30s cap) via `attempt_tcp_probe()` (TCP-only, no WS upgrade)
- Reconnection events: `remote-machine-reconnecting`, `remote-machine-reconnect-ready`
### Phase D: Frontend integration [DONE]
- v2/src/lib/adapters/remote-bridge.ts — machine management IPC adapter
- v2/src/lib/stores/machines.svelte.ts — remote machine state store
- Pane.remoteMachineId field in layout store
- agent-bridge.ts and pty-bridge.ts route to remote commands when remoteMachineId is set
- SettingsDialog "Remote Machines" section
- Sidebar auto-groups remote panes by machine label
### Remaining Work
- [x] Reconnection logic with exponential backoff (1s-30s cap) — implemented in remote.rs
- [x] Relay command response propagation (pty_created, pong, error events) — implemented in main.rs
- [ ] Real-world relay testing (2 machines)
- [ ] TLS/certificate pinning
## Security Considerations
| Threat | Mitigation |
|--------|-----------|
| Token interception | TLS required (reject `ws://` without `--insecure`) |
| Token brute-force | Rate limit auth attempts (5/min), lockout after 10 failures |
| Relay impersonation | Pin relay certificate fingerprint (future: mTLS) |
| Command injection | Relay validates all command payloads against schema |
| Lateral movement | Relay runs as unprivileged user, no shell access beyond PTY/sidecar |
| Data exfiltration | Agent output streams to controller only, no relay-to-relay traffic |
## Performance Considerations
| Concern | Mitigation |
|---------|-----------|
| WebSocket latency | Typical LAN: <1ms. WAN: 20-100ms. Acceptable for agent output (text, not video) |
| Bandwidth | Agent NDJSON: ~50KB/s peak. Terminal: ~200KB/s peak. Trivial even on slow links |
| Connection count | Max 10 machines initially (UI constraint, not technical) |
| Message ordering | Single WebSocket per machine = ordered delivery guaranteed |
## What This Does NOT Cover (Future)
- **Multi-controller** — multiple BTerminal instances observing the same relay (needs pub/sub)
- **Relay discovery** — automatic detection of relays on LAN (mDNS/Bonjour)
- **Agent migration** — moving a running agent from one machine to another
- **Relay-to-relay** — direct communication between remote machines
- **mTLS** — mutual TLS for enterprise environments (Phase B+ enhancement)

View file

@ -4,14 +4,14 @@ See [task_plan.md](task_plan.md) for architecture decisions, error handling, and
---
## Phase 1: Project Scaffolding [status: not_started] — MVP
## Phase 1: Project Scaffolding [status: complete] — MVP
- [ ] Create feature branch `v2-mission-control`
- [ ] Initialize Tauri 2.x project with Svelte 5 frontend
- [ ] Project structure (see below)
- [ ] Basic Tauri window with Catppuccin Mocha CSS variables
- [ ] Verify Tauri builds and launches on target system
- [ ] Set up dev scripts (dev, build, lint)
- [x] Create feature branch `v2-mission-control`
- [x] Initialize Tauri 2.x project with Svelte 5 frontend
- [x] Project structure (see below)
- [x] Basic Tauri window with Catppuccin Mocha CSS variables
- [x] Verify Tauri builds and launches on target system
- [x] Set up dev scripts (dev, build, lint)
### File Structure
```
@ -20,42 +20,68 @@ bterminal-v2/
src/
main.rs # Tauri app entry
pty.rs # PTY management (portable-pty, not plugin)
sidecar.rs # Node.js sidecar lifecycle (spawn, restart, health)
sidecar.rs # Sidecar lifecycle (unified .mjs bundle, Deno-first + Node.js fallback)
watcher.rs # File watcher for markdown viewer
session.rs # Session persistence (SQLite via rusqlite)
session.rs # Session + SSH session persistence (SQLite via rusqlite)
ctx.rs # Read-only ctx context DB access
Cargo.toml
src/
App.svelte # Root layout
App.svelte # Root layout + detached pane mode
lib/
components/
Layout/
TilingGrid.svelte # Dynamic tiling manager
PaneContainer.svelte # Individual pane wrapper
PaneHeader.svelte # Pane title bar with controls
Terminal/
TerminalPane.svelte # xterm.js terminal pane
TerminalPane.svelte # xterm.js terminal pane (theme-aware)
Agent/
AgentPane.svelte # SDK agent structured output
AgentTree.svelte # Subagent tree visualization
ToolCallCard.svelte # Individual tool call display
AgentTree.svelte # Subagent tree visualization (SVG)
Markdown/
MarkdownPane.svelte # Live markdown file viewer
MarkdownPane.svelte # Live markdown file viewer (shiki highlighting)
Context/
ContextPane.svelte # ctx database viewer (projects, entries, search)
SSH/
SshDialog.svelte # SSH session create/edit modal
SshSessionList.svelte # SSH session list in sidebar
Sidebar/
SessionList.svelte # Session browser
SessionList.svelte # Session browser + SSH list
StatusBar/
StatusBar.svelte # Global status bar (pane counts, cost)
Notifications/
ToastContainer.svelte # Toast notification display
Settings/
SettingsDialog.svelte # Settings modal (shell, cwd, max panes, theme)
stores/
sessions.ts # Session state ($state runes)
agents.ts # Active agent tracking
layout.ts # Pane layout state
sessions.svelte.ts # Session state ($state runes)
agents.svelte.ts # Active agent tracking
layout.svelte.ts # Pane layout state
notifications.svelte.ts # Toast notification state
theme.svelte.ts # Catppuccin theme flavor state
adapters/
sdk-messages.ts # SDK message abstraction layer
pty-bridge.ts # PTY IPC wrapper
agent-bridge.ts # Agent IPC wrapper (local + remote routing)
claude-bridge.ts # Claude profiles + skills IPC wrapper
settings-bridge.ts # Settings IPC wrapper
ctx-bridge.ts # ctx database IPC wrapper
ssh-bridge.ts # SSH session IPC wrapper
remote-bridge.ts # Remote machine management IPC wrapper
session-bridge.ts # Session/layout persistence IPC wrapper
utils/
agent-tree.ts # Agent tree builder (hierarchy from messages)
highlight.ts # Shiki syntax highlighter (lazy singleton)
detach.ts # Detached pane mode (pop-out windows)
updater.ts # Tauri auto-updater utility
styles/
catppuccin.css # Theme CSS variables
catppuccin.css # Theme CSS variables (Mocha defaults)
themes.ts # All 4 Catppuccin flavor definitions
app.css
sidecar/
agent-runner.ts # Node.js sidecar entry point
agent-runner.ts # Sidecar source (compiled to .mjs by esbuild)
dist/
agent-runner.mjs # Bundled sidecar (runs on both Deno and Node.js)
package.json # Agent SDK dependency
esbuild.config.ts # Bundle to single file
package.json
svelte.config.js
vite.config.ts
@ -66,7 +92,7 @@ bterminal-v2/
---
## Phase 2: Terminal Pane + Layout [status: not_started] — MVP
## Phase 2: Terminal Pane + Layout [status: complete] — MVP
### Layout (responsive)
@ -86,92 +112,219 @@ bterminal-v2/
+--------+-------------------------+
```
- [ ] CSS Grid layout with sidebar + main area + optional right panel
- [ ] Responsive breakpoints (ultrawide / standard / narrow)
- [ ] Pane resize via drag handles (use CSS `resize` or lightweight lib)
- [ ] Layout presets: 1-col, 2-col, 3-col, 2x2, master+stack
- [ ] Save/restore layout to SQLite
- [ ] Keyboard: Ctrl+1-4 focus pane, Ctrl+Shift+arrows move
- [x] CSS Grid layout with sidebar + main area + optional right panel
- [x] Responsive breakpoints (ultrawide / standard / narrow)
- [x] Pane resize via drag handles (splitter overlays in TilingGrid with mouse drag, min/max 10%/90%)
- [x] Layout presets: 1-col, 2-col, 3-col, 2x2, master+stack
- [ ] Save/restore layout to SQLite (Phase 4)
- [x] Keyboard: Ctrl+1-4 focus pane, Ctrl+N new terminal
### Terminal
- [ ] xterm.js with Canvas addon (explicit — no WebGL dependency)
- [ ] Catppuccin Mocha theme for xterm.js
- [ ] PTY spawn from Rust (portable-pty), stream to frontend via Tauri events
- [ ] Terminal resize -> PTY resize
- [ ] Copy/paste (Ctrl+Shift+C/V)
- [ ] SSH session: spawn `ssh` command in PTY
- [ ] Local shell: spawn user's $SHELL
- [ ] Claude Code CLI: spawn `claude` in PTY (fallback mode)
- [x] xterm.js with Canvas addon (explicit — no WebGL dependency)
- [x] Catppuccin Mocha theme for xterm.js
- [x] PTY spawn from Rust (portable-pty), stream to frontend via Tauri events
- [x] Terminal resize -> PTY resize (100ms debounce)
- [x] Copy/paste (Ctrl+Shift+C/V) — via attachCustomKeyEventHandler
- [x] SSH session: spawn `ssh` command in PTY (via shell args)
- [x] Local shell: spawn user's $SHELL
- [x] Claude Code CLI: spawn `claude` in PTY (via shell args)
**Milestone: After Phase 2, we have a working multi-pane terminal.** Usable as a daily driver even without agent features.
---
## Phase 3: Agent SDK Integration [status: not_started] — MVP
## Phase 3: Agent SDK Integration [status: complete] — MVP
- [ ] Node.js sidecar: thin wrapper around Agent SDK `query()`
- [ ] Sidecar communication: Rust spawns Node.js, stdio NDJSON
- [ ] Sidecar lifecycle: spawn on demand, detect crash, restart
- [ ] SDK message adapter (abstraction layer)
- [ ] Agent pane: renders structured messages
- Text -> markdown rendered
- Tool calls -> collapsible cards (tool name + input + output)
- Subagent spawn -> tree node + optional new pane
- Errors -> highlighted error card
- Cost/tokens -> pane header metrics
- [ ] Auto-scroll with scroll-lock on user scroll-up
- [ ] Agent status indicator (running/thinking/waiting/done/error)
- [ ] Start/stop/cancel agent from UI
- [ ] Session resume (SDK `resume: sessionId`)
### Backend
- [x] Node.js/Deno sidecar: uses `@anthropic-ai/claude-agent-sdk` query() function (migrated from raw CLI spawning due to piped stdio hang bug #6775)
- [x] Sidecar communication: Rust spawns Node.js, stdio NDJSON
- [x] Sidecar lifecycle: auto-start on app launch, shutdown on exit
- [x] Sidecar lifecycle: detect crash, offer restart in UI (agent_restart command + restart button)
- [x] Tauri commands: agent_query, agent_stop, agent_ready, agent_restart
### Frontend
- [x] SDK message adapter: parses stream-json into 9 typed AgentMessage types (abstraction layer)
- [x] Agent bridge: Tauri IPC adapter (invoke + event listeners)
- [x] Agent dispatcher: singleton routing sidecar events to store, crash detection
- [x] Agent store: session state, message history, cost tracking (Svelte 5 $state)
- [x] Agent pane: renders structured messages
- [x] Text -> plain text (markdown rendering deferred)
- [x] Tool calls -> collapsible cards (tool name + input)
- [x] Tool results -> collapsible cards
- [x] Thinking -> collapsible details
- [x] Init -> model badge
- [x] Cost -> USD/tokens/turns/duration summary
- [x] Errors -> highlighted error card
- [x] Subagent spawn -> auto-creates child agent pane with parent/child navigation (Phase 7)
- [x] Agent status indicator (starting/running/done/error)
- [x] Start/stop agent from UI (prompt form + stop button)
- [x] Auto-scroll with scroll-lock on user scroll-up
- [x] Session resume (follow-up prompt in AgentPane, resume_session_id passed to SDK)
- [x] Keyboard: Ctrl+Shift+N new agent
- [x] Sidebar: agent session button
**Milestone: After Phase 3, we have the core differentiator.** SDK agents run in structured panes alongside raw terminals.
---
## Phase 4: Session Management + Markdown Viewer [status: not_started] — MVP
## Phase 4: Session Management + Markdown Viewer [status: complete] — MVP
### Sessions
- [ ] SQLite persistence for sessions (rusqlite)
- [ ] Session types: SSH, Claude CLI, Agent SDK, Local Shell
- [ ] Session CRUD in sidebar
- [ ] Session groups/folders
- [ ] Remember last layout on restart
- [x] SQLite persistence for sessions (rusqlite with bundled feature)
- [x] Session types: terminal, agent, markdown (SSH via terminal args)
- [x] Session CRUD: save, delete, update_title, touch (last_used_at)
- [x] Session groups/folders — group_name column, setPaneGroup, grouped sidebar with collapsible headers
- [x] Remember last layout on restart (preset + pane_ids in layout_state table)
- [x] Auto-restore panes on app startup (restoreFromDb in layout store)
### Markdown Viewer
- [ ] File watcher (notify crate) -> Tauri events -> frontend
- [ ] Markdown rendering (marked.js or remark)
- [ ] Syntax highlighting (Shiki)
- [ ] Open from sidebar or from agent output file references
- [ ] Debounce file watcher (200ms)
- [x] File watcher (notify crate v6) -> Tauri events -> frontend
- [x] Markdown rendering (marked.js)
- [x] Syntax highlighting (Shiki) — added in Phase 5 (highlight.ts, 13 preloaded languages)
- [x] Open from sidebar (file picker button "M")
- [x] Catppuccin-themed markdown styles (h1-h3, code, pre, tables, blockquotes)
- [x] Live reload on file change
**Milestone: After Phase 4 = MVP ship.** Full session management, structured agent panes, terminal panes, markdown viewer.
---
## Phase 5: Agent Tree + Polish [status: not_started] — Post-MVP
## Phase 5: Agent Tree + Polish [status: complete] — Post-MVP
- [ ] Agent tree visualization (SVG, compact horizontal layout)
- [ ] Click tree node -> focus agent pane
- [ ] Aggregate cost per subtree
- [ ] Global status bar (total cost, active agents, uptime)
- [ ] Notification system (agent done, error)
- [ ] Global keyboard shortcuts
- [ ] Settings dialog
- [ ] ctx integration (port from v1)
- [x] Agent tree visualization (SVG, compact horizontal layout) — AgentTree.svelte + agent-tree.ts utility
- [x] Click tree node -> scroll to message (handleTreeNodeClick in AgentPane, scrollIntoView smooth)
- [x] Aggregate cost per subtree (subtreeCost displayed in yellow below each tree node label)
- [x] Terminal copy/paste (Ctrl+Shift+C/V via attachCustomKeyEventHandler)
- [x] Terminal theme hot-swap (onThemeChange callback registry in theme.svelte.ts, TerminalPane subscribes)
- [x] Pane drag-resize handles (splitter overlays in TilingGrid with mouse drag)
- [x] Session resume (follow-up prompt, resume_session_id to SDK)
- [x] Global status bar (terminal/agent counts, active agents pulse, token/cost totals) — StatusBar.svelte
- [x] Notification system (toast: success/error/warning/info, auto-dismiss 4s, max 5) — notifications.svelte.ts + ToastContainer.svelte
- [x] Agent dispatcher toast integration (agent complete, error, sidecar crash notifications)
- [x] Global keyboard shortcuts — Ctrl+W close focused pane, Ctrl+, open settings
- [x] Settings dialog (default shell, cwd, max panes, theme flavor) — SettingsDialog.svelte + settings-bridge.ts
- [x] Settings backend — settings table in SQLite (session.rs), Tauri commands settings_get/set/list (lib.rs)
- [x] ctx integration — read-only access to ~/.claude-context/context.db (ctx.rs, ctx-bridge.ts, ContextPane.svelte)
- [x] SSH session management — CRUD in SQLite (SshSession struct, SshDialog.svelte, SshSessionList.svelte, ssh-bridge.ts)
- [x] Catppuccin theme flavors — Latte/Frappe/Macchiato/Mocha selectable (themes.ts, theme.svelte.ts)
- [x] Detached pane mode — pop-out terminal/agent into standalone windows (detach.ts, App.svelte)
- [x] Syntax highlighting — Shiki integration for markdown + agent messages (highlight.ts, shiki dep)
---
## Phase 6: Packaging + Distribution [status: not_started] — Post-MVP
## Phase 6: Packaging + Distribution [status: complete] — Post-MVP
- [ ] install.sh v2 (check Node.js, install Tauri runtime deps)
- [ ] AppImage build (single file, works everywhere)
- [ ] .deb package (Debian/Ubuntu)
- [ ] GitHub Actions CI for building releases
- [ ] Auto-update mechanism (Tauri updater)
- [ ] Migrate bterminal.svg icon
- [ ] README update
- [x] install-v2.sh — build-from-source installer with dependency checks (Node.js 20+, Rust 1.77+, system libs)
- Checks: WebKit2GTK, GTK3, GLib, libayatana-appindicator, librsvg, openssl, build-essential, pkg-config, curl, wget, FUSE
- Prompts to install missing packages via apt
- Builds with `npx tauri build`, installs binary as `bterminal-v2` in `~/.local/bin/`
- Creates desktop entry and installs SVG icon
- [x] Tauri bundle configuration — targets: `["deb", "appimage"]`, category: DeveloperTool
- .deb depends: libwebkit2gtk-4.1-0, libgtk-3-0, libayatana-appindicator3-1
- AppImage: bundleMediaFramework disabled
- [x] Icons regenerated from bterminal.svg — RGBA PNGs (32x32, 128x128, 128x128@2x, 512x512, .ico)
- [x] GitHub Actions release workflow (`.github/workflows/release.yml`)
- Triggered on `v*` tags, Ubuntu 22.04 runner
- Caches Rust and npm dependencies
- Builds .deb + AppImage, uploads as GitHub Release artifacts
- [x] Build verified: .deb (4.3 MB), AppImage (103 MB)
- [x] Auto-updater plugin integrated (tauri-plugin-updater Rust + @tauri-apps/plugin-updater npm + updater.ts)
- [x] Auto-update latest.json generation in CI (version, platform URL, signature from .sig file)
- [x] release.yml: TAURI_SIGNING_PRIVATE_KEY env vars passed to build step
- [x] Auto-update signing key generated, pubkey set in tauri.conf.json
- [x] TAURI_SIGNING_PRIVATE_KEY secret set in GitHub repo settings via `gh secret set`
---
## Phase 7: Agent Teams / Subagent Support [status: complete] — Post-MVP
- [x] Agent store parent/child hierarchy — parentSessionId, parentToolUseId, childSessionIds fields on AgentSession
- [x] Agent store functions — findChildByToolUseId(), getChildSessions(), parent-aware createAgentSession()
- [x] Agent dispatcher subagent detection — SUBAGENT_TOOL_NAMES Set ('Agent', 'Task', 'dispatch_agent')
- [x] Agent dispatcher message routing — parentId-bearing messages routed to child panes via toolUseToChildPane Map
- [x] Agent dispatcher pane spawning — spawnSubagentPane() creates child session + layout pane, auto-grouped under parent
- [x] AgentPane parent navigation — SUB badge + button to focus parent agent
- [x] AgentPane children bar — clickable chips per child subagent with status colors (running/done/error)
- [x] SessionList subagent icon — '↳' for subagent panes
- [x] Subagent cost aggregation — getTotalCost() recursive helper in agents.svelte.ts, total cost shown in parent pane done-bar
- [x] Dispatcher tests for subagent routing — 10 tests covering spawn, dedup, child message routing, init/cost forwarding, fallbacks (28 total dispatcher tests)
- [ ] Test with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
### System Requirements
- Node.js 20+ (for Agent SDK sidecar)
- Rust 1.77+ (for building from source)
- WebKit2GTK 4.1+ (Tauri runtime)
- Linux x86_64 (primary target)
---
## Multi-Machine Support (Phases A-D) [status: complete]
Architecture designed in [multi-machine.md](multi-machine.md). Implementation extends BTerminal to manage agents and terminals on remote machines over WebSocket.
### Phase A: Extract `bterminal-core` crate [status: complete]
- [x] Created Cargo workspace at v2/ level (v2/Cargo.toml with members)
- [x] Extracted PtyManager and SidecarManager into shared `bterminal-core` crate
- [x] Created EventSink trait to abstract Tauri event emission (bterminal-core/src/event.rs)
- [x] TauriEventSink in src-tauri/src/event_sink.rs implements EventSink
- [x] src-tauri pty.rs and sidecar.rs now thin re-exports from bterminal-core
### Phase B: Build `bterminal-relay` binary [status: complete]
- [x] WebSocket server using tokio-tungstenite with token auth
- [x] CLI flags: --port, --token, --insecure (clap)
- [x] Routes RelayCommand to PtyManager/SidecarManager, forwards RelayEvent over WebSocket
- [x] Rate limiting on auth failures (10 attempts, 5min lockout)
- [x] Per-connection isolated PTY + sidecar managers
- [x] Command response propagation: structured responses (pty_created, pong, error) via shared event channel
- [x] send_error() helper for consistent error reporting with commandId correlation
- [x] PTY creation confirmation: pty_created event with session ID and commandId
### Phase C: Add `RemoteManager` to controller [status: complete]
- [x] New remote.rs module in src-tauri — WebSocket client connections to relay instances
- [x] Machine lifecycle: add/remove/connect/disconnect
- [x] 12 new Tauri commands for remote operations
- [x] Heartbeat ping every 15s
- [x] PTY creation event: emits remote-pty-created Tauri event with machineId, ptyId, commandId
- [x] Exponential backoff reconnection on disconnect (1s/2s/4s/8s/16s/30s cap)
- [x] attempt_tcp_probe() function: TCP-only probe (5s timeout, default port 9750) — avoids allocating per-connection resources on relay during probes
- [x] Reconnection events: remote-machine-reconnecting, remote-machine-reconnect-ready
### Phase D: Frontend integration [status: complete]
- [x] remote-bridge.ts adapter for machine management + remote events
- [x] machines.svelte.ts store for remote machine state
- [x] Layout store: Pane.remoteMachineId field
- [x] agent-bridge.ts and pty-bridge.ts route to remote commands when remoteMachineId is set
- [x] SettingsDialog "Remote Machines" section (add/remove/connect/disconnect)
- [x] Sidebar auto-groups remote panes by machine label
### Remaining Work
- [x] Reconnection logic with exponential backoff — implemented in remote.rs
- [x] Relay command response propagation — implemented in bterminal-relay main.rs
- [ ] Real-world relay testing (2 machines)
- [ ] TLS/certificate pinning
---
## Extras: Claude Profiles & Skill Discovery [status: complete]
### Claude Profile / Account Switching
- [x] Tauri command claude_list_profiles(): reads ~/.config/switcher/profiles/ directories
- [x] Profile metadata from profile.toml (email, subscription_type, display_name)
- [x] Config dir resolution: ~/.config/switcher-claude/{name}/ or fallback ~/.claude/
- [x] Default profile fallback when no switcher profiles exist
- [x] Profile selector dropdown in AgentPane toolbar (shown when >1 profile)
- [x] Selected profile's config_dir passed as claude_config_dir -> CLAUDE_CONFIG_DIR env override
### Skill Discovery & Autocomplete
- [x] Tauri command claude_list_skills(): reads ~/.claude/skills/ (dirs with SKILL.md or .md files)
- [x] Tauri command claude_read_skill(path): reads skill file content
- [x] Frontend adapter: claude-bridge.ts (ClaudeProfile, ClaudeSkill interfaces, listProfiles/listSkills/readSkill)
- [x] Skill autocomplete in AgentPane: `/` prefix triggers menu, arrow keys navigate, Tab/Enter select
- [x] expandSkillPrompt(): reads skill content, injects as prompt with optional user args
### Extended AgentQueryOptions
- [x] Rust struct (bterminal-core/src/sidecar.rs): setting_sources, system_prompt, model, claude_config_dir, additional_directories
- [x] Sidecar JSON passthrough (both agent-runner.ts and agent-runner-deno.ts)
- [x] SDK query() options: settingSources defaults to ['user', 'project'], systemPrompt, model, additionalDirectories
- [x] CLAUDE_CONFIG_DIR env injection for multi-account support
- [x] Frontend AgentQueryOptions interface (agent-bridge.ts) updated with new fields

273
docs/progress-archive.md Normal file
View file

@ -0,0 +1,273 @@
# BTerminal v2 — Progress Log (Archive: 2026-03-05 to 2026-03-06 early)
> Archived from [progress.md](progress.md). Covers research, Phases 1-6, polish, testing, agent teams, and subagent support.
## Session: 2026-03-05
### Research Phase (complete)
- [x] Analyzed current BTerminal v1 codebase (2092 lines Python, GTK3+VTE)
- [x] Queried Memora — no existing BTerminal memories
- [x] Researched Claude Agent SDK — found structured streaming, subagent tracking, hooks
- [x] Researched Tauri + xterm.js ecosystem — found 4+ working projects
- [x] Researched terminal latency benchmarks — xterm.js acceptable for AI output
- [x] Researched 32:9 ultrawide layout patterns
- [x] Evaluated GTK4 vs Tauri vs pure Rust — Tauri wins for this use case
- [x] Created task_plan.md with 8 phases
- [x] Created findings.md with 7 research areas
### Technology Decision (complete)
- Decision: **Tauri 2.x + Solid.js + Claude Agent SDK + xterm.js**
- Rationale documented in task_plan.md Phase 0
### Adversarial Review (complete)
- [x] Spawned devil's advocate agent to attack the plan
- [x] Identified 5 fatal/critical issues:
1. Node.js sidecar requirement unacknowledged
2. SDK 0.2.x instability — need abstraction layer
3. Three-tier observation overengineered — simplified to two-tier
4. Solid.js ecosystem too small — switched to Svelte 5
5. Missing: packaging, error handling, testing, responsive design
- [x] Revised plan (Rev 2) incorporating all corrections
- [x] Added error handling strategy table
- [x] Added testing strategy table
- [x] Defined MVP boundary (Phases 1-4)
- [x] Added responsive layout requirement (1920px degraded mode)
### Phase 1 Scaffolding (complete)
- [x] Created feature branch `v2-mission-control`
- [x] Initialized Tauri 2.x + Svelte 5 project in `v2/` directory
- [x] Rust backend stubs: main.rs, lib.rs, pty.rs, sidecar.rs, watcher.rs, session.rs
- [x] Svelte frontend: App.svelte with Catppuccin Mocha CSS variables, component stubs
- [x] Node.js sidecar scaffold: agent-runner.ts with NDJSON communication pattern
- [x] Tauri builds and launches (cargo build --release verified)
- [x] Dev scripts: npm run dev, npm run build, npm run tauri dev/build
- [x] 17 operational rules added to `.claude/rules/`
- [x] Project meta files: CLAUDE.md, .claude/CLAUDE.md, TODO.md, CHANGELOG.md
- [x] Documentation structure: docs/README.md, task_plan.md, phases.md, findings.md, progress.md
### Phase 2: Terminal Pane + Layout (complete)
- [x] Rust PTY backend with portable-pty (PtyManager: spawn, write, resize, kill)
- [x] PTY reader thread emitting Tauri events (pty-data-{id}, pty-exit-{id})
- [x] Tauri commands: pty_spawn, pty_write, pty_resize, pty_kill
- [x] xterm.js terminal pane with Canvas addon (explicit, no WebGL)
- [x] Catppuccin Mocha theme for xterm.js (16 ANSI colors)
- [x] FitAddon with ResizeObserver + 100ms debounce
- [x] PTY bridge adapter (spawnPty, writePty, resizePty, killPty, onPtyData, onPtyExit)
- [x] CSS Grid tiling layout with 5 presets (1-col, 2-col, 3-col, 2x2, master-stack)
- [x] Layout store with Svelte 5 $state runes and auto-preset selection
- [x] Sidebar with session list, layout preset selector, new terminal button
- [x] Keyboard shortcuts: Ctrl+N new terminal, Ctrl+1-4 focus pane
- [x] PaneContainer with header bar (title, status, close)
- [x] Empty state welcome screen with Ctrl+N hint
- [x] npm dependencies: @xterm/xterm, @xterm/addon-canvas, @xterm/addon-fit
- [x] Cargo dependencies: portable-pty, uuid
### Phase 3: Agent SDK Integration (complete)
- [x] Rust SidecarManager: spawn Node.js, stdio NDJSON, query/stop/shutdown (sidecar.rs, 218 lines)
- [x] Node.js agent-runner: spawns `claude -p --output-format stream-json`, manages sessions (agent-runner.ts, 176 lines)
- [x] Tauri commands: agent_query, agent_stop, agent_ready in lib.rs
- [x] Sidecar auto-start on app launch
- [x] SDK message adapter: full stream-json parser with 9 typed message types (sdk-messages.ts, 234 lines)
- [x] Agent bridge: Tauri IPC adapter for sidecar communication (agent-bridge.ts, 53 lines)
- [x] Agent dispatcher: routes sidecar events to agent store (agent-dispatcher.ts, 87 lines)
- [x] Agent store: session state with messages, cost tracking (agents.svelte.ts, 91 lines)
- [x] AgentPane component: prompt input, message rendering, stop button, cost display (AgentPane.svelte, 420 lines)
- [x] UI integration: Ctrl+Shift+N for new agent, sidebar agent button, TilingGrid routing
Architecture decision: Initially used `claude` CLI with `--output-format stream-json`. Migrated to `@anthropic-ai/claude-agent-sdk` query() due to CLI piped stdio hang bug (#6775). SDK outputs same message format, so adapter unchanged.
### Bug Fix: Svelte 5 Rune File Extensions (2026-03-06)
- [x] Diagnosed blank screen / "rune_outside_svelte" runtime error
- [x] Root cause: store files used `.ts` extension but contain Svelte 5 `$state`/`$derived` runes, which only work in `.svelte` and `.svelte.ts` files
- [x] Renamed: `layout.ts` -> `layout.svelte.ts`, `agents.ts` -> `agents.svelte.ts`, `sessions.ts` -> `sessions.svelte.ts`
- [x] Updated all import paths in 5 files to use `.svelte` suffix (e.g., `from './stores/layout.svelte'`)
### Phase 3 Polish (2026-03-06)
- [x] Sidecar crash detection: dispatcher listens for sidecar-exited event, marks running sessions as error
- [x] Restart UI: "Restart Sidecar" button in AgentPane error bar, calls agent_restart command
- [x] Auto-scroll lock: scroll handler disables auto-scroll when user scrolls >50px from bottom, "Scroll to bottom" button appears
### Phase 4: Session Management + Markdown Viewer (2026-03-06)
- [x] rusqlite 0.31 (bundled) + dirs 5 + notify 6 added to Cargo.toml
- [x] SessionDb: SQLite with WAL mode, sessions table + layout_state singleton
- [x] Session CRUD: list, save, delete, update_title, touch (7 Tauri commands)
- [x] Frontend session-bridge.ts: typed invoke wrappers for all session/layout commands
- [x] Layout store wired to persistence: addPane/removePane/focusPane/setPreset all persist
- [x] restoreFromDb() on app startup restores panes in layout order
- [x] FileWatcherManager: notify crate watches files, emits Tauri "file-changed" events
- [x] MarkdownPane component: marked.js rendering, Catppuccin-themed styles, live reload
- [x] Sidebar "M" button opens file picker for .md/.markdown/.txt files
- [x] TilingGrid routes markdown pane type to MarkdownPane component
### Phase 5: Agent Tree + Polish (2026-03-06, complete)
- [x] Agent tree visualization (SVG): AgentTree.svelte component with horizontal tree layout, bezier edges, status-colored nodes; agent-tree.ts utility (buildAgentTree, countTreeNodes, subtreeCost)
- [x] Agent tree toggle in AgentPane: collapsible tree view shown when tool_call messages exist
- [x] Global status bar: StatusBar.svelte showing terminal/agent pane counts, active agents with pulse animation, total tokens and cost
- [x] Notification system: notifications.svelte.ts store (notify, dismissNotification, max 5 toasts, 4s auto-dismiss) + ToastContainer.svelte (slide-in animation, color-coded by type)
- [x] Agent dispatcher notifications: toast on agent_stopped (success), agent_error (error), sidecar crash (error), cost result (success with cost/turns)
- [x] Settings dialog: SettingsDialog.svelte modal (default shell, cwd, max panes, theme flavor) with settings-bridge.ts adapter
- [x] Settings backend: settings table (key/value) in session.rs, Tauri commands settings_get/set/list in lib.rs
- [x] Keyboard shortcuts: Ctrl+W close focused pane, Ctrl+, open settings dialog
- [x] CSS grid update: app.css grid-template-rows '1fr' -> '1fr auto' for status bar row
- [x] App.svelte: integrated StatusBar, ToastContainer, SettingsDialog components
### Phase 6: Packaging + Distribution (2026-03-06)
- [x] Created install-v2.sh — build-from-source installer with 6-step dependency check process
- [x] Updated v2/src-tauri/tauri.conf.json: bundle targets ["deb", "appimage"]
- [x] Regenerated all icons in v2/src-tauri/icons/ from bterminal.svg as RGBA PNGs
- [x] Created .github/workflows/release.yml — CI workflow triggered on v* tags
- [x] Build verified: .deb (4.3 MB), AppImage (103 MB) both built successfully
### Phase 5 continued: SSH, ctx, themes, detached mode, auto-updater (2026-03-06)
- [x] ctx integration: Rust ctx.rs, 5 Tauri commands, ctx-bridge.ts adapter, ContextPane.svelte
- [x] SSH session management: SshSession struct, ssh-bridge.ts, SshDialog.svelte, SshSessionList.svelte
- [x] Catppuccin theme flavors: Latte/Frappe/Macchiato/Mocha selectable
- [x] Detached pane mode: pop-out windows via URL params
- [x] Syntax highlighting: Shiki lazy singleton (13 languages)
- [x] Tauri auto-updater plugin integrated
- [x] AgentPane markdown rendering with Shiki highlighting
### Session: 2026-03-06 (continued) — Polish, Testing, Extras
#### Terminal Copy/Paste + Theme Hot-Swap
- [x] Copy/paste in TerminalPane via Ctrl+Shift+C/V
- [x] Theme hot-swap: onThemeChange() callback registry
#### Agent Tree Enhancements
- [x] Click tree node -> scroll to message, subtree cost display
#### Session Resume
- [x] Follow-up prompt input, resume_session_id passed to SDK
#### Pane Drag-Resize Handles
- [x] Splitter overlays in TilingGrid with mouse drag (min 10% / max 90%)
#### Auto-Update Workflow Enhancement
- [x] release.yml: signing key env vars, latest.json generation
#### Deno Sidecar Evaluation
- [x] Created agent-runner-deno.ts proof-of-concept
#### Testing Infrastructure
- [x] Vitest + Cargo tests: 104 vitest + 29 cargo tests, all passing
### Session: 2026-03-06 (continued) — Session Groups, Auto-Update Key, Deno Sidecar, Tests
#### Auto-Update Signing Key
- [x] Generated Tauri signing keypair (minisign), set pubkey in tauri.conf.json
#### Session Groups/Folders
- [x] group_name column, setPaneGroup, grouped sidebar with collapsible headers
#### Deno Sidecar Integration (upgraded from PoC)
- [x] SidecarCommand struct, Deno-first resolution, Node.js fallback
#### E2E/Integration Tests
- [x] layout.test.ts (30), agent-bridge.test.ts (11), agent-dispatcher.test.ts (18), sdk-messages.test.ts (25)
- [x] Total: 104 vitest tests + 29 cargo tests
### Session: 2026-03-06 (continued) — Agent Teams / Subagent Support
#### Agent Teams Frontend Support
- [x] Parent/child hierarchy in agent store, subagent detection in dispatcher
- [x] spawnSubagentPane(), toolUseToChildPane routing, parent/child navigation in AgentPane
- [x] 10 new dispatcher tests for subagent routing (28 total, 114 vitest overall)
#### Subagent Cost Aggregation
- [x] getTotalCost() recursive helper, total cost shown in parent pane
#### TAURI_SIGNING_PRIVATE_KEY
- [x] Set via `gh secret set` on DexterFromLab/BTerminal GitHub repo
### Session: 2026-03-06 (continued) — Multi-Machine Architecture Design
#### Multi-Machine Support Architecture
- [x] Designed full multi-machine architecture in docs/multi-machine.md (303 lines)
- [x] Three-layer model: BTerminal (controller) + bterminal-relay (remote binary) + unified frontend
- [x] WebSocket NDJSON protocol: RelayCommand/RelayEvent envelope wrapping existing sidecar format
- [x] Authentication: pre-shared token + TLS, rate limiting, lockout
- [x] Autonomous relay model: agents keep running when controller disconnects
- [x] Reconnection with exponential backoff (1s-30s), state_sync on reconnect
- [x] 4-phase implementation plan: A (extract bterminal-core crate), B (relay binary), C (RemoteManager), D (frontend)
- [x] Updated TODO.md and docs/task_plan.md to reference the design
### Session: 2026-03-06 (continued) — Multi-Machine Implementation (Phases A-D)
#### Phase A: bterminal-core crate extraction
- [x] Created Cargo workspace at v2/ level (v2/Cargo.toml, workspace members: src-tauri, bterminal-core, bterminal-relay)
- [x] Extracted PtyManager into v2/bterminal-core/src/pty.rs
- [x] Extracted SidecarManager into v2/bterminal-core/src/sidecar.rs
- [x] Created EventSink trait (v2/bterminal-core/src/event.rs) to abstract event emission
- [x] TauriEventSink (v2/src-tauri/src/event_sink.rs) implements EventSink for Tauri AppHandle
- [x] src-tauri/src/pty.rs and sidecar.rs now thin re-export wrappers
- [x] Cargo.lock moved from src-tauri/ to workspace root (v2/)
#### Phase B: bterminal-relay binary
- [x] New Rust binary at v2/bterminal-relay/ with WebSocket server (tokio-tungstenite)
- [x] Token auth via Authorization: Bearer header on WebSocket upgrade
- [x] CLI flags: --port (default 9750), --token (required), --insecure (allow ws://)
- [x] Routes RelayCommand types (pty_create/write/resize/close, agent_query/stop, sidecar_restart, ping)
- [x] Forwards RelayEvent types (pty_data/exit, sidecar_message/exited, error, pong, ready)
- [x] Rate limiting: 10 failed auth attempts triggers 5-minute lockout
- [x] Per-connection isolated PtyManager + SidecarManager instances
#### Phase C: RemoteManager in controller
- [x] New v2/src-tauri/src/remote.rs module — RemoteManager struct
- [x] WebSocket client connections to relay instances (tokio-tungstenite)
- [x] RemoteMachine struct: id, label, url, token, status (Connected/Connecting/Disconnected/Error)
- [x] Machine lifecycle: add_machine, remove_machine, connect, disconnect
- [x] 12 new Tauri commands: remote_add_machine, remote_remove_machine, remote_connect, remote_disconnect, remote_list_machines, remote_pty_spawn/write/resize/kill, remote_agent_query/stop, remote_sidecar_restart
- [x] Heartbeat ping every 15s to detect stale connections
#### Phase D: Frontend integration
- [x] v2/src/lib/adapters/remote-bridge.ts — IPC adapter for machine management + remote events
- [x] v2/src/lib/stores/machines.svelte.ts — Svelte 5 store for remote machine state
- [x] Layout store: added remoteMachineId?: string to Pane interface
- [x] agent-bridge.ts: routes to remote_agent_query/stop when pane has remoteMachineId
- [x] pty-bridge.ts: routes to remote_pty_spawn/write/resize/kill when pane has remoteMachineId
- [x] SettingsDialog: new "Remote Machines" section (add/remove/connect/disconnect UI)
- [x] SessionList sidebar: auto-groups remote panes by machine label
#### Verification
- cargo check --workspace: clean (0 errors)
- vitest: 114/114 tests passing
- svelte-check: clean (0 errors)
#### New dependencies added
- bterminal-core: serde, serde_json, log, portable-pty, uuid (extracted from src-tauri)
- bterminal-relay: tokio, tokio-tungstenite, clap, env_logger, futures-util
- src-tauri: tokio-tungstenite, tokio, futures-util, uuid (added for RemoteManager)
### Session: 2026-03-06 (continued) — Relay Hardening & Reconnection
#### Relay Command Response Propagation
- [x] Shared event channel between EventSink and command response sender (sink_tx clone in bterminal-relay)
- [x] send_error() helper function: all command failures now emit RelayEvent with commandId + error message instead of just logging
- [x] ping command: now sends pong response via event channel (was a no-op)
- [x] pty_create: returns pty_created event with session ID and commandId for correlation
- [x] All error paths (pty_write, pty_resize, pty_close, agent_query, agent_stop, sidecar_restart) use send_error()
#### RemoteManager Reconnection
- [x] Exponential backoff reconnection in remote.rs: spawns async tokio task on disconnect
- [x] Backoff schedule: 1s, 2s, 4s, 8s, 16s, 30s (capped)
- [x] attempt_tcp_probe() function: TCP-only connect probe (5s timeout, default port 9750) — avoids allocating per-connection resources on relay
- [x] Emits remote-machine-reconnecting (with backoffSecs) and remote-machine-reconnect-ready Tauri events
- [x] Cancellation: stops if machine removed (not in HashMap) or manually reconnected (status != disconnected)
- [x] Fixed scoping: disconnection cleanup uses inner block to release mutex before emitting event
#### RemoteManager PTY Creation Confirmation
- [x] Handles pty_created event type from relay: emits remote-pty-created Tauri event with machineId, ptyId, commandId
### Session: 2026-03-06 (continued) — Reconnection Hardening
#### TCP Probe Refactor
- [x] Replaced attempt_ws_connect() with attempt_tcp_probe() in remote.rs: TCP-only connect (no WS upgrade), 5s timeout, default port 9750
- [x] Avoids allocating per-connection resources (PtyManager, SidecarManager) on the relay during reconnection probes
- [x] Probe no longer needs auth token — only checks TCP reachability
#### Frontend Reconnection Listeners
- [x] Added onRemoteMachineReconnecting() listener in remote-bridge.ts: receives machineId + backoffSecs
- [x] Added onRemoteMachineReconnectReady() listener in remote-bridge.ts: receives machineId when probe succeeds
- [x] machines.svelte.ts: reconnecting handler sets machine status to 'reconnecting', shows toast with backoff duration
- [x] machines.svelte.ts: reconnect-ready handler auto-calls connectMachine() to re-establish full WebSocket connection
- [x] Updated docs/multi-machine.md to reflect TCP probe and frontend listener changes

View file

@ -1,37 +1,249 @@
# BTerminal v2 — Progress Log
## Session: 2026-03-05
> Earlier sessions (2026-03-05 to 2026-03-06 multi-machine): see [progress-archive.md](progress-archive.md)
### Research Phase (complete)
- [x] Analyzed current BTerminal v1 codebase (2092 lines Python, GTK3+VTE)
- [x] Queried Memora — no existing BTerminal memories
- [x] Researched Claude Agent SDK — found structured streaming, subagent tracking, hooks
- [x] Researched Tauri + xterm.js ecosystem — found 4+ working projects
- [x] Researched terminal latency benchmarks — xterm.js acceptable for AI output
- [x] Researched 32:9 ultrawide layout patterns
- [x] Evaluated GTK4 vs Tauri vs pure Rust — Tauri wins for this use case
- [x] Created task_plan.md with 8 phases
- [x] Created findings.md with 7 research areas
### Session: 2026-03-09 — AgentPane + MarkdownPane UI Redesign
### Technology Decision (complete)
- Decision: **Tauri 2.x + Solid.js + Claude Agent SDK + xterm.js**
- Rationale documented in task_plan.md Phase 0
#### Tribunal-Elected Design (S-3-R4, 88% confidence)
- [x] AgentPane full rewrite: sans-serif root font, tool call/result pairing via `$derived.by` toolResultMap, hook message collapsing, context window meter, cost bar minimized, session summary styling
- [x] Two-phase scroll anchoring (`$effect.pre` + `$effect`)
- [x] Tool-aware output truncation (Bash 500, Read/Write 50, Glob/Grep 20, default 30 lines)
- [x] Colors softened via `color-mix(in srgb, var(--ctp-*) 65%, var(--ctp-surface1) 35%)`
- [x] MarkdownPane: container query wrapper, shared responsive padding variable
- [x] catppuccin.css: `--bterminal-pane-padding-inline: clamp(0.75rem, 3.5cqi, 2rem)`
- [x] 139/139 vitest passing, 0 new TypeScript errors
- [ ] Visual verification in dev mode (pending)
### Adversarial Review (complete)
- [x] Spawned devil's advocate agent to attack the plan
- [x] Identified 5 fatal/critical issues:
1. Node.js sidecar requirement unacknowledged
2. SDK 0.2.x instability — need abstraction layer
3. Three-tier observation overengineered → simplified to two-tier
4. Solid.js ecosystem too small → switched to Svelte 5
5. Missing: packaging, error handling, testing, responsive design
- [x] Revised plan (Rev 2) incorporating all corrections
- [x] Added error handling strategy table
- [x] Added testing strategy table
- [x] Defined MVP boundary (Phases 1-4)
- [x] Added responsive layout requirement (1920px degraded mode)
### Session: 2026-03-06 (continued) — Sidecar Env Var Bug Fix
#### CLAUDE* Environment Variable Leak (critical fix)
- [x] Diagnosed silent hang in agent sessions when BTerminal launched from Claude Code terminal
- [x] Root cause: Claude Code sets ~8 CLAUDE* env vars for nesting/sandbox detection
- [x] Fixed both sidecar runners to filter out all keys starting with 'CLAUDE'
### Session: 2026-03-06 (continued) — Sidecar SDK Migration
#### Migration from CLI Spawning to Agent SDK
- [x] Diagnosed root cause: claude CLI v2.1.69 hangs with piped stdio (bug #6775)
- [x] Migrated both runners to @anthropic-ai/claude-agent-sdk query() function
- [x] Added build:sidecar script (esbuild bundle, SDK included)
- [x] SDK options: permissionMode configurable, allowDangerouslySkipPermissions conditional
#### Bug Found and Fixed
- [x] AgentPane onDestroy killed running sessions on layout remounts (fixed: moved to TilingGrid onClose)
### Session: 2026-03-06 (continued) — Permission Mode, AgentPane Bug Fix, SDK Bundling
#### Permission Mode Passthrough
- [x] permission_mode field flows Rust -> sidecar -> SDK, defaults to 'bypassPermissions'
#### AgentPane onDestroy Bug Fix
- [x] Stop-on-close moved from AgentPane onDestroy to TilingGrid onClose handler
#### SDK Bundling Fix
- [x] SDK bundled into agent-runner.mjs (no external dependency at runtime)
### Session: 2026-03-07 — Unified Sidecar Bundle
#### Sidecar Resolution Simplification
- [x] Consolidated to single pre-built bundle (dist/agent-runner.mjs) running on both Deno and Node.js
- [x] resolve_sidecar_command() checks runtime availability upfront, prefers Deno
- [x] agent-runner-deno.ts retained in repo but not used by SidecarManager
### Session: 2026-03-07 (continued) — Rust-Side CLAUDE* Env Var Stripping
#### Dual-Layer Env Var Stripping
- [x] Rust SidecarManager uses env_clear() + envs(clean_env) before spawn (primary defense)
- [x] JS-side stripping retained as defense-in-depth
### Session: 2026-03-07 (continued) — Claude CLI Path Detection
#### pathToClaudeCodeExecutable for SDK
- [x] Added findClaudeCli() to agent-runner.ts (Node.js): checks ~/.local/bin/claude, ~/.claude/local/claude, /usr/local/bin/claude, /usr/bin/claude, then falls back to `which claude`/`where claude`
- [x] Added findClaudeCli() to agent-runner-deno.ts (Deno): same candidate paths, uses Deno.statSync() + Deno.Command("which")
- [x] Both runners now pass pathToClaudeCodeExecutable to SDK query() options
- [x] Early error: if Claude CLI not found, agent_error emitted immediately instead of cryptic SDK failure
- [x] CLI path resolved once at sidecar startup, logged for debugging
### Session: 2026-03-07 (continued) — Claude Profiles & Skill Discovery
#### Claude Profile / Account Switching (switcher-claude integration)
- [x] New Tauri commands: claude_list_profiles(), claude_list_skills(), claude_read_skill(), pick_directory()
- [x] claude_list_profiles() reads ~/.config/switcher/profiles/ for profile directories with profile.toml metadata
- [x] Config dir resolution: ~/.config/switcher-claude/{name}/ or fallback ~/.claude/
- [x] extract_toml_value() simple TOML parser for profile metadata (email, subscription_type, display_name)
- [x] Always includes "default" profile if no switcher profiles found
#### Skill Discovery & Autocomplete
- [x] claude_list_skills() reads ~/.claude/skills/ directory (directories with SKILL.md or standalone .md files)
- [x] Description extracted from first non-heading, non-empty line (max 120 chars)
- [x] claude_read_skill(path) reads full skill file content
- [x] New frontend adapter: v2/src/lib/adapters/claude-bridge.ts (ClaudeProfile, ClaudeSkill interfaces)
#### AgentPane Session Toolbar
- [x] Working directory input (cwdInput) — editable text field, replaces fixed cwd prop
- [x] Profile/account selector dropdown (shown when >1 profile available)
- [x] Selected profile's config_dir passed as claude_config_dir in AgentQueryOptions
- [x] Skill autocomplete menu: type `/` to trigger, arrow keys navigate, Tab/Enter select, Escape dismiss
- [x] expandSkillPrompt(): reads skill content via readSkill(), prepends to prompt with optional user args
#### Extended AgentQueryOptions (full stack passthrough)
- [x] New fields in Rust AgentQueryOptions struct: setting_sources, system_prompt, model, claude_config_dir, additional_directories
- [x] Sidecar QueryMessage interface updated with matching fields
- [x] Both sidecar runners (agent-runner.ts, agent-runner-deno.ts) pass new fields to SDK query()
- [x] CLAUDE_CONFIG_DIR injected into cleanEnv when claudeConfigDir provided (multi-account support)
- [x] settingSources defaults to ['user', 'project'] (loads CLAUDE.md and project settings)
- [x] Frontend AgentQueryOptions interface updated in agent-bridge.ts
### Session: 2026-03-07 (continued) — v3 Mission Control Planning
#### v3 Architecture Planning
- [x] Created docs/v3-task_plan.md — core concept, user requirements, architecture questions
- [x] Created docs/v3-findings.md — codebase reuse analysis (what to keep/replace/drop)
- [x] Created docs/v3-progress.md — v3-specific progress log
- [x] Launched 3 adversarial architecture agents (Architect, Devil's Advocate, UX+Perf Specialist)
- [x] Collect adversarial agent findings
- [x] Produce final architecture plan
- [x] Create v3 implementation phases
### Session: 2026-03-07 (continued) — v3 Mission Control MVP Implementation (Phases 1-5)
#### Phase 1: Data Model + Config
- [x] Created v2/src/lib/types/groups.ts (ProjectConfig, GroupConfig, GroupsFile interfaces)
- [x] Created v2/src-tauri/src/groups.rs (Rust structs + load/save groups.json + default_groups())
- [x] Added groups_load, groups_save Tauri commands to lib.rs
- [x] SQLite migrations in session.rs: project_id column, agent_messages table, project_agent_state table
- [x] Created v2/src/lib/adapters/groups-bridge.ts (IPC wrapper)
- [x] Created v2/src/lib/stores/workspace.svelte.ts (replaces layout.svelte.ts for v3, Svelte 5 runes)
- [x] Added --group CLI argument parsing in main.rs
- [x] 24 vitest tests for workspace store + 7 cargo tests for groups
#### Phase 2: Project Box Shell
- [x] Created 12 new Workspace components in v2/src/lib/components/Workspace/
- [x] GlobalTabBar, ProjectGrid, ProjectBox, ProjectHeader, CommandPalette, DocsTab, ContextTab, SettingsTab
- [x] Rewrote App.svelte (no sidebar, no TilingGrid — GlobalTabBar + tab content + StatusBar)
#### Phase 3: Claude Session Integration
- [x] Created ClaudeSession.svelte wrapping AgentPane per-project
#### Phase 4: Terminal Tabs
- [x] Created TerminalTabs.svelte with shell/SSH/agent tab types
#### Phase 5: Team Agents Panel
- [x] Created TeamAgentsPanel.svelte + AgentCard.svelte
#### Bug Fix
- [x] Fixed AgentPane Svelte 5 event modifier: on:click -> onclick
#### Verification
- All 138 vitest + 36 cargo tests pass, vite build succeeds
### Session: 2026-03-07 (continued) — v3 Phases 6-10 Completion
#### Phase 6: Session Continuity
- [x] Added persistSessionForProject() to agent-dispatcher (saves state + messages to SQLite on complete)
- [x] Added registerSessionProject() + sessionProjectMap for session->project persistence routing
- [x] ClaudeSession restoreMessagesFromRecords() restores cached messages on mount
- [x] Added getAgentSession() export to agents store
#### Phase 7: Workspace Teardown
- [x] Added clearAllAgentSessions() to agents store
- [x] switchGroup() calls clearAllAgentSessions() + resets terminal tabs
- [x] Updated workspace.test.ts with clearAllAgentSessions mock
#### Phase 10: Dead Component Removal + Polish
- [x] Deleted 7 dead v2 components (~1,836 lines): TilingGrid, PaneContainer, PaneHeader, SessionList, SshSessionList, SshDialog, SettingsDialog
- [x] Removed empty directories: Layout/, Sidebar/, Settings/, SSH/
- [x] Rewrote StatusBar for workspace store (group name, project count, "BTerminal v3")
- [x] Fixed subagent routing: project-scoped sessions skip layout pane (render in TeamAgentsPanel)
- [x] Updated v3-task_plan.md to mark all 10 phases complete
#### Verification
- All 138 vitest + 36 cargo tests pass, vite build succeeds
### Session: 2026-03-07 (continued) — Multi-Theme System
#### Theme System Generalization (7 Editor Themes)
- [x] Generalized CatppuccinFlavor to ThemeId union type (11 themes)
- [x] Added 7 editor themes: VSCode Dark+, Atom One Dark, Monokai, Dracula, Nord, Solarized Dark, GitHub Dark
- [x] Added ThemePalette, ThemeMeta, THEME_LIST types; deprecated old Catppuccin-only types
- [x] Theme store: getCurrentTheme()/setTheme() with deprecated wrappers for backwards compat
- [x] SettingsTab: optgroup-based theme selector, fixed input overflow with min-width:0
- [x] All themes map to same --ctp-* CSS vars — zero component changes needed
#### Verification
- All 138 vitest + 35 cargo tests pass
### Session: 2026-03-07 (continued) — Deep Dark Theme Group
#### 6 New Deep Dark Themes
- [x] Added Tokyo Night, Gruvbox Dark, Ayu Dark, Poimandres, Vesper, Midnight to themes.ts
- [x] Extended ThemeId from 11 to 17 values, THEME_LIST from 11 to 17 entries
- [x] New "Deep Dark" theme group (3rd group alongside Catppuccin and Editor)
- [x] Midnight is pure OLED black (#000000), Ayu Dark near-black (#0b0e14), Vesper warm dark (#101010)
- [x] All 6 themes map to same 26 --ctp-* CSS vars — zero component changes needed
### Session: 2026-03-07 (continued) — Custom Theme Dropdown
#### SettingsTab Theme Picker Redesign
- [x] Replaced native `<select>` with custom themed dropdown in SettingsTab.svelte
- [x] Trigger: color swatch (base) + label + arrow; menu: grouped sections with styled headers
- [x] Options show color swatch + label + 4 accent dots (red/green/blue/yellow) via getPalette()
- [x] Click-outside and Escape to close; aria-haspopup/aria-expanded for a11y
- [x] Uses --ctp-* CSS vars — fully themed with active theme
### Session: 2026-03-07 (continued) — Global Font Controls
#### SettingsTab Font Controls + Layout Restructure
- [x] Added font family select (9 monospace fonts + Default) with live CSS var preview
- [x] Added font size +/- stepper (8-24px range) with live CSS var preview
- [x] Restructured global settings: 2-column grid layout with labels above controls (replaced inline rows)
- [x] Added --ui-font-family and --ui-font-size CSS custom properties to catppuccin.css
- [x] app.css body rule now uses CSS vars instead of hardcoded font values
- [x] initTheme() in theme.svelte.ts restores saved font settings on startup (try/catch, non-fatal)
- [x] Font settings persisted as 'font_family' and 'font_size' keys in SQLite settings table
### Session: 2026-03-07 (continued) — SettingsTab Global Settings Redesign
#### Font Settings Split (UI Font + Terminal Font)
- [x] Split single font into UI font (sans-serif: System Sans-Serif, Inter, Roboto, etc.) and Terminal font (monospace: JetBrains Mono, Fira Code, etc.)
- [x] Each font dropdown renders preview text in its own typeface
- [x] Independent size steppers (8-24px) for UI and Terminal font
- [x] Setting keys changed: font_family/font_size -> ui_font_family/ui_font_size + term_font_family/term_font_size
#### SettingsTab Layout + CSS Updates
- [x] Rewrote global settings: single-column layout, "Appearance" + "Defaults" subsections
- [x] All dropdowns are custom themed (no native `<select>` anywhere)
- [x] Added --term-font-family and --term-font-size CSS vars to catppuccin.css
- [x] Updated initTheme() to restore 4 font settings instead of 2
### Session: 2026-03-08 — CSS Relative Units Rule
- [x] Created `.claude/rules/18-relative-units.md` — enforces rem/em for layout CSS (px only for icons/borders)
- [x] Converted GlobalTabBar.svelte styles from px to rem (rail width, button size, gap, padding, border-radius)
- [x] Converted App.svelte sidebar header styles from px to rem (padding, close button, border-radius)
- [x] Changed GlobalTabBar rail-btn color from --ctp-overlay1 to --ctp-subtext0
### Session: 2026-03-09 — AgentPane Collapsibles, Aspect Ratio, Desktop Integration
#### AgentPane Collapsible Messages
- [x] Text messages (`msg.type === 'text'`) wrapped in `<details open>` — open by default, collapsible
- [x] Cost summary (`cost.result`) wrapped in `<details>` — collapsed by default, expandable
- [x] CSS: `.msg-text-collapsible` and `.msg-summary-collapsible` with preview text
#### Project Max Aspect Ratio Setting
- [x] New `project_max_aspect` SQLite setting (float, default 1.0, range 0.33.0)
- [x] ProjectGrid: `max-width: calc(100vh * var(--project-max-aspect, 1))` on `.project-slot`
- [x] SettingsTab: stepper UI in Appearance section
- [x] App.svelte: restore on startup via getSetting()
#### Desktop Integration
- [x] install-v2.sh: added `StartupWMClass=bterminal` to .desktop template
- [x] GNOME auto-move extension compatible
#### No-Implicit-Push Rule
- [x] Created `.claude/rules/52-no-implicit-push.md` — never push unless explicitly asked
### Next Steps
- [ ] Present plan to user for review and decision
- [ ] Create feature branch
- [ ] Begin Phase 1: Project scaffolding
- [ ] Real-world relay testing (2 machines)
- [ ] TLS/certificate pinning for relay connections
- [ ] Test agent teams with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1

View file

@ -0,0 +1,62 @@
# Agent Provider Adapter — Findings
## Architecture Exploration (2026-03-11)
### Claude-Specific Coupling Severity Map
Full codebase exploration of 13+ files revealed coupling at 4 severity levels:
#### CRITICAL (hardcoded SDK, must abstract)
| File | Coupling | Impact |
|------|----------|--------|
| `sidecar/agent-runner.ts` | Imports `@anthropic-ai/claude-agent-sdk`, calls `query()`, hardcoded `findClaudeCli()` | Entire sidecar is Claude-only. Must become `claude-runner.ts`. Other providers get own runners. |
| `bterminal-core/src/sidecar.rs` | `AgentQueryOptions` struct has no `provider` field. `SidecarCommand` hardcodes `agent-runner.mjs` path. | Must add `provider: String` field. Runner selection must be provider-based. |
| `src/lib/adapters/sdk-messages.ts` | `parseMessage()` assumes Claude SDK JSON format (assistant/user/result types, subagent tool names like `dispatch_agent`) | Must become `claude-messages.ts`. Other providers get own parsers. Registry selects by provider. |
#### HIGH (TS mirror types, provider-specific commands)
| File | Coupling | Impact |
|------|----------|--------|
| `src/lib/adapters/agent-bridge.ts` | `AgentQueryOptions` interface mirrors Rust struct — no provider field. `queryAgent()` passes options directly. | Add `provider` field. Options shape stays generic (provider_config blob). |
| `src-tauri/src/lib.rs` | `claude_list_profiles`, `claude_list_skills`, `claude_read_skill` commands are Claude-specific. | Keep as-is — they're provider-specific commands, not generic agent commands. UI gates by capability. |
| `src/lib/adapters/claude-bridge.ts` | `listClaudeProfiles()`, `listClaudeSkills()` — provider-specific adapter. | Stays as `claude-bridge.ts`. Other providers get own bridges. Provider-bridge.ts for generic routing. |
#### MEDIUM (provider-aware routing, UI rendering)
| File | Coupling | Impact |
|------|----------|--------|
| `src/lib/agent-dispatcher.ts` | `handleAgentMessage()` calls `parseMessage()` (Claude-specific). Subagent tool names hardcoded (`dispatch_agent`). | Route through message adapter registry. Subagent detection becomes provider-capability. |
| `src/lib/components/Agent/AgentPane.svelte` | Profile selector, skill autocomplete, Claude-specific tool names in rendering logic. | Gate by `ProviderCapabilities`. No `if(provider==='claude')` — use `capabilities.hasProfiles`. |
| `src/lib/components/Workspace/ClaudeSession.svelte` | Name says "Claude" but logic is mostly generic (session management, prompt, AgentPane). | Rename to `AgentSession.svelte`. Add provider prop. |
#### LOW (mostly generic already)
| File | Coupling | Impact |
|------|----------|--------|
| `src/lib/stores/agents.svelte.ts` | AgentMessage type is already generic (text, tool_call, tool_result). No Claude-specific logic. | No changes needed. Common AgentMessage type stays. |
| `src/lib/stores/health.svelte.ts` | Tracks activity/cost/context per project. Provider-agnostic. | No changes needed. |
| `src/lib/stores/conflicts.svelte.ts` | File overlap detection. Provider-agnostic (operates on tool_call file paths). | No changes needed. |
| `bterminal-relay/` | Forwards AgentQueryOptions as-is. No provider logic. | No changes needed (will forward `provider` field transparently). |
### Key Design Insights
1. **Sidecar is the natural boundary**: Each provider needs its own JS runner because SDKs are incompatible (Claude Agent SDK vs Codex CLI vs Ollama REST). The Rust sidecar manager selects which runner to spawn based on `provider` field.
2. **Message format is the main divergence**: Claude SDK emits structured JSON (assistant/user/result with specific fields). Codex CLI has different output format. Ollama uses OpenAI-compatible streaming. Per-provider message adapters normalize to common AgentMessage.
3. **Settings are per-provider + per-project**: Global defaults (API keys, model preferences) are per-provider. Project-level setting is just "which provider to use" (with override for model). Current SettingsTab has room for a collapsible Providers section without needing tabs.
4. **Capability flags eliminate provider switches**: Instead of `if (provider === 'claude') showProfiles()`, use `if (capabilities.hasProfiles) showProfiles()`. This means adding a new provider only requires registering its capabilities — no UI code changes.
5. **env var stripping is provider-specific**: Claude needs CLAUDE* vars stripped (nesting detection). Codex may need CODEX* stripped. Ollama needs nothing stripped. This is part of provider config, not generic logic.
### Risk Assessment
| Risk | Likelihood | Mitigation |
|------|-----------|------------|
| Rename breaks imports across 20+ files | High | Do renames one-at-a-time with full grep verification. Run tests after each. |
| AgentQueryOptions Rust/TS mismatch | Medium | Add provider field to both simultaneously. Default to 'claude'. |
| Message parser regression | Medium | sdk-messages.ts has 25 tests. Copy tests to claude-messages.ts test file. All must pass. |
| Settings persistence migration | Low | New settings keys (provider defaults) — no migration needed, just new keys. |
| UI regression from capability gating | Medium | Start with Claude capabilities = all true. Verify AgentPane renders identically. |

View file

@ -0,0 +1,95 @@
# Agent Provider Adapter — Progress
## Session Log
### 2026-03-11 — Planning Phase
**Duration:** ~30 min
**What happened:**
1. Explored 13+ files across Rust backend, TypeScript bridges, Svelte UI, and JS sidecar to map Claude-specific coupling
2. Classified coupling into 4 severity levels (CRITICAL/HIGH/MEDIUM/LOW)
3. Ran /ultra-think for deep architectural analysis — evaluated 3 design options for sidecar routing, message adapters, and settings UI
4. Made 6 architecture decisions (PA-1 through PA-6)
5. Created 3-phase implementation plan (16 + 5 + 3 tasks)
6. Created planning files: task_plan.md, findings.md, progress.md
**Architecture decisions made:**
- PA-1: Per-provider sidecar binaries (not single multi-SDK bundle)
- PA-2: Generic provider_config blob in AgentQueryOptions
- PA-3: Per-provider message adapter files → common AgentMessage type
- PA-4: Provider selection per-project with global default
- PA-5: Capability flags drive UI rendering (not provider ID checks)
- PA-6: Providers section in SettingsTab scroll (not inner tabs)
**Status:** Planning complete. Ready for Phase 1 implementation.
### 2026-03-11 — Implementation (All 3 Phases)
**Duration:** ~60 min
**What happened:**
**Phase 1 — Core Abstraction Layer (16 tasks):**
1. Created provider types (ProviderId, ProviderCapabilities, ProviderMeta, ProviderSettings)
2. Created Svelte 5 rune-based provider registry (registry.svelte.ts)
3. Created Claude provider meta constant (claude.ts)
4. Renamed sdk-messages.ts → claude-messages.ts, updated 13+ import references
5. Created message adapter registry (message-adapters.ts) with per-provider routing
6. Updated Rust AgentQueryOptions with `provider` and `provider_config` fields (serde defaults for backward compat)
7. Updated agent-bridge.ts TypeScript options
8. Renamed agent-runner.ts → claude-runner.ts, rebuilt dist bundle
9. Added provider field to ProjectConfig (groups.ts)
10. Renamed ClaudeSession.svelte → AgentSession.svelte with provider awareness
11. Updated agent-dispatcher.ts with sessionProviderMap for provider-based message routing
12. Updated AgentPane.svelte with capability-driven rendering (hasProfiles, hasSkills, supportsResume gates)
13. Created provider-bridge.ts (generic adapter delegating to provider-specific bridges)
14. Registered CLAUDE_PROVIDER in App.svelte onMount
15. Updated all test mocks (dispatcher test: adaptMessage mock with provider param)
16. Verified: 202 vitest + 42 cargo tests pass
**Phase 2 — Settings UI (5 tasks):**
1. Added "Providers" section to SettingsTab with collapsible per-provider config panels
2. Each panel: enabled toggle, default model input, capabilities badge display
3. Added per-project provider dropdown in project cards (conditional on >1 provider)
4. Provider settings persisted as JSON blob via `provider_settings` settings key
5. AgentPane already capability-aware from Phase 1
**Phase 3 — Sidecar Routing (3 tasks):**
1. Refactored resolve_sidecar_command() → resolve_sidecar_for_provider(provider) — looks for `{provider}-runner.mjs`
2. query() validates provider runner exists before sending message
3. Extracted strip_provider_env_var() — strips CLAUDE*/CODEX*/OLLAMA* env vars (whitelists CLAUDE_CODE_EXPERIMENTAL_*)
**Status:** All 3 phases complete. 202 vitest + 42 cargo tests pass. Zero regression.
### 2026-03-11 — Provider Runners (Codex + Ollama)
**Duration:** ~45 min
**What happened:**
**Research:**
1. Researched OpenAI Codex CLI programmatic interface (SDK, NDJSON stream format, thread events, sandbox/approval modes, session resume)
2. Researched Ollama REST API (/api/chat, NDJSON streaming, tool calling, token counts, health check)
**Codex Provider (3 files):**
1. Created providers/codex.ts — ProviderMeta (gpt-5.4 default, hasSandbox=true, supportsResume=true, no profiles/skills/cost)
2. Created adapters/codex-messages.ts — adaptCodexMessage() maps ThreadEvents to AgentMessage[] (agent_message→text, reasoning→thinking, command_execution→Bash tool pair, file_change→Write/Edit/Bash per change, mcp_tool_call→server:tool, web_search→WebSearch, turn.completed→cost with tokens)
3. Created sidecar/codex-runner.ts — @openai/codex-sdk wrapper (dynamic import, graceful failure, sandbox/approval mapping, CODEX_API_KEY auth, session resume via thread ID)
**Ollama Provider (3 files):**
1. Created providers/ollama.ts — ProviderMeta (qwen3:8b default, hasModelSelection only, all other capabilities false)
2. Created adapters/ollama-messages.ts — adaptOllamaMessage() maps synthesized chunk events (text, thinking from Qwen3, done→cost with eval_duration/token counts, always $0)
3. Created sidecar/ollama-runner.ts — Direct HTTP to localhost:11434/api/chat (zero deps, health check, NDJSON stream parsing, configurable host/model/num_ctx/think)
**Registration + Build:**
1. Registered CODEX_PROVIDER + OLLAMA_PROVIDER in App.svelte onMount
2. Registered adaptCodexMessage + adaptOllamaMessage in message-adapters.ts
3. Updated build:sidecar script to build all 3 runners via esbuild
**Tests:**
- 19 new tests for codex-messages.ts (all event types)
- 11 new tests for ollama-messages.ts (all event types)
- 256 vitest + 42 cargo tests pass. Zero regression.
**Status:** Provider runners complete. Both providers infrastructure-ready (will work when CLI/server installed).

View file

@ -0,0 +1,134 @@
# Agent Provider Adapter — Task Plan
## Goal
Multi-provider agent support (Claude Code, Codex CLI, Ollama) via adapter pattern. Claude Code remains primary and fully functional. Zero regression.
## Architecture Decisions
| # | Date | Decision | Rationale |
|---|------|----------|-----------|
| PA-1 | 2026-03-11 | Per-provider sidecar binaries (not single multi-SDK bundle) | Independent testing, no bloat, clean separation. SidecarCommand already abstracts binary path. |
| PA-2 | 2026-03-11 | Generic provider_config blob in AgentQueryOptions (not discriminated union) | Rust passes through without parsing. TypeScript uses discriminated unions for compile-time safety. Minimal Rust changes. |
| PA-3 | 2026-03-11 | Per-provider message adapter files → common AgentMessage type | sdk-messages.ts becomes claude-messages.ts. Registry selects parser by provider. Store/UI unchanged. |
| PA-4 | 2026-03-11 | Provider selection per-project with global default | ProjectConfig.provider field (default: 'claude'). Matches real workflow. |
| PA-5 | 2026-03-11 | Capability flags drive UI rendering (not provider ID checks) | ProviderCapabilities interface. AgentPane checks hasProfiles/hasSkills/etc. No hardcoded if(provider==='claude'). |
| PA-6 | 2026-03-11 | Providers section in SettingsTab scroll (not inner tabs) | Current sections aren't long enough for tabs. Collapsible per-provider config panels. |
## Phases
### Phase 1: Core Abstraction Layer (no functional change)
**Goal:** Insert abstraction boundary. Claude remains the only registered provider. Zero user-visible change.
| # | Task | Files | Status |
|---|------|-------|--------|
| 1.1 | Create provider types | NEW: `src/lib/providers/types.ts` | done |
| 1.2 | Create provider registry | NEW: `src/lib/providers/registry.svelte.ts` | done |
| 1.3 | Create Claude provider meta | NEW: `src/lib/providers/claude.ts` | done |
| 1.4 | Rename sdk-messages.ts → claude-messages.ts | RENAME + update imports | done |
| 1.5 | Create message adapter registry | NEW: `src/lib/adapters/message-adapters.ts` | done |
| 1.6 | Update Rust AgentQueryOptions | MOD: `bterminal-core/src/sidecar.rs` | done |
| 1.7 | Update agent-bridge.ts options shape | MOD: `src/lib/adapters/agent-bridge.ts` | done |
| 1.8 | Rename agent-runner.ts → claude-runner.ts | RENAME + update build script | done |
| 1.9 | Add provider field to ProjectConfig | MOD: `src/lib/types/groups.ts` | done |
| 1.10 | Rename ClaudeSession.svelte → AgentSession.svelte | RENAME + update imports | done |
| 1.11 | Update agent-dispatcher provider routing | MOD: `src/lib/agent-dispatcher.ts` | done |
| 1.12 | Update AgentPane for capability-driven rendering | MOD: `src/lib/components/Agent/AgentPane.svelte` | done |
| 1.13 | Rename claude-bridge.ts → provider-bridge.ts | RENAME + genericize | done |
| 1.14 | Update Rust lib.rs commands | MOD: `src-tauri/src/lib.rs` | done |
| 1.15 | Update all tests | MOD: test files | done |
| 1.16 | Verify: 202 vitest + 42 cargo tests pass | — | done |
### Phase 2: Settings UI
| # | Task | Files | Status |
|---|------|-------|--------|
| 2.1 | Add Providers section to SettingsTab | MOD: `SettingsTab.svelte` | done |
| 2.2 | Per-provider collapsible config panels | MOD: `SettingsTab.svelte` | done |
| 2.3 | Per-project provider dropdown | MOD: `SettingsTab.svelte` | done |
| 2.4 | Persist provider settings | MOD: `settings-bridge.ts` | done |
| 2.5 | Provider-aware AgentPane | MOD: `AgentPane.svelte` | done |
### Phase 3: Sidecar Routing
| # | Task | Files | Status |
|---|------|-------|--------|
| 3.1 | SidecarManager provider-based runner selection | MOD: `bterminal-core/src/sidecar.rs` | done |
| 3.2 | Per-provider runner discovery | MOD: `bterminal-core/src/sidecar.rs` | done |
| 3.3 | Provider-specific env var stripping | MOD: `bterminal-core/src/sidecar.rs` | done |
## Type System
### ProviderQueryOptions (TypeScript → Rust → Sidecar)
```
Frontend (typed):
AgentQueryOptions {
provider: ProviderId // 'claude' | 'codex' | 'ollama'
session_id: string
prompt: string
model?: string
max_turns?: number
provider_config: Record<string, unknown> // provider-specific
}
↓ (Tauri invoke)
Rust (generic):
AgentQueryOptions {
provider: String
session_id: String
prompt: String
model: Option<String>
max_turns: Option<u32>
provider_config: serde_json::Value
}
↓ (stdin NDJSON)
Sidecar (provider-specific):
claude-runner.ts parses provider_config as ClaudeProviderConfig
codex-runner.ts parses provider_config as CodexProviderConfig
ollama-runner.ts parses provider_config as OllamaProviderConfig
```
### Message Flow (Sidecar → Frontend)
```
Sidecar stdout (NDJSON, provider-specific format)
Rust SidecarManager (pass-through, adds sessionId)
agent-dispatcher.ts
→ message-adapters.ts registry
→ claude-messages.ts (if provider=claude)
→ codex-messages.ts (if provider=codex, future)
→ ollama-messages.ts (if provider=ollama, future)
→ AgentMessage (common type)
agents.svelte.ts store (unchanged)
AgentPane.svelte (renders AgentMessage, capability-driven)
```
## File Inventory
### New Files (Phase 1)
- `v2/src/lib/providers/types.ts`
- `v2/src/lib/providers/registry.svelte.ts`
- `v2/src/lib/providers/claude.ts`
- `v2/src/lib/adapters/message-adapters.ts`
### Renamed Files (Phase 1)
- `sdk-messages.ts``claude-messages.ts`
- `agent-runner.ts``claude-runner.ts`
- `ClaudeSession.svelte``AgentSession.svelte`
- `claude-bridge.ts``provider-bridge.ts` (genericized)
### Modified Files (Phase 1)
- `bterminal-core/src/sidecar.rs` — AgentQueryOptions struct
- `src-tauri/src/lib.rs` — command handlers
- `src/lib/adapters/agent-bridge.ts` — options interface
- `src/lib/agent-dispatcher.ts` — provider routing
- `src/lib/components/Agent/AgentPane.svelte` — capability checks
- `src/lib/components/Workspace/ProjectBox.svelte` — import rename
- `src/lib/types/groups.ts` — ProjectConfig.provider field
- `package.json` — build:sidecar script path
- Test files — import path updates

View file

@ -3,7 +3,7 @@
## Goal
Redesign BTerminal from a GTK3 terminal emulator into a **multi-session Claude agent dashboard** optimized for 32:9 ultrawide (5120x1440). Simultaneous visibility of all active sessions, agent tree visualization, inline markdown rendering, maximum information density.
## Status: PLANNING (Rev 2 — post-adversarial review)
## Status: Phases 1-7 + Multi-Machine (A-D) + Profiles/Skills Complete — Rev 6
---
@ -74,12 +74,13 @@ The Agent SDK cannot run in Rust or the webview. Solution:
└─────────────────────────────────────────────────────┘
```
- Rust spawns Node.js child process on demand (when user starts an SDK agent session)
- Rust spawns Node.js/Deno child process on app launch (auto-start in setup, Deno-first)
- Communication: stdio with newline-delimited JSON (simple, no socket server)
- Node.js process runs a thin wrapper that calls `query()` and forwards messages
- Node.js/Deno process uses `@anthropic-ai/claude-agent-sdk` query() function which handles claude subprocess management internally
- SDK messages forwarded as-is via NDJSON — same format as CLI stream-json
- If sidecar crashes: detect via process exit, show error in UI, offer restart
- **Packaging:** Bundle the sidecar JS as a single file (esbuild bundle). Require Node.js 20+ as system dependency. Document in install.sh.
- **Future:** Could replace Node.js with Deno (single binary, no npm) for better packaging.
- **Packaging:** Bundle the sidecar JS + SDK as a single file (esbuild bundle, SDK included). Require Node.js 20+ as system dependency. Document in install.sh.
- **Unified bundle:** Single pre-built agent-runner.mjs works with both Deno and Node.js. SidecarCommand struct abstracts runtime. Deno preferred (faster startup). Falls back to Node.js.
### SDK Abstraction Layer
@ -103,10 +104,11 @@ When SDK changes its message format, only the adapter needs updating.
## Implementation Phases
See [phases.md](phases.md) for the full phased implementation plan (Phases 1-6).
See [phases.md](phases.md) for the full phased implementation plan.
- **MVP:** Phases 1-4 (scaffolding, terminal+layout, agent SDK, session mgmt+markdown)
- **Post-MVP:** Phases 5-6 (agent tree, polish, packaging)
- **Post-MVP:** Phases 5-7 (agent tree, polish, packaging, agent teams)
- **Multi-Machine:** Phases A-D (bterminal-core extraction, relay binary, RemoteManager, frontend)
---
@ -123,12 +125,43 @@ See [phases.md](phases.md) for the full phased implementation plan (Phases 1-6).
| SDK abstraction layer | SDK is 0.2.x, 127 versions in 5 months. Must insulate UI from wire format changes | 2026-03-05 |
| MVP = Phases 1-4 | Ship usable tool before tackling tree viz, packaging, polish | 2026-03-05 |
| Canvas addon (not WebGL) | WebKit2GTK has no WebGL. Explicit Canvas addon avoids silent fallback | 2026-03-05 |
| claude CLI over Agent SDK query() | SUPERSEDED — initially used `claude -p --output-format stream-json` to avoid SDK dep. CLI hangs with piped stdio (bug #6775). Migrated to `@anthropic-ai/claude-agent-sdk` query() which handles subprocess internally | 2026-03-06 |
| Agent SDK migration | Replaced raw CLI spawning with @anthropic-ai/claude-agent-sdk query(). SDK handles subprocess management, auth, nesting detection. Messages same format as stream-json so adapter unchanged. AbortController for session stop. | 2026-03-06 |
| `.svelte.ts` for rune stores | Svelte 5 `$state`/`$derived` runes require `.svelte.ts` extension (not `.ts`). Compiler silently passes `.ts` but runes fail at runtime. All store files must use `.svelte.ts`. | 2026-03-06 |
| SQLite settings table for app config | Key-value `settings` table in session.rs for persisting user preferences (shell, cwd, max panes). Simple and extensible without schema migrations. | 2026-03-06 |
| Toast notifications over persistent log | Ephemeral toasts (4s auto-dismiss, max 5) for agent events rather than a persistent notification log. Keeps UI clean; persistent logs can be added later if needed. | 2026-03-06 |
| Build-from-source installer over pre-built binaries | install-v2.sh checks deps and builds locally. Pre-built binaries via GitHub Actions CI (.deb + AppImage on v* tags). Auto-update deferred until signing key infrastructure is set up. | 2026-03-06 |
| ctx read-only access from Rust | Open ~/.claude-context/context.db with SQLITE_OPEN_READ_ONLY. Never write — ctx CLI owns the schema. Separate CtxDb struct in ctx.rs with Option<Connection> for graceful absence. | 2026-03-06 |
| SSH via PTY shell args | SSH sessions spawn TerminalPane with shell=/usr/bin/ssh and args=[-p, port, [-i, keyfile], user@host]. No special SSH library — PTY handles it natively. | 2026-03-06 |
| Catppuccin 4 flavors at runtime | CSS variables overridden at runtime. onThemeChange() callback registry in theme.svelte.ts allows open terminals to hot-swap themes. | 2026-03-06 |
| Detached pane via URL params | Pop-out windows use ?detached=1&type=terminal URL params. App.svelte conditionally renders single pane without sidebar/grid chrome. Simple, no IPC needed. | 2026-03-06 |
| Shiki over highlight.js | Shiki provides VS Code-grade syntax highlighting with Catppuccin theme. Lazy singleton pattern avoids repeated WASM init. 13 languages preloaded. | 2026-03-06 |
| Vitest for frontend tests | Vitest over Jest — zero-config with Vite, same transform pipeline, faster. Test config in vite.config.ts. | 2026-03-06 |
| Deno sidecar evaluation | Proof-of-concept agent-runner-deno.ts created. Deno compiles to single binary (better packaging). Same NDJSON protocol. Not yet integrated. | 2026-03-06 |
| Splitter overlays for pane resize | Fixed-position divs outside CSS Grid (avoids layout interference). Mouse drag updates customColumns/customRows state. Resets on preset change. | 2026-03-06 |
| Unified sidecar bundle | Single agent-runner.mjs works with both Deno and Node.js. resolve_sidecar_command() checks runtime availability upfront, prefers Deno (faster startup). Only .mjs bundled in tauri.conf.json resources. agent-runner-deno.ts removed from bundle. | 2026-03-07 |
| Session groups/folders | group_name column in sessions table with ALTER TABLE migration. Pane.group field in layout store. Collapsible group headers in sidebar. Right-click to set group. | 2026-03-06 |
| Auto-update signing key | Generated minisign keypair. Pubkey set in tauri.conf.json. Private key for TAURI_SIGNING_PRIVATE_KEY GitHub secret. | 2026-03-06 |
| Agent teams: frontend routing only | Subagent panes created by frontend dispatcher, not separate sidecar processes. Parent sidecar handles all messages; routing uses SDK's parentId field. Avoids process explosion for nested subagents. | 2026-03-06 |
| SUBAGENT_TOOL_NAMES detection | Detect subagent spawn by tool_call name ('Agent', 'Task', 'dispatch_agent'). Simple Set lookup, easily extensible. | 2026-03-06 |
| Cargo workspace at v2/ level | Extract bterminal-core shared crate for PtyManager + SidecarManager. Workspace members: src-tauri, bterminal-core, bterminal-relay. Enables code reuse between Tauri app and relay binary. | 2026-03-06 |
| EventSink trait for event abstraction | Generic trait (emit method) decouples PtyManager/SidecarManager from Tauri. TauriEventSink wraps AppHandle; relay uses WebSocket EventSink. | 2026-03-06 |
| bterminal-relay as standalone binary | Rust binary with WebSocket server for remote machine management. Token auth + rate limiting. Per-connection isolated managers. | 2026-03-06 |
| RemoteManager WebSocket client | Controller-side WebSocket client in remote.rs. Manages connections to multiple relays with heartbeat ping. 12 new Tauri commands for remote operations. | 2026-03-06 |
| Frontend remote routing via remoteMachineId | Pane.remoteMachineId field determines local vs remote. Bridge adapters route to appropriate Tauri commands transparently. | 2026-03-06 |
| Permission mode passthrough | AgentQueryOptions.permission_mode flows Rust -> sidecar -> SDK. Defaults to 'bypassPermissions', supports 'default'. Enables non-bypass agent sessions. | 2026-03-06 |
| Stop-on-close in TilingGrid, not AgentPane | Removed onDestroy stopAgent() from AgentPane (fired on layout remounts). Stop logic moved to TilingGrid onClose handler — only fires on explicit user close. | 2026-03-06 |
| Bundle SDK into sidecar | Removed --external flag from esbuild build:sidecar. SDK bundled into agent-runner.mjs — no runtime dependency on node_modules. | 2026-03-06 |
| pathToClaudeCodeExecutable | Auto-detect Claude CLI path at sidecar startup via findClaudeCli() (checks common paths + `which`). Pass to SDK query() options. Early error if CLI not found. | 2026-03-07 |
| Claude profiles (switcher-claude) | Read ~/.config/switcher/profiles/ for multi-account support. Profile selector in AgentPane toolbar when >1 profile. Selected profile's config_dir passed as CLAUDE_CONFIG_DIR to SDK env. | 2026-03-07 |
| Skill discovery & autocomplete | Read ~/.claude/skills/ for skill files. `/` prefix triggers autocomplete in prompt textarea. Skill content read and injected as prompt. | 2026-03-07 |
| Extended AgentQueryOptions | Added setting_sources, system_prompt, model, claude_config_dir, additional_directories to full stack (Rust struct -> sidecar JSON -> SDK options). settingSources defaults to ['user', 'project']. | 2026-03-07 |
## Open Questions
1. **Node.js or Deno for sidecar?** Node.js has the SDK package. Deno would be a single binary (better packaging) but needs SDK compatibility testing. → Start Node.js, evaluate Deno later.
2. **Multi-machine support?** Remote agents via WebSocket. Phase 7+ feature.
3. **Agent Teams integration?** Experimental Anthropic feature. Natural fit but adds complexity. Phase 7+.
1. **Node.js or Deno for sidecar?** Resolved: Single pre-built agent-runner.mjs runs on both Deno and Node.js. SidecarCommand struct in sidecar.rs abstracts the runtime choice. Deno preferred (faster startup). Falls back to Node.js. Both use `@anthropic-ai/claude-agent-sdk` query() bundled into the .mjs file.
2. **Multi-machine support?** Resolved: Implemented (Phases A-D complete). See [multi-machine.md](multi-machine.md) for architecture. bterminal-core crate extracted, bterminal-relay binary built, RemoteManager + frontend integration done. Reconnection with exponential backoff implemented. Remaining: real-world testing, TLS.
3. **Agent Teams integration?** Phase 7 — frontend routing implemented (subagent pane spawning, parent/child navigation). Needs real-world testing with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1.
4. **Electron escape hatch threshold?** If Canvas xterm.js proves >50ms latency on target system with 4 panes, switch to Electron. Benchmark in Phase 2.
## Error Handling Strategy
@ -155,4 +188,9 @@ See [phases.md](phases.md) for the full phased implementation plan (Phases 1-6).
## Errors Encountered
(none yet)
| Error | Cause | Fix | Date |
|---|---|---|---|
| Blank screen, "rune_outside_svelte" runtime error | Store files used `.ts` extension but contain Svelte 5 `$state`/`$derived` runes. Runes only work in `.svelte` and `.svelte.ts` files. Compiler silently passes but fails at runtime. | Renamed stores to `.svelte.ts`, updated all import paths to use `.svelte` suffix | 2026-03-06 |
| Agent sessions produce no output (silent hang) | Claude CLI v2.1.69 hangs when spawned via child_process.spawn() with piped stdio. Known bug: github.com/anthropics/claude-code/issues/6775 | Migrated sidecar from raw CLI spawning to `@anthropic-ai/claude-agent-sdk` query() function. SDK handles subprocess management internally. | 2026-03-06 |
| CLAUDE* env vars leak to sidecar | When BTerminal launched from Claude Code terminal, CLAUDE* env vars trigger nesting detection in sidecar | Dual-layer stripping: Rust SidecarManager uses env_clear()+envs(clean_env) before spawn (primary), JS runner strips via SDK env option (defense-in-depth) | 2026-03-07 |
| Running agents killed on pane remount | AgentPane.svelte onDestroy called stopAgent() on component unmount, including layout changes and remounts — not just explicit close. | Removed onDestroy from AgentPane. Moved stop-on-close to TilingGrid onClose handler which only fires on explicit user action. | 2026-03-06 |

62
docs/v3-findings.md Normal file
View file

@ -0,0 +1,62 @@
# BTerminal v3 — Research Findings
## Adversarial Review Results (2026-03-07)
### Agent: Architect (Advocate)
- Proposed full component tree, data model, 10-phase plan
- JSON config at `~/.config/bterminal/groups.json`
- Single shared sidecar (multiplexed sessions)
- ClaudeSession + TeamAgentsPanel split from AgentPane
- SQLite tables: agent_messages, project_agent_state
- MVP at Phase 5
### Agent: Devil's Advocate
- Found 12 issues, 4 critical:
1. xterm.js 4-instance ceiling (hard OOM wall)
2. Single sidecar SPOF
3. Layout store has no workspace concept
4. 384px per project unusable on 1920px
- Recommended: fix workspace concept, xterm budget, UI density, persistence before anything else
- Proposed suspend/resume ring buffer for terminals
- Proposed per-project sidecar pool (max 3) — deferred to v3.1
### Agent: UX + Performance Specialist
- Wireframes for 5120px (5 projects) and 1920px (3 projects)
- Adaptive project count: `Math.floor(width / 520)`
- xterm budget: lazy-init + scrollback serialization
- RAF batching for 5 concurrent streams
- <100ms workspace switch via serialize/unmount/remount
- Memory budget: ~225MB total (within WebKit2GTK limits)
- Team panel: inline >2560px, overlay <2560px
- Command palette: Ctrl+K, floating overlay, fuzzy search
## Codebase Reuse Analysis
### Survives (with modifications)
- TerminalPane.svelte — add suspend/resume lifecycle
- MarkdownPane.svelte — unchanged
- AgentTree.svelte — reused inside ClaudeSession
- ContextPane.svelte — extracted to workspace tab
- StatusBar.svelte — modified for per-project costs
- ToastContainer.svelte — unchanged
- agents.svelte.ts — add projectId field
- theme.svelte.ts — unchanged
- notifications.svelte.ts — unchanged
- All adapters (agent-bridge, pty-bridge, claude-bridge, sdk-messages, session-bridge, ctx-bridge, ssh-bridge)
- All Rust backend (sidecar, pty, session, ctx, watcher)
- highlight.ts, agent-tree.ts utils
### Replaced
- layout.svelte.ts → workspace.svelte.ts
- TilingGrid.svelte → ProjectGrid.svelte
- PaneContainer.svelte → ProjectBox.svelte
- SessionList.svelte → ProjectHeader + command palette
- SettingsDialog.svelte → SettingsTab.svelte
- AgentPane.svelte → ClaudeSession.svelte + TeamAgentsPanel.svelte
- App.svelte → full rewrite
### Dropped (v3.0)
- Detached pane mode (doesn't fit workspace model)
- Drag-resize splitters (project boxes have fixed internal layout)
- Layout presets (1-col, 2-col, etc.) — replaced by adaptive project count
- Remote machine integration (deferred to v3.1, elevated to project level)

885
docs/v3-progress.md Normal file
View file

@ -0,0 +1,885 @@
# BTerminal v3 — Progress Log
### Session: 2026-03-07 — Architecture Planning + MVP Implementation (Phases 1-5)
#### Phase: Adversarial Design Review
- [x] Launch 3 architecture agents (Architect, Devil's Advocate, UX+Performance Specialist)
- [x] Collect findings — 12 issues identified, all resolved
- [x] Produce final architecture plan in docs/v3-task_plan.md
- [x] Create 10-phase implementation plan
#### Phase 1: Data Model + Config
- [x] Created `v2/src/lib/types/groups.ts` — TypeScript interfaces (ProjectConfig, GroupConfig, GroupsFile)
- [x] Created `v2/src-tauri/src/groups.rs` — Rust structs + load/save groups.json
- [x] Added `groups_load`, `groups_save` Tauri commands to lib.rs
- [x] SQLite migrations in session.rs: project_id column, agent_messages table, project_agent_state table
- [x] Created `v2/src/lib/adapters/groups-bridge.ts` (IPC wrapper)
- [x] Created `v2/src/lib/stores/workspace.svelte.ts` (replaces layout.svelte.ts, Svelte 5 runes)
- [x] Added `--group` CLI argument parsing in main.rs
- [x] Wrote 24 vitest tests for workspace store (workspace.test.ts)
- [x] Wrote cargo tests for groups load/save/default
#### Phase 2: Project Box Shell
- [x] Created GlobalTabBar.svelte (Sessions | Docs | Context | Settings)
- [x] Created ProjectGrid.svelte (flex + scroll-snap container)
- [x] Created ProjectBox.svelte (CSS grid: header | session-area | terminal-area)
- [x] Created ProjectHeader.svelte (icon + name + status dot + accent color)
- [x] Rewrote App.svelte (GlobalTabBar + tab content + StatusBar, no sidebar/TilingGrid)
- [x] Created CommandPalette.svelte (Ctrl+K overlay with fuzzy search)
- [x] Created DocsTab.svelte (markdown file browser per project)
- [x] Created ContextTab.svelte (wrapper for ContextPane)
- [x] Created SettingsTab.svelte (per-project + global settings editor)
- [x] CSS for responsive project count + Catppuccin accent colors
#### Phase 3: Claude Session Integration
- [x] Created ClaudeSession.svelte (wraps AgentPane, passes project cwd/profile/config_dir)
#### Phase 4: Terminal Tabs
- [x] Created TerminalTabs.svelte (tab bar + content, shell/SSH/agent tab types)
#### Phase 5: Team Agents Panel
- [x] Created TeamAgentsPanel.svelte (right panel for subagents)
- [x] Created AgentCard.svelte (compact subagent view: status, messages, cost)
#### Bug Fix
- [x] Fixed AgentPane Svelte 5 event modifier syntax: `on:click` -> `onclick` (Svelte 5 requires lowercase event attributes)
#### Verification
- All 138 vitest tests pass (114 existing + 24 new workspace tests)
- All 36 cargo tests pass (29 existing + 7 new groups tests)
- Vite build succeeds
### Session: 2026-03-07 — Phases 6-10 Completion
#### Phase 6: Session Continuity
- [x] Added `persistSessionForProject()` to agent-dispatcher — saves agent state + messages to SQLite on session complete
- [x] Added `registerSessionProject()` — maps sessionId -> projectId for persistence routing
- [x] Added `sessionProjectMap` (Map<string, string>) in agent-dispatcher
- [x] Updated ClaudeSession.svelte: `restoreMessagesFromRecords()` restores cached messages into agent store on mount
- [x] ClaudeSession loads previous state via `loadProjectAgentState()`, restores session ID and messages
- [x] Added `getAgentSession()` export to agents store
#### Phase 7: Workspace Teardown on Group Switch
- [x] Added `clearAllAgentSessions()` to agents store (clears sessions array)
- [x] Updated `switchGroup()` in workspace store to call `clearAllAgentSessions()` + reset terminal tabs
- [x] Updated workspace.test.ts to mock `clearAllAgentSessions`
#### Phase 10: Dead Component Removal + Polish
- [x] Deleted `TilingGrid.svelte` (328 lines), `PaneContainer.svelte` (113 lines), `PaneHeader.svelte` (44 lines)
- [x] Deleted `SessionList.svelte` (374 lines), `SshSessionList.svelte` (263 lines), `SshDialog.svelte` (281 lines), `SettingsDialog.svelte` (433 lines)
- [x] Removed empty directories: Layout/, Sidebar/, Settings/, SSH/
- [x] Rewrote StatusBar.svelte for workspace store (group name, project count, agent count, "BTerminal v3" label)
- [x] Fixed subagent routing in agent-dispatcher: project-scoped sessions skip layout pane creation (subagents render in TeamAgentsPanel instead)
- [x] Updated v3-task_plan.md to mark all 10 phases complete
#### Verification
- All 138 vitest tests pass (including updated workspace tests with clearAllAgentSessions mock)
- All 36 cargo tests pass
- Vite build succeeds
- ~1,836 lines of dead code removed
### Session: 2026-03-07 — SettingsTab Global Settings + Cleanup
#### SettingsTab Global Settings Section
- [x] Added "Global" section to SettingsTab.svelte with three settings:
- Theme flavor dropdown (Catppuccin Latte/Frappe/Macchiato/Mocha) via `setFlavor()` from theme store
- Default shell text input (persisted via `setSetting('default_shell', ...)`)
- Default CWD text input (persisted via `setSetting('default_cwd', ...)`)
- [x] Global settings load on mount via `getSetting()` from settings-bridge
- [x] Added imports: `onMount`, `getSetting`/`setSetting`, `getCurrentFlavor`/`setFlavor`, `CatppuccinFlavor` type
#### A11y Fixes
- [x] Changed project field labels from `<div class="project-field"><label>` to wrapping `<label class="project-field"><span class="field-label">` pattern — proper label/input association
- [x] Global settings use `id`/`for` label association (e.g., `id="theme-flavor"`, `id="default-shell"`)
#### CSS Cleanup
- [x] Removed unused `.project-field label` selector (replaced by `.field-label`)
- [x] Simplified `.project-field input[type="text"], .project-field input:not([type])` to `.project-field input:not([type="checkbox"])`
#### Rust Cleanup (committed separately)
- [x] Removed dead `update_ssh_session()` method from session.rs and its test
- [x] Fixed stale TilingGrid comment in AgentPane.svelte
### Session: 2026-03-07 — Multi-Theme System (7 Editor Themes)
#### Theme System Generalization
- [x] Generalized `CatppuccinFlavor` type to `ThemeId` union type (11 values)
- [x] Added 7 new editor themes: VSCode Dark+, Atom One Dark, Monokai, Dracula, Nord, Solarized Dark, GitHub Dark
- [x] Added `ThemePalette` interface (26-color slots) — all themes map to same slots
- [x] Added `ThemeMeta` interface (id, label, group, isDark) for UI metadata
- [x] Added `THEME_LIST: ThemeMeta[]` with group metadata ('Catppuccin' or 'Editor')
- [x] Added `ALL_THEME_IDS: ThemeId[]` derived from THEME_LIST for validation
- [x] Deprecated `CatppuccinFlavor`, `CatppuccinPalette`, `FLAVOR_LABELS`, `ALL_FLAVORS` (kept as backwards compat aliases)
#### Theme Store Updates
- [x] `getCurrentTheme(): ThemeId` replaces `getCurrentFlavor()` as primary getter
- [x] `setTheme(theme: ThemeId)` replaces `setFlavor()` as primary setter
- [x] `initTheme()` validates saved theme against `ALL_THEME_IDS`
- [x] Deprecated `getCurrentFlavor()` and `setFlavor()` with delegation wrappers
#### SettingsTab Theme Selector
- [x] Theme dropdown uses `<optgroup>` per theme group (Catppuccin, Editor)
- [x] `themeGroups` derived from `THEME_LIST` using Map grouping
- [x] `handleThemeChange()` replaces direct `setFlavor()` call
- [x] Fixed input overflow in `.setting-row` with `min-width: 0`
#### Design Decision
All editor themes map to the same `--ctp-*` CSS custom property names (26 vars). This means every component works unchanged — no component-level theme awareness needed. Each theme provides its own mapping of colors to the 26 semantic slots.
#### Verification
- All 138 vitest + 35 cargo tests pass
### Session: 2026-03-07 — Deep Dark Theme Group (6 Themes)
#### New Theme Group: Deep Dark
- [x] Added 6 new "Deep Dark" themes to `v2/src/lib/styles/themes.ts`:
- Tokyo Night (base: #1a1b26)
- Gruvbox Dark (base: #1d2021)
- Ayu Dark (base: #0b0e14, near-black)
- Poimandres (base: #1b1e28)
- Vesper (base: #101010, warm dark)
- Midnight (base: #000000, pure OLED black)
- [x] Extended `ThemeId` union type from 11 to 17 values
- [x] Added `THEME_LIST` entries with `group: 'Deep Dark'`
- [x] Added all 6 palette definitions (26 colors each) mapped to --ctp-* slots
- [x] Total themes: 17 across 3 groups (Catppuccin 4, Editor 7, Deep Dark 6)
#### Verification
- No test changes needed — theme palettes are data-only, no logic changes
### Session: 2026-03-07 — Custom Theme Dropdown
#### SettingsTab Theme Picker Redesign
- [x] Replaced native `<select>` with custom themed dropdown in SettingsTab.svelte
- [x] Dropdown trigger shows color swatch (base color from getPalette()) + theme label + arrow indicator
- [x] Dropdown menu groups themes by category (Catppuccin/Editor/Deep Dark) with styled uppercase headers
- [x] Each option shows: color swatch + label + 4 accent color dots (red/green/blue/yellow)
- [x] Active theme highlighted with surface0 background + bold text
- [x] Click-outside handler and Escape key to close dropdown
- [x] Uses --ctp-* CSS vars throughout — fully themed with any active theme
- [x] Added `getPalette` import from themes.ts for live color rendering
- [x] Added aria-haspopup/aria-expanded attributes for accessibility
#### Verification
- No test changes needed — UI-only change, no logic changes
### Session: 2026-03-07 — Theme Dropdown CSS Polish
#### SettingsTab Dropdown Sizing Fix
- [x] Set `min-width: 180px` on `.theme-dropdown` container (was `min-width: 0`) to prevent trigger from collapsing
- [x] Set `min-width: 280px` on `.theme-options` dropdown menu (was `right: 0`) to ensure full theme names visible
- [x] Increased `max-height` from 320px to 400px on dropdown menu for better scrolling experience
- [x] Added `white-space: nowrap` on `.theme-option-label` (was `min-width: 0`) to prevent label text wrapping
#### Verification
- No test changes needed — CSS-only change
### Session: 2026-03-07 — Global Font Controls
#### SettingsTab Font Family + Font Size Controls
- [x] Added font family `<select>` with 9 monospace font options (JetBrains Mono, Fira Code, Cascadia Code, Source Code Pro, IBM Plex Mono, Hack, Inconsolata, Ubuntu Mono, monospace) + "Default" option
- [x] Added font size +/- stepper control with numeric input (range 8-24px)
- [x] Both controls apply live preview via CSS custom properties (`--ui-font-family`, `--ui-font-size`)
- [x] Both settings persisted to SQLite via settings-bridge (`font_family`, `font_size` keys)
- [x] `handleFontFamilyChange()` and `handleFontSizeChange()` functions with validation
#### SettingsTab Layout Restructure
- [x] Restructured global settings from inline `.setting-row` (label left, control right) to 2-column `.global-grid` with `.setting-field` (label above control)
- [x] Labels now uppercase, 0.7rem, subtext0 color — consistent compact labeling
- [x] All inputs/selects use consistent styling (surface0 bg, surface1 border, 4px radius)
#### CSS Typography Variables
- [x] Added `--ui-font-family` and `--ui-font-size` to catppuccin.css `:root` (defaults: JetBrains Mono fallback chain, 13px)
- [x] Updated `app.css` body rule to use CSS vars instead of hardcoded font values
#### Theme Store Font Restoration
- [x] Extended `initTheme()` in `theme.svelte.ts` to load and apply saved `font_family` and `font_size` settings on startup
- [x] Font restoration wrapped in try/catch — failures are non-fatal (CSS defaults apply)
#### Verification
- No test changes needed — UI/CSS-only changes, no logic changes
### Session: 2026-03-07 — Settings Drawer Conversion
#### Settings Tab to Drawer
- [x] Converted Settings from a full-page tab to a collapsible side drawer
- [x] GlobalTabBar now has 3 tabs (Sessions/Docs/Context) + gear icon toggle for settings drawer
- [x] App.svelte renders SettingsTab in an `<aside>` drawer (right side, 32em width, semi-transparent backdrop)
- [x] Drawer close: Escape key, click-outside (backdrop), close button (X icon)
- [x] Gear icon in GlobalTabBar highlights blue when drawer is open (active state)
- [x] GlobalTabBar accepts props: `settingsOpen`, `ontoggleSettings`
- [x] Removed 'settings' from WorkspaceTab union type (now 'sessions' | 'docs' | 'context')
- [x] Alt+1..3 for tabs (was Alt+1..4), Ctrl+, toggles drawer (was setActiveTab('settings'))
- [x] SettingsTab padding reduced (12px 16px), max-width removed, flex:1 for drawer context
#### Verification
- All 138 vitest tests pass
### Session: 2026-03-08 — VSCode-Style Sidebar Redesign
#### UI Layout Redesign (Top Tab Bar -> Left Sidebar)
- [x] Redesigned GlobalTabBar.svelte from horizontal tab bar to vertical icon rail (36px wide)
- 4 SVG icon buttons: Sessions (grid), Docs (document), Context (clock), Settings (gear)
- Each button uses SVG path from `icons` record mapped by WorkspaceTab
- Props renamed: `settingsOpen` -> `expanded`, `ontoggleSettings` -> `ontoggle`
- `handleTabClick()` manages toggle: clicking active tab collapses drawer
- [x] Rewrote App.svelte layout from vertical (top tab bar + content area + settings drawer) to horizontal (icon rail + sidebar panel + workspace)
- `.main-row` flex container: GlobalTabBar | sidebar-panel (28em, max 50%) | workspace
- ProjectGrid always visible in main workspace (not inside tab content)
- Sidebar panel renders active tab content (Sessions/Docs/Context/Settings)
- Panel header with title + close button
- Removed backdrop overlay, drawer is inline sidebar not overlay
- [x] Re-added 'settings' to WorkspaceTab union type (was removed when settings was a drawer)
- [x] SettingsTab CSS: changed `flex: 1` to `height: 100%` for sidebar panel context
- [x] Updated keyboard shortcuts:
- Alt+1..4 (was Alt+1..3): switch tabs + open drawer, toggle if same tab
- Ctrl+B (new): toggle sidebar open/closed
- Ctrl+, : open settings panel (toggle if already active)
- Escape: close drawer
- [x] State variables renamed: `settingsOpen` -> `drawerOpen`, `toggleSettings()` -> `toggleDrawer()`
- [x] Added `panelTitles` record for drawer header labels
#### Design Decisions
- VSCode-style sidebar chosen for: always-visible workspace, progressive disclosure, familiar UX
- Settings as regular tab (not special drawer) simplifies code and mental model
- Icon rail at 36px minimizes horizontal space cost
- No backdrop overlay — sidebar is inline, not modal
#### Verification
- All 138 vitest tests pass
- svelte-check clean (only 2 third-party esrap warnings)
### Session: 2026-03-07 — SettingsTab Global Settings Redesign
#### Font Settings Split (UI Font + Terminal Font)
- [x] Split single font setting into UI font (sans-serif options) and Terminal font (monospace options)
- [x] UI font dropdown: System Sans-Serif, Inter, Roboto, Open Sans, Lato, Noto Sans, Source Sans 3, IBM Plex Sans, Ubuntu + Default
- [x] Terminal font dropdown: JetBrains Mono, Fira Code, Cascadia Code, Source Code Pro, IBM Plex Mono, Hack, Inconsolata, Ubuntu Mono, monospace + Default
- [x] Each font dropdown renders preview text in its own typeface
- [x] Size steppers (8-24px) for both UI and Terminal font independently
- [x] Changed setting keys: font_family -> ui_font_family, font_size -> ui_font_size, + new term_font_family, term_font_size
#### SettingsTab Layout Redesign
- [x] Rewrote global settings as single-column layout with labels above controls
- [x] Split into "Appearance" subsection (theme, UI font, terminal font) and "Defaults" subsection (shell, CWD)
- [x] All dropdowns now use reusable custom themed dropdowns (no native `<select>` anywhere)
#### CSS + Theme Store Updates
- [x] Added `--term-font-family` and `--term-font-size` CSS custom properties to catppuccin.css
- [x] Updated `initTheme()` in theme.svelte.ts: loads 4 font settings (ui_font_family, ui_font_size, term_font_family, term_font_size) instead of 2
- [x] UI font fallback changed from monospace to sans-serif
#### Verification
- No test changes needed — UI/CSS-only changes, no logic changes
### Session: 2026-03-08 — CSS Relative Units Rule
#### New Rule: 18-relative-units.md
- [x] Created `.claude/rules/18-relative-units.md` enforcing rem/em for layout CSS
- [x] Pixels allowed only for icon sizes, borders/outlines, box shadows
- [x] Exception: --ui-font-size/--term-font-size CSS vars store px (xterm.js API requirement)
- [x] Added rule #18 to `.claude/CLAUDE.md` rule index
#### CSS Conversions
- [x] GlobalTabBar.svelte: rail width 36px -> 2.75rem, button 28px -> 2rem, gap 2px -> 0.25rem, padding 6px 4px -> 0.5rem 0.375rem, border-radius 4px -> 0.375rem
- [x] App.svelte: sidebar header padding 8px 12px -> 0.5rem 0.75rem, close button 22px -> 1.375rem, border-radius 4px -> 0.25rem
- [x] Also changed GlobalTabBar rail-btn color from --ctp-overlay1 to --ctp-subtext0 for better contrast
### Session: 2026-03-08 — Content-Driven Sidebar Width
#### Sidebar Panel Sizing
- [x] Changed `.sidebar-panel` from fixed `width: 28em` to `width: max-content` with `min-width: 16em` and `max-width: 50%`
- [x] Changed `.sidebar-panel` and `.panel-content` from `overflow: hidden` to `overflow-y: auto` — hidden was blocking content from driving parent width
- [x] Each tab component now defines its own `min-width: 22em` (SettingsTab, ContextTab, DocsTab)
#### Additional px → rem Conversions
- [x] SettingsTab.svelte: padding 12px 16px → 0.75rem 1rem
- [x] DocsTab.svelte: file-picker 220px → 14em, picker-title padding → rem, file-btn padding → rem, empty/loading padding → rem
- [x] ContextPane.svelte: font-size, padding, margin, gap converted from px to rem; added `white-space: nowrap` on `.ctx-header`/`.ctx-error` for intrinsic width measurement
#### Fix: Sidebar Drawer Content-Driven Width
- [x] Root cause found: `#app` in `app.css` had leftover v2 grid layout (`display: grid; grid-template-columns: var(--sidebar-width) 1fr`) constraining `.app-shell` to 260px first column
- [x] Removed v2 grid + both media queries from `#app` — v3 `.app-shell` manages its own flexbox layout
- [x] Added JS `$effect` in App.svelte: measures content width via `requestAnimationFrame` + `querySelectorAll` for nowrap elements, headings, inputs, tab-specific selectors; `panelWidth` state drives inline `style:width`
- [x] Verified all 4 tabs scale to content: Sessions ~473px, Settings ~322px, Context ~580px, Docs varies by content
- [x] Investigation path: CSS intrinsic sizing (max-content, fit-content) failed due to column-flex circular dependency → JS measurement approach → discovered inline style set but rendered width wrong → Playwright inspection revealed parent `.main-row` only 260px → traced to `#app` grid layout
### Session: 2026-03-08 — Native Directory Picker
#### tauri-plugin-dialog Integration
- [x] Added `tauri-plugin-dialog` Rust crate + `@tauri-apps/plugin-dialog` npm package
- [x] Registered plugin in lib.rs (`tauri_plugin_dialog::init()`)
- [x] Removed stub `pick_directory` Tauri command (always returned None)
- [x] Added `browseDirectory()` helper in SettingsTab.svelte using `open({ directory: true })`
- [x] Added folder browse button (folder SVG icon) to: Default CWD, existing project CWD, Add Project path
- [x] Styled `.input-with-browse` layout (flex row, themed browse button)
- [x] Fixed nested input theme: `.setting-field .input-with-browse input` selector for dark background
- [x] Fixed dialog not opening: added `"dialog:default"` permission to `v2/src-tauri/capabilities/default.json` — Tauri IPC security blocked invoke() without capability
- [x] Verified via Playwright: error was `Cannot read properties of undefined (reading 'invoke')` in browser context (expected — Tauri IPC only exists in WebView), confirming code is correct
- [x] Clean rebuild required after capability changes (cached binary doesn't pick up new permissions)
#### Modal + Dark-Themed Dialog
- [x] Root cause: `tauri-plugin-dialog` skips `set_parent(&window)` on Linux via `cfg(any(windows, target_os = "macos"))` gate in commands.rs — dialog not modal
- [x] Root cause: native GTK file chooser uses system GTK theme, not app's CSS theme — dialog appears light
- [x] Fix: custom `pick_directory` Tauri command using `rfd::AsyncFileDialog` directly with `.set_parent(&window)` — modal on Linux
- [x] Fix: `std::env::set_var("GTK_THEME", "Adwaita:dark")` at start of `run()` in lib.rs — dark-themed dialog
- [x] Added `rfd = { version = "0.16", default-features = false, features = ["gtk3"] }` as direct dep — MUST disable defaults to avoid gtk3+xdg-portal feature conflict
- [x] Switched SettingsTab from `@tauri-apps/plugin-dialog` `open()` to `invoke<string | null>('pick_directory')`
### Session: 2026-03-08 — Project Workspace Layout Redesign + Icon Fix
#### Icon Fix
- [x] Replaced Nerd Font codepoints (`\uf120`) with emoji (`📁` default) — Nerd Font not installed, showed "?"
- [x] Added emoji picker grid (24 project-relevant emoji, 8-column popup) in SettingsTab instead of plain text input
- [x] Removed `font-family: 'NerdFontsSymbols Nerd Font'` from ProjectHeader and TerminalTabs
#### ProjectBox Layout Redesign
- [x] Switched ProjectBox from flex to CSS grid (`grid-template-rows: auto 1fr auto`) — header | session | terminal zones
- [x] Terminal area: explicit `height: 16rem` instead of collapsing to content
- [x] Session area: `min-height: 0` for proper flex child overflow
#### AgentPane Prompt Layout
- [x] Prompt area anchored to bottom (`justify-content: flex-end`) instead of vertical center
- [x] Removed `max-width: 600px` constraint on form and toolbar — uses full panel width
- [x] Toolbar sits directly above textarea
#### CSS px → rem Conversions
- [x] ProjectGrid.svelte: gap 4px → 0.25rem, padding 4px → 0.25rem, min-width 480px → 30rem
- [x] TerminalTabs.svelte: tab bar, tabs, close/add buttons all converted to rem
- [x] ProjectBox.svelte: min-width 480px → 30rem
### Session: 2026-03-08 — Project-Level Tabs + Clean AgentPane
#### ProjectHeader Info Bar
- [x] Added CWD path display (ellipsized from START via `direction: rtl` + `text-overflow: ellipsis`)
- [x] Added profile name as info-only text (right side of header)
- [x] Home dir shortening: `/home/user/foo``~/foo`
#### Project-Level Tab Bar
- [x] Added tab bar in ProjectBox below header: Claude | Files | Context
- [x] Content area switches between ClaudeSession, ProjectFiles, ContextPane based on selected tab
- [x] CSS grid updated to 4 rows: `auto auto 1fr auto` (header | tabs | content | terminal)
- [x] TeamAgentsPanel still renders alongside ClaudeSession in Claude tab
#### ProjectFiles Component (NEW)
- [x] Created `ProjectFiles.svelte` — project-scoped markdown file viewer
- [x] Accepts `cwd` + `projectName` props (not workspace store)
- [x] File picker sidebar (10rem) + MarkdownPane content area
- [x] Auto-selects priority file or first file
#### AgentPane Cleanup
- [x] Removed entire session toolbar (DIR/ACC interactive inputs + all CSS)
- [x] Added `profile` prop — resolved via `listProfiles()` to get config_dir
- [x] CWD passed as prop from parent (project.cwd), no longer editable in pane
- [x] Clean chat interface: prompt (bottom-anchored) + messages + send button
- [x] ClaudeSession now passes `project.profile` to AgentPane
#### Verification
- All 138 vitest tests pass
- Vite build succeeds
### Session: 2026-03-08 — Security Audit Fixes + OTEL Telemetry
#### Security Audit Fixes
- [x] Fixed all CRITICAL (5) + HIGH (4) findings — path traversal, race conditions, memory leaks, listener leaks, transaction safety
- [x] Fixed all MEDIUM (6) findings — runtime type guards, ANTHROPIC_* env stripping, timestamp mismatch, async lock, error propagation
- [x] Fixed all LOW (8) findings — input validation, mutex poisoning, log warnings, payload validation
- [x] 3 false positives dismissed with rationale
- [x] 172/172 tests pass (138 vitest + 34 cargo)
#### OTEL Telemetry Implementation
- [x] Added 6 Rust deps: tracing, tracing-subscriber, opentelemetry 0.28, opentelemetry_sdk 0.28, opentelemetry-otlp 0.28, tracing-opentelemetry 0.29
- [x] Created `v2/src-tauri/src/telemetry.rs` — TelemetryGuard, layer composition, OTLP export via BTERMINAL_OTLP_ENDPOINT env var
- [x] Integrated into lib.rs: TelemetryGuard in AppState, init before Tauri builder
- [x] Instrumented 10 Tauri commands with `#[tracing::instrument]`: pty_spawn, pty_kill, agent_query/stop/restart, remote_connect/disconnect/agent_query/agent_stop/pty_spawn
- [x] Added `frontend_log` Tauri command for frontend→Rust tracing bridge
- [x] Created `v2/src/lib/adapters/telemetry-bridge.ts``tel.info/warn/error/debug/trace()` convenience API
- [x] Wired agent dispatcher lifecycle events: agent_started, agent_stopped, agent_error, sidecar_crashed, cost metrics
- [x] Created Docker compose stack: `docker/tempo/` — Tempo (4317/4318/3200) + Grafana (port 9715)
### Session: 2026-03-08 — Teardown Race Fix + px→rem Conversion
#### Workspace Teardown Race Fix
- [x] Added `pendingPersistCount` counter + `waitForPendingPersistence()` export in agent-dispatcher.ts
- [x] `persistSessionForProject()` increments/decrements counter in try/finally
- [x] `switchGroup()` in workspace.svelte.ts now awaits `waitForPendingPersistence()` before clearing state
- [x] SettingsTab.svelte switchGroup onclick handler made async with await
- [x] Added test for `waitForPendingPersistence` in agent-dispatcher.test.ts
- [x] Added mock for `waitForPendingPersistence` in workspace.test.ts
- [x] Last open HIGH audit finding resolved (workspace teardown race)
#### px→rem Conversion (Rule 18 Compliance)
- [x] Converted ~100 px layout violations to rem across 10 components
- [x] AgentPane.svelte (~35 violations: font-size, padding, gap, margin, max-height, border-radius)
- [x] ToastContainer.svelte, CommandPalette.svelte, TeamAgentsPanel.svelte, AgentCard.svelte
- [x] StatusBar.svelte, AgentTree.svelte, TerminalPane.svelte, AgentPreviewPane.svelte, SettingsTab.svelte
- [x] Icon/decorative dot dimensions kept as px per rule 18
- [x] 139 vitest + 34 cargo tests pass, vite build succeeds
### Session: 2026-03-08 — E2E Testing Infrastructure
#### WebdriverIO + tauri-driver Setup
- [x] Installed @wdio/cli, @wdio/local-runner, @wdio/mocha-framework, @wdio/spec-reporter (v9.24.0)
- [x] Created wdio.conf.js with tauri-driver lifecycle hooks (onPrepare builds debug binary, beforeSession/afterSession spawns/kills tauri-driver)
- [x] Created tsconfig.json for e2e test TypeScript compilation
- [x] Created smoke.test.ts with 6 tests: app title, status bar, version text, sidebar rail, workspace area, sidebar toggle
- [x] Added `test:e2e` npm script (`wdio run tests/e2e/wdio.conf.js`)
- [x] Updated README.md with complete setup instructions and CI guide
- [x] Key decision: WebdriverIO over Playwright (Playwright cannot control Tauri/WebKit2GTK apps)
- [x] Prerequisites: tauri-driver (cargo install), webkit2gtk-driver (apt), display server or xvfb-run
#### E2E Fixes (wdio v9 + tauri-driver compatibility)
- [x] Fixed wdio v9 BiDi: added `wdio:enforceWebDriverClassic: true` — wdio v9 injects webSocketUrl:true which tauri-driver rejects
- [x] Removed `browserName: 'wry'` from capabilities (not needed in wdio, only Selenium)
- [x] Fixed binary path: Cargo workspace target is v2/target/debug/, not v2/src-tauri/target/debug/
- [x] Fixed tauri-plugin-log panic: telemetry::init() registers tracing-subscriber before plugin-log → removed tauri-plugin-log entirely (redundant with telemetry::init())
- [x] Removed tauri-plugin-log from Cargo.toml dependency
#### E2E Coverage Expansion (25 tests, single spec file)
- [x] Consolidated 4 spec files into single bterminal.test.ts — Tauri creates one app session per spec file; after first spec completes, app closes and subsequent specs get "invalid session id"
- [x] Added Workspace & Projects tests (8): project grid, project boxes, header with name, 3 project tabs, active highlight, tab switching, status bar counts
- [x] Added Settings Panel tests (6): settings tab, sections, theme dropdown, dropdown open+options, group list, close button
- [x] Added Keyboard Shortcuts tests (5): Ctrl+K command palette, Ctrl+, settings, Ctrl+B sidebar, Escape close, palette group list
- [x] Fixed WebDriver clicks on Svelte 5 components: `element.click()` doesn't reliably trigger onclick inside complex components via WebKit2GTK/tauri-driver — use `browser.execute()` for JS-level clicks
- [x] Fixed CSS text-transform: `.ptab` getText() returns uppercase — use `.toLowerCase()` for comparison
- [x] Fixed element scoping: `browser.$('.ptab')` returns ALL tabs across project boxes — scope via `box.$('.ptab')`
- [x] Fixed keyboard focus: `browser.execute(() => document.body.focus())` before sending shortcuts
- [x] Removed old individual spec files (smoke.test.ts, keyboard.test.ts, settings.test.ts, workspace.test.ts)
- [x] All 25 E2E tests pass (9s runtime after build)
### Session: 2026-03-10 — Tab System Overhaul
#### Tab Renames + New Tabs
- [x] Renamed Claude → Model, Files → Docs in ProjectBox
- [x] Added 3 new tabs: Files (directory browser), SSH (connection manager), Memory (knowledge explorer)
- [x] Implemented PERSISTED-EAGER (Model/Docs/Context — display:flex/none) vs PERSISTED-LAZY (Files/SSH/Memory — {#if everActivated} + display:flex/none) mount strategy
- [x] Tab type union: 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories'
#### Files Tab (FilesTab.svelte)
- [x] VSCode-style tree sidebar (14rem) + content viewer
- [x] Rust list_directory_children command: lazy expansion, hidden files skipped, dirs-first sort
- [x] Rust read_file_content command: FileContent tagged union (Text/Binary/TooLarge), 10MB gate, 30+ language mappings
- [x] Frontend files-bridge.ts adapter (DirEntry, FileContent types)
- [x] Shiki syntax highlighting for code files, image display via convertFileSrc, emoji file icons
#### SSH Tab (SshTab.svelte)
- [x] CRUD panel for SSH connections using existing ssh-bridge.ts/SshSession model
- [x] Launch button spawns terminal tab in Model tab's TerminalTabs section via addTerminalTab()
#### Memory Tab (MemoriesTab.svelte)
- [x] Pluggable MemoryAdapter interface (memory-adapter.ts): name, available, list(), search(), get()
- [x] Adapter registry: registerMemoryAdapter(), getDefaultAdapter(), getAvailableAdapters()
- [x] UI: search bar, tag display, expandable cards, adapter switcher, placeholder when no adapter
#### Context Tab Repurpose (ContextTab.svelte)
- [x] Replaced ContextPane (ctx database viewer) with LLM context window visualization
- [x] Tribunal debate for design (S-1-R4 winner at 82% confidence)
- [x] Stats bar: input/output tokens, cost, turns, duration
- [x] Segmented token meter: CSS flex bar with color-coded categories (assistant/thinking/tool calls/tool results)
- [x] File references: extracted from tool_call messages, colored op badges
- [x] Turn breakdown: collapsible message groups by user prompt
- [x] Token estimation via ~4 chars/token heuristic
- [x] Wired into ProjectBox (replaces ContextPane, passes sessionId)
- [x] Sub-tab navigation: Overview | AST | Graph
- [x] AST tab: per-turn SVG conversation trees (Thinking/Response/ToolCall/File nodes, bezier edges, token counts)
- [x] Graph tab: bipartite tool→file DAG (tools left, files right, curved edges, count badges)
- [x] Compaction detection: sdk-messages.ts adapts `compact_boundary` system messages → `CompactionContent` type
- [x] Stats bar compaction pill: yellow count badge with tooltip (last trigger, tokens removed)
- [x] AST compaction boundaries: red "Compacted" nodes inserted between turns at compaction points
#### FilesTab Fixes & CodeMirror Editor
- [x] Fixed HTML nesting error: `<button>` inside `<button>``<div role="tab">`
- [x] Fixed Svelte 5 $state proxy reactivity: look up tab from reactive array before setting content
- [x] CodeEditor.svelte: CodeMirror 6 with 15 lazy-loaded language modes, Catppuccin theme
- [x] Dirty tracking, Ctrl+S save, save-on-blur setting (files_save_on_blur in SettingsTab)
- [x] write_file_content Rust command (safety: existing files only)
#### Project Health Dashboard (S-3 — Mission Control)
- [x] health.svelte.ts store: per-project ActivityState (running/idle/stalled), burn rate ($/hr EMA), context pressure (% of model limit), attention scoring
- [x] StatusBar → Mission Control bar: running/idle/stalled counts, $/hr burn rate, "needs attention" priority queue dropdown
- [x] ProjectHeader health indicators: status dot (color-coded), context pressure badge, burn rate badge
- [x] session_metrics SQLite table: per-project historical metrics (100-row retention)
- [x] Rust commands: session_metric_save, session_metrics_load
- [x] TypeScript bridge: SessionMetric interface, saveSessionMetric(), loadSessionMetrics()
- [x] agent-dispatcher wiring: recordActivity, recordToolDone, recordTokenSnapshot, sessionStartTimes, metric persistence on completion
- [x] ClaudeSession: trackProject() on session create/restore
- [x] App.svelte: startHealthTick()/stopHealthTick() lifecycle
- [x] workspace.svelte.ts: clearHealthTracking() on group switch
#### Verification
- [x] svelte-check: 0 new errors (only pre-existing esrap type errors)
- [x] vitest: 139/139 tests pass
- [x] cargo test: 34/34 pass
### Session: 2026-03-11 — S-1 Phase 1.5: Conflict Detection Enhancements
#### Bash Write Detection
- [x] BASH_WRITE_PATTERNS regex array in tool-files.ts: >, >>, sed -i, tee [-a], cp dest, mv dest, chmod/chown
- [x] extractBashWritePaths() helper with /dev/null and flag-target filtering
- [x] Write detection prioritized over read detection for ambiguous commands (cat file > out)
- [x] extractWritePaths() now captures Bash writes alongside Write/Edit
#### Acknowledge/Dismiss Conflicts
- [x] acknowledgeConflicts(projectId) API in conflicts.svelte.ts — marks current conflicts as acknowledged
- [x] acknowledgedFiles Map state — suppresses badge until new session writes to acknowledged file
- [x] ProjectHeader conflict badge → clickable button with ✕ (stopPropagation, hover darkens)
- [x] Ack auto-cleared when new session writes to previously-acknowledged file
#### Worktree-Aware Conflict Suppression
- [x] sessionWorktrees Map in conflicts store — tracks worktree path per session (null = main tree)
- [x] setSessionWorktree(sessionId, path) API
- [x] areInDifferentWorktrees() / hasRealConflict() — suppresses conflicts between sessions in different worktrees
- [x] extractWorktreePath(tc) in tool-files.ts — detects Agent/Task isolation:"worktree" and EnterWorktree
- [x] agent-dispatcher.ts wiring: registers worktree paths from tool_call events
- [x] useWorktrees?: boolean field on ProjectConfig (groups.ts) for future per-project setting
#### Verification
- [x] vitest: 194/194 tests pass (+24 new: 5 extractWorktreePath, 10 bash write, 9 acknowledge/worktree)
- [x] cargo test: 34/34 pass
### Session: 2026-03-11 — S-1 Phase 2: Filesystem Write Detection
#### Rust Backend — ProjectFsWatcher
- [x] New module `v2/src-tauri/src/fs_watcher.rs` — per-project recursive inotify watchers via notify crate v6
- [x] Debouncing (100ms per-file), ignored dirs (.git/, node_modules/, target/, etc.)
- [x] Emits `fs-write-detected` Tauri events with FsWritePayload { project_id, file_path, timestamp_ms }
- [x] Two Tauri commands: `fs_watch_project`, `fs_unwatch_project`
- [x] ProjectFsWatcher added to AppState, initialized in setup()
- [x] 5 Rust unit tests for path filtering (should_ignore_path)
#### Frontend Bridge
- [x] New `v2/src/lib/adapters/fs-watcher-bridge.ts` — fsWatchProject(), fsUnwatchProject(), onFsWriteDetected()
#### External Write Detection (conflicts store)
- [x] EXTERNAL_SESSION_ID = '__external__' sentinel for non-agent writers
- [x] agentWriteTimestamps Map — tracks when agents write files (for timing heuristic)
- [x] recordExternalWrite(projectId, filePath, timestampMs) — 2s grace window suppresses agent's own writes
- [x] getExternalConflictCount(projectId) — counts external-only conflicts
- [x] FileConflict.isExternal flag, ProjectConflicts.externalConflictCount field
- [x] clearAllConflicts/clearProjectConflicts clear timestamp state
#### Health Store Integration
- [x] externalConflictCount added to ProjectHealth interface
- [x] Attention reason includes "(N external)" note when external conflicts present
#### UI Updates
- [x] ProjectBox $effect: starts/stops fs watcher per project CWD, listens for events, calls recordExternalWrite
- [x] ProjectHeader: split conflict badge into orange "ext write" badge + red "agent conflict" badge
- [x] Toast notification on new external write conflict
#### Verification
- [x] vitest: 202/202 tests pass (+8 new external write tests)
- [x] cargo test: 39/39 pass (+5 new fs_watcher tests)
### Session: 2026-03-11 — Files Tab: PDF Viewer + CSV Table View
#### PDF Viewer
- [x] Added pdfjs-dist@5.5.207 dependency (WebKit2GTK has no built-in PDF viewer)
- [x] Created PdfViewer.svelte — canvas-based multi-page renderer
- [x] Zoom controls (0.5x3x, 25% steps), HiDPI-aware (devicePixelRatio scaling)
- [x] Reads PDF via convertFileSrc() → pdfjs (no new Rust commands needed)
- [x] Page shadow, themed toolbar, error handling
#### CSV Table View
- [x] Created CsvTable.svelte — RFC 4180 CSV parser (no external dependency)
- [x] Auto-detects delimiter (comma, semicolon, tab)
- [x] Sortable columns (numeric-aware), sticky header, row numbers
- [x] Row hover, text truncation at 20rem, themed via --ctp-* vars
#### FilesTab Routing
- [x] Binary+pdf → PdfViewer (via isPdfExt check)
- [x] Text+csv → CsvTable (via isCsvLang check)
- [x] Updated file icons: 📕 PDF, 📊 CSV
- [x] Both viewers are read-only
#### Verification
- [x] vitest: 202/202 tests pass (no regressions)
- [x] Vite build: clean
- [x] cargo check: clean
### Session: 2026-03-11 — S-2 Session Anchors
#### Implementation
- [x] Created types/anchors.ts — AnchorType, SessionAnchor, AnchorSettings, budget constants
- [x] Created adapters/anchors-bridge.ts — 5 Tauri IPC functions (save, load, delete, clear, updateType)
- [x] Created stores/anchors.svelte.ts — Svelte 5 rune store (per-project anchor management)
- [x] Created utils/anchor-serializer.ts — observation masking, turn grouping, token estimation
- [x] Created utils/anchor-serializer.test.ts — 17 tests (4 describe blocks)
- [x] Added session_anchors SQLite table + SessionAnchorRecord struct + 5 CRUD methods (session.rs)
- [x] Added 5 Tauri commands for anchor persistence (lib.rs)
- [x] Auto-anchor logic in agent-dispatcher.ts on first compaction event per project
- [x] Re-injection in AgentPane.startQuery() via system_prompt field
- [x] Pin button on AgentPane text messages
- [x] Anchor section in ContextTab: budget meter, promote/demote, remove
#### Verification
- [x] vitest: 219/219 tests pass (+17 new anchor tests)
- [x] cargo test: 42/42 pass (+3 new session_anchors tests)
### Session: 2026-03-11 — Configurable Anchor Budget + Truncation Fix
#### Research-backed truncation fix
- [x] Removed 500-char assistant text truncation in anchor-serializer.ts
- [x] Research consensus (JetBrains NeurIPS 2025, SWE-agent, OpenDev ACC): reasoning must never be truncated, only tool outputs get masked
#### Configurable anchor budget scale
- [x] Added AnchorBudgetScale type ('small'|'medium'|'large'|'full') with preset map (2K/6K/12K/20K)
- [x] Added anchorBudgetScale? field to ProjectConfig (persisted in groups.json)
- [x] Updated getAnchorSettings() to resolve budget from scale
- [x] Added 4-stop range slider to SettingsTab per-project settings
- [x] Updated ContextTab to derive budget from anchorBudgetScale prop
- [x] Updated agent-dispatcher to look up project's budget scale
#### Cleanup
- [x] Removed Ollama-specific warning toast from AgentPane (budget slider handles generically)
- [x] Removed unused notify import from AgentPane
#### Verification
- [x] vitest: 219/219 tests pass (no regressions)
- [x] cargo test: 42/42 pass (no regressions)
### Session: 2026-03-11 — S-1 Phase 3: Worktree Isolation Per Project
#### UI toggle
- [x] Added 'Worktree Isolation' checkbox to SettingsTab per-project card (card-field-row CSS layout)
- [x] ProjectConfig.useWorktrees? already existed — wired to toggle
#### Spawn with worktree flag
- [x] Added worktree_name: Option<String> to AgentQueryOptions (Rust sidecar.rs)
- [x] Added worktree_name?: string to TS AgentQueryOptions (agent-bridge.ts)
- [x] Sidecar JSON passes worktreeName field to claude-runner.ts
- [x] claude-runner.ts passes extraArgs: { worktree: name } to SDK query() (maps to --worktree CLI flag)
- [x] AgentPane: added useWorktrees prop, passes worktree_name=sessionId when enabled
- [x] AgentSession: passes useWorktrees={project.useWorktrees} to AgentPane
- [x] Rebuilt sidecar bundle (claude-runner.mjs)
#### CWD-based worktree detection
- [x] Added detectWorktreeFromCwd() to agent-dispatcher.ts (matches .claude/.codex/.cursor worktree patterns)
- [x] Init event handler now calls setSessionWorktree() when CWD contains worktree path
- [x] Dual detection: CWD-based (primary) + tool_call-based extractWorktreePath (subagent fallback)
#### Tests
- [x] Added 7 new tests to agent-dispatcher.test.ts (detectWorktreeFromCwd unit tests + init CWD integration)
- [x] vitest: 226/226 tests pass
- [x] cargo test: 42/42 pass
### Session: 2026-03-11 — Provider Runners (Codex + Ollama)
#### Codex Provider
- [x] providers/codex.ts — ProviderMeta (gpt-5.4, hasSandbox, supportsResume)
- [x] adapters/codex-messages.ts — adaptCodexMessage (ThreadEvents → AgentMessage[])
- [x] sidecar/codex-runner.ts — @openai/codex-sdk wrapper (dynamic import, graceful failure)
- [x] adapters/codex-messages.test.ts — 19 tests
#### Ollama Provider
- [x] providers/ollama.ts — ProviderMeta (qwen3:8b, modelSelection only)
- [x] adapters/ollama-messages.ts — adaptOllamaMessage (streaming chunks → AgentMessage[])
- [x] sidecar/ollama-runner.ts — Direct HTTP to localhost:11434 (zero deps)
- [x] adapters/ollama-messages.test.ts — 11 tests
#### Registration + Build
- [x] App.svelte: register CODEX_PROVIDER + OLLAMA_PROVIDER
- [x] message-adapters.ts: register codex + ollama adapters
- [x] package.json: build:sidecar builds all 3 runners
- [x] vitest: 256/256 tests pass
- [x] cargo test: 42/42 pass
### 2026-03-11 — Register Memora Adapter
**Duration:** ~15 min
**What happened:**
Registered a concrete MemoraAdapter that bridges the MemoryAdapter interface to the Memora SQLite database. Direct read-only SQLite access (no MCP/CLI dependency at runtime).
#### Rust Backend
- [x] memora.rs — MemoraDb struct (read-only SQLite, Option<Connection>, graceful absence)
- [x] list() with tag filtering via json_each() + IN clause
- [x] search() via FTS5 MATCH on memories_fts, optional tag join
- [x] get() by ID
- [x] 4 Tauri commands: memora_available, memora_list, memora_search, memora_get
- [x] 7 cargo tests (missing-db error paths)
#### TypeScript Bridge + Adapter
- [x] memora-bridge.ts — IPC wrappers + MemoraAdapter class implementing MemoryAdapter
- [x] App.svelte — registers MemoraAdapter on mount with async availability check
- [x] memora-bridge.test.ts — 16 tests (IPC + adapter)
#### Results
- [x] vitest: 272/272 tests pass
- [x] cargo test: 49/49 pass
### 2026-03-11 — Configurable Stall Threshold
**Duration:** ~10 min
**What happened:**
Made the hardcoded 15-minute stall threshold configurable per-project via a range slider in SettingsTab (560 min, step 5).
#### Changes
- [x] groups.ts — Added `stallThresholdMin?: number` to ProjectConfig
- [x] health.svelte.ts — Replaced hardcoded constant with per-project `stallThresholds` Map + `setStallThreshold()` API, fallback to DEFAULT_STALL_THRESHOLD_MS (15 min)
- [x] SettingsTab.svelte — Range slider per project card (560 min, step 5, default 15)
- [x] ProjectBox.svelte — `$effect` syncs `project.stallThresholdMin``setStallThreshold()` on mount/change
#### Results
- [x] No test changes — UI/config wiring only
- [x] vitest: 272/272 tests pass
- [x] cargo test: 49/49 pass
### 2026-03-11 — Nemesis Security Audit + Reconnect Loop Fix
**Duration:** ~15 min
**What happened:**
Ran nemezis-audit on Rust backend. 0 verified exploitable findings, 10 recon targets identified (all previously known from 2026-03-08 security audit). Fixed Priority 8 reconnect loop race condition.
#### Nemesis Audit
- [x] Ran nemezis orchestrator on v2/src-tauri (Rust backend, 496s, $0.57)
- [x] 0 verified findings, 10 attack surface targets in recon hit list
- [x] All targets match previous 2026-03-08 security audit — no new vulnerabilities
#### Reconnect Loop Fix
- [x] remote.rs — Added `cancelled: Arc<AtomicBool>` to RemoteMachine struct
- [x] remove_machine() and disconnect() set cancelled=true before aborting tasks
- [x] connect() resets cancelled=false for new connections
- [x] Reconnect loop checks flag at top of each iteration, exits immediately when set
#### Results
- [x] cargo check: clean
- [x] cargo test: 49/49 pass
### Session 2026-03-11 (SOLID Phase 1 Refactoring)
#### SOLID Analysis
- [x] Ran /solid on full v2 codebase (TypeScript + Rust)
- [x] Identified 3 critical, 5 high, 5 medium issues; 8 good-practice modules
#### Phase 1 Refactoring — High-impact, Low-risk
- [x] **AttentionScorer extraction**: scoreAttention() pure function from health.svelte.ts → utils/attention-scorer.ts (14 tests)
- [x] **Shared type guards**: str()/num() from 3 adapter copies → utils/type-guards.ts
- [x] **lib.rs command module split**: 976 → 170 lines, 48 commands → 11 domain modules under commands/
- [x] Skipped withRemoteSupport() HOF — parameter shapes differ, 3-line duplication doesn't justify abstraction
#### Results
- [x] vitest: 286/286 pass (14 new attention-scorer tests)
- [x] cargo check: clean
- [x] cargo test: 49/49 pass
### Session 2026-03-11 (SOLID Phase 2 Refactoring)
#### agent-dispatcher.ts Split (496→260 lines)
- [x] Extracted utils/worktree-detection.ts — detectWorktreeFromCwd() pure function (17 lines, 5 tests)
- [x] Extracted utils/session-persistence.ts — session maps + persistSessionForProject (107 lines)
- [x] Extracted utils/auto-anchoring.ts — triggerAutoAnchor (48 lines)
- [x] Extracted utils/subagent-router.ts — spawnSubagentPane + SUBAGENT_TOOL_NAMES (73 lines)
- [x] Dispatcher is now thin coordinator with re-exports for backward compat
#### session.rs Split (1,008 lines → 7 sub-modules)
- [x] session/mod.rs — SessionDb struct + open() + migrate() + re-exports (153 lines)
- [x] session/sessions.rs — Session CRUD (9 tests)
- [x] session/layout.rs — LayoutState save/load (3 tests)
- [x] session/settings.rs — Settings CRUD (5 tests)
- [x] session/ssh.rs — SshSession CRUD (4 tests)
- [x] session/agents.rs — AgentMessageRecord + ProjectAgentState
- [x] session/metrics.rs — SessionMetric save/load
- [x] session/anchors.rs — SessionAnchorRecord CRUD
- [x] conn field: pub(in crate::session) for sub-module access
#### Results
- [x] vitest: 286/286 pass (5 worktree tests moved to new file)
- [x] cargo check: clean
- [x] cargo test: 49/49 pass
### Session 2026-03-11 (SOLID Phase 3 — Branded Types)
#### Implementation
- [x] Introduced SessionId/ProjectId branded types (types/ids.ts)
- [x] Applied branded types to conflicts.svelte.ts and health.svelte.ts Map keys
- [x] Branded sessionId at sidecar boundary in agent-dispatcher
- [x] Applied branded types at Svelte component call sites
#### Results
- [x] cargo check: clean
- [x] vitest: 286/286 pass
### Session 2026-03-11 — Multi-Agent Orchestration System
#### btmsg Group Agent Messenger CLI
- [x] Created btmsg CLI tool — inter-agent messaging (inbox, send, reply, contacts, history, channels)
- [x] btmsg graph command — visual agent hierarchy with status
- [x] Admin role (tier 0), channel messaging (create/list/send/history), mark-read, global feed
#### btmsg Rust Backend + Tauri Bridge
- [x] Created btmsg.rs module — SQLite-backed messaging (shared DB: ~/.local/share/bterminal/btmsg.db)
- [x] 8+ Tauri commands: btmsg_inbox, btmsg_send, btmsg_read, btmsg_contacts, btmsg_feed, btmsg_channels, etc.
- [x] CommsTab: sidebar chat interface with activity feed, DMs, channels (Ctrl+M)
#### Agent Unification (Tier 1 → ProjectBoxes)
- [x] agentToProject() converter in groups.ts — Tier 1 agents rendered as full ProjectBoxes
- [x] getAllWorkItems() in workspace store combines agents + projects for ProjectGrid
- [x] GroupAgentsPanel: click-to-navigate agent cards to their ProjectBox
#### Agent System Prompts
- [x] Created utils/agent-prompts.ts — generateAgentPrompt() builds comprehensive introductory context
- Sections: Identity, Environment, Team hierarchy, btmsg docs, bttask docs, Custom context, Workflow
- Role-specific workflows (Manager: check inbox → review board → coordinate; Architect: code review focus; Tester: write/run tests)
- [x] AgentSession builds system prompt: Tier 1 gets full generated prompt, Tier 2 gets custom context
#### BTMSG_AGENT_ID Environment Passthrough (5-layer chain)
- [x] agent-bridge.ts: added extra_env?: Record<string,string> to AgentQueryOptions
- [x] bterminal-core/sidecar.rs: added extra_env: HashMap<String,String> with #[serde(default)]
- [x] claude-runner.ts: extraEnv merged into cleanEnv after provider var stripping
- [x] codex-runner.ts: same extraEnv pattern
- [x] AgentSession injects { BTMSG_AGENT_ID: project.id } for agent projects
#### Tier 1 Agent Config in SettingsTab
- [x] Agent cards: icon + name + role badge + enable toggle + CWD + model + wake interval (manager)
- [x] Custom Context textarea per agent (appended to auto-generated prompt)
- [x] Collapsible preview of full generated introductory prompt
- [x] updateAgent() function in workspace store for Tier 1 config persistence
#### Custom Context for Tier 2 Projects
- [x] Custom Context textarea in SettingsTab project cards
- [x] Stored as project.systemPrompt, passed through AgentSession → AgentPane
#### Periodic System Prompt Re-injection
- [x] AgentSession: 1-hour timer (REINJECTION_INTERVAL_MS = 3,600,000ms)
- [x] Checks every 60s if elapsed > 1 hour, sets contextRefreshPrompt
- [x] AgentPane: autoPrompt prop consumed only when agent is idle (done/error state)
- [x] Different refresh messages: Tier 1 (check inbox + task board), Tier 2 (review instructions)
#### bttask Kanban Backend + UI
- [x] Created bttask.rs — Task/TaskComment structs, 6 operations (list, comments, update_status, create, delete, add_comment)
- [x] Created commands/bttask.rs — 6 Tauri commands registered in lib.rs
- [x] Created bttask-bridge.ts — TypeScript IPC adapter
- [x] Created TaskBoardTab.svelte — Kanban board with 5 columns (todo/progress/review/done/blocked)
- Task creation form (title, description, priority)
- Expandable task detail with status actions, comments, delete
- 5-second polling
- Pending count badge
#### ArchitectureTab (PlantUML Diagrams)
- [x] Created ArchitectureTab.svelte — PlantUML diagram viewer/editor
- Sidebar with diagram list + new diagram form (4 templates: Class, Sequence, State, Component)
- PlantUML source editor + SVG preview via plantuml.com server (~h hex encoding)
- Stores .puml files in .architecture/ directory
- Read/write via files-bridge.ts
#### TestingTab (Selenium + Automated Tests)
- [x] Created TestingTab.svelte — dual-mode component
- Selenium mode: screenshot gallery (.selenium/screenshots/), session log viewer, 3s polling
- Tests mode: discovers test files in standard dirs (tests/, test/, spec/, __tests__/, e2e/), file content viewer
#### Role-Specific Tabs in ProjectBox
- [x] Extended ProjectTab type: added 'tasks' | 'architecture' | 'selenium' | 'tests'
- [x] Conditional tab buttons: Manager→Tasks, Architect→Arch, Tester→Selenium+Tests
- [x] PERSISTED-LAZY rendering via {#if everActivated[tab]} pattern
- [x] .ptab-role CSS class (mauve accent color for agent-specific tabs)
#### Bug Fix
- [x] Fixed FileContent type case: 'text' → 'Text' in ArchitectureTab and TestingTab (files-bridge uses capital T)
#### Verification
- [x] cargo check: clean (bttask module + commands)
- [x] svelte-check: 0 project errors
- [x] Sidecar rebuilt with extraEnv support

348
docs/v3-task_plan.md Normal file
View file

@ -0,0 +1,348 @@
# BTerminal v3 — Mission Control Redesign
## Goal
Transform BTerminal from a multi-pane terminal/agent tool into a **multi-project mission control** — a helm for managing multiple development projects simultaneously, each with its own Claude agent session, team agents, terminals, and settings.
## Status: All Phases Complete (1-10) — Rev 3 (Sidebar Redesign)
---
## Core Concept
**Project Groups** are workspaces. Each group has up to 5 projects arranged horizontally. One group visible at a time. Projects have their own Claude subscription, working directory, icon, and settings. The app is a dashboard for orchestrating Claude agents across a portfolio of projects.
### Key Mental Model
```
BTerminal v2: Terminal emulator with agent sessions (panes in a grid)
BTerminal v3: Project orchestration dashboard (projects in a workspace)
```
### User Requirements
1. Projects arranged in **project groups** (many groups, switch between them)
2. Each group has **up to 5 projects** shown horizontally
3. Group/project config via **main menu** (command palette / hidden drawer, Ctrl+K)
4. Per-project settings: Claude subscription, working dir, icon (nerd font), name, identifier, description, enabled
5. Project group = workspace on screen
6. Each project box: Claude session (default, resume previous) + team agents (right) + terminal tabs (below)
7. **VSCode-style left sidebar**: Vertical icon rail (Sessions/Docs/Context/Settings) + expandable drawer panel + always-visible workspace
8. App launchable with `--group <name>` CLI arg
9. JSON config file defines all groups (`~/.config/bterminal/groups.json`)
10. Session continuity: resume previous + restore history visually
11. SSH sessions: spawnable within a project's terminal tabs
12. ctx viewer: workspace tab #3
---
## Architecture (Post-Adversarial Review)
### Adversarial Review Summary
3 agents reviewed the architecture: Architect (advocate), Devil's Advocate (attacker), UX+Performance Specialist.
**12 issues identified by Devil's Advocate. Resolutions:**
| # | Issue | Severity | Resolution |
|---|---|---|---|
| 1 | xterm.js 4-instance ceiling (WebKit2GTK OOM) | Critical | Lazy-init + scrollback serialization. Budget: 4 active xterm, unlimited suspended (text buffer). Enforced in code. |
| 2 | Single sidecar = SPOF for all projects | Critical | Accept for v3.0 (existing crash recovery). Per-project pool deferred to v3.1 if needed. |
| 3 | Session identity collision (sdkSessionId not persisted) | Major | Persist sdkSessionId in SQLite `project_agent_state` table. Per-project CLAUDE_CONFIG_DIR isolation. |
| 4 | Layout store has no workspace concept | Critical | Full rewrite: `workspace.svelte.ts` replaces `layout.svelte.ts`. |
| 5 | 384px per project unusable on 1920px | Major | Adaptive: compute visible count from viewport width (`Math.floor(width / 520)`). 5@5120px, 3@1920px, scroll-snap for rest. min-width 480px. |
| 6 | JSON config + SQLite = split-brain | Major | JSON for groups/projects config (human-editable). SQLite for session state. JSON loaded at startup only, no hot-reload. |
| 7 | Agent dispatcher is global singleton, no project scoping | Major | Add projectId to AgentSession. Dispatcher routes by project. Per-project cleanup on workspace switch. |
| 8 | Markdown discovery undefined | Minor | Priority list: CLAUDE.md, README.md, docs/*.md (max 20). Rust command scans with depth limit. |
| 9 | Keyboard shortcut conflicts (3 layers) | Major | Shortcut manager: Terminal layer (focused only), Workspace layer (Ctrl+1-5), App layer (Ctrl+K, Ctrl+G). |
| 10 | Remote machine support orphaned | Major | Elevate to project level (project.remote_machine_id). Defer integration to v3.1. |
| 11 | No graceful degradation for broken projects | Major | Project health state: healthy/degraded/unavailable/error. Colored dot indicator. |
| 12 | Flat event stream wastes CPU for hidden projects | Minor | Buffer messages for inactive workspace projects. Flush on activation. |
---
## Data Model
### Project Group Config (`~/.config/bterminal/groups.json`)
```jsonc
{
"version": 1,
"groups": [
{
"id": "work-ai",
"name": "AI Projects",
"projects": [
{
"id": "bterminal",
"name": "BTerminal",
"identifier": "bterminal",
"description": "Terminal emulator with Claude integration",
"icon": "\uf120",
"cwd": "/home/hibryda/code/ai/BTerminal",
"profile": "default",
"enabled": true
}
]
}
],
"activeGroupId": "work-ai"
}
```
### TypeScript Types (`v2/src/lib/types/groups.ts`)
```typescript
export interface ProjectConfig {
id: string;
name: string;
identifier: string;
description: string;
icon: string;
cwd: string;
profile: string;
enabled: boolean;
}
export interface GroupConfig {
id: string;
name: string;
projects: ProjectConfig[]; // max 5
}
export interface GroupsFile {
version: number;
groups: GroupConfig[];
activeGroupId: string;
}
```
### SQLite Schema Additions
```sql
ALTER TABLE sessions ADD COLUMN project_id TEXT DEFAULT '';
CREATE TABLE IF NOT EXISTS agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
project_id TEXT NOT NULL,
sdk_session_id TEXT,
message_type TEXT NOT NULL,
content TEXT NOT NULL,
parent_id TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
CREATE INDEX idx_agent_messages_session ON agent_messages(session_id);
CREATE INDEX idx_agent_messages_project ON agent_messages(project_id);
CREATE TABLE IF NOT EXISTS project_agent_state (
project_id TEXT PRIMARY KEY,
last_session_id TEXT NOT NULL,
sdk_session_id TEXT,
status TEXT NOT NULL,
cost_usd REAL DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
last_prompt TEXT,
updated_at INTEGER NOT NULL
);
```
---
## Component Architecture
### Component Tree
```
App.svelte [REWRITTEN — VSCode-style sidebar]
├── CommandPalette.svelte [NEW]
├── GlobalTabBar.svelte [NEW] Vertical icon rail (36px, 4 SVG icons)
├── [Sidebar Panel] Expandable drawer (28em, max 50%)
│ ├── [Tab: Sessions] ProjectGrid [renders in sidebar when open]
│ ├── [Tab: Docs] DocsTab
│ ├── [Tab: Context] ContextPane
│ └── [Tab: Settings] SettingsTab
├── [Main Workspace] Always visible
│ └── ProjectGrid.svelte [NEW] Horizontal flex + scroll-snap
│ └── ProjectBox.svelte [NEW] Per-project container
│ ├── ProjectHeader.svelte [NEW] Icon + name + status dot
│ ├── ClaudeSession.svelte [NEW, from AgentPane] Main session
│ ├── TeamAgentsPanel.svelte [NEW] Right panel for subagents
│ │ └── AgentCard.svelte [NEW] Compact subagent view
│ └── TerminalTabs.svelte [NEW] Tabbed terminals
│ └── TerminalPane.svelte [SURVIVES]
├── StatusBar.svelte [MODIFIED]
└── ToastContainer.svelte [SURVIVES]
```
### What Dies
| v2 Component/Store | Reason |
|---|---|
| TilingGrid.svelte | Replaced by ProjectGrid |
| PaneContainer.svelte | Fixed project box structure |
| SessionList.svelte (sidebar) | No sidebar; project headers replace |
| SshSessionList.svelte | Absorbed into TerminalTabs |
| SettingsDialog.svelte | Replaced by SettingsTab |
| AgentPane.svelte | Split into ClaudeSession + TeamAgentsPanel |
| layout.svelte.ts | Replaced by workspace.svelte.ts |
| layout.test.ts | Replaced by workspace tests |
### What Survives
TerminalPane, MarkdownPane, AgentTree, ContextPane, StatusBar, ToastContainer, theme store, notifications store, agents store (modified), all adapters (agent-bridge, pty-bridge, claude-bridge, sdk-messages, session-bridge, ctx-bridge, ssh-bridge), all Rust backend (sidecar, pty, session, ctx, watcher), highlight utils.
---
## Layout System
### Project Grid (Flexbox + scroll-snap)
```css
.project-grid {
display: flex;
gap: 4px;
height: 100%;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.project-box {
flex: 0 0 calc((100% - (N-1) * 4px) / N);
scroll-snap-align: start;
min-width: 480px;
}
```
N computed from viewport: `Math.min(projects.length, Math.max(1, Math.floor(containerWidth / 520)))`
### Project Box Internal Layout
```
┌─ ProjectHeader (28px) ──────────────────┐
├─────────────────────┬───────────────────┤
│ ClaudeSession │ TeamAgentsPanel │
│ (flex: 1) │ (240px or overlay)│
├─────────────────────┴───────────────────┤
│ [Tab1] [Tab2] [+] TabBar 26px │
├─────────────────────────────────────────┤
│ Terminal content (xterm or scrollback) │
└─────────────────────────────────────────┘
```
Team panel: inline at >2560px, overlay at <2560px. Collapsed when no subagents.
### Responsive Breakpoints
| Width | Visible Projects | Team Panel |
|-------|-----------------|------------|
| 5120px+ | 5 | inline 240px |
| 3840px | 4 | inline 200px |
| 2560px | 3 | overlay |
| 1920px | 3 | overlay |
| <1600px | 1 + project tabs | overlay |
### xterm.js Budget: 4 Active Instances
| State | xterm? | Memory |
|-------|--------|--------|
| Active-Focused | Yes | ~20MB |
| Active-Background | Yes (if budget allows) | ~20MB |
| Suspended | No (HTML pre scrollback) | ~200KB |
| Uninitialized | No (placeholder) | 0 |
On focus: serialize least-recent xterm scrollback, destroy it, create new for focused tab, reconnect PTY.
### Project Accent Colors (Catppuccin)
| Slot | Color | Variable |
|------|-------|----------|
| 1 | Blue | --ctp-blue |
| 2 | Green | --ctp-green |
| 3 | Mauve | --ctp-mauve |
| 4 | Peach | --ctp-peach |
| 5 | Pink | --ctp-pink |
---
## Sidecar Strategy
**Single shared sidecar** (unchanged from v2). Per-project isolation via:
- `cwd` per query (already implemented)
- `claude_config_dir` per query (already implemented)
- `session_id` routing (already implemented)
No sidecar changes needed for v3.0.
---
## Keyboard Shortcuts
| Shortcut | Action | Layer |
|----------|--------|-------|
| Ctrl+K | Command palette | App |
| Ctrl+G | Switch group (palette filtered) | App |
| Ctrl+1..5 | Focus project by index | App |
| Alt+1..4 | Switch sidebar tab + open drawer | App |
| Ctrl+B | Toggle sidebar open/closed | App |
| Ctrl+, | Toggle settings panel | App |
| Escape | Close sidebar drawer | App |
| Ctrl+N | New terminal in focused project | Workspace |
| Ctrl+Shift+N | New agent query | Workspace |
| Ctrl+Tab | Next terminal tab | Project |
| Ctrl+W | Close terminal tab | Project |
| Ctrl+Shift+C/V | Copy/paste in terminal | Terminal |
---
## Implementation Phases
All 10 phases complete. Detailed checklists in [v3-progress.md](v3-progress.md).
| Phase | Scope | Status |
|-------|-------|--------|
| 1 | Data Model + Config (groups.rs, workspace store, SQLite migrations) | Complete |
| 2 | Project Box Shell (GlobalTabBar, ProjectGrid/Box/Header, App.svelte, sidebar redesign 2026-03-08) | Complete |
| 3 | Claude Session Integration (ClaudeSession.svelte wraps AgentPane) | Complete |
| 4 | Terminal Tabs (TerminalTabs.svelte, per-project tabbed terminals) | Complete |
| 5 | Team Agents Panel (TeamAgentsPanel, AgentCard) — **MVP boundary** | Complete |
| 6 | Session Continuity (persist/restore agent messages, sdkSessionId) | Complete |
| 7 | Command Palette + Group Switching (workspace teardown) | Complete |
| 8 | Docs Tab (DocsTab.svelte, markdown discovery) | Complete |
| 9 | Settings Tab (group/project CRUD, 5-project limit) | Complete |
| 10 | Polish + Cleanup (dead v2 components removed, StatusBar rewrite) | Complete |
---
## Decisions Log
| Decision | Rationale | Date |
|---|---|---|
| JSON for groups config, SQLite for session state | JSON is human-editable, shareable, version-controllable. SQLite for ephemeral runtime state. Load at startup only. | 2026-03-07 |
| Adaptive project count from viewport width | 5@5120px, 3@1920px, scroll-snap for overflow. min-width 480px. Better than forcing 5 at all sizes. | 2026-03-07 |
| Single shared sidecar (v3.0) | Existing multiplexed protocol handles concurrent sessions. Per-project pool deferred to v3.1 if crash isolation needed. Saves ~200MB RAM. | 2026-03-07 |
| xterm budget: 4 active, unlimited suspended | WebKit2GTK OOM at ~5 instances. Serialize scrollback to text buffer, destroy xterm, recreate on focus. PTY stays alive. | 2026-03-07 |
| Flexbox + scroll-snap over CSS Grid | Allows horizontal scroll on narrow screens. Scroll-snap gives clean project-to-project scrolling. | 2026-03-07 |
| Team panel: inline >2560px, overlay <2560px | Adapts to available space. Collapsed when no subagents running. | 2026-03-07 |
| VSCode-style left sidebar (replaces top tab bar + settings drawer) | Vertical icon rail (2.75rem, 4 SVG icons) + expandable drawer panel (28em, max 50%) + always-visible workspace. Settings is a regular tab, not special drawer. ProjectGrid always visible. Ctrl+B toggles sidebar. | 2026-03-08 |
| CSS relative units (rule 18) | Use rem/em for all layout CSS. Pixels only for icon sizes, borders, box shadows. Exception: --ui-font-size/--term-font-size store px for xterm.js API. | 2026-03-08 |
| Project accent colors from Catppuccin palette | Visual distinction: blue/green/mauve/peach/pink per slot 1-5. Applied to border + header tint. | 2026-03-07 |
| Remote machines deferred to v3.1 | Elevate to project level (project.remote_machine_id) but don't implement in MVP. | 2026-03-07 |
| Keyboard shortcut layers: App > Workspace > Terminal | Prevents conflicts. Terminal captures raw keys only when focused. App layer uses Ctrl+K/G. | 2026-03-07 |
| AgentPane splits into ClaudeSession + TeamAgentsPanel | Team agents shown inline in right panel, not as separate panes. Saves xterm/pane slots. | 2026-03-07 |
| Unmount/remount on group switch | Serialize xterm scrollbacks, destroy, remount new group. <100ms perceived. Frees ~80MB. | 2026-03-07 |
| All themes map to --ctp-* CSS vars | 17 themes in 3 groups: 4 Catppuccin + 7 Editor (VSCode Dark+, Atom One Dark, Monokai, Dracula, Nord, Solarized Dark, GitHub Dark) + 6 Deep Dark (Tokyo Night, Gruvbox Dark, Ayu Dark, Poimandres, Vesper, Midnight). All map to same 26 --ctp-* CSS custom properties — zero component changes needed. | 2026-03-07 |
| Typography via CSS custom properties | --ui-font-family/--ui-font-size + --term-font-family/--term-font-size in catppuccin.css :root. Restored by initTheme() on startup. Persisted as ui_font_family/ui_font_size/term_font_family/term_font_size SQLite settings. | 2026-03-07 |
| Tier 1 agents as ProjectBoxes via agentToProject() | Agents render as full ProjectBoxes (not separate UI). getAllWorkItems() merges agents+projects. Unified rendering = less code, same capabilities. | 2026-03-11 |
| extra_env 5-layer passthrough for BTMSG_AGENT_ID | TS → Rust AgentQueryOptions → NDJSON → JS runner → SDK env. Minimal surface — only agent projects get env injection. | 2026-03-11 |
| Periodic system prompt re-injection (1 hour) | LLM context degrades over long sessions. 1-hour timer re-sends role/tools reminder when agent is idle. autoPrompt/onautopromptconsumed callback pattern between AgentSession and AgentPane. | 2026-03-11 |
| btmsg/bttask shared SQLite DB | Both CLI tools share ~/.local/share/bterminal/btmsg.db. Single DB simplifies deployment, agents already have path. Read-only for non-Manager roles via CLI permissions. | 2026-03-11 |
| Role-specific tabs via conditional rendering | Manager=Tasks, Architect=Arch, Tester=Selenium+Tests. PERSISTED-LAZY pattern (mount on first activation). Conditional on isAgent && agentRole. | 2026-03-11 |
| PlantUML via plantuml.com server (~h hex encoding) | Avoids Java dependency. Hex encoding simpler than deflate+base64. Works with free tier. Trade-off: requires internet. | 2026-03-11 |
## Errors Encountered
| Error | Cause | Fix | Date |
|---|---|---|---|

216
install-v2.sh Executable file
View file

@ -0,0 +1,216 @@
#!/bin/bash
set -euo pipefail
# BTerminal v2 installer — builds from source
# Requires: Node.js 20+, Rust toolchain, system libs (WebKit2GTK, etc.)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
BIN_DIR="$HOME/.local/bin"
ICON_DIR="$HOME/.local/share/icons/hicolor/scalable/apps"
DESKTOP_DIR="$HOME/.local/share/applications"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
info() { echo -e " ${GREEN}${NC} $1"; }
warn() { echo -e " ${YELLOW}!${NC} $1"; }
fail() { echo -e " ${RED}${NC} $1"; exit 1; }
echo "=== BTerminal v2 Installer ==="
echo ""
# ─── 1. Check Node.js ────────────────────────────────────────────────────────
echo "[1/6] Checking Node.js..."
if ! command -v node &>/dev/null; then
fail "Node.js not found. Install Node.js 20+ (https://nodejs.org)"
fi
NODE_VER=$(node -v | sed 's/^v//' | cut -d. -f1)
if [ "$NODE_VER" -lt 20 ]; then
fail "Node.js $NODE_VER found, need 20+. Upgrade at https://nodejs.org"
fi
info "Node.js $(node -v)"
if ! command -v npm &>/dev/null; then
fail "npm not found. Install Node.js with npm."
fi
info "npm $(npm -v)"
# ─── 2. Check Rust toolchain ─────────────────────────────────────────────────
echo "[2/6] Checking Rust toolchain..."
if ! command -v rustc &>/dev/null; then
fail "Rust not found. Install via: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh"
fi
RUST_VER=$(rustc --version | awk '{print $2}')
RUST_MAJOR=$(echo "$RUST_VER" | cut -d. -f1)
RUST_MINOR=$(echo "$RUST_VER" | cut -d. -f2)
if [ "$RUST_MAJOR" -lt 1 ] || { [ "$RUST_MAJOR" -eq 1 ] && [ "$RUST_MINOR" -lt 77 ]; }; then
fail "Rust $RUST_VER found, need 1.77+. Run: rustup update"
fi
info "Rust $RUST_VER"
if ! command -v cargo &>/dev/null; then
fail "Cargo not found. Reinstall Rust toolchain."
fi
info "Cargo $(cargo --version | awk '{print $2}')"
# ─── 3. Check system libraries ───────────────────────────────────────────────
echo "[3/6] Checking system libraries..."
MISSING_PKGS=()
# WebKit2GTK 4.1 (required by Tauri 2.x on Linux)
if ! pkg-config --exists webkit2gtk-4.1 2>/dev/null; then
MISSING_PKGS+=("libwebkit2gtk-4.1-dev")
fi
# GTK3
if ! pkg-config --exists gtk+-3.0 2>/dev/null; then
MISSING_PKGS+=("libgtk-3-dev")
fi
# GLib/GIO
if ! pkg-config --exists gio-2.0 2>/dev/null; then
MISSING_PKGS+=("libglib2.0-dev")
fi
# libayatana-appindicator (system tray)
if ! pkg-config --exists ayatana-appindicator3-0.1 2>/dev/null; then
MISSING_PKGS+=("libayatana-appindicator3-dev")
fi
# librsvg (SVG icon rendering)
if ! pkg-config --exists librsvg-2.0 2>/dev/null; then
MISSING_PKGS+=("librsvg2-dev")
fi
# libssl (TLS)
if ! pkg-config --exists openssl 2>/dev/null; then
MISSING_PKGS+=("libssl-dev")
fi
# Build essentials
if ! command -v cc &>/dev/null; then
MISSING_PKGS+=("build-essential")
fi
# pkg-config itself
if ! command -v pkg-config &>/dev/null; then
MISSING_PKGS+=("pkg-config")
fi
# curl (needed for some build steps)
if ! command -v curl &>/dev/null; then
MISSING_PKGS+=("curl")
fi
# wget (needed for AppImage tools)
if ! command -v wget &>/dev/null; then
MISSING_PKGS+=("wget")
fi
# FUSE (needed for AppImage)
if ! dpkg -s libfuse2t64 &>/dev/null 2>&1 && ! dpkg -s libfuse2 &>/dev/null 2>&1; then
# Try the newer package name first (Debian trixie+), fall back to older
if apt-cache show libfuse2t64 &>/dev/null 2>&1; then
MISSING_PKGS+=("libfuse2t64")
else
MISSING_PKGS+=("libfuse2")
fi
fi
if [ ${#MISSING_PKGS[@]} -gt 0 ]; then
echo ""
warn "Missing system packages: ${MISSING_PKGS[*]}"
echo ""
read -rp " Install with apt? [Y/n] " REPLY
REPLY=${REPLY:-Y}
if [[ "$REPLY" =~ ^[Yy]$ ]]; then
sudo apt update
sudo apt install -y "${MISSING_PKGS[@]}"
info "System packages installed"
else
fail "Cannot continue without: ${MISSING_PKGS[*]}"
fi
else
info "All system libraries present"
fi
# ─── 4. Install npm dependencies ─────────────────────────────────────────────
echo "[4/6] Installing npm dependencies..."
cd "$SCRIPT_DIR/v2"
npm ci --legacy-peer-deps
info "npm dependencies installed"
# ─── 5. Build Tauri app ──────────────────────────────────────────────────────
echo "[5/6] Building BTerminal (this may take a few minutes on first build)..."
npx tauri build 2>&1 | tail -5
info "Build complete"
# Locate built artifacts
BUNDLE_DIR="$SCRIPT_DIR/v2/src-tauri/target/release/bundle"
DEB_FILE=$(find "$BUNDLE_DIR/deb" -name "*.deb" 2>/dev/null | head -1)
APPIMAGE_FILE=$(find "$BUNDLE_DIR/appimage" -name "*.AppImage" 2>/dev/null | head -1)
BINARY="$SCRIPT_DIR/v2/src-tauri/target/release/bterminal"
# ─── 6. Install ──────────────────────────────────────────────────────────────
echo "[6/6] Installing..."
mkdir -p "$BIN_DIR" "$ICON_DIR" "$DESKTOP_DIR"
# Copy binary
cp "$BINARY" "$BIN_DIR/bterminal-v2"
chmod +x "$BIN_DIR/bterminal-v2"
info "Binary: $BIN_DIR/bterminal-v2"
# Copy icon
if [ -f "$SCRIPT_DIR/bterminal.svg" ]; then
cp "$SCRIPT_DIR/bterminal.svg" "$ICON_DIR/bterminal.svg"
info "Icon: $ICON_DIR/bterminal.svg"
fi
# Desktop entry
cat > "$DESKTOP_DIR/bterminal-v2.desktop" << EOF
[Desktop Entry]
Name=BTerminal v2
Comment=Multi-session Claude agent dashboard
Exec=$BIN_DIR/bterminal-v2
Icon=bterminal
Type=Application
Categories=System;TerminalEmulator;Development;
Terminal=false
StartupNotify=true
StartupWMClass=bterminal
EOF
info "Desktop entry created"
echo ""
echo "=== Installation complete ==="
echo ""
if [ -n "${DEB_FILE:-}" ]; then
echo " .deb package: $DEB_FILE"
fi
if [ -n "${APPIMAGE_FILE:-}" ]; then
echo " AppImage: $APPIMAGE_FILE"
fi
echo " Binary: $BIN_DIR/bterminal-v2"
echo ""
echo "Run: bterminal-v2"
echo ""
echo "Make sure $BIN_DIR is in your PATH."
echo "If not, add to ~/.bashrc:"
echo " export PATH=\"\$HOME/.local/bin:\$PATH\""

27
v2/.gitignore vendored Normal file
View file

@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
public/pdf.worker.min.mjs
dist-ssr
*.local
sidecar/dist
sidecar/node_modules
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
v2/.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

6306
v2/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

3
v2/Cargo.toml Normal file
View file

@ -0,0 +1,3 @@
[workspace]
members = ["src-tauri", "bterminal-core", "bterminal-relay"]
resolver = "2"

47
v2/README.md Normal file
View file

@ -0,0 +1,47 @@
# Svelte + TS + Vite
This template should help get you started developing with Svelte and TypeScript in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
## Need an official Svelte framework?
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
## Technical considerations
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `allowJs` in the TS template?**
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```ts
// store.ts
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```

View file

@ -0,0 +1,13 @@
[package]
name = "bterminal-core"
version = "0.1.0"
edition = "2021"
description = "Shared PTY and sidecar management for BTerminal"
license = "MIT"
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
portable-pty = "0.8"
uuid = { version = "1", features = ["v4"] }

View file

@ -0,0 +1,5 @@
/// Trait for emitting events from PTY and sidecar managers.
/// Implemented by Tauri's AppHandle (controller) and WebSocket sender (relay).
pub trait EventSink: Send + Sync {
fn emit(&self, event: &str, payload: serde_json::Value);
}

View file

@ -0,0 +1,3 @@
pub mod event;
pub mod pty;
pub mod sidecar;

View file

@ -0,0 +1,173 @@
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::io::{BufReader, Write};
use std::sync::{Arc, Mutex};
use std::thread;
use uuid::Uuid;
use crate::event::EventSink;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PtyOptions {
pub shell: Option<String>,
pub cwd: Option<String>,
pub args: Option<Vec<String>>,
pub cols: Option<u16>,
pub rows: Option<u16>,
}
struct PtyInstance {
master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
}
pub struct PtyManager {
instances: Arc<Mutex<HashMap<String, PtyInstance>>>,
sink: Arc<dyn EventSink>,
}
impl PtyManager {
pub fn new(sink: Arc<dyn EventSink>) -> Self {
Self {
instances: Arc::new(Mutex::new(HashMap::new())),
sink,
}
}
pub fn spawn(&self, options: PtyOptions) -> Result<String, String> {
let pty_system = native_pty_system();
let cols = options.cols.unwrap_or(80);
let rows = options.rows.unwrap_or(24);
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("Failed to open PTY: {e}"))?;
let shell = options.shell.unwrap_or_else(|| {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string())
});
let mut cmd = CommandBuilder::new(&shell);
if let Some(args) = &options.args {
for arg in args {
cmd.arg(arg);
}
}
if let Some(cwd) = &options.cwd {
cmd.cwd(cwd);
}
let _child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn command: {e}"))?;
drop(pair.slave);
let id = Uuid::new_v4().to_string();
let reader = pair
.master
.try_clone_reader()
.map_err(|e| format!("Failed to clone PTY reader: {e}"))?;
let writer = pair
.master
.take_writer()
.map_err(|e| format!("Failed to take PTY writer: {e}"))?;
let event_id = id.clone();
let sink = self.sink.clone();
thread::spawn(move || {
let mut buf_reader = BufReader::with_capacity(4096, reader);
let mut buf = vec![0u8; 4096];
loop {
match std::io::Read::read(&mut buf_reader, &mut buf) {
Ok(0) => {
sink.emit(
&format!("pty-exit-{event_id}"),
serde_json::Value::Null,
);
break;
}
Ok(n) => {
let data = String::from_utf8_lossy(&buf[..n]).to_string();
sink.emit(
&format!("pty-data-{event_id}"),
serde_json::Value::String(data),
);
}
Err(e) => {
log::error!("PTY read error for {event_id}: {e}");
sink.emit(
&format!("pty-exit-{event_id}"),
serde_json::Value::Null,
);
break;
}
}
}
});
let instance = PtyInstance {
master: pair.master,
writer,
};
self.instances.lock().unwrap().insert(id.clone(), instance);
log::info!("Spawned PTY {id} ({shell})");
Ok(id)
}
pub fn write(&self, id: &str, data: &str) -> Result<(), String> {
let mut instances = self.instances.lock().unwrap();
let instance = instances
.get_mut(id)
.ok_or_else(|| format!("PTY {id} not found"))?;
instance
.writer
.write_all(data.as_bytes())
.map_err(|e| format!("PTY write error: {e}"))?;
instance
.writer
.flush()
.map_err(|e| format!("PTY flush error: {e}"))?;
Ok(())
}
pub fn resize(&self, id: &str, cols: u16, rows: u16) -> Result<(), String> {
let instances = self.instances.lock().unwrap();
let instance = instances
.get(id)
.ok_or_else(|| format!("PTY {id} not found"))?;
instance
.master
.resize(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("PTY resize error: {e}"))?;
Ok(())
}
pub fn kill(&self, id: &str) -> Result<(), String> {
let mut instances = self.instances.lock().unwrap();
if instances.remove(id).is_some() {
log::info!("Killed PTY {id}");
Ok(())
} else {
Err(format!("PTY {id} not found"))
}
}
/// List active PTY session IDs.
pub fn list_sessions(&self) -> Vec<String> {
self.instances.lock().unwrap().keys().cloned().collect()
}
}

View file

@ -0,0 +1,348 @@
// Sidecar lifecycle management (Deno-first, Node.js fallback)
// Spawns bundled agent-runner.mjs via deno or node, communicates via stdio NDJSON
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader, Write};
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use crate::event::EventSink;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentQueryOptions {
#[serde(default = "default_provider")]
pub provider: String,
pub session_id: String,
pub prompt: String,
pub cwd: Option<String>,
pub max_turns: Option<u32>,
pub max_budget_usd: Option<f64>,
pub resume_session_id: Option<String>,
pub permission_mode: Option<String>,
pub setting_sources: Option<Vec<String>>,
pub system_prompt: Option<String>,
pub model: Option<String>,
pub claude_config_dir: Option<String>,
pub additional_directories: Option<Vec<String>>,
/// When set, agent runs in a git worktree for isolation (passed as --worktree <name> CLI flag)
pub worktree_name: Option<String>,
/// Provider-specific configuration blob (passed through to sidecar as-is)
#[serde(default)]
pub provider_config: serde_json::Value,
/// Extra environment variables injected into the agent process (e.g. BTMSG_AGENT_ID)
#[serde(default)]
pub extra_env: std::collections::HashMap<String, String>,
}
fn default_provider() -> String {
"claude".to_string()
}
/// Directories to search for sidecar scripts.
#[derive(Debug, Clone)]
pub struct SidecarConfig {
pub search_paths: Vec<PathBuf>,
}
struct SidecarCommand {
program: String,
args: Vec<String>,
}
pub struct SidecarManager {
child: Arc<Mutex<Option<Child>>>,
stdin_writer: Arc<Mutex<Option<Box<dyn Write + Send>>>>,
ready: Arc<Mutex<bool>>,
sink: Arc<dyn EventSink>,
config: SidecarConfig,
}
impl SidecarManager {
pub fn new(sink: Arc<dyn EventSink>, config: SidecarConfig) -> Self {
Self {
child: Arc::new(Mutex::new(None)),
stdin_writer: Arc::new(Mutex::new(None)),
ready: Arc::new(Mutex::new(false)),
sink,
config,
}
}
pub fn start(&self) -> Result<(), String> {
let mut child_lock = self.child.lock().unwrap();
if child_lock.is_some() {
return Err("Sidecar already running".to_string());
}
let cmd = self.resolve_sidecar_command()?;
log::info!("Starting sidecar: {} {}", cmd.program, cmd.args.join(" "));
// Build a clean environment stripping provider-specific vars to prevent
// SDKs from detecting nesting when BTerminal is launched from a provider terminal.
// Per-provider prefixes: CLAUDE* (whitelist CLAUDE_CODE_EXPERIMENTAL_*),
// CODEX* and OLLAMA* for future providers.
let clean_env: Vec<(String, String)> = std::env::vars()
.filter(|(k, _)| {
strip_provider_env_var(k)
})
.collect();
let mut child = Command::new(&cmd.program)
.args(&cmd.args)
.env_clear()
.envs(clean_env)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("Failed to start sidecar: {e}"))?;
let child_stdin = child
.stdin
.take()
.ok_or("Failed to capture sidecar stdin")?;
let child_stdout = child
.stdout
.take()
.ok_or("Failed to capture sidecar stdout")?;
let child_stderr = child
.stderr
.take()
.ok_or("Failed to capture sidecar stderr")?;
*self.stdin_writer.lock().unwrap() = Some(Box::new(child_stdin));
// Stdout reader thread — forwards NDJSON to event sink
let sink = self.sink.clone();
let ready = self.ready.clone();
thread::spawn(move || {
let reader = BufReader::new(child_stdout);
for line in reader.lines() {
match line {
Ok(line) => {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<serde_json::Value>(&line) {
Ok(msg) => {
if msg.get("type").and_then(|t| t.as_str()) == Some("ready") {
*ready.lock().unwrap() = true;
log::info!("Sidecar ready");
}
sink.emit("sidecar-message", msg);
}
Err(e) => {
log::warn!("Invalid JSON from sidecar: {e}: {line}");
}
}
}
Err(e) => {
log::error!("Sidecar stdout read error: {e}");
break;
}
}
}
log::info!("Sidecar stdout reader exited");
sink.emit("sidecar-exited", serde_json::Value::Null);
});
// Stderr reader thread — logs only
thread::spawn(move || {
let reader = BufReader::new(child_stderr);
for line in reader.lines() {
match line {
Ok(line) => log::info!("[sidecar stderr] {line}"),
Err(e) => {
log::error!("Sidecar stderr read error: {e}");
break;
}
}
}
});
*child_lock = Some(child);
Ok(())
}
pub fn send_message(&self, msg: &serde_json::Value) -> Result<(), String> {
let mut writer_lock = self.stdin_writer.lock().unwrap();
let writer = writer_lock.as_mut().ok_or("Sidecar not running")?;
let line =
serde_json::to_string(msg).map_err(|e| format!("JSON serialize error: {e}"))?;
writer
.write_all(line.as_bytes())
.map_err(|e| format!("Sidecar write error: {e}"))?;
writer
.write_all(b"\n")
.map_err(|e| format!("Sidecar write error: {e}"))?;
writer
.flush()
.map_err(|e| format!("Sidecar flush error: {e}"))?;
Ok(())
}
pub fn query(&self, options: &AgentQueryOptions) -> Result<(), String> {
if !*self.ready.lock().unwrap() {
return Err("Sidecar not ready".to_string());
}
// Validate that the requested provider has a runner available
let runner_name = format!("{}-runner.mjs", options.provider);
let runner_exists = self
.config
.search_paths
.iter()
.any(|base| base.join("dist").join(&runner_name).exists());
if !runner_exists {
return Err(format!(
"No sidecar runner found for provider '{}' (expected {})",
options.provider, runner_name
));
}
let msg = serde_json::json!({
"type": "query",
"provider": options.provider,
"sessionId": options.session_id,
"prompt": options.prompt,
"cwd": options.cwd,
"maxTurns": options.max_turns,
"maxBudgetUsd": options.max_budget_usd,
"resumeSessionId": options.resume_session_id,
"permissionMode": options.permission_mode,
"settingSources": options.setting_sources,
"systemPrompt": options.system_prompt,
"model": options.model,
"claudeConfigDir": options.claude_config_dir,
"additionalDirectories": options.additional_directories,
"worktreeName": options.worktree_name,
"providerConfig": options.provider_config,
"extraEnv": options.extra_env,
});
self.send_message(&msg)
}
pub fn stop_session(&self, session_id: &str) -> Result<(), String> {
let msg = serde_json::json!({
"type": "stop",
"sessionId": session_id,
});
self.send_message(&msg)
}
pub fn restart(&self) -> Result<(), String> {
log::info!("Restarting sidecar");
let _ = self.shutdown();
self.start()
}
pub fn shutdown(&self) -> Result<(), String> {
let mut child_lock = self.child.lock().unwrap();
if let Some(ref mut child) = *child_lock {
log::info!("Shutting down sidecar");
*self.stdin_writer.lock().unwrap() = None;
let _ = child.kill();
let _ = child.wait();
}
*child_lock = None;
*self.ready.lock().unwrap() = false;
Ok(())
}
pub fn is_ready(&self) -> bool {
*self.ready.lock().unwrap()
}
/// Resolve a sidecar runner command. Uses the default claude-runner for startup.
/// Future providers will have their own runners (e.g. codex-runner.mjs).
fn resolve_sidecar_command(&self) -> Result<SidecarCommand, String> {
self.resolve_sidecar_for_provider("claude")
}
/// Resolve a sidecar command for a specific provider's runner file.
fn resolve_sidecar_for_provider(&self, provider: &str) -> Result<SidecarCommand, String> {
let runner_name = format!("{}-runner.mjs", provider);
// Try Deno first (faster startup, better perf), fall back to Node.js.
let has_deno = Command::new("deno")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok();
let has_node = Command::new("node")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok();
let mut checked = Vec::new();
for base in &self.config.search_paths {
let mjs_path = base.join("dist").join(&runner_name);
if mjs_path.exists() {
if has_deno {
return Ok(SidecarCommand {
program: "deno".to_string(),
args: vec![
"run".to_string(),
"--allow-run".to_string(),
"--allow-env".to_string(),
"--allow-read".to_string(),
"--allow-write".to_string(),
"--allow-net".to_string(),
mjs_path.to_string_lossy().to_string(),
],
});
}
if has_node {
return Ok(SidecarCommand {
program: "node".to_string(),
args: vec![mjs_path.to_string_lossy().to_string()],
});
}
}
checked.push(mjs_path);
}
let paths: Vec<_> = checked.iter().map(|p| p.display().to_string()).collect();
let runtime_note = if !has_deno && !has_node {
". Neither deno nor node found in PATH"
} else {
""
};
Err(format!(
"Sidecar not found for provider '{}'. Checked: {}{}",
provider,
paths.join(", "),
runtime_note,
))
}
}
/// Returns true if the env var should be KEPT (not stripped).
/// Strips CLAUDE*, CODEX*, OLLAMA* prefixes to prevent nesting detection.
/// Whitelists CLAUDE_CODE_EXPERIMENTAL_* for feature flags.
fn strip_provider_env_var(key: &str) -> bool {
if key.starts_with("CLAUDE_CODE_EXPERIMENTAL_") {
return true;
}
if key.starts_with("CLAUDE") || key.starts_with("CODEX") || key.starts_with("OLLAMA") {
return false;
}
true
}
impl Drop for SidecarManager {
fn drop(&mut self) {
let _ = self.shutdown();
}
}

View file

@ -0,0 +1,22 @@
[package]
name = "bterminal-relay"
version = "0.1.0"
edition = "2021"
description = "Remote relay server for BTerminal multi-machine support"
license = "MIT"
[[bin]]
name = "bterminal-relay"
path = "src/main.rs"
[dependencies]
bterminal-core = { path = "../bterminal-core" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
env_logger = "0.11"
tokio = { version = "1", features = ["full"] }
tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
futures-util = "0.3"
clap = { version = "4", features = ["derive"] }
uuid = { version = "1", features = ["v4"] }

View file

@ -0,0 +1,342 @@
// bterminal-relay — WebSocket relay server for remote PTY and agent management
use bterminal_core::event::EventSink;
use bterminal_core::pty::{PtyManager, PtyOptions};
use bterminal_core::sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager};
use clap::Parser;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use std::sync::Arc;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::http;
#[derive(Parser)]
#[command(name = "bterminal-relay", about = "BTerminal remote relay server")]
struct Cli {
/// Port to listen on
#[arg(short, long, default_value = "9750")]
port: u16,
/// Authentication token (required)
#[arg(short, long)]
token: String,
/// Allow insecure ws:// connections (dev mode only)
#[arg(long, default_value = "false")]
insecure: bool,
/// Additional sidecar search paths
#[arg(long)]
sidecar_path: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RelayCommand {
id: String,
#[serde(rename = "type")]
type_: String,
payload: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RelayEvent {
#[serde(rename = "type")]
type_: String,
#[serde(rename = "sessionId", skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
payload: Option<serde_json::Value>,
}
/// EventSink that sends events as JSON over an mpsc channel (forwarded to WebSocket).
struct WsEventSink {
tx: mpsc::UnboundedSender<RelayEvent>,
}
impl EventSink for WsEventSink {
fn emit(&self, event: &str, payload: serde_json::Value) {
// Parse event name to extract session ID for PTY events like "pty-data-{id}"
let (type_, session_id) = if let Some(id) = event.strip_prefix("pty-data-") {
("pty_data".to_string(), Some(id.to_string()))
} else if let Some(id) = event.strip_prefix("pty-exit-") {
("pty_exit".to_string(), Some(id.to_string()))
} else {
(event.replace('-', "_"), None)
};
let _ = self.tx.send(RelayEvent {
type_,
session_id,
payload: if payload.is_null() { None } else { Some(payload) },
});
}
}
#[tokio::main]
async fn main() {
env_logger::init();
let cli = Cli::parse();
let addr = SocketAddr::from(([0, 0, 0, 0], cli.port));
let listener = TcpListener::bind(&addr).await.expect("Failed to bind");
log::info!("bterminal-relay listening on {addr}");
// Build sidecar config
let mut search_paths: Vec<std::path::PathBuf> = cli
.sidecar_path
.iter()
.map(std::path::PathBuf::from)
.collect();
// Default: look in current dir and next to binary
if let Ok(exe_dir) = std::env::current_exe().map(|p| p.parent().unwrap().to_path_buf()) {
search_paths.push(exe_dir.join("sidecar"));
}
search_paths.push(std::path::PathBuf::from("sidecar"));
let sidecar_config = SidecarConfig { search_paths };
let token = Arc::new(cli.token);
// Rate limiting state for auth failures
let auth_failures: Arc<tokio::sync::Mutex<std::collections::HashMap<SocketAddr, (u32, std::time::Instant)>>> =
Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new()));
while let Ok((stream, peer)) = listener.accept().await {
let token = token.clone();
let sidecar_config = sidecar_config.clone();
let auth_failures = auth_failures.clone();
tokio::spawn(async move {
// Check rate limit
{
let mut failures = auth_failures.lock().await;
if let Some((count, last)) = failures.get(&peer) {
if *count >= 10 && last.elapsed() < std::time::Duration::from_secs(300) {
log::warn!("Rate limited: {peer}");
return;
}
// Reset after cooldown
if last.elapsed() >= std::time::Duration::from_secs(300) {
failures.remove(&peer);
}
}
}
if let Err(e) = handle_connection(stream, peer, &token, &sidecar_config, &auth_failures).await {
log::error!("Connection error from {peer}: {e}");
}
});
}
}
async fn handle_connection(
stream: TcpStream,
peer: SocketAddr,
expected_token: &str,
sidecar_config: &SidecarConfig,
auth_failures: &tokio::sync::Mutex<std::collections::HashMap<SocketAddr, (u32, std::time::Instant)>>,
) -> Result<(), String> {
// Accept WebSocket with auth validation
let ws_stream = tokio_tungstenite::accept_hdr_async(stream, |req: &http::Request<()>, response: http::Response<()>| {
// Validate auth token from headers
let auth = req.headers().get("authorization").and_then(|v| v.to_str().ok());
match auth {
Some(value) if value == format!("Bearer {expected_token}") => Ok(response),
_ => {
Err(http::Response::builder()
.status(http::StatusCode::UNAUTHORIZED)
.body(Some("Invalid token".to_string()))
.unwrap())
}
}
})
.await
.map_err(|e| {
// Record auth failure
let _ = auth_failures.try_lock().map(|mut f| {
let entry = f.entry(peer).or_insert((0, std::time::Instant::now()));
entry.0 += 1;
entry.1 = std::time::Instant::now();
});
format!("WebSocket handshake failed: {e}")
})?;
log::info!("Client connected: {peer}");
// Set up event channel — shared between EventSink and command response sender
let (event_tx, mut event_rx) = mpsc::unbounded_channel::<RelayEvent>();
let sink_tx = event_tx.clone();
let sink: Arc<dyn EventSink> = Arc::new(WsEventSink { tx: event_tx });
// Create managers for this connection
let pty_manager = Arc::new(PtyManager::new(sink.clone()));
let sidecar_manager = Arc::new(SidecarManager::new(sink, sidecar_config.clone()));
// Start sidecar
if let Err(e) = sidecar_manager.start() {
log::warn!("Sidecar startup failed for {peer}: {e}");
}
let (mut ws_tx, mut ws_rx) = ws_stream.split();
// Send ready signal
let ready_event = RelayEvent {
type_: "ready".to_string(),
session_id: None,
payload: None,
};
let _ = ws_tx
.send(Message::Text(serde_json::to_string(&ready_event).unwrap()))
.await;
// Forward events to WebSocket
let event_writer = tokio::spawn(async move {
while let Some(event) = event_rx.recv().await {
if let Ok(json) = serde_json::to_string(&event) {
if ws_tx.send(Message::Text(json)).await.is_err() {
break;
}
}
}
});
// Process incoming commands
let pty_mgr = pty_manager.clone();
let sidecar_mgr = sidecar_manager.clone();
let response_tx = sink_tx;
let command_reader = tokio::spawn(async move {
while let Some(msg) = ws_rx.next().await {
match msg {
Ok(Message::Text(text)) => {
if let Ok(cmd) = serde_json::from_str::<RelayCommand>(&text) {
handle_relay_command(&pty_mgr, &sidecar_mgr, &response_tx, cmd).await;
}
}
Ok(Message::Close(_)) => break,
Err(e) => {
log::error!("WebSocket read error from {peer}: {e}");
break;
}
_ => {}
}
}
});
// Wait for either task to finish
tokio::select! {
_ = event_writer => {}
_ = command_reader => {}
}
// Cleanup
let _ = sidecar_manager.shutdown();
log::info!("Client disconnected: {peer}");
Ok(())
}
async fn handle_relay_command(
pty: &PtyManager,
sidecar: &SidecarManager,
response_tx: &mpsc::UnboundedSender<RelayEvent>,
cmd: RelayCommand,
) {
match cmd.type_.as_str() {
"ping" => {
let _ = response_tx.send(RelayEvent {
type_: "pong".to_string(),
session_id: None,
payload: None,
});
}
"pty_create" => {
let options: PtyOptions = match serde_json::from_value(cmd.payload) {
Ok(opts) => opts,
Err(e) => {
send_error(response_tx, &cmd.id, &format!("Invalid pty_create payload: {e}"));
return;
}
};
match pty.spawn(options) {
Ok(pty_id) => {
log::info!("Spawned remote PTY: {pty_id}");
let _ = response_tx.send(RelayEvent {
type_: "pty_created".to_string(),
session_id: Some(pty_id),
payload: Some(serde_json::json!({ "commandId": cmd.id })),
});
}
Err(e) => send_error(response_tx, &cmd.id, &format!("Failed to spawn PTY: {e}")),
}
}
"pty_write" => {
if let (Some(id), Some(data)) = (
cmd.payload.get("id").and_then(|v| v.as_str()),
cmd.payload.get("data").and_then(|v| v.as_str()),
) {
if let Err(e) = pty.write(id, data) {
send_error(response_tx, &cmd.id, &format!("PTY write error: {e}"));
}
}
}
"pty_resize" => {
if let (Some(id), Some(cols), Some(rows)) = (
cmd.payload.get("id").and_then(|v| v.as_str()),
cmd.payload.get("cols").and_then(|v| v.as_u64()),
cmd.payload.get("rows").and_then(|v| v.as_u64()),
) {
if let Err(e) = pty.resize(id, cols as u16, rows as u16) {
send_error(response_tx, &cmd.id, &format!("PTY resize error: {e}"));
}
}
}
"pty_close" => {
if let Some(id) = cmd.payload.get("id").and_then(|v| v.as_str()) {
if let Err(e) = pty.kill(id) {
send_error(response_tx, &cmd.id, &format!("PTY kill error: {e}"));
}
}
}
"agent_query" => {
let options: AgentQueryOptions = match serde_json::from_value(cmd.payload) {
Ok(opts) => opts,
Err(e) => {
send_error(response_tx, &cmd.id, &format!("Invalid agent_query payload: {e}"));
return;
}
};
if let Err(e) = sidecar.query(&options) {
send_error(response_tx, &cmd.id, &format!("Agent query error: {e}"));
}
}
"agent_stop" => {
if let Some(session_id) = cmd.payload.get("sessionId").and_then(|v| v.as_str()) {
if let Err(e) = sidecar.stop_session(session_id) {
send_error(response_tx, &cmd.id, &format!("Agent stop error: {e}"));
}
}
}
"sidecar_restart" => {
if let Err(e) = sidecar.restart() {
send_error(response_tx, &cmd.id, &format!("Sidecar restart error: {e}"));
}
}
other => {
log::warn!("Unknown relay command: {other}");
}
}
}
fn send_error(tx: &mpsc::UnboundedSender<RelayEvent>, cmd_id: &str, message: &str) {
log::error!("{message}");
let _ = tx.send(RelayEvent {
type_: "error".to_string(),
session_id: None,
payload: Some(serde_json::json!({
"commandId": cmd_id,
"message": message,
})),
});
}

12
v2/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BTerminal</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

9722
v2/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

60
v2/package.json Normal file
View file

@ -0,0 +1,60 @@
{
"name": "bterminal-v2",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"prebuild": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
"tauri": "cargo tauri",
"tauri:dev": "cargo tauri dev",
"tauri:build": "cargo tauri build",
"test": "vitest run",
"test:e2e": "wdio run tests/e2e/wdio.conf.js",
"build:sidecar": "esbuild sidecar/claude-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/claude-runner.mjs && esbuild sidecar/codex-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/codex-runner.mjs && esbuild sidecar/ollama-runner.ts --bundle --platform=node --format=esm --outfile=sidecar/dist/ollama-runner.mjs"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tsconfig/svelte": "^5.0.6",
"@types/node": "^24.10.1",
"@wdio/cli": "^9.24.0",
"@wdio/local-runner": "^9.24.0",
"@wdio/mocha-framework": "^9.24.0",
"@wdio/spec-reporter": "^9.24.0",
"svelte": "^5.45.2",
"svelte-check": "^4.3.4",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.18"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.70",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.2",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-updater": "^2.10.0",
"@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"codemirror": "^6.0.2",
"marked": "^17.0.4",
"pdfjs-dist": "^5.5.207",
"shiki": "^4.0.1"
}
}

View file

@ -0,0 +1,209 @@
// Agent Runner — Deno sidecar entry point
// Drop-in replacement for agent-runner.ts using Deno APIs
// Uses @anthropic-ai/claude-agent-sdk via npm: specifier
// Run: deno run --allow-run --allow-env --allow-read --allow-write --allow-net agent-runner-deno.ts
import { TextLineStream } from "https://deno.land/std@0.224.0/streams/text_line_stream.ts";
import { query } from "npm:@anthropic-ai/claude-agent-sdk";
const encoder = new TextEncoder();
// Active sessions with abort controllers
const sessions = new Map<string, AbortController>();
function send(msg: Record<string, unknown>) {
Deno.stdout.writeSync(encoder.encode(JSON.stringify(msg) + "\n"));
}
function log(message: string) {
Deno.stderr.writeSync(encoder.encode(`[sidecar] ${message}\n`));
}
interface QueryMessage {
type: "query";
sessionId: string;
prompt: string;
cwd?: string;
maxTurns?: number;
maxBudgetUsd?: number;
resumeSessionId?: string;
permissionMode?: string;
settingSources?: string[];
systemPrompt?: string;
model?: string;
claudeConfigDir?: string;
additionalDirectories?: string[];
}
interface StopMessage {
type: "stop";
sessionId: string;
}
function handleMessage(msg: Record<string, unknown>) {
switch (msg.type) {
case "ping":
send({ type: "pong" });
break;
case "query":
handleQuery(msg as unknown as QueryMessage);
break;
case "stop":
handleStop(msg as unknown as StopMessage);
break;
default:
send({ type: "error", message: `Unknown message type: ${msg.type}` });
}
}
async function handleQuery(msg: QueryMessage) {
const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories } = msg;
if (sessions.has(sessionId)) {
send({ type: "error", sessionId, message: "Session already running" });
return;
}
log(`Starting agent session ${sessionId} via SDK`);
const controller = new AbortController();
// Strip CLAUDE* env vars to prevent nesting detection
const cleanEnv: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(Deno.env.toObject())) {
if (!key.startsWith("CLAUDE")) {
cleanEnv[key] = value;
}
}
// Override CLAUDE_CONFIG_DIR for multi-account support
if (claudeConfigDir) {
cleanEnv["CLAUDE_CONFIG_DIR"] = claudeConfigDir;
}
if (!claudePath) {
send({ type: "agent_error", sessionId, message: "Claude CLI not found. Install Claude Code first." });
return;
}
try {
const q = query({
prompt,
options: {
pathToClaudeCodeExecutable: claudePath,
abortController: controller,
cwd: cwd || Deno.cwd(),
env: cleanEnv,
maxTurns: maxTurns ?? undefined,
maxBudgetUsd: maxBudgetUsd ?? undefined,
resume: resumeSessionId ?? undefined,
allowedTools: [
"Bash", "Read", "Write", "Edit", "Glob", "Grep",
"WebSearch", "WebFetch", "TodoWrite", "NotebookEdit",
],
permissionMode: (permissionMode ?? "bypassPermissions") as "bypassPermissions" | "default",
allowDangerouslySkipPermissions: (permissionMode ?? "bypassPermissions") === "bypassPermissions",
settingSources: settingSources ?? ["user", "project"],
systemPrompt: systemPrompt
? systemPrompt
: { type: "preset" as const, preset: "claude_code" as const },
model: model ?? undefined,
additionalDirectories: additionalDirectories ?? undefined,
},
});
sessions.set(sessionId, controller);
send({ type: "agent_started", sessionId });
for await (const message of q) {
const sdkMsg = message as Record<string, unknown>;
send({
type: "agent_event",
sessionId,
event: sdkMsg,
});
}
sessions.delete(sessionId);
send({
type: "agent_stopped",
sessionId,
exitCode: 0,
signal: null,
});
} catch (err: unknown) {
sessions.delete(sessionId);
const errMsg = err instanceof Error ? err.message : String(err);
if (errMsg.includes("aborted") || errMsg.includes("AbortError")) {
log(`Agent session ${sessionId} aborted`);
send({
type: "agent_stopped",
sessionId,
exitCode: null,
signal: "SIGTERM",
});
} else {
log(`Agent session ${sessionId} error: ${errMsg}`);
send({
type: "agent_error",
sessionId,
message: errMsg,
});
}
}
}
function handleStop(msg: StopMessage) {
const { sessionId } = msg;
const controller = sessions.get(sessionId);
if (!controller) {
send({ type: "error", sessionId, message: "Session not found" });
return;
}
log(`Stopping agent session ${sessionId}`);
controller.abort();
}
function findClaudeCli(): string | undefined {
const home = Deno.env.get("HOME") ?? Deno.env.get("USERPROFILE") ?? "";
const candidates = [
`${home}/.local/bin/claude`,
`${home}/.claude/local/claude`,
"/usr/local/bin/claude",
"/usr/bin/claude",
];
for (const p of candidates) {
try { Deno.statSync(p); return p; } catch { /* not found */ }
}
try {
const proc = new Deno.Command("which", { args: ["claude"], stdout: "piped", stderr: "null" });
const out = new TextDecoder().decode(proc.outputSync().stdout).trim();
if (out) return out.split("\n")[0];
} catch { /* not found */ }
return undefined;
}
const claudePath = findClaudeCli();
if (claudePath) {
log(`Found Claude CLI at ${claudePath}`);
} else {
log("WARNING: Claude CLI not found — agent sessions will fail");
}
// Main: read NDJSON from stdin
log("Sidecar started (Deno)");
send({ type: "ready" });
const lines = Deno.stdin.readable
.pipeThrough(new TextDecoderStream())
.pipeThrough(new TextLineStream());
for await (const line of lines) {
try {
const msg = JSON.parse(line);
handleMessage(msg);
} catch {
log(`Invalid JSON: ${line}`);
}
}

224
v2/sidecar/claude-runner.ts Normal file
View file

@ -0,0 +1,224 @@
// Claude Runner — Node.js sidecar entry point for Claude Code provider
// Spawned by Rust SidecarManager, communicates via stdio NDJSON
// Uses @anthropic-ai/claude-agent-sdk for Claude session management
import { stdin, stdout, stderr } from 'process';
import { createInterface } from 'readline';
import { execSync } from 'child_process';
import { existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { query, type Query } from '@anthropic-ai/claude-agent-sdk';
const rl = createInterface({ input: stdin });
// Active agent sessions keyed by session ID
const sessions = new Map<string, { query: Query; controller: AbortController }>();
function send(msg: Record<string, unknown>) {
stdout.write(JSON.stringify(msg) + '\n');
}
function log(message: string) {
stderr.write(`[sidecar] ${message}\n`);
}
rl.on('line', (line: string) => {
try {
const msg = JSON.parse(line);
handleMessage(msg).catch((err: unknown) => {
log(`Unhandled error in message handler: ${err}`);
});
} catch {
log(`Invalid JSON: ${line}`);
}
});
interface QueryMessage {
type: 'query';
sessionId: string;
prompt: string;
cwd?: string;
maxTurns?: number;
maxBudgetUsd?: number;
resumeSessionId?: string;
permissionMode?: string;
settingSources?: string[];
systemPrompt?: string;
model?: string;
claudeConfigDir?: string;
additionalDirectories?: string[];
worktreeName?: string;
extraEnv?: Record<string, string>;
}
interface StopMessage {
type: 'stop';
sessionId: string;
}
async function handleMessage(msg: Record<string, unknown>) {
switch (msg.type) {
case 'ping':
send({ type: 'pong' });
break;
case 'query':
await handleQuery(msg as unknown as QueryMessage);
break;
case 'stop':
handleStop(msg as unknown as StopMessage);
break;
default:
send({ type: 'error', message: `Unknown message type: ${msg.type}` });
}
}
async function handleQuery(msg: QueryMessage) {
const { sessionId, prompt, cwd, maxTurns, maxBudgetUsd, resumeSessionId, permissionMode, settingSources, systemPrompt, model, claudeConfigDir, additionalDirectories, worktreeName, extraEnv } = msg;
if (sessions.has(sessionId)) {
send({ type: 'error', sessionId, message: 'Session already running' });
return;
}
log(`Starting agent session ${sessionId} via SDK`);
const controller = new AbortController();
// Strip CLAUDE* and ANTHROPIC_* env vars to prevent nesting detection by the spawned CLI.
// Whitelist CLAUDE_CODE_EXPERIMENTAL_* so feature flags (e.g. agent teams) pass through.
const cleanEnv: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(process.env)) {
if (key.startsWith('CLAUDE_CODE_EXPERIMENTAL_')) {
cleanEnv[key] = value;
} else if (!key.startsWith('CLAUDE') && !key.startsWith('ANTHROPIC_')) {
cleanEnv[key] = value;
}
}
// Override CLAUDE_CONFIG_DIR for multi-account support
if (claudeConfigDir) {
cleanEnv['CLAUDE_CONFIG_DIR'] = claudeConfigDir;
}
// Inject extra environment variables (e.g. BTMSG_AGENT_ID for agent communication)
if (extraEnv) {
for (const [key, value] of Object.entries(extraEnv)) {
cleanEnv[key] = value;
}
}
try {
if (!claudePath) {
send({ type: 'agent_error', sessionId, message: 'Claude CLI not found. Install Claude Code first.' });
return;
}
const q = query({
prompt,
options: {
pathToClaudeCodeExecutable: claudePath,
abortController: controller,
cwd: cwd || process.cwd(),
env: cleanEnv,
maxTurns: maxTurns ?? undefined,
maxBudgetUsd: maxBudgetUsd ?? undefined,
resume: resumeSessionId ?? undefined,
allowedTools: [
'Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep',
'WebSearch', 'WebFetch', 'TodoWrite', 'NotebookEdit',
],
permissionMode: (permissionMode ?? 'bypassPermissions') as 'bypassPermissions' | 'default',
allowDangerouslySkipPermissions: (permissionMode ?? 'bypassPermissions') === 'bypassPermissions',
settingSources: settingSources ?? ['user', 'project'],
systemPrompt: systemPrompt
? systemPrompt
: { type: 'preset' as const, preset: 'claude_code' as const },
model: model ?? undefined,
additionalDirectories: additionalDirectories ?? undefined,
extraArgs: worktreeName ? { worktree: worktreeName } : undefined,
},
});
sessions.set(sessionId, { query: q, controller });
send({ type: 'agent_started', sessionId });
for await (const message of q) {
// Forward SDK messages as-is — they use the same format as CLI stream-json
const sdkMsg = message as Record<string, unknown>;
send({
type: 'agent_event',
sessionId,
event: sdkMsg,
});
}
// Session completed normally
sessions.delete(sessionId);
send({
type: 'agent_stopped',
sessionId,
exitCode: 0,
signal: null,
});
} catch (err: unknown) {
sessions.delete(sessionId);
const errMsg = err instanceof Error ? err.message : String(err);
if (controller.signal.aborted) {
log(`Agent session ${sessionId} aborted`);
send({
type: 'agent_stopped',
sessionId,
exitCode: null,
signal: 'SIGTERM',
});
} else {
log(`Agent session ${sessionId} error: ${errMsg}`);
send({
type: 'agent_error',
sessionId,
message: errMsg,
});
}
}
}
function handleStop(msg: StopMessage) {
const { sessionId } = msg;
const session = sessions.get(sessionId);
if (!session) {
send({ type: 'error', sessionId, message: 'Session not found' });
return;
}
log(`Stopping agent session ${sessionId}`);
session.controller.abort();
}
function findClaudeCli(): string | undefined {
// Check common locations
const candidates = [
join(homedir(), '.local', 'bin', 'claude'),
join(homedir(), '.claude', 'local', 'claude'),
'/usr/local/bin/claude',
'/usr/bin/claude',
];
for (const p of candidates) {
if (existsSync(p)) return p;
}
// Fall back to which/where
try {
return execSync('which claude 2>/dev/null || where claude 2>nul', { encoding: 'utf-8' }).trim().split('\n')[0];
} catch {
return undefined;
}
}
const claudePath = findClaudeCli();
if (claudePath) {
log(`Found Claude CLI at ${claudePath}`);
} else {
log('WARNING: Claude CLI not found — agent sessions will fail');
}
log('Sidecar started');
send({ type: 'ready' });

229
v2/sidecar/codex-runner.ts Normal file
View file

@ -0,0 +1,229 @@
// Codex Runner — Node.js sidecar entry point for OpenAI Codex provider
// Spawned by Rust SidecarManager, communicates via stdio NDJSON
// Uses @openai/codex-sdk for Codex session management
import { stdin, stdout, stderr } from 'process';
import { createInterface } from 'readline';
import { execSync } from 'child_process';
import { existsSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
const rl = createInterface({ input: stdin });
const sessions = new Map<string, { controller: AbortController }>();
function send(msg: Record<string, unknown>) {
stdout.write(JSON.stringify(msg) + '\n');
}
function log(message: string) {
stderr.write(`[codex-sidecar] ${message}\n`);
}
rl.on('line', (line: string) => {
try {
const msg = JSON.parse(line);
handleMessage(msg).catch((err: unknown) => {
log(`Unhandled error in message handler: ${err}`);
});
} catch {
log(`Invalid JSON: ${line}`);
}
});
interface QueryMessage {
type: 'query';
sessionId: string;
prompt: string;
cwd?: string;
maxTurns?: number;
resumeSessionId?: string;
permissionMode?: string;
systemPrompt?: string;
model?: string;
providerConfig?: Record<string, unknown>;
extraEnv?: Record<string, string>;
}
interface StopMessage {
type: 'stop';
sessionId: string;
}
async function handleMessage(msg: Record<string, unknown>) {
switch (msg.type) {
case 'ping':
send({ type: 'pong' });
break;
case 'query':
await handleQuery(msg as unknown as QueryMessage);
break;
case 'stop':
handleStop(msg as unknown as StopMessage);
break;
default:
send({ type: 'error', message: `Unknown message type: ${msg.type}` });
}
}
async function handleQuery(msg: QueryMessage) {
const { sessionId, prompt, cwd, maxTurns, resumeSessionId, permissionMode, model, providerConfig, extraEnv } = msg;
if (sessions.has(sessionId)) {
send({ type: 'error', sessionId, message: 'Session already running' });
return;
}
log(`Starting Codex session ${sessionId}`);
const controller = new AbortController();
// Strip CODEX*/OPENAI* env vars to prevent nesting issues
const cleanEnv: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(process.env)) {
if (!key.startsWith('CODEX') && !key.startsWith('OPENAI')) {
cleanEnv[key] = value;
}
}
// Re-inject the API key
const apiKey = process.env.CODEX_API_KEY || process.env.OPENAI_API_KEY;
if (apiKey) {
cleanEnv['CODEX_API_KEY'] = apiKey;
}
// Inject extra environment variables (e.g. BTMSG_AGENT_ID for agent communication)
if (extraEnv) {
for (const [key, value] of Object.entries(extraEnv)) {
cleanEnv[key] = value;
}
}
// Dynamically import SDK — fails gracefully if not installed
let Codex: any;
try {
const sdk = await import('@openai/codex-sdk');
Codex = sdk.Codex ?? sdk.default;
} catch {
send({ type: 'agent_error', sessionId, message: 'Codex SDK not installed. Run: npm install @openai/codex-sdk' });
return;
}
if (!apiKey) {
send({ type: 'agent_error', sessionId, message: 'No API key. Set CODEX_API_KEY or OPENAI_API_KEY.' });
return;
}
try {
// Map permission mode to Codex sandbox/approval settings
const sandbox = mapSandboxMode(providerConfig?.sandbox as string | undefined, permissionMode);
const approvalPolicy = permissionMode === 'bypassPermissions' ? 'never' : 'on-request';
const codex = new Codex({
env: cleanEnv as Record<string, string>,
config: {
model: model ?? 'gpt-5.4',
approval_policy: approvalPolicy,
sandbox: sandbox,
},
});
const threadOpts: Record<string, unknown> = {
workingDirectory: cwd || process.cwd(),
};
const thread = resumeSessionId
? codex.resumeThread(resumeSessionId)
: codex.startThread(threadOpts);
sessions.set(sessionId, { controller });
send({ type: 'agent_started', sessionId });
const streamResult = await thread.runStreamed(prompt);
for await (const event of streamResult.events) {
if (controller.signal.aborted) break;
// Forward raw Codex events — the message adapter parses them
send({
type: 'agent_event',
sessionId,
event: event as Record<string, unknown>,
});
}
sessions.delete(sessionId);
send({
type: 'agent_stopped',
sessionId,
exitCode: 0,
signal: null,
});
} catch (err: unknown) {
sessions.delete(sessionId);
const errMsg = err instanceof Error ? err.message : String(err);
if (controller.signal.aborted) {
log(`Codex session ${sessionId} aborted`);
send({
type: 'agent_stopped',
sessionId,
exitCode: null,
signal: 'SIGTERM',
});
} else {
log(`Codex session ${sessionId} error: ${errMsg}`);
send({
type: 'agent_error',
sessionId,
message: errMsg,
});
}
}
}
function handleStop(msg: StopMessage) {
const { sessionId } = msg;
const session = sessions.get(sessionId);
if (!session) {
send({ type: 'error', sessionId, message: 'Session not found' });
return;
}
log(`Stopping Codex session ${sessionId}`);
session.controller.abort();
}
function mapSandboxMode(
configSandbox: string | undefined,
permissionMode: string | undefined,
): string {
if (configSandbox) return configSandbox;
if (permissionMode === 'bypassPermissions') return 'danger-full-access';
return 'workspace-write';
}
function findCodexCli(): string | undefined {
const candidates = [
join(homedir(), '.local', 'bin', 'codex'),
'/usr/local/bin/codex',
'/usr/bin/codex',
];
for (const p of candidates) {
if (existsSync(p)) return p;
}
try {
return execSync('which codex 2>/dev/null || where codex 2>nul', { encoding: 'utf-8' }).trim().split('\n')[0];
} catch {
return undefined;
}
}
const codexPath = findCodexCli();
if (codexPath) {
log(`Found Codex CLI at ${codexPath}`);
} else {
log('Codex CLI not found — will use SDK if available');
}
log('Codex sidecar started');
send({ type: 'ready' });

269
v2/sidecar/ollama-runner.ts Normal file
View file

@ -0,0 +1,269 @@
// Ollama Runner — Node.js sidecar entry point for local Ollama provider
// Spawned by Rust SidecarManager, communicates via stdio NDJSON
// Uses direct HTTP to Ollama REST API (no external dependencies)
import { stdin, stdout, stderr } from 'process';
import { createInterface } from 'readline';
const rl = createInterface({ input: stdin });
const sessions = new Map<string, { controller: AbortController }>();
function send(msg: Record<string, unknown>) {
stdout.write(JSON.stringify(msg) + '\n');
}
function log(message: string) {
stderr.write(`[ollama-sidecar] ${message}\n`);
}
rl.on('line', (line: string) => {
try {
const msg = JSON.parse(line);
handleMessage(msg).catch((err: unknown) => {
log(`Unhandled error in message handler: ${err}`);
});
} catch {
log(`Invalid JSON: ${line}`);
}
});
interface QueryMessage {
type: 'query';
sessionId: string;
prompt: string;
cwd?: string;
model?: string;
systemPrompt?: string;
providerConfig?: Record<string, unknown>;
}
interface StopMessage {
type: 'stop';
sessionId: string;
}
async function handleMessage(msg: Record<string, unknown>) {
switch (msg.type) {
case 'ping':
send({ type: 'pong' });
break;
case 'query':
await handleQuery(msg as unknown as QueryMessage);
break;
case 'stop':
handleStop(msg as unknown as StopMessage);
break;
default:
send({ type: 'error', message: `Unknown message type: ${msg.type}` });
}
}
async function handleQuery(msg: QueryMessage) {
const { sessionId, prompt, cwd, model, systemPrompt, providerConfig } = msg;
if (sessions.has(sessionId)) {
send({ type: 'error', sessionId, message: 'Session already running' });
return;
}
const ollamaHost = (providerConfig?.host as string) || process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
const ollamaModel = model || 'qwen3:8b';
const numCtx = (providerConfig?.num_ctx as number) || 32768;
const think = (providerConfig?.think as boolean) ?? false;
log(`Starting Ollama session ${sessionId} with model ${ollamaModel}`);
// Health check
try {
const healthRes = await fetch(`${ollamaHost}/api/version`);
if (!healthRes.ok) {
send({ type: 'agent_error', sessionId, message: `Ollama not reachable at ${ollamaHost} (HTTP ${healthRes.status})` });
return;
}
} catch (err: unknown) {
const errMsg = err instanceof Error ? err.message : String(err);
send({ type: 'agent_error', sessionId, message: `Cannot connect to Ollama at ${ollamaHost}: ${errMsg}` });
return;
}
const controller = new AbortController();
sessions.set(sessionId, { controller });
send({ type: 'agent_started', sessionId });
// Emit init event
send({
type: 'agent_event',
sessionId,
event: {
type: 'system',
subtype: 'init',
session_id: sessionId,
model: ollamaModel,
cwd: cwd || process.cwd(),
},
});
// Build messages array
const messages: Array<{ role: string; content: string }> = [];
if (systemPrompt && typeof systemPrompt === 'string') {
messages.push({ role: 'system', content: systemPrompt });
}
messages.push({ role: 'user', content: prompt });
try {
const res = await fetch(`${ollamaHost}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: ollamaModel,
messages,
stream: true,
options: { num_ctx: numCtx },
think,
}),
signal: controller.signal,
});
if (!res.ok) {
const errBody = await res.text();
let errMsg: string;
try {
const parsed = JSON.parse(errBody);
errMsg = parsed.error || errBody;
} catch {
errMsg = errBody;
}
send({ type: 'agent_error', sessionId, message: `Ollama error (${res.status}): ${errMsg}` });
sessions.delete(sessionId);
return;
}
if (!res.body) {
send({ type: 'agent_error', sessionId, message: 'No response body from Ollama' });
sessions.delete(sessionId);
return;
}
// Parse NDJSON stream
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
if (controller.signal.aborted) break;
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const chunk = JSON.parse(trimmed) as Record<string, unknown>;
// Check for mid-stream error
if (typeof chunk.error === 'string') {
send({
type: 'agent_event',
sessionId,
event: { type: 'error', message: chunk.error },
});
continue;
}
// Forward as chunk event for the message adapter
send({
type: 'agent_event',
sessionId,
event: {
type: 'chunk',
message: chunk.message,
done: chunk.done,
done_reason: chunk.done_reason,
model: chunk.model,
prompt_eval_count: chunk.prompt_eval_count,
eval_count: chunk.eval_count,
eval_duration: chunk.eval_duration,
total_duration: chunk.total_duration,
},
});
} catch {
log(`Failed to parse Ollama chunk: ${trimmed}`);
}
}
}
// Process remaining buffer
if (buffer.trim()) {
try {
const chunk = JSON.parse(buffer.trim()) as Record<string, unknown>;
send({
type: 'agent_event',
sessionId,
event: {
type: 'chunk',
message: chunk.message,
done: chunk.done,
done_reason: chunk.done_reason,
model: chunk.model,
prompt_eval_count: chunk.prompt_eval_count,
eval_count: chunk.eval_count,
eval_duration: chunk.eval_duration,
total_duration: chunk.total_duration,
},
});
} catch {
log(`Failed to parse final Ollama buffer: ${buffer}`);
}
}
sessions.delete(sessionId);
send({
type: 'agent_stopped',
sessionId,
exitCode: 0,
signal: null,
});
} catch (err: unknown) {
sessions.delete(sessionId);
const errMsg = err instanceof Error ? err.message : String(err);
if (controller.signal.aborted) {
log(`Ollama session ${sessionId} aborted`);
send({
type: 'agent_stopped',
sessionId,
exitCode: null,
signal: 'SIGTERM',
});
} else {
log(`Ollama session ${sessionId} error: ${errMsg}`);
send({
type: 'agent_error',
sessionId,
message: errMsg,
});
}
}
}
function handleStop(msg: StopMessage) {
const { sessionId } = msg;
const session = sessions.get(sessionId);
if (!session) {
send({ type: 'error', sessionId, message: 'Session not found' });
return;
}
log(`Stopping Ollama session ${sessionId}`);
session.controller.abort();
}
log('Ollama sidecar started');
send({ type: 'ready' });

481
v2/sidecar/package-lock.json generated Normal file
View file

@ -0,0 +1,481 @@
{
"name": "bterminal-sidecar",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bterminal-sidecar",
"version": "0.1.0",
"devDependencies": {
"esbuild": "0.25.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.4.tgz",
"integrity": "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.4.tgz",
"integrity": "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.4.tgz",
"integrity": "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.4.tgz",
"integrity": "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.4.tgz",
"integrity": "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.4.tgz",
"integrity": "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.4.tgz",
"integrity": "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.4.tgz",
"integrity": "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.4.tgz",
"integrity": "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.4.tgz",
"integrity": "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.4.tgz",
"integrity": "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.4.tgz",
"integrity": "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.4.tgz",
"integrity": "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.4.tgz",
"integrity": "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.4.tgz",
"integrity": "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.4.tgz",
"integrity": "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.4.tgz",
"integrity": "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.4.tgz",
"integrity": "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.4.tgz",
"integrity": "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.4.tgz",
"integrity": "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.4.tgz",
"integrity": "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.4.tgz",
"integrity": "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.4.tgz",
"integrity": "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.4.tgz",
"integrity": "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.4.tgz",
"integrity": "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/esbuild": {
"version": "0.25.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.4.tgz",
"integrity": "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.25.4",
"@esbuild/android-arm": "0.25.4",
"@esbuild/android-arm64": "0.25.4",
"@esbuild/android-x64": "0.25.4",
"@esbuild/darwin-arm64": "0.25.4",
"@esbuild/darwin-x64": "0.25.4",
"@esbuild/freebsd-arm64": "0.25.4",
"@esbuild/freebsd-x64": "0.25.4",
"@esbuild/linux-arm": "0.25.4",
"@esbuild/linux-arm64": "0.25.4",
"@esbuild/linux-ia32": "0.25.4",
"@esbuild/linux-loong64": "0.25.4",
"@esbuild/linux-mips64el": "0.25.4",
"@esbuild/linux-ppc64": "0.25.4",
"@esbuild/linux-riscv64": "0.25.4",
"@esbuild/linux-s390x": "0.25.4",
"@esbuild/linux-x64": "0.25.4",
"@esbuild/netbsd-arm64": "0.25.4",
"@esbuild/netbsd-x64": "0.25.4",
"@esbuild/openbsd-arm64": "0.25.4",
"@esbuild/openbsd-x64": "0.25.4",
"@esbuild/sunos-x64": "0.25.4",
"@esbuild/win32-arm64": "0.25.4",
"@esbuild/win32-ia32": "0.25.4",
"@esbuild/win32-x64": "0.25.4"
}
}
}
}

12
v2/sidecar/package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "bterminal-sidecar",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"build": "esbuild claude-runner.ts --bundle --platform=node --target=node20 --outfile=dist/claude-runner.mjs --format=esm"
},
"devDependencies": {
"esbuild": "0.25.4"
}
}

4
v2/src-tauri/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
# Generated by Cargo
# will have compiled files and executables
/target/
/gen/schemas

43
v2/src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,43 @@
[package]
name = "bterminal"
version = "0.1.0"
description = "Multi-session Claude agent dashboard"
authors = ["DexterFromLab"]
license = "MIT"
edition = "2021"
rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
name = "bterminal_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2.5.6", features = [] }
[dependencies]
bterminal-core = { path = "../bterminal-core" }
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
log = "0.4"
tauri = { version = "2.10.3", features = [] }
rusqlite = { version = "0.31", features = ["bundled"] }
dirs = "5"
notify = { version = "6", features = ["macos_fsevent"] }
tauri-plugin-updater = "2.10.0"
tauri-plugin-dialog = "2"
rfd = { version = "0.16", default-features = false, features = ["gtk3"] }
uuid = { version = "1", features = ["v4"] }
tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
tokio = { version = "1", features = ["full"] }
futures-util = "0.3"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
opentelemetry = "0.28"
opentelemetry_sdk = { version = "0.28", features = ["rt-tokio"] }
opentelemetry-otlp = { version = "0.28", features = ["http-proto", "reqwest-client"] }
tracing-opentelemetry = "0.29"
[dev-dependencies]
tempfile = "3"

3
v2/src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,12 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "enables the default permissions",
"windows": [
"main"
],
"permissions": [
"core:default",
"dialog:default"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

BIN
v2/src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
v2/src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

405
v2/src-tauri/src/btmsg.rs Normal file
View file

@ -0,0 +1,405 @@
// btmsg — Read-only access to btmsg SQLite database
// Database at ~/.local/share/bterminal/btmsg.db (created by btmsg CLI)
use rusqlite::{params, Connection, OpenFlags};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
fn db_path() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("bterminal")
.join("btmsg.db")
}
fn open_db() -> Result<Connection, String> {
let path = db_path();
if !path.exists() {
return Err("btmsg database not found. Run 'btmsg register' first.".into());
}
Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE)
.map_err(|e| format!("Failed to open btmsg.db: {e}"))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BtmsgAgent {
pub id: String,
pub name: String,
pub role: String,
pub group_id: String,
pub tier: i32,
pub model: Option<String>,
pub status: String,
pub unread_count: i32,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BtmsgMessage {
pub id: String,
pub from_agent: String,
pub to_agent: String,
pub content: String,
pub read: bool,
pub reply_to: Option<String>,
pub created_at: String,
pub sender_name: Option<String>,
pub sender_role: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BtmsgFeedMessage {
pub id: String,
pub from_agent: String,
pub to_agent: String,
pub content: String,
pub created_at: String,
pub reply_to: Option<String>,
pub sender_name: String,
pub sender_role: String,
pub recipient_name: String,
pub recipient_role: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BtmsgChannel {
pub id: String,
pub name: String,
pub group_id: String,
pub created_by: String,
pub member_count: i32,
pub created_at: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BtmsgChannelMessage {
pub id: String,
pub channel_id: String,
pub from_agent: String,
pub content: String,
pub created_at: String,
pub sender_name: String,
pub sender_role: String,
}
pub fn get_agents(group_id: &str) -> Result<Vec<BtmsgAgent>, String> {
let db = open_db()?;
let mut stmt = db.prepare(
"SELECT a.*, (SELECT COUNT(*) FROM messages m WHERE m.to_agent = a.id AND m.read = 0) as unread_count \
FROM agents a WHERE a.group_id = ? ORDER BY a.tier, a.role, a.name"
).map_err(|e| format!("Query error: {e}"))?;
let agents = stmt.query_map(params![group_id], |row| {
Ok(BtmsgAgent {
id: row.get(0)?,
name: row.get(1)?,
role: row.get(2)?,
group_id: row.get(3)?,
tier: row.get(4)?,
model: row.get(5)?,
status: row.get::<_, Option<String>>(7)?.unwrap_or_else(|| "stopped".into()),
unread_count: row.get("unread_count")?,
})
}).map_err(|e| format!("Query error: {e}"))?;
agents.collect::<Result<Vec<_>, _>>().map_err(|e| format!("Row error: {e}"))
}
pub fn unread_count(agent_id: &str) -> Result<i32, String> {
let db = open_db()?;
db.query_row(
"SELECT COUNT(*) FROM messages WHERE to_agent = ? AND read = 0",
params![agent_id],
|row| row.get(0),
).map_err(|e| format!("Query error: {e}"))
}
pub fn unread_messages(agent_id: &str) -> Result<Vec<BtmsgMessage>, String> {
let db = open_db()?;
let mut stmt = db.prepare(
"SELECT m.id, m.from_agent, m.to_agent, m.content, m.read, m.reply_to, m.created_at, \
a.name, a.role \
FROM messages m JOIN agents a ON m.from_agent = a.id \
WHERE m.to_agent = ? AND m.read = 0 ORDER BY m.created_at ASC"
).map_err(|e| format!("Query error: {e}"))?;
let msgs = stmt.query_map(params![agent_id], |row| {
Ok(BtmsgMessage {
id: row.get(0)?,
from_agent: row.get(1)?,
to_agent: row.get(2)?,
content: row.get(3)?,
read: row.get::<_, i32>(4)? != 0,
reply_to: row.get(5)?,
created_at: row.get(6)?,
sender_name: row.get(7)?,
sender_role: row.get(8)?,
})
}).map_err(|e| format!("Query error: {e}"))?;
msgs.collect::<Result<Vec<_>, _>>().map_err(|e| format!("Row error: {e}"))
}
pub fn history(agent_id: &str, other_id: &str, limit: i32) -> Result<Vec<BtmsgMessage>, String> {
let db = open_db()?;
let mut stmt = db.prepare(
"SELECT m.id, m.from_agent, m.to_agent, m.content, m.read, m.reply_to, m.created_at, \
a.name, a.role \
FROM messages m JOIN agents a ON m.from_agent = a.id \
WHERE (m.from_agent = ?1 AND m.to_agent = ?2) OR (m.from_agent = ?2 AND m.to_agent = ?1) \
ORDER BY m.created_at ASC LIMIT ?3"
).map_err(|e| format!("Query error: {e}"))?;
let msgs = stmt.query_map(params![agent_id, other_id, limit], |row| {
Ok(BtmsgMessage {
id: row.get(0)?,
from_agent: row.get(1)?,
to_agent: row.get(2)?,
content: row.get(3)?,
read: row.get::<_, i32>(4)? != 0,
reply_to: row.get(5)?,
created_at: row.get(6)?,
sender_name: row.get(7)?,
sender_role: row.get(8)?,
})
}).map_err(|e| format!("Query error: {e}"))?;
msgs.collect::<Result<Vec<_>, _>>().map_err(|e| format!("Row error: {e}"))
}
pub fn send_message(from_agent: &str, to_agent: &str, content: &str) -> Result<String, String> {
let db = open_db()?;
// Get sender's group and tier
let (group_id, sender_tier): (String, i32) = db.query_row(
"SELECT group_id, tier FROM agents WHERE id = ?",
params![from_agent],
|row| Ok((row.get(0)?, row.get(1)?)),
).map_err(|e| format!("Sender not found: {e}"))?;
// Admin (tier 0) bypasses contact restrictions
if sender_tier > 0 {
let allowed: bool = db.query_row(
"SELECT COUNT(*) > 0 FROM contacts WHERE agent_id = ? AND contact_id = ?",
params![from_agent, to_agent],
|row| row.get(0),
).map_err(|e| format!("Contact check error: {e}"))?;
if !allowed {
return Err(format!("Not allowed to message '{to_agent}'"));
}
}
let msg_id = uuid::Uuid::new_v4().to_string();
db.execute(
"INSERT INTO messages (id, from_agent, to_agent, content, group_id) VALUES (?1, ?2, ?3, ?4, ?5)",
params![msg_id, from_agent, to_agent, content, group_id],
).map_err(|e| format!("Insert error: {e}"))?;
Ok(msg_id)
}
pub fn set_status(agent_id: &str, status: &str) -> Result<(), String> {
let db = open_db()?;
db.execute(
"UPDATE agents SET status = ?, last_active_at = datetime('now') WHERE id = ?",
params![status, agent_id],
).map_err(|e| format!("Update error: {e}"))?;
Ok(())
}
pub fn ensure_admin(group_id: &str) -> Result<(), String> {
let db = open_db()?;
let exists: bool = db.query_row(
"SELECT COUNT(*) > 0 FROM agents WHERE id = 'admin'",
[],
|row| row.get(0),
).map_err(|e| format!("Query error: {e}"))?;
if !exists {
db.execute(
"INSERT INTO agents (id, name, role, group_id, tier, status) \
VALUES ('admin', 'Operator', 'admin', ?, 0, 'active')",
params![group_id],
).map_err(|e| format!("Insert error: {e}"))?;
}
// Ensure admin has bidirectional contacts with ALL agents in the group
let mut stmt = db.prepare(
"SELECT id FROM agents WHERE group_id = ? AND id != 'admin'"
).map_err(|e| format!("Query error: {e}"))?;
let agent_ids: Vec<String> = stmt.query_map(params![group_id], |row| row.get(0))
.map_err(|e| format!("Query error: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row error: {e}"))?;
drop(stmt);
for aid in &agent_ids {
db.execute(
"INSERT OR IGNORE INTO contacts (agent_id, contact_id) VALUES ('admin', ?)",
params![aid],
).map_err(|e| format!("Insert error: {e}"))?;
db.execute(
"INSERT OR IGNORE INTO contacts (agent_id, contact_id) VALUES (?, 'admin')",
params![aid],
).map_err(|e| format!("Insert error: {e}"))?;
}
Ok(())
}
pub fn all_feed(group_id: &str, limit: i32) -> Result<Vec<BtmsgFeedMessage>, String> {
let db = open_db()?;
let mut stmt = db.prepare(
"SELECT m.id, m.from_agent, m.to_agent, m.content, m.created_at, m.reply_to, \
a1.name, a1.role, a2.name, a2.role \
FROM messages m \
JOIN agents a1 ON m.from_agent = a1.id \
JOIN agents a2 ON m.to_agent = a2.id \
WHERE m.group_id = ? \
ORDER BY m.created_at DESC LIMIT ?"
).map_err(|e| format!("Query error: {e}"))?;
let msgs = stmt.query_map(params![group_id, limit], |row| {
Ok(BtmsgFeedMessage {
id: row.get(0)?,
from_agent: row.get(1)?,
to_agent: row.get(2)?,
content: row.get(3)?,
created_at: row.get(4)?,
reply_to: row.get(5)?,
sender_name: row.get(6)?,
sender_role: row.get(7)?,
recipient_name: row.get(8)?,
recipient_role: row.get(9)?,
})
}).map_err(|e| format!("Query error: {e}"))?;
msgs.collect::<Result<Vec<_>, _>>().map_err(|e| format!("Row error: {e}"))
}
pub fn mark_read_conversation(reader_id: &str, sender_id: &str) -> Result<(), String> {
let db = open_db()?;
db.execute(
"UPDATE messages SET read = 1 WHERE to_agent = ? AND from_agent = ? AND read = 0",
params![reader_id, sender_id],
).map_err(|e| format!("Update error: {e}"))?;
Ok(())
}
pub fn get_channels(group_id: &str) -> Result<Vec<BtmsgChannel>, String> {
let db = open_db()?;
let mut stmt = db.prepare(
"SELECT c.id, c.name, c.group_id, c.created_by, \
(SELECT COUNT(*) FROM channel_members cm WHERE cm.channel_id = c.id), \
c.created_at \
FROM channels c WHERE c.group_id = ? ORDER BY c.name"
).map_err(|e| format!("Query error: {e}"))?;
let channels = stmt.query_map(params![group_id], |row| {
Ok(BtmsgChannel {
id: row.get(0)?,
name: row.get(1)?,
group_id: row.get(2)?,
created_by: row.get(3)?,
member_count: row.get(4)?,
created_at: row.get(5)?,
})
}).map_err(|e| format!("Query error: {e}"))?;
channels.collect::<Result<Vec<_>, _>>().map_err(|e| format!("Row error: {e}"))
}
pub fn get_channel_messages(channel_id: &str, limit: i32) -> Result<Vec<BtmsgChannelMessage>, String> {
let db = open_db()?;
let mut stmt = db.prepare(
"SELECT cm.id, cm.channel_id, cm.from_agent, cm.content, cm.created_at, \
a.name, a.role \
FROM channel_messages cm JOIN agents a ON cm.from_agent = a.id \
WHERE cm.channel_id = ? ORDER BY cm.created_at ASC LIMIT ?"
).map_err(|e| format!("Query error: {e}"))?;
let msgs = stmt.query_map(params![channel_id, limit], |row| {
Ok(BtmsgChannelMessage {
id: row.get(0)?,
channel_id: row.get(1)?,
from_agent: row.get(2)?,
content: row.get(3)?,
created_at: row.get(4)?,
sender_name: row.get(5)?,
sender_role: row.get(6)?,
})
}).map_err(|e| format!("Query error: {e}"))?;
msgs.collect::<Result<Vec<_>, _>>().map_err(|e| format!("Row error: {e}"))
}
pub fn send_channel_message(channel_id: &str, from_agent: &str, content: &str) -> Result<String, String> {
let db = open_db()?;
// Verify channel exists
let _: String = db.query_row(
"SELECT id FROM channels WHERE id = ?",
params![channel_id],
|row| row.get(0),
).map_err(|e| format!("Channel not found: {e}"))?;
// Check membership (admin bypasses)
let sender_tier: i32 = db.query_row(
"SELECT tier FROM agents WHERE id = ?",
params![from_agent],
|row| row.get(0),
).map_err(|e| format!("Sender not found: {e}"))?;
if sender_tier > 0 {
let is_member: bool = db.query_row(
"SELECT COUNT(*) > 0 FROM channel_members WHERE channel_id = ? AND agent_id = ?",
params![channel_id, from_agent],
|row| row.get(0),
).map_err(|e| format!("Membership check error: {e}"))?;
if !is_member {
return Err("Not a member of this channel".into());
}
}
let msg_id = uuid::Uuid::new_v4().to_string();
db.execute(
"INSERT INTO channel_messages (id, channel_id, from_agent, content) VALUES (?1, ?2, ?3, ?4)",
params![msg_id, channel_id, from_agent, content],
).map_err(|e| format!("Insert error: {e}"))?;
Ok(msg_id)
}
pub fn create_channel(name: &str, group_id: &str, created_by: &str) -> Result<String, String> {
let db = open_db()?;
let channel_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
db.execute(
"INSERT INTO channels (id, name, group_id, created_by) VALUES (?1, ?2, ?3, ?4)",
params![channel_id, name, group_id, created_by],
).map_err(|e| format!("Insert error: {e}"))?;
// Auto-add creator as member
db.execute(
"INSERT INTO channel_members (channel_id, agent_id) VALUES (?1, ?2)",
params![channel_id, created_by],
).map_err(|e| format!("Insert error: {e}"))?;
Ok(channel_id)
}
pub fn add_channel_member(channel_id: &str, agent_id: &str) -> Result<(), String> {
let db = open_db()?;
db.execute(
"INSERT OR IGNORE INTO channel_members (channel_id, agent_id) VALUES (?1, ?2)",
params![channel_id, agent_id],
).map_err(|e| format!("Insert error: {e}"))?;
Ok(())
}

169
v2/src-tauri/src/bttask.rs Normal file
View file

@ -0,0 +1,169 @@
// bttask — Read access to task board SQLite tables in btmsg.db
// Tasks table created by bttask CLI, shared DB with btmsg
use rusqlite::{params, Connection, OpenFlags};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
fn db_path() -> PathBuf {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("bterminal")
.join("btmsg.db")
}
fn open_db() -> Result<Connection, String> {
let path = db_path();
if !path.exists() {
return Err("btmsg database not found".into());
}
Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE)
.map_err(|e| format!("Failed to open btmsg.db: {e}"))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Task {
pub id: String,
pub title: String,
pub description: String,
pub status: String,
pub priority: String,
pub assigned_to: Option<String>,
pub created_by: String,
pub group_id: String,
pub parent_task_id: Option<String>,
pub sort_order: i32,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskComment {
pub id: String,
pub task_id: String,
pub agent_id: String,
pub content: String,
pub created_at: String,
}
/// Get all tasks for a group
pub fn list_tasks(group_id: &str) -> Result<Vec<Task>, String> {
let db = open_db()?;
let mut stmt = db
.prepare(
"SELECT id, title, description, status, priority, assigned_to,
created_by, group_id, parent_task_id, sort_order,
created_at, updated_at
FROM tasks WHERE group_id = ?1
ORDER BY sort_order ASC, created_at DESC",
)
.map_err(|e| format!("Query error: {e}"))?;
let rows = stmt
.query_map(params![group_id], |row| {
Ok(Task {
id: row.get(0)?,
title: row.get(1)?,
description: row.get::<_, String>(2).unwrap_or_default(),
status: row.get::<_, String>(3).unwrap_or_else(|_| "todo".into()),
priority: row.get::<_, String>(4).unwrap_or_else(|_| "medium".into()),
assigned_to: row.get(5)?,
created_by: row.get(6)?,
group_id: row.get(7)?,
parent_task_id: row.get(8)?,
sort_order: row.get::<_, i32>(9).unwrap_or(0),
created_at: row.get::<_, String>(10).unwrap_or_default(),
updated_at: row.get::<_, String>(11).unwrap_or_default(),
})
})
.map_err(|e| format!("Query error: {e}"))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row error: {e}"))
}
/// Get comments for a task
pub fn task_comments(task_id: &str) -> Result<Vec<TaskComment>, String> {
let db = open_db()?;
let mut stmt = db
.prepare(
"SELECT id, task_id, agent_id, content, created_at
FROM task_comments WHERE task_id = ?1
ORDER BY created_at ASC",
)
.map_err(|e| format!("Query error: {e}"))?;
let rows = stmt
.query_map(params![task_id], |row| {
Ok(TaskComment {
id: row.get(0)?,
task_id: row.get(1)?,
agent_id: row.get(2)?,
content: row.get(3)?,
created_at: row.get::<_, String>(4).unwrap_or_default(),
})
})
.map_err(|e| format!("Query error: {e}"))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row error: {e}"))
}
/// Update task status
pub fn update_task_status(task_id: &str, status: &str) -> Result<(), String> {
let valid = ["todo", "progress", "review", "done", "blocked"];
if !valid.contains(&status) {
return Err(format!("Invalid status '{}'. Valid: {:?}", status, valid));
}
let db = open_db()?;
db.execute(
"UPDATE tasks SET status = ?1, updated_at = datetime('now') WHERE id = ?2",
params![status, task_id],
)
.map_err(|e| format!("Update error: {e}"))?;
Ok(())
}
/// Add a comment to a task
pub fn add_comment(task_id: &str, agent_id: &str, content: &str) -> Result<String, String> {
let db = open_db()?;
let id = uuid::Uuid::new_v4().to_string();
db.execute(
"INSERT INTO task_comments (id, task_id, agent_id, content) VALUES (?1, ?2, ?3, ?4)",
params![id, task_id, agent_id, content],
)
.map_err(|e| format!("Insert error: {e}"))?;
Ok(id)
}
/// Create a new task
pub fn create_task(
title: &str,
description: &str,
priority: &str,
group_id: &str,
created_by: &str,
assigned_to: Option<&str>,
) -> Result<String, String> {
let db = open_db()?;
let id = uuid::Uuid::new_v4().to_string();
db.execute(
"INSERT INTO tasks (id, title, description, priority, group_id, created_by, assigned_to)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![id, title, description, priority, group_id, created_by, assigned_to],
)
.map_err(|e| format!("Insert error: {e}"))?;
Ok(id)
}
/// Delete a task
pub fn delete_task(task_id: &str) -> Result<(), String> {
let db = open_db()?;
db.execute("DELETE FROM task_comments WHERE task_id = ?1", params![task_id])
.map_err(|e| format!("Delete comments error: {e}"))?;
db.execute("DELETE FROM tasks WHERE id = ?1", params![task_id])
.map_err(|e| format!("Delete task error: {e}"))?;
Ok(())
}

View file

@ -0,0 +1,29 @@
use tauri::State;
use crate::AppState;
use crate::sidecar::AgentQueryOptions;
#[tauri::command]
#[tracing::instrument(skip(state, options), fields(session_id = %options.session_id))]
pub fn agent_query(
state: State<'_, AppState>,
options: AgentQueryOptions,
) -> Result<(), String> {
state.sidecar_manager.query(&options)
}
#[tauri::command]
#[tracing::instrument(skip(state))]
pub fn agent_stop(state: State<'_, AppState>, session_id: String) -> Result<(), String> {
state.sidecar_manager.stop_session(&session_id)
}
#[tauri::command]
pub fn agent_ready(state: State<'_, AppState>) -> bool {
state.sidecar_manager.is_ready()
}
#[tauri::command]
#[tracing::instrument(skip(state))]
pub fn agent_restart(state: State<'_, AppState>) -> Result<(), String> {
state.sidecar_manager.restart()
}

View file

@ -0,0 +1,71 @@
use crate::btmsg;
#[tauri::command]
pub fn btmsg_get_agents(group_id: String) -> Result<Vec<btmsg::BtmsgAgent>, String> {
btmsg::get_agents(&group_id)
}
#[tauri::command]
pub fn btmsg_unread_count(agent_id: String) -> Result<i32, String> {
btmsg::unread_count(&agent_id)
}
#[tauri::command]
pub fn btmsg_unread_messages(agent_id: String) -> Result<Vec<btmsg::BtmsgMessage>, String> {
btmsg::unread_messages(&agent_id)
}
#[tauri::command]
pub fn btmsg_history(agent_id: String, other_id: String, limit: i32) -> Result<Vec<btmsg::BtmsgMessage>, String> {
btmsg::history(&agent_id, &other_id, limit)
}
#[tauri::command]
pub fn btmsg_send(from_agent: String, to_agent: String, content: String) -> Result<String, String> {
btmsg::send_message(&from_agent, &to_agent, &content)
}
#[tauri::command]
pub fn btmsg_set_status(agent_id: String, status: String) -> Result<(), String> {
btmsg::set_status(&agent_id, &status)
}
#[tauri::command]
pub fn btmsg_ensure_admin(group_id: String) -> Result<(), String> {
btmsg::ensure_admin(&group_id)
}
#[tauri::command]
pub fn btmsg_all_feed(group_id: String, limit: i32) -> Result<Vec<btmsg::BtmsgFeedMessage>, String> {
btmsg::all_feed(&group_id, limit)
}
#[tauri::command]
pub fn btmsg_mark_read(reader_id: String, sender_id: String) -> Result<(), String> {
btmsg::mark_read_conversation(&reader_id, &sender_id)
}
#[tauri::command]
pub fn btmsg_get_channels(group_id: String) -> Result<Vec<btmsg::BtmsgChannel>, String> {
btmsg::get_channels(&group_id)
}
#[tauri::command]
pub fn btmsg_channel_messages(channel_id: String, limit: i32) -> Result<Vec<btmsg::BtmsgChannelMessage>, String> {
btmsg::get_channel_messages(&channel_id, limit)
}
#[tauri::command]
pub fn btmsg_channel_send(channel_id: String, from_agent: String, content: String) -> Result<String, String> {
btmsg::send_channel_message(&channel_id, &from_agent, &content)
}
#[tauri::command]
pub fn btmsg_create_channel(name: String, group_id: String, created_by: String) -> Result<String, String> {
btmsg::create_channel(&name, &group_id, &created_by)
}
#[tauri::command]
pub fn btmsg_add_channel_member(channel_id: String, agent_id: String) -> Result<(), String> {
btmsg::add_channel_member(&channel_id, &agent_id)
}

View file

@ -0,0 +1,38 @@
use crate::bttask;
#[tauri::command]
pub fn bttask_list(group_id: String) -> Result<Vec<bttask::Task>, String> {
bttask::list_tasks(&group_id)
}
#[tauri::command]
pub fn bttask_comments(task_id: String) -> Result<Vec<bttask::TaskComment>, String> {
bttask::task_comments(&task_id)
}
#[tauri::command]
pub fn bttask_update_status(task_id: String, status: String) -> Result<(), String> {
bttask::update_task_status(&task_id, &status)
}
#[tauri::command]
pub fn bttask_add_comment(task_id: String, agent_id: String, content: String) -> Result<String, String> {
bttask::add_comment(&task_id, &agent_id, &content)
}
#[tauri::command]
pub fn bttask_create(
title: String,
description: String,
priority: String,
group_id: String,
created_by: String,
assigned_to: Option<String>,
) -> Result<String, String> {
bttask::create_task(&title, &description, &priority, &group_id, &created_by, assigned_to.as_deref())
}
#[tauri::command]
pub fn bttask_delete(task_id: String) -> Result<(), String> {
bttask::delete_task(&task_id)
}

View file

@ -0,0 +1,158 @@
// Claude profile and skill discovery commands
#[derive(serde::Serialize)]
pub struct ClaudeProfile {
pub name: String,
pub email: Option<String>,
pub subscription_type: Option<String>,
pub display_name: Option<String>,
pub config_dir: String,
}
#[derive(serde::Serialize)]
pub struct ClaudeSkill {
pub name: String,
pub description: String,
pub source_path: String,
}
#[tauri::command]
pub fn claude_list_profiles() -> Vec<ClaudeProfile> {
let mut profiles = Vec::new();
let config_dir = dirs::config_dir().unwrap_or_default();
let profiles_dir = config_dir.join("switcher").join("profiles");
let alt_dir_root = config_dir.join("switcher-claude");
if let Ok(entries) = std::fs::read_dir(&profiles_dir) {
for entry in entries.flatten() {
if !entry.path().is_dir() { continue; }
let name = entry.file_name().to_string_lossy().to_string();
let toml_path = entry.path().join("profile.toml");
let (email, subscription_type, display_name) = if toml_path.exists() {
let content = std::fs::read_to_string(&toml_path).unwrap_or_else(|e| {
log::warn!("Failed to read {}: {e}", toml_path.display());
String::new()
});
(
extract_toml_value(&content, "email"),
extract_toml_value(&content, "subscription_type"),
extract_toml_value(&content, "display_name"),
)
} else {
(None, None, None)
};
let alt_path = alt_dir_root.join(&name);
let config_dir_str = if alt_path.exists() {
alt_path.to_string_lossy().to_string()
} else {
dirs::home_dir()
.unwrap_or_default()
.join(".claude")
.to_string_lossy()
.to_string()
};
profiles.push(ClaudeProfile {
name,
email,
subscription_type,
display_name,
config_dir: config_dir_str,
});
}
}
if profiles.is_empty() {
let home = dirs::home_dir().unwrap_or_default();
profiles.push(ClaudeProfile {
name: "default".to_string(),
email: None,
subscription_type: None,
display_name: None,
config_dir: home.join(".claude").to_string_lossy().to_string(),
});
}
profiles
}
fn extract_toml_value(content: &str, key: &str) -> Option<String> {
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix(key) {
if let Some(rest) = rest.trim().strip_prefix('=') {
let val = rest.trim().trim_matches('"');
if !val.is_empty() {
return Some(val.to_string());
}
}
}
}
None
}
#[tauri::command]
pub fn claude_list_skills() -> Vec<ClaudeSkill> {
let mut skills = Vec::new();
let home = dirs::home_dir().unwrap_or_default();
let skills_dir = home.join(".claude").join("skills");
if let Ok(entries) = std::fs::read_dir(&skills_dir) {
for entry in entries.flatten() {
let path = entry.path();
let (name, skill_file) = if path.is_dir() {
let skill_md = path.join("SKILL.md");
if skill_md.exists() {
(entry.file_name().to_string_lossy().to_string(), skill_md)
} else {
continue;
}
} else if path.extension().map_or(false, |e| e == "md") {
let stem = path.file_stem().unwrap_or_default().to_string_lossy().to_string();
(stem, path.clone())
} else {
continue;
};
let description = if let Ok(content) = std::fs::read_to_string(&skill_file) {
content.lines()
.filter(|l| !l.trim().is_empty() && !l.starts_with('#'))
.next()
.unwrap_or("")
.trim()
.chars()
.take(120)
.collect()
} else {
String::new()
};
skills.push(ClaudeSkill {
name,
description,
source_path: skill_file.to_string_lossy().to_string(),
});
}
}
skills
}
#[tauri::command]
pub fn claude_read_skill(path: String) -> Result<String, String> {
let skills_dir = dirs::home_dir()
.ok_or("Cannot determine home directory")?
.join(".claude")
.join("skills");
let canonical_skills = skills_dir.canonicalize()
.map_err(|_| "Skills directory does not exist".to_string())?;
let canonical_path = std::path::Path::new(&path).canonicalize()
.map_err(|e| format!("Invalid skill path: {e}"))?;
if !canonical_path.starts_with(&canonical_skills) {
return Err("Access denied: path is outside skills directory".to_string());
}
std::fs::read_to_string(&canonical_path).map_err(|e| format!("Failed to read skill: {e}"))
}

View file

@ -0,0 +1,130 @@
// File browser commands (Files tab)
#[derive(serde::Serialize)]
pub struct DirEntry {
pub name: String,
pub path: String,
pub is_dir: bool,
pub size: u64,
pub ext: String,
}
/// Content types for file viewer routing
#[derive(serde::Serialize)]
#[serde(tag = "type")]
pub enum FileContent {
Text { content: String, lang: String },
Binary { message: String },
TooLarge { size: u64 },
}
#[tauri::command]
pub fn list_directory_children(path: String) -> Result<Vec<DirEntry>, String> {
let dir = std::path::Path::new(&path);
if !dir.is_dir() {
return Err(format!("Not a directory: {path}"));
}
let mut entries = Vec::new();
let read_dir = std::fs::read_dir(dir).map_err(|e| format!("Failed to read directory: {e}"))?;
for entry in read_dir {
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
let metadata = entry.metadata().map_err(|e| format!("Failed to read metadata: {e}"))?;
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let is_dir = metadata.is_dir();
let ext = if is_dir {
String::new()
} else {
std::path::Path::new(&name)
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default()
};
entries.push(DirEntry {
name,
path: entry.path().to_string_lossy().into_owned(),
is_dir,
size: metadata.len(),
ext,
});
}
entries.sort_by(|a, b| {
b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(entries)
}
#[tauri::command]
pub fn read_file_content(path: String) -> Result<FileContent, String> {
let file_path = std::path::Path::new(&path);
if !file_path.is_file() {
return Err(format!("Not a file: {path}"));
}
let metadata = std::fs::metadata(&path).map_err(|e| format!("Failed to read metadata: {e}"))?;
let size = metadata.len();
if size > 10 * 1024 * 1024 {
return Ok(FileContent::TooLarge { size });
}
let ext = file_path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let binary_exts = ["png", "jpg", "jpeg", "gif", "webp", "svg", "ico", "bmp",
"pdf", "zip", "tar", "gz", "7z", "rar",
"mp3", "mp4", "wav", "ogg", "webm", "avi",
"woff", "woff2", "ttf", "otf", "eot",
"exe", "dll", "so", "dylib", "wasm"];
if binary_exts.contains(&ext.as_str()) {
return Ok(FileContent::Binary { message: format!("Binary file ({ext}), {size} bytes") });
}
let content = std::fs::read_to_string(&path)
.map_err(|_| format!("Binary or non-UTF-8 file"))?;
let lang = match ext.as_str() {
"rs" => "rust",
"ts" | "tsx" => "typescript",
"js" | "jsx" | "mjs" | "cjs" => "javascript",
"py" => "python",
"svelte" => "svelte",
"html" | "htm" => "html",
"css" | "scss" | "less" => "css",
"json" => "json",
"toml" => "toml",
"yaml" | "yml" => "yaml",
"md" | "markdown" => "markdown",
"sh" | "bash" | "zsh" => "bash",
"sql" => "sql",
"xml" => "xml",
"csv" => "csv",
"dockerfile" => "dockerfile",
"lock" => "text",
_ => "text",
}.to_string();
Ok(FileContent::Text { content, lang })
}
#[tauri::command]
pub fn write_file_content(path: String, content: String) -> Result<(), String> {
let file_path = std::path::Path::new(&path);
if !file_path.is_file() {
return Err(format!("Not an existing file: {path}"));
}
std::fs::write(&path, content.as_bytes())
.map_err(|e| format!("Failed to write file: {e}"))
}
#[tauri::command]
pub async fn pick_directory(window: tauri::Window) -> Result<Option<String>, String> {
let dialog = rfd::AsyncFileDialog::new()
.set_title("Select Directory")
.set_parent(&window);
let folder = dialog.pick_folder().await;
Ok(folder.map(|f| f.path().to_string_lossy().into_owned()))
}

View file

@ -0,0 +1,16 @@
use crate::groups::{GroupsFile, MdFileEntry};
#[tauri::command]
pub fn groups_load() -> Result<GroupsFile, String> {
crate::groups::load_groups()
}
#[tauri::command]
pub fn groups_save(config: GroupsFile) -> Result<(), String> {
crate::groups::save_groups(&config)
}
#[tauri::command]
pub fn discover_markdown_files(cwd: String) -> Result<Vec<MdFileEntry>, String> {
crate::groups::discover_markdown_files(&cwd)
}

View file

@ -0,0 +1,67 @@
use tauri::State;
use crate::AppState;
use crate::{ctx, memora};
// --- ctx commands ---
#[tauri::command]
pub fn ctx_init_db(state: State<'_, AppState>) -> Result<(), String> {
state.ctx_db.init_db()
}
#[tauri::command]
pub fn ctx_register_project(state: State<'_, AppState>, name: String, description: String, work_dir: Option<String>) -> Result<(), String> {
state.ctx_db.register_project(&name, &description, work_dir.as_deref())
}
#[tauri::command]
pub fn ctx_get_context(state: State<'_, AppState>, project: String) -> Result<Vec<ctx::CtxEntry>, String> {
state.ctx_db.get_context(&project)
}
#[tauri::command]
pub fn ctx_get_shared(state: State<'_, AppState>) -> Result<Vec<ctx::CtxEntry>, String> {
state.ctx_db.get_shared()
}
#[tauri::command]
pub fn ctx_get_summaries(state: State<'_, AppState>, project: String, limit: i64) -> Result<Vec<ctx::CtxSummary>, String> {
state.ctx_db.get_summaries(&project, limit)
}
#[tauri::command]
pub fn ctx_search(state: State<'_, AppState>, query: String) -> Result<Vec<ctx::CtxEntry>, String> {
state.ctx_db.search(&query)
}
// --- Memora commands (read-only) ---
#[tauri::command]
pub fn memora_available(state: State<'_, AppState>) -> bool {
state.memora_db.is_available()
}
#[tauri::command]
pub fn memora_list(
state: State<'_, AppState>,
tags: Option<Vec<String>>,
limit: Option<i64>,
offset: Option<i64>,
) -> Result<memora::MemoraSearchResult, String> {
state.memora_db.list(tags, limit.unwrap_or(50), offset.unwrap_or(0))
}
#[tauri::command]
pub fn memora_search(
state: State<'_, AppState>,
query: String,
tags: Option<Vec<String>>,
limit: Option<i64>,
) -> Result<memora::MemoraSearchResult, String> {
state.memora_db.search(&query, tags, limit.unwrap_or(50))
}
#[tauri::command]
pub fn memora_get(state: State<'_, AppState>, id: i64) -> Result<Option<memora::MemoraNode>, String> {
state.memora_db.get(id)
}

View file

@ -0,0 +1,41 @@
// Miscellaneous commands — CLI args, URL opening, frontend telemetry
#[tauri::command]
pub fn cli_get_group() -> Option<String> {
let args: Vec<String> = std::env::args().collect();
let mut i = 1;
while i < args.len() {
if args[i] == "--group" {
if i + 1 < args.len() {
return Some(args[i + 1].clone());
}
} else if let Some(val) = args[i].strip_prefix("--group=") {
return Some(val.to_string());
}
i += 1;
}
None
}
#[tauri::command]
pub fn open_url(url: String) -> Result<(), String> {
if !url.starts_with("http://") && !url.starts_with("https://") {
return Err("Only http/https URLs are allowed".into());
}
std::process::Command::new("xdg-open")
.arg(&url)
.spawn()
.map_err(|e| format!("Failed to open URL: {e}"))?;
Ok(())
}
#[tauri::command]
pub fn frontend_log(level: String, message: String, context: Option<serde_json::Value>) {
match level.as_str() {
"error" => tracing::error!(source = "frontend", ?context, "{message}"),
"warn" => tracing::warn!(source = "frontend", ?context, "{message}"),
"info" => tracing::info!(source = "frontend", ?context, "{message}"),
"debug" => tracing::debug!(source = "frontend", ?context, "{message}"),
_ => tracing::trace!(source = "frontend", ?context, "{message}"),
}
}

View file

@ -0,0 +1,13 @@
pub mod pty;
pub mod agent;
pub mod watcher;
pub mod session;
pub mod persistence;
pub mod knowledge;
pub mod claude;
pub mod groups;
pub mod files;
pub mod remote;
pub mod misc;
pub mod btmsg;
pub mod bttask;

View file

@ -0,0 +1,109 @@
use tauri::State;
use crate::AppState;
use crate::session::{AgentMessageRecord, ProjectAgentState, SessionMetric, SessionAnchorRecord};
// --- Agent message persistence ---
#[tauri::command]
pub fn agent_messages_save(
state: State<'_, AppState>,
session_id: String,
project_id: String,
sdk_session_id: Option<String>,
messages: Vec<AgentMessageRecord>,
) -> Result<(), String> {
state.session_db.save_agent_messages(
&session_id,
&project_id,
sdk_session_id.as_deref(),
&messages,
)
}
#[tauri::command]
pub fn agent_messages_load(
state: State<'_, AppState>,
project_id: String,
) -> Result<Vec<AgentMessageRecord>, String> {
state.session_db.load_agent_messages(&project_id)
}
// --- Project agent state ---
#[tauri::command]
pub fn project_agent_state_save(
state: State<'_, AppState>,
agent_state: ProjectAgentState,
) -> Result<(), String> {
state.session_db.save_project_agent_state(&agent_state)
}
#[tauri::command]
pub fn project_agent_state_load(
state: State<'_, AppState>,
project_id: String,
) -> Result<Option<ProjectAgentState>, String> {
state.session_db.load_project_agent_state(&project_id)
}
// --- Session metrics ---
#[tauri::command]
pub fn session_metric_save(
state: State<'_, AppState>,
metric: SessionMetric,
) -> Result<(), String> {
state.session_db.save_session_metric(&metric)
}
#[tauri::command]
pub fn session_metrics_load(
state: State<'_, AppState>,
project_id: String,
limit: i64,
) -> Result<Vec<SessionMetric>, String> {
state.session_db.load_session_metrics(&project_id, limit)
}
// --- Session anchors ---
#[tauri::command]
pub fn session_anchors_save(
state: State<'_, AppState>,
anchors: Vec<SessionAnchorRecord>,
) -> Result<(), String> {
state.session_db.save_session_anchors(&anchors)
}
#[tauri::command]
pub fn session_anchors_load(
state: State<'_, AppState>,
project_id: String,
) -> Result<Vec<SessionAnchorRecord>, String> {
state.session_db.load_session_anchors(&project_id)
}
#[tauri::command]
pub fn session_anchor_delete(
state: State<'_, AppState>,
id: String,
) -> Result<(), String> {
state.session_db.delete_session_anchor(&id)
}
#[tauri::command]
pub fn session_anchors_clear(
state: State<'_, AppState>,
project_id: String,
) -> Result<(), String> {
state.session_db.delete_project_anchors(&project_id)
}
#[tauri::command]
pub fn session_anchor_update_type(
state: State<'_, AppState>,
id: String,
anchor_type: String,
) -> Result<(), String> {
state.session_db.update_anchor_type(&id, &anchor_type)
}

View file

@ -0,0 +1,33 @@
use tauri::State;
use crate::AppState;
use crate::pty::PtyOptions;
#[tauri::command]
#[tracing::instrument(skip(state), fields(shell = ?options.shell))]
pub fn pty_spawn(
state: State<'_, AppState>,
options: PtyOptions,
) -> Result<String, String> {
state.pty_manager.spawn(options)
}
#[tauri::command]
pub fn pty_write(state: State<'_, AppState>, id: String, data: String) -> Result<(), String> {
state.pty_manager.write(&id, &data)
}
#[tauri::command]
pub fn pty_resize(
state: State<'_, AppState>,
id: String,
cols: u16,
rows: u16,
) -> Result<(), String> {
state.pty_manager.resize(&id, cols, rows)
}
#[tauri::command]
#[tracing::instrument(skip(state))]
pub fn pty_kill(state: State<'_, AppState>, id: String) -> Result<(), String> {
state.pty_manager.kill(&id)
}

View file

@ -0,0 +1,65 @@
use tauri::State;
use crate::AppState;
use crate::remote::{RemoteMachineConfig, RemoteMachineInfo};
use crate::pty::PtyOptions;
use crate::sidecar::AgentQueryOptions;
#[tauri::command]
pub async fn remote_list(state: State<'_, AppState>) -> Result<Vec<RemoteMachineInfo>, String> {
Ok(state.remote_manager.list_machines().await)
}
#[tauri::command]
pub async fn remote_add(state: State<'_, AppState>, config: RemoteMachineConfig) -> Result<String, String> {
Ok(state.remote_manager.add_machine(config).await)
}
#[tauri::command]
pub async fn remote_remove(state: State<'_, AppState>, machine_id: String) -> Result<(), String> {
state.remote_manager.remove_machine(&machine_id).await
}
#[tauri::command]
#[tracing::instrument(skip(app, state))]
pub async fn remote_connect(app: tauri::AppHandle, state: State<'_, AppState>, machine_id: String) -> Result<(), String> {
state.remote_manager.connect(&app, &machine_id).await
}
#[tauri::command]
#[tracing::instrument(skip(state))]
pub async fn remote_disconnect(state: State<'_, AppState>, machine_id: String) -> Result<(), String> {
state.remote_manager.disconnect(&machine_id).await
}
#[tauri::command]
#[tracing::instrument(skip(state, options), fields(session_id = %options.session_id))]
pub async fn remote_agent_query(state: State<'_, AppState>, machine_id: String, options: AgentQueryOptions) -> Result<(), String> {
state.remote_manager.agent_query(&machine_id, &options).await
}
#[tauri::command]
#[tracing::instrument(skip(state))]
pub async fn remote_agent_stop(state: State<'_, AppState>, machine_id: String, session_id: String) -> Result<(), String> {
state.remote_manager.agent_stop(&machine_id, &session_id).await
}
#[tauri::command]
#[tracing::instrument(skip(state), fields(shell = ?options.shell))]
pub async fn remote_pty_spawn(state: State<'_, AppState>, machine_id: String, options: PtyOptions) -> Result<String, String> {
state.remote_manager.pty_spawn(&machine_id, &options).await
}
#[tauri::command]
pub async fn remote_pty_write(state: State<'_, AppState>, machine_id: String, id: String, data: String) -> Result<(), String> {
state.remote_manager.pty_write(&machine_id, &id, &data).await
}
#[tauri::command]
pub async fn remote_pty_resize(state: State<'_, AppState>, machine_id: String, id: String, cols: u16, rows: u16) -> Result<(), String> {
state.remote_manager.pty_resize(&machine_id, &id, cols, rows).await
}
#[tauri::command]
pub async fn remote_pty_kill(state: State<'_, AppState>, machine_id: String, id: String) -> Result<(), String> {
state.remote_manager.pty_kill(&machine_id, &id).await
}

View file

@ -0,0 +1,81 @@
use tauri::State;
use crate::AppState;
use crate::session::{Session, LayoutState, SshSession};
// --- Session persistence ---
#[tauri::command]
pub fn session_list(state: State<'_, AppState>) -> Result<Vec<Session>, String> {
state.session_db.list_sessions()
}
#[tauri::command]
pub fn session_save(state: State<'_, AppState>, session: Session) -> Result<(), String> {
state.session_db.save_session(&session)
}
#[tauri::command]
pub fn session_delete(state: State<'_, AppState>, id: String) -> Result<(), String> {
state.session_db.delete_session(&id)
}
#[tauri::command]
pub fn session_update_title(state: State<'_, AppState>, id: String, title: String) -> Result<(), String> {
state.session_db.update_title(&id, &title)
}
#[tauri::command]
pub fn session_touch(state: State<'_, AppState>, id: String) -> Result<(), String> {
state.session_db.touch_session(&id)
}
#[tauri::command]
pub fn session_update_group(state: State<'_, AppState>, id: String, group_name: String) -> Result<(), String> {
state.session_db.update_group(&id, &group_name)
}
// --- Layout ---
#[tauri::command]
pub fn layout_save(state: State<'_, AppState>, layout: LayoutState) -> Result<(), String> {
state.session_db.save_layout(&layout)
}
#[tauri::command]
pub fn layout_load(state: State<'_, AppState>) -> Result<LayoutState, String> {
state.session_db.load_layout()
}
// --- Settings ---
#[tauri::command]
pub fn settings_get(state: State<'_, AppState>, key: String) -> Result<Option<String>, String> {
state.session_db.get_setting(&key)
}
#[tauri::command]
pub fn settings_set(state: State<'_, AppState>, key: String, value: String) -> Result<(), String> {
state.session_db.set_setting(&key, &value)
}
#[tauri::command]
pub fn settings_list(state: State<'_, AppState>) -> Result<Vec<(String, String)>, String> {
state.session_db.get_all_settings()
}
// --- SSH sessions ---
#[tauri::command]
pub fn ssh_session_list(state: State<'_, AppState>) -> Result<Vec<SshSession>, String> {
state.session_db.list_ssh_sessions()
}
#[tauri::command]
pub fn ssh_session_save(state: State<'_, AppState>, session: SshSession) -> Result<(), String> {
state.session_db.save_ssh_session(&session)
}
#[tauri::command]
pub fn ssh_session_delete(state: State<'_, AppState>, id: String) -> Result<(), String> {
state.session_db.delete_ssh_session(&id)
}

View file

@ -0,0 +1,43 @@
use tauri::State;
use crate::AppState;
use crate::fs_watcher::FsWatcherStatus;
#[tauri::command]
pub fn file_watch(
app: tauri::AppHandle,
state: State<'_, AppState>,
pane_id: String,
path: String,
) -> Result<String, String> {
state.file_watcher.watch(&app, &pane_id, &path)
}
#[tauri::command]
pub fn file_unwatch(state: State<'_, AppState>, pane_id: String) {
state.file_watcher.unwatch(&pane_id);
}
#[tauri::command]
pub fn file_read(state: State<'_, AppState>, path: String) -> Result<String, String> {
state.file_watcher.read_file(&path)
}
#[tauri::command]
pub fn fs_watch_project(
app: tauri::AppHandle,
state: State<'_, AppState>,
project_id: String,
cwd: String,
) -> Result<(), String> {
state.fs_watcher.watch_project(&app, &project_id, &cwd)
}
#[tauri::command]
pub fn fs_unwatch_project(state: State<'_, AppState>, project_id: String) {
state.fs_watcher.unwatch_project(&project_id);
}
#[tauri::command]
pub fn fs_watcher_status(state: State<'_, AppState>) -> FsWatcherStatus {
state.fs_watcher.status()
}

309
v2/src-tauri/src/ctx.rs Normal file
View file

@ -0,0 +1,309 @@
// ctx — Read-only access to the Claude Code context manager database
// Database: ~/.claude-context/context.db (managed by ctx CLI tool)
use rusqlite::{Connection, params};
use serde::Serialize;
use std::sync::Mutex;
#[derive(Debug, Clone, Serialize)]
pub struct CtxEntry {
pub project: String,
pub key: String,
pub value: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct CtxSummary {
pub project: String,
pub summary: String,
pub created_at: String,
}
pub struct CtxDb {
conn: Mutex<Option<Connection>>,
}
impl CtxDb {
fn db_path() -> std::path::PathBuf {
dirs::home_dir()
.unwrap_or_default()
.join(".claude-context")
.join("context.db")
}
pub fn new() -> Self {
let db_path = Self::db_path();
let conn = if db_path.exists() {
Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
).ok()
} else {
None
};
Self { conn: Mutex::new(conn) }
}
/// Create the context database directory and schema, then open a read-only connection.
pub fn init_db(&self) -> Result<(), String> {
let db_path = Self::db_path();
// Create parent directory
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory: {e}"))?;
}
// Open read-write to create schema
let conn = Connection::open(&db_path)
.map_err(|e| format!("Failed to create database: {e}"))?;
conn.execute_batch("PRAGMA journal_mode=WAL;").map_err(|e| format!("WAL mode failed: {e}"))?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS sessions (
name TEXT PRIMARY KEY,
description TEXT,
work_dir TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS contexts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(project, key)
);
CREATE TABLE IF NOT EXISTS shared (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project TEXT NOT NULL,
summary TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE VIRTUAL TABLE IF NOT EXISTS contexts_fts USING fts5(
project, key, value, content=contexts, content_rowid=id
);
CREATE VIRTUAL TABLE IF NOT EXISTS shared_fts USING fts5(
key, value, content=shared
);
CREATE TRIGGER IF NOT EXISTS contexts_ai AFTER INSERT ON contexts BEGIN
INSERT INTO contexts_fts(rowid, project, key, value)
VALUES (new.id, new.project, new.key, new.value);
END;
CREATE TRIGGER IF NOT EXISTS contexts_ad AFTER DELETE ON contexts BEGIN
INSERT INTO contexts_fts(contexts_fts, rowid, project, key, value)
VALUES ('delete', old.id, old.project, old.key, old.value);
END;
CREATE TRIGGER IF NOT EXISTS contexts_au AFTER UPDATE ON contexts BEGIN
INSERT INTO contexts_fts(contexts_fts, rowid, project, key, value)
VALUES ('delete', old.id, old.project, old.key, old.value);
INSERT INTO contexts_fts(rowid, project, key, value)
VALUES (new.id, new.project, new.key, new.value);
END;"
).map_err(|e| format!("Schema creation failed: {e}"))?;
drop(conn);
// Re-open as read-only for normal operation
let ro_conn = Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
).map_err(|e| format!("Failed to reopen database: {e}"))?;
let mut lock = self.conn.lock().map_err(|_| "ctx database lock poisoned".to_string())?;
*lock = Some(ro_conn);
Ok(())
}
/// Register a project in the ctx database (creates if not exists).
/// Opens a brief read-write connection; the main self.conn stays read-only.
pub fn register_project(&self, name: &str, description: &str, work_dir: Option<&str>) -> Result<(), String> {
let db_path = Self::db_path();
let conn = Connection::open(&db_path)
.map_err(|e| format!("ctx database not found: {e}"))?;
conn.execute(
"INSERT OR IGNORE INTO sessions (name, description, work_dir) VALUES (?1, ?2, ?3)",
rusqlite::params![name, description, work_dir],
).map_err(|e| format!("Failed to register project: {e}"))?;
Ok(())
}
pub fn get_context(&self, project: &str) -> Result<Vec<CtxEntry>, String> {
let lock = self.conn.lock().map_err(|_| "ctx database lock poisoned".to_string())?;
let conn = lock.as_ref().ok_or("ctx database not found")?;
let mut stmt = conn
.prepare("SELECT project, key, value, updated_at FROM contexts WHERE project = ?1 ORDER BY key")
.map_err(|e| format!("ctx query failed: {e}"))?;
let entries = stmt
.query_map(params![project], |row| {
Ok(CtxEntry {
project: row.get(0)?,
key: row.get(1)?,
value: row.get(2)?,
updated_at: row.get(3)?,
})
})
.map_err(|e| format!("ctx query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("ctx row read failed: {e}"))?;
Ok(entries)
}
pub fn get_shared(&self) -> Result<Vec<CtxEntry>, String> {
let lock = self.conn.lock().map_err(|_| "ctx database lock poisoned".to_string())?;
let conn = lock.as_ref().ok_or("ctx database not found")?;
let mut stmt = conn
.prepare("SELECT key, value, updated_at FROM shared ORDER BY key")
.map_err(|e| format!("ctx query failed: {e}"))?;
let entries = stmt
.query_map([], |row| {
Ok(CtxEntry {
project: "shared".to_string(),
key: row.get(0)?,
value: row.get(1)?,
updated_at: row.get(2)?,
})
})
.map_err(|e| format!("ctx query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("ctx row read failed: {e}"))?;
Ok(entries)
}
pub fn get_summaries(&self, project: &str, limit: i64) -> Result<Vec<CtxSummary>, String> {
let lock = self.conn.lock().map_err(|_| "ctx database lock poisoned".to_string())?;
let conn = lock.as_ref().ok_or("ctx database not found")?;
let mut stmt = conn
.prepare("SELECT project, summary, created_at FROM summaries WHERE project = ?1 ORDER BY created_at DESC LIMIT ?2")
.map_err(|e| format!("ctx query failed: {e}"))?;
let summaries = stmt
.query_map(params![project, limit], |row| {
Ok(CtxSummary {
project: row.get(0)?,
summary: row.get(1)?,
created_at: row.get(2)?,
})
})
.map_err(|e| format!("ctx query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("ctx row read failed: {e}"))?;
Ok(summaries)
}
pub fn search(&self, query: &str) -> Result<Vec<CtxEntry>, String> {
let lock = self.conn.lock().map_err(|_| "ctx database lock poisoned".to_string())?;
let conn = lock.as_ref().ok_or("ctx database not found")?;
let mut stmt = conn
.prepare("SELECT project, key, value FROM contexts_fts WHERE contexts_fts MATCH ?1 LIMIT 50")
.map_err(|e| format!("ctx search failed: {e}"))?;
let entries = stmt
.query_map(params![query], |row| {
Ok(CtxEntry {
project: row.get(0)?,
key: row.get(1)?,
value: row.get(2)?,
updated_at: String::new(), // FTS5 virtual table doesn't store updated_at
})
})
.map_err(|e| {
let msg = e.to_string();
if msg.contains("fts5") || msg.contains("syntax") {
format!("Invalid search query syntax: {e}")
} else {
format!("ctx search failed: {e}")
}
})?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("ctx row read failed: {e}"))?;
Ok(entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Create a CtxDb with conn set to None, simulating a missing database.
fn make_missing_db() -> CtxDb {
CtxDb { conn: Mutex::new(None) }
}
#[test]
fn test_new_does_not_panic() {
// CtxDb::new() should never panic even if ~/.claude-context/context.db
// doesn't exist — it just stores None for the connection.
let _db = CtxDb::new();
}
#[test]
fn test_get_context_missing_db_returns_error() {
let db = make_missing_db();
let result = db.get_context("any-project");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "ctx database not found");
}
#[test]
fn test_get_shared_missing_db_returns_error() {
let db = make_missing_db();
let result = db.get_shared();
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "ctx database not found");
}
#[test]
fn test_get_summaries_missing_db_returns_error() {
let db = make_missing_db();
let result = db.get_summaries("any-project", 10);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "ctx database not found");
}
#[test]
fn test_search_missing_db_returns_error() {
let db = make_missing_db();
let result = db.search("anything");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "ctx database not found");
}
#[test]
fn test_search_empty_query_missing_db_returns_error() {
let db = make_missing_db();
let result = db.search("");
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "ctx database not found");
}
}

View file

@ -0,0 +1,11 @@
use bterminal_core::event::EventSink;
use tauri::{AppHandle, Emitter};
/// Bridges bterminal-core's EventSink trait to Tauri's event system.
pub struct TauriEventSink(pub AppHandle);
impl EventSink for TauriEventSink {
fn emit(&self, event: &str, payload: serde_json::Value) {
let _ = self.0.emit(event, &payload);
}
}

View file

@ -0,0 +1,351 @@
// Filesystem write detection for project directories
// Uses notify crate (inotify on Linux) to detect file modifications.
// Emits Tauri events so frontend can detect external writes vs agent-managed writes.
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tauri::Emitter;
/// Payload emitted on fs-write-detected events
#[derive(Clone, Serialize)]
pub struct FsWritePayload {
pub project_id: String,
pub file_path: String,
pub timestamp_ms: u64,
}
/// Directories to skip when watching recursively
const IGNORED_DIRS: &[&str] = &[
".git",
"node_modules",
"target",
".svelte-kit",
"dist",
"__pycache__",
".next",
".nuxt",
".cache",
"build",
];
/// Status of inotify watch capacity
#[derive(Clone, Serialize)]
pub struct FsWatcherStatus {
/// Kernel limit from /proc/sys/fs/inotify/max_user_watches
pub max_watches: u64,
/// Estimated directories being watched across all projects
pub estimated_watches: u64,
/// Usage ratio (0.0 - 1.0)
pub usage_ratio: f64,
/// Number of actively watched projects
pub active_projects: usize,
/// Warning message if approaching limit, null otherwise
pub warning: Option<String>,
}
struct ProjectWatch {
_watcher: RecommendedWatcher,
_cwd: String,
/// Estimated number of directories (inotify watches) for this project
dir_count: u64,
}
pub struct ProjectFsWatcher {
watches: Mutex<HashMap<String, ProjectWatch>>,
}
impl ProjectFsWatcher {
pub fn new() -> Self {
Self {
watches: Mutex::new(HashMap::new()),
}
}
/// Start watching a project's CWD for file writes (Create, Modify, Rename).
/// Debounces events per-file (100ms) to avoid flooding on rapid writes.
pub fn watch_project(
&self,
app: &tauri::AppHandle,
project_id: &str,
cwd: &str,
) -> Result<(), String> {
let cwd_path = Path::new(cwd);
if !cwd_path.is_dir() {
return Err(format!("Not a directory: {cwd}"));
}
let mut watches = self.watches.lock().unwrap();
// Don't duplicate — unwatch first if already watching
if watches.contains_key(project_id) {
drop(watches);
self.unwatch_project(project_id);
watches = self.watches.lock().unwrap();
}
let app_handle = app.clone();
let project_id_owned = project_id.to_string();
let cwd_owned = cwd.to_string();
// Per-file debounce state
let debounce: std::sync::Arc<Mutex<HashMap<String, Instant>>> =
std::sync::Arc::new(Mutex::new(HashMap::new()));
let debounce_duration = Duration::from_millis(100);
let mut watcher = RecommendedWatcher::new(
move |res: Result<Event, notify::Error>| {
let event = match res {
Ok(e) => e,
Err(_) => return,
};
// Only care about file writes (create, modify, rename-to)
let is_write = matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_)
);
if !is_write {
return;
}
for path in &event.paths {
// Skip directories
if path.is_dir() {
continue;
}
let path_str = path.to_string_lossy().to_string();
// Skip ignored directories
if should_ignore_path(&path_str) {
continue;
}
// Debounce: skip if same file was emitted within debounce window
let now = Instant::now();
let mut db = debounce.lock().unwrap();
if let Some(last) = db.get(&path_str) {
if now.duration_since(*last) < debounce_duration {
continue;
}
}
db.insert(path_str.clone(), now);
// Prune old debounce entries (keep map from growing unbounded)
if db.len() > 1000 {
let max_age = debounce_duration * 10;
db.retain(|_, v| now.duration_since(*v) < max_age);
}
drop(db);
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
let _ = app_handle.emit(
"fs-write-detected",
FsWritePayload {
project_id: project_id_owned.clone(),
file_path: path_str,
timestamp_ms,
},
);
}
},
Config::default(),
)
.map_err(|e| format!("Failed to create fs watcher: {e}"))?;
watcher
.watch(cwd_path, RecursiveMode::Recursive)
.map_err(|e| format!("Failed to watch directory: {e}"))?;
let dir_count = count_watched_dirs(cwd_path);
log::info!("Started fs watcher for project {project_id} at {cwd} (~{dir_count} directories)");
watches.insert(
project_id.to_string(),
ProjectWatch {
_watcher: watcher,
_cwd: cwd_owned,
dir_count,
},
);
Ok(())
}
/// Stop watching a project's CWD
pub fn unwatch_project(&self, project_id: &str) {
let mut watches = self.watches.lock().unwrap();
if watches.remove(project_id).is_some() {
log::info!("Stopped fs watcher for project {project_id}");
}
}
/// Get current watcher status including inotify limit check
pub fn status(&self) -> FsWatcherStatus {
let max_watches = read_inotify_max_watches();
let watches = self.watches.lock().unwrap();
let active_projects = watches.len();
let estimated_watches: u64 = watches.values().map(|w| w.dir_count).sum();
let usage_ratio = if max_watches > 0 {
estimated_watches as f64 / max_watches as f64
} else {
0.0
};
let warning = if usage_ratio > 0.90 {
Some(format!(
"inotify watch limit critical: using ~{estimated_watches}/{max_watches} watches ({:.0}%). \
Increase with: echo {} | sudo tee /proc/sys/fs/inotify/max_user_watches",
usage_ratio * 100.0,
max_watches * 2
))
} else if usage_ratio > 0.75 {
Some(format!(
"inotify watch limit warning: using ~{estimated_watches}/{max_watches} watches ({:.0}%). \
Consider increasing with: echo {} | sudo tee /proc/sys/fs/inotify/max_user_watches",
usage_ratio * 100.0,
max_watches * 2
))
} else {
None
};
FsWatcherStatus {
max_watches,
estimated_watches,
usage_ratio,
active_projects,
warning,
}
}
}
/// Check if a path contains any ignored directory component
fn should_ignore_path(path: &str) -> bool {
for component in Path::new(path).components() {
if let std::path::Component::Normal(name) = component {
let name_str = name.to_string_lossy();
if IGNORED_DIRS.contains(&name_str.as_ref()) {
return true;
}
}
}
false
}
/// Read the kernel inotify watch limit from /proc/sys/fs/inotify/max_user_watches.
/// Returns 0 on non-Linux or if the file can't be read.
fn read_inotify_max_watches() -> u64 {
std::fs::read_to_string("/proc/sys/fs/inotify/max_user_watches")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.unwrap_or(0)
}
/// Count directories under a path that would become inotify watches.
/// Skips ignored directories. Caps the walk at 30,000 to avoid blocking on huge monorepos.
fn count_watched_dirs(root: &Path) -> u64 {
const MAX_WALK: u64 = 30_000;
let mut count: u64 = 1; // root itself
fn walk_dir(dir: &Path, count: &mut u64, max: u64) {
if *count >= max {
return;
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
if *count >= max {
return;
}
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = entry.file_name();
let name_str = name.to_string_lossy();
if IGNORED_DIRS.contains(&name_str.as_ref()) {
continue;
}
*count += 1;
walk_dir(&path, count, max);
}
}
walk_dir(root, &mut count, MAX_WALK);
count
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_should_ignore_git() {
assert!(should_ignore_path("/home/user/project/.git/objects/abc"));
assert!(should_ignore_path("/home/user/project/.git/HEAD"));
}
#[test]
fn test_should_ignore_node_modules() {
assert!(should_ignore_path("/project/node_modules/pkg/index.js"));
}
#[test]
fn test_should_ignore_target() {
assert!(should_ignore_path("/project/target/debug/build/foo"));
}
#[test]
fn test_should_not_ignore_src() {
assert!(!should_ignore_path("/project/src/main.rs"));
assert!(!should_ignore_path("/project/src/lib/stores/health.svelte.ts"));
}
#[test]
fn test_should_not_ignore_root_file() {
assert!(!should_ignore_path("/project/Cargo.toml"));
}
#[test]
fn test_read_inotify_max_watches() {
// On Linux this should return a positive number
let max = read_inotify_max_watches();
if cfg!(target_os = "linux") {
assert!(max > 0, "Expected positive inotify limit on Linux, got {max}");
}
}
#[test]
fn test_count_watched_dirs_tempdir() {
let tmp = std::env::temp_dir().join("bterminal_test_count_dirs");
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(tmp.join("src/lib")).unwrap();
std::fs::create_dir_all(tmp.join("node_modules/pkg")).unwrap(); // should be skipped
std::fs::create_dir_all(tmp.join(".git/objects")).unwrap(); // should be skipped
let count = count_watched_dirs(&tmp);
// root + src + src/lib = 3 (node_modules and .git skipped)
assert_eq!(count, 3, "Expected 3 watched dirs, got {count}");
let _ = std::fs::remove_dir_all(&tmp);
}
#[test]
fn test_watcher_status_no_projects() {
let watcher = ProjectFsWatcher::new();
let status = watcher.status();
assert_eq!(status.active_projects, 0);
assert_eq!(status.estimated_watches, 0);
assert!(status.warning.is_none());
}
}

268
v2/src-tauri/src/groups.rs Normal file
View file

@ -0,0 +1,268 @@
// Project group configuration
// Reads/writes ~/.config/bterminal/groups.json
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectConfig {
pub id: String,
pub name: String,
pub identifier: String,
pub description: String,
pub icon: String,
pub cwd: String,
pub profile: String,
pub enabled: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GroupAgentConfig {
pub id: String,
pub name: String,
pub role: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub wake_interval_min: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroupConfig {
pub id: String,
pub name: String,
pub projects: Vec<ProjectConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agents: Vec<GroupAgentConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GroupsFile {
pub version: u32,
pub groups: Vec<GroupConfig>,
pub active_group_id: String,
}
impl Default for GroupsFile {
fn default() -> Self {
Self {
version: 1,
groups: Vec::new(),
active_group_id: String::new(),
}
}
}
fn config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("bterminal")
.join("groups.json")
}
pub fn load_groups() -> Result<GroupsFile, String> {
let path = config_path();
if !path.exists() {
return Ok(GroupsFile::default());
}
let content = std::fs::read_to_string(&path)
.map_err(|e| format!("Failed to read groups.json: {e}"))?;
serde_json::from_str(&content)
.map_err(|e| format!("Invalid groups.json: {e}"))
}
pub fn save_groups(config: &GroupsFile) -> Result<(), String> {
let path = config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create config dir: {e}"))?;
}
let json = serde_json::to_string_pretty(config)
.map_err(|e| format!("JSON serialize error: {e}"))?;
std::fs::write(&path, json)
.map_err(|e| format!("Failed to write groups.json: {e}"))
}
/// Discover markdown files in a project directory for the Docs tab.
/// Returns paths relative to cwd, prioritized: CLAUDE.md, README.md, docs/*.md
pub fn discover_markdown_files(cwd: &str) -> Result<Vec<MdFileEntry>, String> {
let root = PathBuf::from(cwd);
if !root.is_dir() {
return Err(format!("Directory not found: {cwd}"));
}
let mut entries = Vec::new();
// Priority files at root
for name in &["CLAUDE.md", "README.md", "CHANGELOG.md", "TODO.md", "SETUP.md"] {
let path = root.join(name);
if path.is_file() {
entries.push(MdFileEntry {
name: name.to_string(),
path: path.to_string_lossy().to_string(),
priority: true,
});
}
}
// docs/ or doc/ directory (max 20 entries, depth 2)
for dir_name in &["docs", "doc"] {
let docs_dir = root.join(dir_name);
if docs_dir.is_dir() {
scan_md_dir(&docs_dir, &mut entries, 2, 20);
}
}
Ok(entries)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MdFileEntry {
pub name: String,
pub path: String,
pub priority: bool,
}
fn scan_md_dir(dir: &PathBuf, entries: &mut Vec<MdFileEntry>, max_depth: u32, max_count: usize) {
if max_depth == 0 || entries.len() >= max_count {
return;
}
let Ok(read_dir) = std::fs::read_dir(dir) else { return };
for entry in read_dir.flatten() {
if entries.len() >= max_count {
break;
}
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
if ext == "md" || ext == "markdown" {
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
entries.push(MdFileEntry {
name,
path: path.to_string_lossy().to_string(),
priority: false,
});
}
}
} else if path.is_dir() {
// Skip common non-doc directories
let dir_name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
if !matches!(dir_name.as_str(), "node_modules" | ".git" | "target" | "dist" | "build" | ".next" | "__pycache__") {
scan_md_dir(&path, entries, max_depth - 1, max_count);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_groups_file() {
let g = GroupsFile::default();
assert_eq!(g.version, 1);
assert!(g.groups.is_empty());
assert!(g.active_group_id.is_empty());
}
#[test]
fn test_groups_roundtrip() {
let config = GroupsFile {
version: 1,
groups: vec![GroupConfig {
id: "test".to_string(),
name: "Test Group".to_string(),
projects: vec![ProjectConfig {
id: "p1".to_string(),
name: "Project One".to_string(),
identifier: "project-one".to_string(),
description: "A test project".to_string(),
icon: "\u{f120}".to_string(),
cwd: "/tmp/test".to_string(),
profile: "default".to_string(),
enabled: true,
}],
}],
active_group_id: "test".to_string(),
};
let json = serde_json::to_string(&config).unwrap();
let parsed: GroupsFile = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.groups.len(), 1);
assert_eq!(parsed.groups[0].projects.len(), 1);
assert_eq!(parsed.groups[0].projects[0].identifier, "project-one");
}
#[test]
fn test_load_missing_file_returns_default() {
// config_path() will point to a non-existent file in test
// We test the default case directly
let g = GroupsFile::default();
assert_eq!(g.version, 1);
}
#[test]
fn test_discover_nonexistent_dir() {
let result = discover_markdown_files("/nonexistent/path/12345");
assert!(result.is_err());
}
#[test]
fn test_discover_empty_dir() {
let dir = tempfile::tempdir().unwrap();
let result = discover_markdown_files(dir.path().to_str().unwrap()).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_discover_finds_readme() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("README.md"), "# Hello").unwrap();
let result = discover_markdown_files(dir.path().to_str().unwrap()).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "README.md");
assert!(result[0].priority);
}
#[test]
fn test_discover_finds_docs() {
let dir = tempfile::tempdir().unwrap();
let docs = dir.path().join("docs");
std::fs::create_dir(&docs).unwrap();
std::fs::write(docs.join("guide.md"), "# Guide").unwrap();
std::fs::write(docs.join("api.md"), "# API").unwrap();
let result = discover_markdown_files(dir.path().to_str().unwrap()).unwrap();
assert_eq!(result.len(), 2);
assert!(result.iter().all(|e| !e.priority));
}
#[test]
fn test_discover_finds_doc_dir() {
let dir = tempfile::tempdir().unwrap();
let doc = dir.path().join("doc");
std::fs::create_dir(&doc).unwrap();
std::fs::write(doc.join("requirements.md"), "# Req").unwrap();
let result = discover_markdown_files(dir.path().to_str().unwrap()).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "requirements.md");
assert!(!result[0].priority);
}
#[test]
fn test_discover_finds_setup_md() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("SETUP.md"), "# Setup").unwrap();
let result = discover_markdown_files(dir.path().to_str().unwrap()).unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "SETUP.md");
assert!(result[0].priority);
}
}

226
v2/src-tauri/src/lib.rs Normal file
View file

@ -0,0 +1,226 @@
mod btmsg;
mod bttask;
mod commands;
mod ctx;
mod event_sink;
mod fs_watcher;
mod groups;
mod memora;
mod pty;
mod remote;
mod sidecar;
mod session;
mod telemetry;
mod watcher;
use event_sink::TauriEventSink;
use pty::PtyManager;
use remote::RemoteManager;
use session::SessionDb;
use sidecar::{SidecarConfig, SidecarManager};
use fs_watcher::ProjectFsWatcher;
use watcher::FileWatcherManager;
use std::sync::Arc;
use tauri::Manager;
pub(crate) struct AppState {
pub pty_manager: Arc<PtyManager>,
pub sidecar_manager: Arc<SidecarManager>,
pub session_db: Arc<SessionDb>,
pub file_watcher: Arc<FileWatcherManager>,
pub fs_watcher: Arc<ProjectFsWatcher>,
pub ctx_db: Arc<ctx::CtxDb>,
pub memora_db: Arc<memora::MemoraDb>,
pub remote_manager: Arc<RemoteManager>,
_telemetry: telemetry::TelemetryGuard,
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Force dark GTK theme for native dialogs (file chooser, etc.)
std::env::set_var("GTK_THEME", "Adwaita:dark");
// Initialize tracing + optional OTLP export (before any tracing macros)
let telemetry_guard = telemetry::init();
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
// PTY
commands::pty::pty_spawn,
commands::pty::pty_write,
commands::pty::pty_resize,
commands::pty::pty_kill,
// Agent/sidecar
commands::agent::agent_query,
commands::agent::agent_stop,
commands::agent::agent_ready,
commands::agent::agent_restart,
// File watcher
commands::watcher::file_watch,
commands::watcher::file_unwatch,
commands::watcher::file_read,
commands::watcher::fs_watch_project,
commands::watcher::fs_unwatch_project,
commands::watcher::fs_watcher_status,
// Session/layout/settings/SSH
commands::session::session_list,
commands::session::session_save,
commands::session::session_delete,
commands::session::session_update_title,
commands::session::session_touch,
commands::session::session_update_group,
commands::session::layout_save,
commands::session::layout_load,
commands::session::settings_get,
commands::session::settings_set,
commands::session::settings_list,
commands::session::ssh_session_list,
commands::session::ssh_session_save,
commands::session::ssh_session_delete,
// Agent persistence (messages, state, metrics, anchors)
commands::persistence::agent_messages_save,
commands::persistence::agent_messages_load,
commands::persistence::project_agent_state_save,
commands::persistence::project_agent_state_load,
commands::persistence::session_metric_save,
commands::persistence::session_metrics_load,
commands::persistence::session_anchors_save,
commands::persistence::session_anchors_load,
commands::persistence::session_anchor_delete,
commands::persistence::session_anchors_clear,
commands::persistence::session_anchor_update_type,
// ctx + Memora
commands::knowledge::ctx_init_db,
commands::knowledge::ctx_register_project,
commands::knowledge::ctx_get_context,
commands::knowledge::ctx_get_shared,
commands::knowledge::ctx_get_summaries,
commands::knowledge::ctx_search,
commands::knowledge::memora_available,
commands::knowledge::memora_list,
commands::knowledge::memora_search,
commands::knowledge::memora_get,
// Claude profiles/skills
commands::claude::claude_list_profiles,
commands::claude::claude_list_skills,
commands::claude::claude_read_skill,
// Groups
commands::groups::groups_load,
commands::groups::groups_save,
commands::groups::discover_markdown_files,
// File browser
commands::files::list_directory_children,
commands::files::read_file_content,
commands::files::write_file_content,
commands::files::pick_directory,
// Remote machines
commands::remote::remote_list,
commands::remote::remote_add,
commands::remote::remote_remove,
commands::remote::remote_connect,
commands::remote::remote_disconnect,
commands::remote::remote_agent_query,
commands::remote::remote_agent_stop,
commands::remote::remote_pty_spawn,
commands::remote::remote_pty_write,
commands::remote::remote_pty_resize,
commands::remote::remote_pty_kill,
// btmsg (agent messenger)
commands::btmsg::btmsg_get_agents,
commands::btmsg::btmsg_unread_count,
commands::btmsg::btmsg_unread_messages,
commands::btmsg::btmsg_history,
commands::btmsg::btmsg_send,
commands::btmsg::btmsg_set_status,
commands::btmsg::btmsg_ensure_admin,
commands::btmsg::btmsg_all_feed,
commands::btmsg::btmsg_mark_read,
commands::btmsg::btmsg_get_channels,
commands::btmsg::btmsg_channel_messages,
commands::btmsg::btmsg_channel_send,
commands::btmsg::btmsg_create_channel,
commands::btmsg::btmsg_add_channel_member,
// bttask (task board)
commands::bttask::bttask_list,
commands::bttask::bttask_comments,
commands::bttask::bttask_update_status,
commands::bttask::bttask_add_comment,
commands::bttask::bttask_create,
commands::bttask::bttask_delete,
// Misc
commands::misc::cli_get_group,
commands::misc::open_url,
commands::misc::frontend_log,
])
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_dialog::init())
.setup(move |app| {
// Note: tauri-plugin-log is NOT initialized here because telemetry::init()
// already sets up tracing-subscriber (which bridges the `log` crate via
// tracing's compatibility layer). Adding plugin-log would panic with
// "attempted to set a logger after the logging system was already initialized".
// Create TauriEventSink for core managers
let sink: Arc<dyn bterminal_core::event::EventSink> =
Arc::new(TauriEventSink(app.handle().clone()));
// Build sidecar config from Tauri paths
let resource_dir = app
.handle()
.path()
.resource_dir()
.unwrap_or_else(|e| {
log::warn!("Failed to resolve resource_dir: {e}");
std::path::PathBuf::new()
});
let dev_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.to_path_buf();
let sidecar_config = SidecarConfig {
search_paths: vec![
resource_dir.join("sidecar"),
dev_root.join("sidecar"),
],
};
let pty_manager = Arc::new(PtyManager::new(sink.clone()));
let sidecar_manager = Arc::new(SidecarManager::new(sink, sidecar_config));
// Initialize session database
let data_dir = dirs::data_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("bterminal");
let session_db = Arc::new(
SessionDb::open(&data_dir).expect("Failed to open session database"),
);
let file_watcher = Arc::new(FileWatcherManager::new());
let fs_watcher = Arc::new(ProjectFsWatcher::new());
let ctx_db = Arc::new(ctx::CtxDb::new());
let memora_db = Arc::new(memora::MemoraDb::new());
let remote_manager = Arc::new(RemoteManager::new());
// Start local sidecar
match sidecar_manager.start() {
Ok(()) => log::info!("Sidecar startup initiated"),
Err(e) => log::warn!("Sidecar startup failed (agent features unavailable): {e}"),
}
app.manage(AppState {
pty_manager,
sidecar_manager,
session_db,
file_watcher,
fs_watcher,
ctx_db,
memora_db,
remote_manager,
_telemetry: telemetry_guard,
});
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

6
v2/src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
bterminal_lib::run();
}

333
v2/src-tauri/src/memora.rs Normal file
View file

@ -0,0 +1,333 @@
// memora — Read-only access to the Memora memory database
// Database: ~/.local/share/memora/memories.db (managed by Memora MCP server)
use rusqlite::{Connection, params};
use serde::Serialize;
use std::sync::Mutex;
#[derive(Debug, Clone, Serialize)]
pub struct MemoraNode {
pub id: i64,
pub content: String,
pub tags: Vec<String>,
pub metadata: Option<serde_json::Value>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct MemoraSearchResult {
pub nodes: Vec<MemoraNode>,
pub total: i64,
}
pub struct MemoraDb {
conn: Mutex<Option<Connection>>,
}
impl MemoraDb {
fn db_path() -> std::path::PathBuf {
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".local/share"))
.join("memora")
.join("memories.db")
}
pub fn new() -> Self {
let db_path = Self::db_path();
let conn = if db_path.exists() {
Connection::open_with_flags(
&db_path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX,
).ok()
} else {
None
};
Self { conn: Mutex::new(conn) }
}
/// Check if the database connection is available.
pub fn is_available(&self) -> bool {
let lock = self.conn.lock().unwrap_or_else(|e| e.into_inner());
lock.is_some()
}
fn parse_row(row: &rusqlite::Row) -> rusqlite::Result<MemoraNode> {
let tags_raw: String = row.get(2)?;
let tags: Vec<String> = serde_json::from_str(&tags_raw).unwrap_or_default();
let meta_raw: Option<String> = row.get(3)?;
let metadata = meta_raw.and_then(|m| serde_json::from_str(&m).ok());
Ok(MemoraNode {
id: row.get(0)?,
content: row.get(1)?,
tags,
metadata,
created_at: row.get(4)?,
updated_at: row.get(5)?,
})
}
pub fn list(
&self,
tags: Option<Vec<String>>,
limit: i64,
offset: i64,
) -> Result<MemoraSearchResult, String> {
let lock = self.conn.lock().map_err(|_| "memora database lock poisoned".to_string())?;
let conn = lock.as_ref().ok_or("memora database not found")?;
if let Some(ref tag_list) = tags {
if !tag_list.is_empty() {
return self.list_by_tags(conn, tag_list, limit, offset);
}
}
let total: i64 = conn
.query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0))
.map_err(|e| format!("memora count failed: {e}"))?;
let mut stmt = conn
.prepare(
"SELECT id, content, tags, metadata, created_at, updated_at
FROM memories ORDER BY id DESC LIMIT ?1 OFFSET ?2",
)
.map_err(|e| format!("memora query failed: {e}"))?;
let nodes = stmt
.query_map(params![limit, offset], Self::parse_row)
.map_err(|e| format!("memora query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("memora row read failed: {e}"))?;
Ok(MemoraSearchResult { nodes, total })
}
fn list_by_tags(
&self,
conn: &Connection,
tags: &[String],
limit: i64,
offset: i64,
) -> Result<MemoraSearchResult, String> {
// Filter memories whose JSON tags array contains ANY of the given tags.
// Uses json_each() to expand the tags array and match against the filter list.
let placeholders: Vec<String> = tags.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect();
let in_clause = placeholders.join(", ");
let count_sql = format!(
"SELECT COUNT(DISTINCT m.id) FROM memories m, json_each(m.tags) j WHERE j.value IN ({in_clause})"
);
let query_sql = format!(
"SELECT DISTINCT m.id, m.content, m.tags, m.metadata, m.created_at, m.updated_at
FROM memories m, json_each(m.tags) j
WHERE j.value IN ({in_clause})
ORDER BY m.id DESC LIMIT ?{} OFFSET ?{}",
tags.len() + 1,
tags.len() + 2,
);
let tag_params: Vec<&dyn rusqlite::ToSql> = tags.iter().map(|t| t as &dyn rusqlite::ToSql).collect();
let count_params = tag_params.clone();
let total: i64 = conn
.query_row(&count_sql, count_params.as_slice(), |r| r.get(0))
.map_err(|e| format!("memora count failed: {e}"))?;
let mut query_params = tag_params;
query_params.push(&limit);
query_params.push(&offset);
let mut stmt = conn
.prepare(&query_sql)
.map_err(|e| format!("memora query failed: {e}"))?;
let nodes = stmt
.query_map(query_params.as_slice(), Self::parse_row)
.map_err(|e| format!("memora query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("memora row read failed: {e}"))?;
Ok(MemoraSearchResult { nodes, total })
}
pub fn search(
&self,
query: &str,
tags: Option<Vec<String>>,
limit: i64,
) -> Result<MemoraSearchResult, String> {
let lock = self.conn.lock().map_err(|_| "memora database lock poisoned".to_string())?;
let conn = lock.as_ref().ok_or("memora database not found")?;
// Use FTS5 for text search with optional tag filter
let fts_query = query.to_string();
if let Some(ref tag_list) = tags {
if !tag_list.is_empty() {
return self.search_with_tags(conn, &fts_query, tag_list, limit);
}
}
let mut stmt = conn
.prepare(
"SELECT m.id, m.content, m.tags, m.metadata, m.created_at, m.updated_at
FROM memories_fts f
JOIN memories m ON m.id = f.rowid
WHERE memories_fts MATCH ?1
ORDER BY rank
LIMIT ?2",
)
.map_err(|e| format!("memora search failed: {e}"))?;
let nodes = stmt
.query_map(params![fts_query, limit], Self::parse_row)
.map_err(|e| {
let msg = e.to_string();
if msg.contains("fts5") || msg.contains("syntax") {
format!("Invalid search query: {e}")
} else {
format!("memora search failed: {e}")
}
})?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("memora row read failed: {e}"))?;
let total = nodes.len() as i64;
Ok(MemoraSearchResult { nodes, total })
}
fn search_with_tags(
&self,
conn: &Connection,
query: &str,
tags: &[String],
limit: i64,
) -> Result<MemoraSearchResult, String> {
let placeholders: Vec<String> = tags.iter().enumerate().map(|(i, _)| format!("?{}", i + 3)).collect();
let in_clause = placeholders.join(", ");
let sql = format!(
"SELECT DISTINCT m.id, m.content, m.tags, m.metadata, m.created_at, m.updated_at
FROM memories_fts f
JOIN memories m ON m.id = f.rowid
JOIN json_each(m.tags) j ON j.value IN ({in_clause})
WHERE memories_fts MATCH ?1
ORDER BY rank
LIMIT ?2"
);
let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
params.push(Box::new(query.to_string()));
params.push(Box::new(limit));
for tag in tags {
params.push(Box::new(tag.clone()));
}
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn
.prepare(&sql)
.map_err(|e| format!("memora search failed: {e}"))?;
let nodes = stmt
.query_map(param_refs.as_slice(), Self::parse_row)
.map_err(|e| {
let msg = e.to_string();
if msg.contains("fts5") || msg.contains("syntax") {
format!("Invalid search query: {e}")
} else {
format!("memora search failed: {e}")
}
})?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("memora row read failed: {e}"))?;
let total = nodes.len() as i64;
Ok(MemoraSearchResult { nodes, total })
}
pub fn get(&self, id: i64) -> Result<Option<MemoraNode>, String> {
let lock = self.conn.lock().map_err(|_| "memora database lock poisoned".to_string())?;
let conn = lock.as_ref().ok_or("memora database not found")?;
let mut stmt = conn
.prepare(
"SELECT id, content, tags, metadata, created_at, updated_at
FROM memories WHERE id = ?1",
)
.map_err(|e| format!("memora query failed: {e}"))?;
let mut rows = stmt
.query_map(params![id], Self::parse_row)
.map_err(|e| format!("memora query failed: {e}"))?;
match rows.next() {
Some(Ok(node)) => Ok(Some(node)),
Some(Err(e)) => Err(format!("memora row read failed: {e}")),
None => Ok(None),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_missing_db() -> MemoraDb {
MemoraDb { conn: Mutex::new(None) }
}
#[test]
fn test_new_does_not_panic() {
let _db = MemoraDb::new();
}
#[test]
fn test_missing_db_not_available() {
let db = make_missing_db();
assert!(!db.is_available());
}
#[test]
fn test_list_missing_db_returns_error() {
let db = make_missing_db();
let result = db.list(None, 50, 0);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "memora database not found");
}
#[test]
fn test_search_missing_db_returns_error() {
let db = make_missing_db();
let result = db.search("test", None, 50);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "memora database not found");
}
#[test]
fn test_get_missing_db_returns_error() {
let db = make_missing_db();
let result = db.get(1);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "memora database not found");
}
#[test]
fn test_list_with_tags_missing_db_returns_error() {
let db = make_missing_db();
let result = db.list(Some(vec!["bterminal".to_string()]), 50, 0);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "memora database not found");
}
#[test]
fn test_search_with_tags_missing_db_returns_error() {
let db = make_missing_db();
let result = db.search("test", Some(vec!["bterminal".to_string()]), 50);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), "memora database not found");
}
}

4
v2/src-tauri/src/pty.rs Normal file
View file

@ -0,0 +1,4 @@
// Thin wrapper — re-exports bterminal_core::pty types.
// PtyManager is now in bterminal-core; this module only re-exports for lib.rs.
pub use bterminal_core::pty::{PtyManager, PtyOptions};

461
v2/src-tauri/src/remote.rs Normal file
View file

@ -0,0 +1,461 @@
// Remote machine management — WebSocket client connections to bterminal-relay instances
use bterminal_core::pty::PtyOptions;
use bterminal_core::sidecar::AgentQueryOptions;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tokio::sync::{Mutex, mpsc};
use tokio_tungstenite::tungstenite::Message;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteMachineConfig {
pub label: String,
pub url: String,
pub token: String,
pub auto_connect: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemoteMachineInfo {
pub id: String,
pub label: String,
pub url: String,
pub status: String,
pub auto_connect: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RelayCommand {
id: String,
#[serde(rename = "type")]
type_: String,
payload: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RelayEvent {
#[serde(rename = "type")]
type_: String,
#[serde(rename = "sessionId")]
session_id: Option<String>,
#[serde(rename = "machineId")]
machine_id: Option<String>,
payload: Option<serde_json::Value>,
}
struct WsConnection {
tx: mpsc::UnboundedSender<String>,
_handle: tokio::task::JoinHandle<()>,
}
struct RemoteMachine {
id: String,
config: RemoteMachineConfig,
status: String,
connection: Option<WsConnection>,
/// Cancellation signal — set to true to stop reconnect loops for this machine
cancelled: Arc<std::sync::atomic::AtomicBool>,
}
pub struct RemoteManager {
machines: Arc<Mutex<HashMap<String, RemoteMachine>>>,
}
impl RemoteManager {
pub fn new() -> Self {
Self {
machines: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn list_machines(&self) -> Vec<RemoteMachineInfo> {
let machines = self.machines.lock().await;
machines.values().map(|m| RemoteMachineInfo {
id: m.id.clone(),
label: m.config.label.clone(),
url: m.config.url.clone(),
status: m.status.clone(),
auto_connect: m.config.auto_connect,
}).collect()
}
pub async fn add_machine(&self, config: RemoteMachineConfig) -> String {
let id = uuid::Uuid::new_v4().to_string();
let machine = RemoteMachine {
id: id.clone(),
config,
status: "disconnected".to_string(),
connection: None,
cancelled: Arc::new(std::sync::atomic::AtomicBool::new(false)),
};
self.machines.lock().await.insert(id.clone(), machine);
id
}
pub async fn remove_machine(&self, machine_id: &str) -> Result<(), String> {
let mut machines = self.machines.lock().await;
if let Some(machine) = machines.get_mut(machine_id) {
// Signal cancellation to stop any reconnect loops
machine.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
// Abort connection tasks before removing to prevent resource leaks
if let Some(conn) = machine.connection.take() {
conn._handle.abort();
}
}
machines.remove(machine_id)
.ok_or_else(|| format!("Machine {machine_id} not found"))?;
Ok(())
}
pub async fn connect(&self, app: &AppHandle, machine_id: &str) -> Result<(), String> {
let (url, token) = {
let mut machines = self.machines.lock().await;
let machine = machines.get_mut(machine_id)
.ok_or_else(|| format!("Machine {machine_id} not found"))?;
if machine.connection.is_some() {
return Err("Already connected".to_string());
}
machine.status = "connecting".to_string();
// Reset cancellation flag for new connection
machine.cancelled.store(false, std::sync::atomic::Ordering::Relaxed);
(machine.config.url.clone(), machine.config.token.clone())
};
// Build WebSocket request with auth header
let request = tokio_tungstenite::tungstenite::http::Request::builder()
.uri(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Sec-WebSocket-Key", tokio_tungstenite::tungstenite::handshake::client::generate_key())
.header("Sec-WebSocket-Version", "13")
.header("Connection", "Upgrade")
.header("Upgrade", "websocket")
.header("Host", extract_host(&url).unwrap_or_default())
.body(())
.map_err(|e| format!("Failed to build request: {e}"))?;
let (ws_stream, _) = tokio_tungstenite::connect_async(request)
.await
.map_err(|e| format!("WebSocket connect failed: {e}"))?;
let (mut ws_tx, mut ws_rx) = ws_stream.split();
// Channel for sending messages to the WebSocket
let (send_tx, mut send_rx) = mpsc::unbounded_channel::<String>();
// Writer task — forwards channel messages to WebSocket
let writer_handle = tokio::spawn(async move {
while let Some(msg) = send_rx.recv().await {
if ws_tx.send(Message::Text(msg)).await.is_err() {
break;
}
}
});
// Reader task — forwards WebSocket messages to Tauri events
let app_handle = app.clone();
let mid = machine_id.to_string();
let machines_ref = self.machines.clone();
let cancelled_flag = {
let machines = self.machines.lock().await;
machines.get(machine_id).map(|m| m.cancelled.clone())
.unwrap_or_else(|| Arc::new(std::sync::atomic::AtomicBool::new(false)))
};
let reader_handle = tokio::spawn(async move {
while let Some(msg) = ws_rx.next().await {
match msg {
Ok(Message::Text(text)) => {
if let Ok(mut event) = serde_json::from_str::<RelayEvent>(&text) {
event.machine_id = Some(mid.clone());
// Route relay events to Tauri events
match event.type_.as_str() {
"sidecar_message" => {
if let Some(payload) = &event.payload {
let _ = app_handle.emit("remote-sidecar-message", &serde_json::json!({
"machineId": mid,
"sessionId": event.session_id,
"event": payload,
}));
}
}
"pty_data" => {
if let Some(payload) = &event.payload {
let _ = app_handle.emit("remote-pty-data", &serde_json::json!({
"machineId": mid,
"sessionId": event.session_id,
"data": payload,
}));
}
}
"pty_exit" => {
let _ = app_handle.emit("remote-pty-exit", &serde_json::json!({
"machineId": mid,
"sessionId": event.session_id,
}));
}
"ready" => {
let _ = app_handle.emit("remote-machine-ready", &serde_json::json!({
"machineId": mid,
}));
}
"state_sync" => {
let _ = app_handle.emit("remote-state-sync", &serde_json::json!({
"machineId": mid,
"payload": event.payload,
}));
}
"pty_created" => {
// Relay confirmed PTY spawn — emit with real PTY ID
let _ = app_handle.emit("remote-pty-created", &serde_json::json!({
"machineId": mid,
"ptyId": event.session_id,
"commandId": event.payload.as_ref().and_then(|p| p.get("commandId")).and_then(|v| v.as_str()),
}));
}
"pong" => {} // heartbeat response, ignore
"error" => {
let _ = app_handle.emit("remote-error", &serde_json::json!({
"machineId": mid,
"error": event.payload,
}));
}
_ => {
log::warn!("Unknown relay event type: {}", event.type_);
}
}
}
}
Ok(Message::Close(_)) => break,
Err(e) => {
log::error!("WebSocket read error for machine {mid}: {e}");
break;
}
_ => {}
}
}
// Mark disconnected and clear connection
{
let mut machines = machines_ref.lock().await;
if let Some(machine) = machines.get_mut(&mid) {
machine.status = "disconnected".to_string();
machine.connection = None;
}
}
let _ = app_handle.emit("remote-machine-disconnected", &serde_json::json!({
"machineId": mid,
}));
// Exponential backoff reconnection (1s, 2s, 4s, 8s, 16s, 30s cap)
let reconnect_machines = machines_ref.clone();
let reconnect_app = app_handle.clone();
let reconnect_mid = mid.clone();
let reconnect_cancelled = cancelled_flag.clone();
tokio::spawn(async move {
let mut delay = std::time::Duration::from_secs(1);
let max_delay = std::time::Duration::from_secs(30);
loop {
tokio::time::sleep(delay).await;
// Check cancellation flag first (set by remove_machine/disconnect)
if reconnect_cancelled.load(std::sync::atomic::Ordering::Relaxed) {
log::info!("Reconnection cancelled (machine removed) for {reconnect_mid}");
break;
}
// Check if machine still exists and wants reconnection
let should_reconnect = {
let machines = reconnect_machines.lock().await;
machines.get(&reconnect_mid)
.map(|m| m.status == "disconnected" && m.connection.is_none())
.unwrap_or(false)
};
if !should_reconnect {
log::info!("Reconnection cancelled for machine {reconnect_mid}");
break;
}
log::info!("Attempting reconnection to {reconnect_mid} (backoff: {}s)", delay.as_secs());
let _ = reconnect_app.emit("remote-machine-reconnecting", &serde_json::json!({
"machineId": reconnect_mid,
"backoffSecs": delay.as_secs(),
}));
// Try to get URL for TCP probe
let url = {
let machines = reconnect_machines.lock().await;
machines.get(&reconnect_mid).map(|m| m.config.url.clone())
};
if let Some(url) = url {
if attempt_tcp_probe(&url).await.is_ok() {
log::info!("Reconnection probe succeeded for {reconnect_mid}");
// Mark as ready for reconnection — frontend should call connect()
let _ = reconnect_app.emit("remote-machine-reconnect-ready", &serde_json::json!({
"machineId": reconnect_mid,
}));
break;
}
} else {
break; // Machine removed
}
delay = std::cmp::min(delay * 2, max_delay);
}
});
});
// Combine reader + writer into one handle
let combined_handle = tokio::spawn(async move {
tokio::select! {
_ = reader_handle => {}
_ = writer_handle => {}
}
});
// Store connection
let mut machines = self.machines.lock().await;
if let Some(machine) = machines.get_mut(machine_id) {
machine.status = "connected".to_string();
machine.connection = Some(WsConnection {
tx: send_tx,
_handle: combined_handle,
});
}
// Start heartbeat
let ping_tx = {
let machines = self.machines.lock().await;
machines.get(machine_id).and_then(|m| m.connection.as_ref().map(|c| c.tx.clone()))
};
if let Some(tx) = ping_tx {
let mid = machine_id.to_string();
tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(15));
loop {
interval.tick().await;
let ping = serde_json::json!({"id": "", "type": "ping", "payload": {}});
if tx.send(ping.to_string()).is_err() {
log::info!("Heartbeat stopped for machine {mid}");
break;
}
}
});
}
Ok(())
}
pub async fn disconnect(&self, machine_id: &str) -> Result<(), String> {
let mut machines = self.machines.lock().await;
let machine = machines.get_mut(machine_id)
.ok_or_else(|| format!("Machine {machine_id} not found"))?;
// Signal cancellation to stop any reconnect loops
machine.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
if let Some(conn) = machine.connection.take() {
conn._handle.abort();
}
machine.status = "disconnected".to_string();
Ok(())
}
// --- Remote command helpers ---
async fn send_command(&self, machine_id: &str, cmd: RelayCommand) -> Result<(), String> {
let machines = self.machines.lock().await;
let machine = machines.get(machine_id)
.ok_or_else(|| format!("Machine {machine_id} not found"))?;
let conn = machine.connection.as_ref()
.ok_or_else(|| format!("Machine {machine_id} not connected"))?;
let json = serde_json::to_string(&cmd)
.map_err(|e| format!("Serialize error: {e}"))?;
conn.tx.send(json)
.map_err(|_| format!("Send channel closed for machine {machine_id}"))
}
pub async fn agent_query(&self, machine_id: &str, options: &AgentQueryOptions) -> Result<(), String> {
self.send_command(machine_id, RelayCommand {
id: uuid::Uuid::new_v4().to_string(),
type_: "agent_query".to_string(),
payload: serde_json::to_value(options).unwrap_or_default(),
}).await
}
pub async fn agent_stop(&self, machine_id: &str, session_id: &str) -> Result<(), String> {
self.send_command(machine_id, RelayCommand {
id: uuid::Uuid::new_v4().to_string(),
type_: "agent_stop".to_string(),
payload: serde_json::json!({ "sessionId": session_id }),
}).await
}
pub async fn pty_spawn(&self, machine_id: &str, options: &PtyOptions) -> Result<String, String> {
// Send spawn command; the relay will respond with the PTY ID via a relay event
let cmd_id = uuid::Uuid::new_v4().to_string();
self.send_command(machine_id, RelayCommand {
id: cmd_id.clone(),
type_: "pty_create".to_string(),
payload: serde_json::to_value(options).unwrap_or_default(),
}).await?;
// Return the command ID as a placeholder; the real PTY ID comes via event
Ok(cmd_id)
}
pub async fn pty_write(&self, machine_id: &str, id: &str, data: &str) -> Result<(), String> {
self.send_command(machine_id, RelayCommand {
id: uuid::Uuid::new_v4().to_string(),
type_: "pty_write".to_string(),
payload: serde_json::json!({ "id": id, "data": data }),
}).await
}
pub async fn pty_resize(&self, machine_id: &str, id: &str, cols: u16, rows: u16) -> Result<(), String> {
self.send_command(machine_id, RelayCommand {
id: uuid::Uuid::new_v4().to_string(),
type_: "pty_resize".to_string(),
payload: serde_json::json!({ "id": id, "cols": cols, "rows": rows }),
}).await
}
pub async fn pty_kill(&self, machine_id: &str, id: &str) -> Result<(), String> {
self.send_command(machine_id, RelayCommand {
id: uuid::Uuid::new_v4().to_string(),
type_: "pty_close".to_string(),
payload: serde_json::json!({ "id": id }),
}).await
}
}
/// Probe whether a relay is reachable via TCP connect only (no WS upgrade).
/// This avoids allocating per-connection resources (PtyManager, SidecarManager) on the relay.
async fn attempt_tcp_probe(url: &str) -> Result<(), String> {
let host = extract_host(url).ok_or_else(|| "Invalid URL".to_string())?;
// Parse host:port, default to 9750 if no port
let addr = if host.contains(':') {
host.clone()
} else {
format!("{host}:9750")
};
tokio::time::timeout(
std::time::Duration::from_secs(5),
tokio::net::TcpStream::connect(&addr),
)
.await
.map_err(|_| "Connection timeout".to_string())?
.map_err(|e| format!("TCP connect failed: {e}"))?;
Ok(())
}
fn extract_host(url: &str) -> Option<String> {
url.replace("wss://", "")
.replace("ws://", "")
.split('/')
.next()
.map(|s| s.to_string())
}

View file

@ -0,0 +1,144 @@
// Agent message and project state persistence (agent_messages + project_agent_state tables)
use rusqlite::params;
use serde::{Deserialize, Serialize};
use super::SessionDb;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentMessageRecord {
#[serde(default)]
pub id: i64,
pub session_id: String,
pub project_id: String,
pub sdk_session_id: Option<String>,
pub message_type: String,
pub content: String,
pub parent_id: Option<String>,
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectAgentState {
pub project_id: String,
pub last_session_id: String,
pub sdk_session_id: Option<String>,
pub status: String,
pub cost_usd: f64,
pub input_tokens: i64,
pub output_tokens: i64,
pub last_prompt: Option<String>,
pub updated_at: i64,
}
impl SessionDb {
pub fn save_agent_messages(
&self,
session_id: &str,
project_id: &str,
sdk_session_id: Option<&str>,
messages: &[AgentMessageRecord],
) -> Result<(), String> {
let conn = self.conn.lock().unwrap();
// Wrap DELETE+INSERTs in a transaction to prevent partial writes on crash
let tx = conn.unchecked_transaction()
.map_err(|e| format!("Begin transaction failed: {e}"))?;
// Clear previous messages for this session
tx.execute(
"DELETE FROM agent_messages WHERE session_id = ?1",
params![session_id],
).map_err(|e| format!("Delete old messages failed: {e}"))?;
let mut stmt = tx.prepare(
"INSERT INTO agent_messages (session_id, project_id, sdk_session_id, message_type, content, parent_id, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)"
).map_err(|e| format!("Prepare insert failed: {e}"))?;
for msg in messages {
stmt.execute(params![
session_id,
project_id,
sdk_session_id,
msg.message_type,
msg.content,
msg.parent_id,
msg.created_at,
]).map_err(|e| format!("Insert message failed: {e}"))?;
}
drop(stmt);
tx.commit().map_err(|e| format!("Commit failed: {e}"))?;
Ok(())
}
pub fn load_agent_messages(&self, project_id: &str) -> Result<Vec<AgentMessageRecord>, String> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, session_id, project_id, sdk_session_id, message_type, content, parent_id, created_at
FROM agent_messages
WHERE project_id = ?1
ORDER BY created_at ASC"
).map_err(|e| format!("Query prepare failed: {e}"))?;
let messages = stmt.query_map(params![project_id], |row| {
Ok(AgentMessageRecord {
id: row.get(0)?,
session_id: row.get(1)?,
project_id: row.get(2)?,
sdk_session_id: row.get(3)?,
message_type: row.get(4)?,
content: row.get(5)?,
parent_id: row.get(6)?,
created_at: row.get(7)?,
})
}).map_err(|e| format!("Query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
Ok(messages)
}
pub fn save_project_agent_state(&self, state: &ProjectAgentState) -> Result<(), String> {
let conn = self.conn.lock().unwrap();
conn.execute(
"INSERT OR REPLACE INTO project_agent_state (project_id, last_session_id, sdk_session_id, status, cost_usd, input_tokens, output_tokens, last_prompt, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
state.project_id,
state.last_session_id,
state.sdk_session_id,
state.status,
state.cost_usd,
state.input_tokens,
state.output_tokens,
state.last_prompt,
state.updated_at,
],
).map_err(|e| format!("Save project agent state failed: {e}"))?;
Ok(())
}
pub fn load_project_agent_state(&self, project_id: &str) -> Result<Option<ProjectAgentState>, String> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT project_id, last_session_id, sdk_session_id, status, cost_usd, input_tokens, output_tokens, last_prompt, updated_at FROM project_agent_state WHERE project_id = ?1"
).map_err(|e| format!("Query prepare failed: {e}"))?;
let result = stmt.query_row(params![project_id], |row| {
Ok(ProjectAgentState {
project_id: row.get(0)?,
last_session_id: row.get(1)?,
sdk_session_id: row.get(2)?,
status: row.get(3)?,
cost_usd: row.get(4)?,
input_tokens: row.get(5)?,
output_tokens: row.get(6)?,
last_prompt: row.get(7)?,
updated_at: row.get(8)?,
})
});
match result {
Ok(state) => Ok(Some(state)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(format!("Load project agent state failed: {e}")),
}
}
}

View file

@ -0,0 +1,90 @@
// Session anchor persistence (session_anchors table)
use rusqlite::params;
use serde::{Deserialize, Serialize};
use super::SessionDb;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionAnchorRecord {
pub id: String,
pub project_id: String,
pub message_id: String,
pub anchor_type: String,
pub content: String,
pub estimated_tokens: i64,
pub turn_index: i64,
pub created_at: i64,
}
impl SessionDb {
pub fn save_session_anchors(&self, anchors: &[SessionAnchorRecord]) -> Result<(), String> {
if anchors.is_empty() {
return Ok(());
}
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"INSERT OR REPLACE INTO session_anchors (id, project_id, message_id, anchor_type, content, estimated_tokens, turn_index, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"
).map_err(|e| format!("Prepare anchor insert failed: {e}"))?;
for anchor in anchors {
stmt.execute(params![
anchor.id,
anchor.project_id,
anchor.message_id,
anchor.anchor_type,
anchor.content,
anchor.estimated_tokens,
anchor.turn_index,
anchor.created_at,
]).map_err(|e| format!("Insert anchor failed: {e}"))?;
}
Ok(())
}
pub fn load_session_anchors(&self, project_id: &str) -> Result<Vec<SessionAnchorRecord>, String> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, project_id, message_id, anchor_type, content, estimated_tokens, turn_index, created_at FROM session_anchors WHERE project_id = ?1 ORDER BY turn_index ASC"
).map_err(|e| format!("Query anchors failed: {e}"))?;
let anchors = stmt.query_map(params![project_id], |row| {
Ok(SessionAnchorRecord {
id: row.get(0)?,
project_id: row.get(1)?,
message_id: row.get(2)?,
anchor_type: row.get(3)?,
content: row.get(4)?,
estimated_tokens: row.get(5)?,
turn_index: row.get(6)?,
created_at: row.get(7)?,
})
}).map_err(|e| format!("Query anchors failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Read anchor row failed: {e}"))?;
Ok(anchors)
}
pub fn delete_session_anchor(&self, id: &str) -> Result<(), String> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM session_anchors WHERE id = ?1", params![id])
.map_err(|e| format!("Delete anchor failed: {e}"))?;
Ok(())
}
pub fn delete_project_anchors(&self, project_id: &str) -> Result<(), String> {
let conn = self.conn.lock().unwrap();
conn.execute("DELETE FROM session_anchors WHERE project_id = ?1", params![project_id])
.map_err(|e| format!("Delete project anchors failed: {e}"))?;
Ok(())
}
pub fn update_anchor_type(&self, id: &str, anchor_type: &str) -> Result<(), String> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE session_anchors SET anchor_type = ?2 WHERE id = ?1",
params![id, anchor_type],
).map_err(|e| format!("Update anchor type failed: {e}"))?;
Ok(())
}
}

View file

@ -0,0 +1,90 @@
// Layout state persistence (layout_state table)
use rusqlite::params;
use serde::{Deserialize, Serialize};
use super::SessionDb;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutState {
pub preset: String,
pub pane_ids: Vec<String>,
}
impl SessionDb {
pub fn save_layout(&self, layout: &LayoutState) -> Result<(), String> {
let conn = self.conn.lock().unwrap();
let pane_ids_json = serde_json::to_string(&layout.pane_ids)
.map_err(|e| format!("Serialize pane_ids failed: {e}"))?;
conn.execute(
"UPDATE layout_state SET preset = ?1, pane_ids = ?2 WHERE id = 1",
params![layout.preset, pane_ids_json],
).map_err(|e| format!("Layout save failed: {e}"))?;
Ok(())
}
pub fn load_layout(&self) -> Result<LayoutState, String> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn
.prepare("SELECT preset, pane_ids FROM layout_state WHERE id = 1")
.map_err(|e| format!("Layout query failed: {e}"))?;
stmt.query_row([], |row| {
let preset: String = row.get(0)?;
let pane_ids_json: String = row.get(1)?;
let pane_ids: Vec<String> = serde_json::from_str(&pane_ids_json).unwrap_or_default();
Ok(LayoutState { preset, pane_ids })
}).map_err(|e| format!("Layout read failed: {e}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_db() -> SessionDb {
let dir = tempfile::tempdir().unwrap();
SessionDb::open(&dir.path().to_path_buf()).unwrap()
}
#[test]
fn test_load_default_layout() {
let db = make_db();
let layout = db.load_layout().unwrap();
assert_eq!(layout.preset, "1-col");
assert!(layout.pane_ids.is_empty());
}
#[test]
fn test_save_and_load_layout() {
let db = make_db();
let layout = LayoutState {
preset: "2-col".to_string(),
pane_ids: vec!["p1".to_string(), "p2".to_string()],
};
db.save_layout(&layout).unwrap();
let loaded = db.load_layout().unwrap();
assert_eq!(loaded.preset, "2-col");
assert_eq!(loaded.pane_ids, vec!["p1", "p2"]);
}
#[test]
fn test_save_layout_overwrites() {
let db = make_db();
let layout1 = LayoutState {
preset: "2-col".to_string(),
pane_ids: vec!["p1".to_string()],
};
db.save_layout(&layout1).unwrap();
let layout2 = LayoutState {
preset: "3-col".to_string(),
pane_ids: vec!["a".to_string(), "b".to_string(), "c".to_string()],
};
db.save_layout(&layout2).unwrap();
let loaded = db.load_layout().unwrap();
assert_eq!(loaded.preset, "3-col");
assert_eq!(loaded.pane_ids.len(), 3);
}
}

Some files were not shown because too many files have changed in this diff Show more