Compare commits

...
Sign in to create a new pull request.

323 commits

Author SHA1 Message Date
Hibryda
5d0d5c5f07 chore: rebase hib_changes onto dexter_changes and update migration TODOs 2026-03-13 02:40:42 +01:00
Hibryda
719496853e docs: add migration plan TODOs and update active tasks 2026-03-12 18:23:40 +01:00
Hibryda
92d8ee2c03 chore: add agent-orchestrator submodule for migration analysis 2026-03-12 18:23:40 +01:00
DexterFromLab
09463810c4 Fix terminal scroll behavior and Claude Code tab titles
Disable auto-scroll on output so users can read scrollback without being
jumped to bottom. Keep Claude Code tab names from config instead of
overwriting with generic VTE title.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:02:17 +01:00
DexterFromLab
5815943861 Update README with full feature coverage
Add Ctx Manager panel, setup wizard, import/export, session colors,
ctx CLI flags (--shared, append, export), and FTS/WAL details.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:02:17 +01:00
DexterFromLab
31fed163d0 Add selective import/export for Ctx Manager with checkbox tree UI
Export dialog lets users pick specific projects, entries, summaries,
and shared context to save as JSON. Import dialog previews file
contents with checkboxes and supports overwrite/skip conflict mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:02:17 +01:00
DexterFromLab
a7077c7987 Add Ctx Manager panel as new sidebar tab for browsing and editing project contexts
Adds a StackSwitcher to the sidebar with Sessions and Ctx tabs. The Ctx panel
provides a tree view of all ctx projects/entries with a detail preview pane,
CRUD operations (add/edit/delete projects and entries), right-click context
menus, and auto-refresh on tab switch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:02:17 +01:00
DexterFromLab
f9ec78ce1e Remove shared context from ctx get output to avoid misleading project info
Shared entries (server, webhooks, workflow) were shown for every project,
causing Claude to misattribute them. Now ctx get shows only project-specific
data. Use --shared flag to include shared context when needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:02:17 +01:00
DexterFromLab
af670871ed Replace ctx auto-setup with step-by-step wizard
- Remove silent setup_ctx and "Edit ctx entries" button from ClaudeCodeDialog
- Add CtxSetupWizard: 3-step guided flow (project registration, first entry, confirm)
- Show ctx status label in session dialog (registered vs new project)
- Launch wizard automatically on save when project_dir is set and ctx not initialized
- Add ctx cleanup prompt when deleting a Claude session
- Extract helper functions: _detect_project_description, _is_ctx_project_registered

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:02:17 +01:00
Hibryda
6938e8c3a9 chore: add nested Claude session E2E TODO + trim completed list 2026-03-12 07:34:40 +01:00
Hibryda
c9927a41e6 docs: update CHANGELOG and TODO for E2E fixture/judge fixes 2026-03-12 07:30:56 +01:00
Hibryda
4c0d27aca3 fix: LLM judge CLI context isolation (--setting-sources user, cwd /tmp) 2026-03-12 07:30:56 +01:00
Hibryda
78afb0e552 test: increase WebDriverIO timeout for LLM-judged E2E tests
Increase global mocha timeout from 60s to 180s in wdio.conf.js to accommodate longer-running LLM judge tests that evaluate agent responses and code generation. Add explicit per-test overrides for Phase B scenarios B4 and B5 to ensure adequate time for agent startup, execution, and LLM verification.

- wdio.conf.js: global timeout 60_000 → 180_000ms
- phase-b.test.ts: explicit 180_000ms timeout for B4 and B5 scenarios
2026-03-12 07:13:57 +01:00
Hibryda
f555186843 test: update WebDriverIO configuration with improved fixture setup and logging 2026-03-12 06:58:58 +01:00
Hibryda
a8a10ee4af chore: remove obsolete rules files (consolidated into 53/54 sequence) 2026-03-12 06:47:58 +01:00
Hibryda
ee198a2fdb chore: reorganize rules files — consolidate duplicates
Migrates legacy rule numbering (18, 20) to standardized sequence (53, 54) and adds new 18-preexisting-issues.md for handling pre-existing issues during development. This consolidates duplicate rule coverage across the old and new numbering schemes.

Files changed:
- Removed: 18-relative-units.md (moved to 53-relative-units.md)
- Removed: 20-testing-gate.md (moved to 54-testing-gate.md)
- Added: 18-preexisting-issues.md (new)
- Added: 53-relative-units.md (renamed from 18)
- Added: 54-testing-gate.md (renamed from 20)
2026-03-12 06:47:47 +01:00
Hibryda
65973fbf06 docs: add comprehensive E2E testing facility documentation
New docs/e2e-testing.md covering all 3 pillars: test fixtures
(isolated temp environments), test mode (BTERMINAL_TEST=1), and
LLM judge (dual-mode CLI/API). Includes spec phases, CI integration,
WebKit2GTK pitfalls, and troubleshooting guide.
2026-03-12 06:35:04 +01:00
Hibryda
a3185656eb feat: refactor LLM judge to dual-mode CLI/API and fix config test race
Refactor llm-judge.ts from raw API-only to dual-mode: CLI first
(spawns claude with --output-format text, unsets CLAUDECODE), API
fallback. Backend selectable via LLM_JUDGE_BACKEND env var.

Fix pre-existing race condition in config.rs tests where parallel
test execution caused env var mutations to interfere. Added static
Mutex to serialize env-mutating tests.
2026-03-12 06:35:04 +01:00
Hibryda
05c9e1abbb test: add Phase C E2E tests and fix pre-existing test failures
- Add phase-c.test.ts: 27 new E2E tests across 11 scenarios covering
  hardening sprint features (command palette, search overlay, notification
  center, keyboard navigation, settings panel, project health, metrics tab,
  context tab, files tab, LLM-judged settings/status bar)
- Fix 3 pre-existing failures in bterminal.test.ts: update stale CSS
  selectors (.group-name → .cmd-label, .palette-item.active → .selected)
- Register phase-c.test.ts in wdio.conf.js specs array
- Update test counts: 444 vitest + 151 cargo + 109 E2E = 704 total
2026-03-12 06:20:21 +01:00
Hibryda
661f092fb2 fix: use tauri::async_runtime::spawn for WAL checkpoint task
tokio::spawn() panics during Tauri setup in WebDriver E2E mode because
the Tokio runtime is not directly accessible. Switch to
tauri::async_runtime::spawn() which uses Tauri's managed runtime.
2026-03-12 05:51:51 +01:00
Hibryda
2aec5889f8 docs: add v3.0 release notes and update meta files for hardening sprint
- docs/v3-release-notes.md: comprehensive v3.0 release notes covering
  Mission Control, multi-agent orchestration, production readiness,
  multi-machine early access, test coverage, and known limitations
- docs/v3-progress.md: hardening sprint session entry
- CHANGELOG.md: security entries (TLS, WAL, plugin sandbox, Landlock)
  and bug fixes (subagent delegation, gitignore)
- TODO.md: hardening complete, remaining items moved to v3.1
- CLAUDE.md: updated test counts (444 vitest + 111 cargo)
2026-03-12 05:30:32 +01:00
Hibryda
8754b64ee3 fix: track plugin-host source and add 35 sandbox security tests
Fix .gitignore 'plugins/' rule that was accidentally ignoring source
files in v2/src/lib/plugins/. Narrow to /plugins/ and /v2/plugins/
(runtime plugin directories only). Track plugin-host.ts (was written
but never committed) and add comprehensive test suite covering all 13
shadowed globals, this-binding, permission gating, API freeze, and
lifecycle management.
2026-03-12 05:25:12 +01:00
Hibryda
e46b9e06d1 feat: add WAL checkpoint task and improve Landlock fallback logging
Add periodic PRAGMA wal_checkpoint(TRUNCATE) every 5 minutes for both
sessions.db and btmsg.db to prevent unbounded WAL growth under sustained
multi-agent load. Improve Landlock fallback log message with kernel
version requirement. Add WAL checkpoint tests.
2026-03-12 05:21:39 +01:00
Hibryda
83c6711cd6 feat: add TLS support to bterminal-relay
Add optional --tls-cert and --tls-key CLI args. When provided, the relay
wraps TCP streams with native-tls before WebSocket upgrade. Refactored
to generic accept_ws_with_auth<S> and run_ws_session<S> to avoid code
duplication between plain and TLS paths. Client side already supports
wss:// URLs via connect_async with native-tls feature.
2026-03-12 05:21:33 +01:00
Hibryda
cd774ab4bd feat: fix subagent delegation for Manager agents
Add multi-agent delegation documentation to Manager system prompt so
Claude knows it can spawn child agents via the Agent tool. Also inject
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 env var for Manager agents.
2026-03-12 05:21:26 +01:00
Hibryda
c304a8c06b docs: update meta files for testing facility and tribunal assessment 2026-03-12 04:57:29 +01:00
Hibryda
bbb5f24cf9 test: update tests for production readiness features
Update btmsg-bridge, bttask-bridge, and agent-dispatcher tests for new
APIs (registerAgents, version param, notification mocks).
2026-03-12 04:57:29 +01:00
Hibryda
c193db49a8 feat: integrate all production readiness modules
Register new commands in lib.rs, add command modules, update Cargo deps
(notify-rust, keyring, bundled-full), fix PRAGMA WAL for bundled-full,
add notifications/heartbeats/FTS5 indexing to agent-dispatcher,
update SettingsTab with secrets/plugins/sandbox/updates sections.
2026-03-12 04:57:29 +01:00
Hibryda
3cb65fd5e5 feat: add optimistic locking for bttask and error classification
Version column in tasks table with WHERE id=? AND version=? guard.
Conflict detection in TaskBoardTab. error-classifier.ts: 6 error types
with actionable messages and retry logic. UsageMeter.svelte.
2026-03-12 04:57:29 +01:00
Hibryda
0fe43de357 feat: add keyboard-first UX and rewrite CommandPalette
Alt+1-5 project jump, Ctrl+H/L vi-nav, Ctrl+Shift+1-9 tab switch,
Ctrl+J terminal toggle, Ctrl+Shift+K focus agent. isEditing() guard.
CommandPalette: 18+ commands, 6 categories, fuzzy filter, arrow nav.
2026-03-12 04:57:29 +01:00
Hibryda
5c31668760 feat: add agent health monitoring, audit log, and dead letter queue
heartbeats + dead_letter_queue + audit_log tables in btmsg.db. 15s
heartbeat polling in ProjectBox, stale detection, ProjectHeader heart
indicator. AuditLogTab for Manager. register_agents_from_groups() with
bidirectional contacts and review channel creation.
2026-03-12 04:57:29 +01:00
Hibryda
b2932273ba feat: add plugin system with sandboxed runtime
Plugin discovery from ~/.config/bterminal/plugins/ with plugin.json
manifest. Sandboxed new Function() execution, permission-gated API
(palette, btmsg:read, bttask:read, events). Plugin store + SettingsTab.
2026-03-12 04:57:29 +01:00
Hibryda
5dd7df03cb feat: add OS + in-app notification system
notify-rust for desktop notifications, NotificationCenter.svelte with
bell icon, unread badge, history (max 100), 6 notification types.
Extended notification store with history and type support.
2026-03-12 04:57:29 +01:00
Hibryda
7cb5cddc7c feat: add secrets management via system keyring
SecretsManager using keyring crate (linux-native/libsecret). Store/get/
delete/list with __bterminal_keys__ metadata tracking. SettingsTab
Secrets section. No plaintext fallback.
2026-03-12 04:57:29 +01:00
Hibryda
944b48ff13 feat: add FTS5 full-text search with Spotlight-style overlay
Upgrade rusqlite to bundled-full for FTS5. SearchDb with 3 virtual tables
(messages, tasks, btmsg). SearchOverlay.svelte: Ctrl+Shift+F, 300ms
debounce, grouped results with highlight snippets.
2026-03-12 04:57:29 +01:00
Hibryda
b2c379516c feat: add Landlock sandbox for sidecar process isolation
SandboxConfig with RW/RO paths applied via pre_exec() in sidecar child
process. Requires kernel 6.2+ with graceful fallback. Per-project toggle
in SettingsTab. 9 unit tests.
2026-03-12 04:57:29 +01:00
Hibryda
548478f115 feat: add sidecar crash recovery supervisor with exponential backoff
SidecarSupervisor wraps SidecarManager with auto-restart (1s-30s backoff,
5 retries), SidecarHealth enum, 5min stability window. 17 unit tests.
2026-03-12 04:57:29 +01:00
Hibryda
243faafd9e docs: update meta files for testing facility and tribunal assessment
Update CLAUDE.md with test runner in key paths and build commands.
Update .claude/CLAUDE.md with testing gate rule index entry.
Update TODO.md with tribunal-derived roadmap items.
Update CHANGELOG.md with test runner and testing gate entries.
2026-03-12 04:05:52 +01:00
Hibryda
c5188757ad feat: add unified test runner and testing gate rule
Create v2/scripts/test-all.sh (vitest + cargo + optional E2E via --e2e).
Add npm scripts: test:all, test:all:e2e, test:cargo.
Add .claude/rules/20-testing-gate.md requiring full suite after major changes.
2026-03-12 04:05:52 +01:00
Hibryda
2e29ba5d9a docs: update meta files for E2E test fixes
Update test counts (82 E2E passing), add CHANGELOG entries for
27 fixed failures and AgentPane template fix, update TODO.md.
2026-03-12 03:50:13 +01:00
Hibryda
9ce7c35325 fix(e2e): fix 27 E2E test failures across 3 spec files
Fix stale v2 CSS selectors for v3 UI, WebKit2GTK keyboard/focus
quirks (JS-dispatched KeyboardEvent, programmatic focus check,
backdrop click close), conditional render timing (waitUntil for
project boxes, null handling for burn-rate/cost elements), and
AgentPane missing closing > on data-testid div tag.
2026-03-12 03:50:13 +01:00
Hibryda
e3594074d2 docs: update meta files for E2E testing engine Phase B+ 2026-03-12 03:07:38 +01:00
Hibryda
c43c83fbe6 ci: add E2E test workflow with xvfb and LLM-judged test gating
3 jobs (vitest, cargo, e2e), path-filtered triggers on v2 source changes,
xvfb-run for headless WebKit2GTK, LLM-judged tests gated on
ANTHROPIC_API_KEY secret availability.
2026-03-12 03:07:38 +01:00
Hibryda
5e4357e4ac feat(e2e): add Phase B scenarios with LLM-judged assertions and multi-project tests
Adds 6 new E2E scenarios in phase-b.test.ts covering multi-project grid
rendering, independent tab switching, status bar fleet state, and
LLM-judged agent response quality evaluation via Claude API.
Includes llm-judge.ts helper (raw Anthropic API fetch, haiku-4-5,
structured verdicts with confidence thresholds).
2026-03-12 03:07:38 +01:00
Hibryda
c4c673a4b0 docs: update meta files for E2E testing engine Phase A 2026-03-12 02:52:14 +01:00
Hibryda
c6c38b91c6 feat(e2e): add Phase A scenarios, fixtures, and results store
7 human-authored test scenarios (22 tests) using data-testid
selectors. Test fixture generator for isolated environments.
JSON results store (no native deps). WebDriverIO config updated
with TCP readiness probe and multi-spec support.
2026-03-12 02:52:14 +01:00
Hibryda
2746b34f83 feat(e2e): add data-testid attributes to 7 key Svelte components
Stable test selectors for E2E: agent-pane, data-agent-status,
project-box, data-project-id, status-bar, agent-session,
sidebar-rail, command-palette, terminal-tabs and more.
2026-03-12 02:52:14 +01:00
Hibryda
4097253921 feat(e2e): add test mode infrastructure with BTERMINAL_TEST env isolation
Rust: watcher.rs/fs_watcher.rs skip watchers in test mode,
is_test_mode Tauri command. Frontend: wake-scheduler disable,
App.svelte test mode detection. AppConfig centralization in
bterminal-core (OnceLock pattern for path overrides).
2026-03-12 02:52:14 +01:00
Hibryda
d1a4d9f220 docs: update meta files for reviewer agent role 2026-03-12 00:54:43 +01:00
Hibryda
323bb1b040 feat(reviewer): add Tier 1 reviewer agent role with auto-channel notifications
Reviewer workflow in agent-prompts.ts (8-step process), Rust auto-post
to #review-queue on task->review transition, reviewQueueDepth in
attention scoring (10pts/task cap 50), Tasks tab for reviewer in
ProjectBox with 10s queue polling. 7 vitest + 4 cargo tests.
2026-03-12 00:54:43 +01:00
Hibryda
61f01e22b8 docs: update meta files for auto-wake Manager session 2026-03-12 00:30:41 +01:00
Hibryda
c774f352ee feat(wake): add auto-wake Manager scheduler with 3 selectable strategies
New wake system for Manager agents: persistent (resume prompt), on-demand
(fresh session), smart (threshold-gated). 6 wake signals from tribunal S-3
hybrid. Pure scorer function (24 tests), Svelte 5 rune scheduler store,
SettingsTab UI (strategy button + threshold slider), AgentSession integration.
2026-03-12 00:30:41 +01:00
Hibryda
5576392d4b docs: update meta files for dashboard metrics panel session 2026-03-12 00:15:09 +01:00
Hibryda
6ca3ffdb8d feat(metrics): add Dashboard Metrics Panel with live health and SVG sparkline history
New MetricsPanel.svelte component as ProjectBox tab (PERSISTED-LAZY, all projects).
Live view: fleet aggregates, project health grid, task board summary, attention queue.
History view: 5 switchable SVG sparklines (cost/tokens/turns/tools/duration), stats row,
recent sessions table. 25 tests for pure utility functions.
2026-03-12 00:15:09 +01:00
Hibryda
d9d67b2bc6 docs: update meta files for branded type call-site fixes 2026-03-11 22:56:52 +01:00
Hibryda
0742309595 refactor(adapters): brand btmsg/bttask/groups bridge interfaces with GroupId/AgentId
Apply branded types to all IPC bridge interfaces and function
parameters. Update test mock data with branded constructors.
2026-03-11 22:56:52 +01:00
Hibryda
f928abd6ce refactor(types): add GroupId and AgentId branded types to ids.ts
Extend the branded type system with two new domain types for
btmsg/bttask agent and group identifiers. Apply to groups.ts
interfaces including agentToProject() domain crossing cast.
2026-03-11 22:56:52 +01:00
Hibryda
46df7949a7 refactor(components): apply branded types at Svelte component call sites
GroupAgentsPanel, TaskBoardTab, SettingsTab now use GroupId/AgentId
branded constructors at their IPC call sites.
2026-03-11 22:56:52 +01:00
Hibryda
ce389a2a39 docs: update meta files for regression tests and sidecar security session 2026-03-11 22:19:03 +01:00
Hibryda
70ebbff699 security(sidecar): add ANTHROPIC_* to Rust env strip + unit tests
Defense-in-depth: Claude CLI uses credentials file for auth, not
ANTHROPIC_API_KEY from env. OPENAI_* intentionally kept (Codex runner
needs it). 8 unit tests for strip_provider_env_var.
2026-03-11 22:19:03 +01:00
Hibryda
e41d237745 test(btmsg): add regression tests for named column access and camelCase serialization
Covers the CRITICAL status vs system_prompt bug (positional index 7),
JOIN alias disambiguation, serde camelCase serialization, TypeScript
bridge IPC commands, and plantuml hex encoding algorithm.
49 new tests: 8 btmsg.rs + 7 bttask.rs + 8 sidecar + 17 btmsg-bridge.ts + 10 bttask-bridge.ts + 7 plantuml-encode.ts
2026-03-11 22:19:03 +01:00
Hibryda
a12f2bec7b docs: update meta files for dexter_changes bug fix session 2026-03-11 21:54:19 +01:00
Hibryda
8678e3474d fix(components): stopPropagation, PlantUML encoding, Tauri 2.x asset URL
GroupAgentsPanel: added e.stopPropagation() on toggleAgent button.
ArchitectureTab: collapsed rawDeflate no-op into single plantumlEncode().
TestingTab: replaced asset://localhost/ with convertFileSrc().
2026-03-11 21:54:19 +01:00
Hibryda
93c2cdf434 fix(btmsg): convert positional column access to named, fix camelCase mismatch
CRITICAL: get_agents() used SELECT * positional index 7 for status,
but column 7 is system_prompt (column 8 is status). Converted all
query functions in btmsg.rs and bttask.rs to named column access.

Fixed BtmsgAgent/BtmsgMessage TypeScript interfaces to use camelCase
matching Rust serde(rename_all = camelCase). Updated CommsTab consumer.
2026-03-11 21:54:19 +01:00
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
274 changed files with 70739 additions and 252 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 + Production Readiness Complete): project groups, workspace store, 15+ Workspace components, session continuity, multi-provider adapter pattern, worktree isolation, session anchors, Memora adapter, SOLID refactoring, multi-agent orchestration (btmsg/bttask, 4 Tier 1 roles, role-specific tabs), dashboard metrics, auto-wake scheduler, reviewer agent. Production: sidecar supervisor (auto-restart, exponential backoff), FTS5 search (3 virtual tables, Spotlight overlay), plugin system (sandboxed new Function(), permission-gated), Landlock sandbox (kernel 6.2+), secrets management (system keyring), OS+in-app notifications, keyboard-first UX (18+ palette commands, vi-nav), agent health monitoring (heartbeats, dead letter queue), audit logging, error classification (6 types), optimistic locking (bttask). Hardening: TLS relay, WAL checkpoint (5min), subagent delegation fix, plugin sandbox tests (35). 444 vitest + 151 cargo + 109 E2E.
- 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,81 @@
- 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). 7 operations: list_tasks, create_task, update_task_status, delete_task, add_comment, task_comments, review_queue_count. Frontend: TaskBoardTab.svelte (kanban 5 columns, 5s poll). CLI `bttask` tool gives agents direct access; Manager has full CRUD, Reviewer has read + status + comments, other roles have read-only + comments. On task→review transition, auto-posts to #review-queue btmsg channel (ensure_review_channels creates #review-queue + #review-log idempotently). Reviewer agent gets Tasks tab in ProjectBox (reuses TaskBoardTab). reviewQueueDepth in AttentionInput: 10pts per review task, capped at 50 (priority between file_conflict 70 and context_high 40). ProjectBox polls review_queue_count every 10s for reviewer agents → setReviewQueueDepth() in health store.
- btmsg/bttask SQLite conventions: Both btmsg.rs and bttask.rs open shared btmsg.db with WAL mode + 5s busy_timeout (concurrent access from Python CLIs + Rust backend). All queries use named column access (`row.get("column_name")`) — never positional indices. Rust structs use `#[serde(rename_all = "camelCase")]`; TypeScript interfaces MUST match camelCase wire format. TestingTab uses `convertFileSrc()` for Tauri 2.x asset URLs (not `asset://localhost/`).
- 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, Metrics, Tasks, Architecture, Selenium, Tests — mount on first activation via {#if everActivated} + display:flex/none). Tab type: `'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories' | 'metrics' | 'tasks' | 'architecture' | 'selenium' | 'tests'`. Role-specific tabs: Manager gets Tasks (kanban), Architect gets Arch (PlantUML), Tester gets Selenium+Tests. Metrics tab (all projects): MetricsPanel.svelte — Live view (fleet aggregates, project health grid, task board summary, attention queue) + History view (SVG sparklines for cost/tokens/turns/tools/duration, stats row, session table from session_metrics_load). 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.
- wake-scheduler.svelte.ts store: Manager auto-wake with 3 user-selectable strategies (persistent=resume prompt, on-demand=fresh session, smart=threshold-gated on-demand). Configurable via SettingsTab (strategy segmented button + threshold slider for smart). 6 wake signals from tribunal S-3 hybrid: AttentionSpike(1.0), ContextPressureCluster(0.9), BurnRateAnomaly(0.8), TaskQueuePressure(0.7), ReviewBacklog(0.6), PeriodicFloor(0.1). Pure scorer in wake-scorer.ts (24 tests). Types in types/wake.ts. GroupAgentConfig: wakeStrategy, wakeThreshold fields. ProjectBox registers managers via $effect. AgentSession polls wake events every 5s. Cleared on group switch via clearWakeScheduler().
- 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).
- E2E test mode (`BTERMINAL_TEST=1`): watcher.rs and fs_watcher.rs skip file watchers, wake-scheduler disabled via `disableWakeScheduler()`, `is_test_mode` Tauri command bridges to frontend. Data/config dirs overridable via `BTERMINAL_TEST_DATA_DIR`/`BTERMINAL_TEST_CONFIG_DIR`. E2E uses WebDriverIO + tauri-driver, single session, TCP readiness probe. Phase A: 7 data-testid-based scenarios in `agent-scenarios.test.ts` (deterministic assertions). Phase B: 6 scenarios in `phase-b.test.ts` (multi-project grid, independent tab switching, status bar fleet state, LLM-judged agent responses/code generation, context tab verification). LLM judge (`llm-judge.ts`): raw fetch to Anthropic API using claude-haiku-4-5, structured verdict (pass/fail + reasoning + confidence), `assertWithJudge()` with configurable threshold, skips when `ANTHROPIC_API_KEY` absent. CI workflow (`.github/workflows/e2e.yml`): unit + cargo + e2e jobs, xvfb-run, path-filtered triggers, LLM tests gated on secret. Test fixtures in `fixtures.ts` create isolated temp environments. Results tracked via JSON store in `results-db.ts`.
- 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 +121,7 @@ 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 |
| 20 | `testing-gate.md` | Run full test suite after major changes |
| 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,12 @@
# Preexisting Issues
Never ignore problems you encounter in the codebase, even if they are outside the current task scope.
## Rules
- When you encounter a bug, lint error, type error, broken test, or code smell while working on a task, do not skip it.
- If the fix is straightforward (under ~15 minutes of work), fix it in a separate commit with a clear message explaining what was wrong.
- If the fix is complex (large refactor, architectural change, risk of regression), stop and inform the user: describe the issue, its severity, where it lives, and propose a plan to fix it. Do not attempt complex fixes without approval.
- Never suppress warnings, disable lint rules, or add `// @ts-ignore` to hide preexisting issues. Surface them.
- When fixing a preexisting issue, add a test that would have caught it if one does not already exist.
- Track issues you cannot fix immediately: flag them to the user and, if Memora is available, create an issue memory.

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.

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,32 @@
# Testing Gate (Post-Implementation)
Run the full test suite after every major change before considering work complete.
## What Counts as a Major Change
- New feature or component
- Refactoring that touches 3+ files
- Store, adapter, or bridge modifications
- Rust backend changes (commands, SQLite, sidecar)
- Build or CI configuration changes
## Required Command
```bash
cd v2 && npm run test:all
```
This runs vitest (frontend) + cargo test (backend). For changes touching E2E-relevant UI or interaction flows, also run:
```bash
cd v2 && npm run test:all:e2e
```
## Rules
- Do NOT skip tests to save time. A broken test suite is a blocking issue.
- If tests fail, fix them before moving on. Do not defer test fixes to a follow-up.
- If a change breaks existing tests, that's signal — investigate whether the change or the test is wrong.
- When adding new logic, add tests in the same commit (TDD preferred, see rule 06).
- After fixing test failures, re-run the full suite to confirm no cascading breakage.
- Report test results to the user: pass count, fail count, skip count.

175
.github/workflows/e2e.yml vendored Normal file
View file

@ -0,0 +1,175 @@
name: E2E Tests
on:
push:
branches: [v2-mission-control]
paths:
- 'v2/src/**'
- 'v2/src-tauri/**'
- 'v2/bterminal-core/**'
- 'v2/tests/e2e/**'
- '.github/workflows/e2e.yml'
pull_request:
branches: [master, v2-mission-control]
paths:
- 'v2/src/**'
- 'v2/src-tauri/**'
- 'v2/bterminal-core/**'
- 'v2/tests/e2e/**'
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
unit-tests:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: v2/package-lock.json
- name: Install npm dependencies
working-directory: v2
run: npm ci --legacy-peer-deps
- name: Run Vitest
working-directory: v2
run: npm run test
cargo-tests:
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
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
v2/target
key: ${{ runner.os }}-cargo-test-${{ hashFiles('v2/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-test-
- name: Run cargo tests
working-directory: v2/src-tauri
run: cargo test
e2e-tests:
runs-on: ubuntu-22.04
needs: [unit-tests, cargo-tests]
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 \
xvfb
- 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/target
key: ${{ runner.os }}-cargo-e2e-${{ hashFiles('v2/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-e2e-
- name: Install tauri-driver
run: cargo install tauri-driver
- name: Install npm dependencies
working-directory: v2
run: npm ci --legacy-peer-deps
- name: Build debug binary
working-directory: v2
run: npx tauri build --debug --no-bundle
- name: Run E2E tests (Phase A — deterministic)
working-directory: v2
env:
BTERMINAL_TEST: '1'
SKIP_BUILD: '1'
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
npx wdio tests/e2e/wdio.conf.js \
--spec tests/e2e/specs/bterminal.test.ts \
--spec tests/e2e/specs/agent-scenarios.test.ts
- name: Run E2E tests (Phase B — multi-project)
if: success()
working-directory: v2
env:
BTERMINAL_TEST: '1'
SKIP_BUILD: '1'
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
npx wdio tests/e2e/wdio.conf.js \
--spec tests/e2e/specs/phase-b.test.ts
# LLM-judged tests only run when API key is available (manual/dispatch)
- name: Run E2E tests (Phase B — LLM-judged)
if: success() && env.ANTHROPIC_API_KEY != ''
working-directory: v2
env:
BTERMINAL_TEST: '1'
SKIP_BUILD: '1'
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
npx wdio tests/e2e/wdio.conf.js \
--spec tests/e2e/specs/phase-b.test.ts
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: e2e-results
path: v2/test-results/
if-no-files-found: ignore

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

10
.gitignore vendored
View file

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

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "agent-orchestrator"]
path = agent-orchestrator
url = git@github.com:DexterFromLab/agent-orchestrator.git

View file

@ -8,10 +8,493 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **Sidecar crash recovery/supervision**`bterminal-core/src/supervisor.rs`: SidecarSupervisor wraps SidecarManager with auto-restart, exponential backoff (1s base, 30s cap, 5 retries), SidecarHealth enum (Healthy/Degraded/Failed), 5min stability window. 17 tests
- **Notification system** — OS desktop notifications via `notify-rust` + in-app NotificationCenter.svelte (bell icon, unread badge, history max 100, 6 notification types). Agent dispatcher emits on complete/error/crash. notifications-bridge.ts adapter
- **Secrets management**`keyring` crate with linux-native (libsecret). SecretsManager in secrets.rs: store/get/delete/list with `__bterminal_keys__` metadata tracking. SettingsTab Secrets section. secrets-bridge.ts adapter. No plaintext fallback
- **Keyboard-first UX** — Alt+1-5 project jump, Ctrl+H/L vi-nav, Ctrl+Shift+1-9 tab switch, Ctrl+J terminal toggle, Ctrl+Shift+K focus agent, Ctrl+Shift+F search overlay. `isEditing()` guard prevents conflicts. CommandPalette rewritten: 18+ commands, 6 categories, fuzzy filter, arrow nav, keyboard shortcuts overlay
- **Agent health monitoring** — heartbeats table + dead_letter_queue table in btmsg.db. 15s heartbeat polling in ProjectBox. Stale detection (5min threshold). ProjectHeader heart indicator (green/yellow/red). StatusBar health badge
- **FTS5 full-text search** — rusqlite upgraded to `bundled-full`. SearchDb with 3 FTS5 virtual tables (search_messages, search_tasks, search_btmsg). SearchOverlay.svelte: Spotlight-style Ctrl+Shift+F overlay, 300ms debounce, grouped results with FTS5 highlight snippets
- **Plugin system**`~/.config/bterminal/plugins/` with plugin.json manifest. plugins.rs: discovery, path-traversal-safe file reading, permission validation. plugin-host.ts: sandboxed `new Function()` execution, permission-gated API (palette, btmsg:read, bttask:read, events). plugins.svelte.ts store. SettingsTab plugins section. Example hello plugin
- **Landlock sandbox**`bterminal-core/src/sandbox.rs`: SandboxConfig with RW/RO paths, applied via `pre_exec()` in sidecar child process. Requires kernel 6.2+ (graceful fallback). Per-project toggle in SettingsTab
- **Error classification**`error-classifier.ts`: classifyApiError() with 6 types (rate_limit, auth, quota, overloaded, network, unknown), actionable messages, retry delays. 20 tests
- **Audit log** — audit_log table in btmsg.db. AuditLogTab.svelte: Manager-only tab, filter by type+agent, 5s auto-refresh. audit-bridge.ts adapter. Events: agent_start/stop/error, task changes, wake events, prompt injection
- **Usage meter** — UsageMeter.svelte: compact inline cost/token meter with color thresholds (50/75/90%), hover tooltip. Integrated in AgentPane cost bar
- **Team agent orchestration** — install_cli_tools() copies btmsg/bttask to ~/.local/bin on startup. register_agents_from_groups() with bidirectional contacts. ensure_review_channels_for_group() creates #review-queue/#review-log per group
- **Optimistic locking for bttask**`version` column in tasks table. `WHERE id=? AND version=?` in update_task_status(). Conflict detection in TaskBoardTab. Both Rust + Python CLI updated
- **Unified test runner**`v2/scripts/test-all.sh` runs vitest + cargo tests with optional E2E (`--e2e` flag). npm scripts: `test:all`, `test:all:e2e`, `test:cargo`. Summary output with color-coded pass/fail
- **Testing gate rule**`.claude/rules/20-testing-gate.md` requires running full test suite after every major change (new features, refactors touching 3+ files, store/adapter/bridge/backend changes)
- **E2E test mode infrastructure**`BTERMINAL_TEST=1` env var disables file watchers (watcher.rs, fs_watcher.rs), wake scheduler, and allows data/config dir overrides via `BTERMINAL_TEST_DATA_DIR`/`BTERMINAL_TEST_CONFIG_DIR`. New `is_test_mode` Tauri command bridges test state to frontend
- **E2E data-testid attributes** — Stable test selectors on 7 key Svelte components: AgentPane (agent-pane, data-agent-status, agent-messages, agent-stop, agent-prompt, agent-submit), ProjectBox (project-box, data-project-id, project-tabs, terminal-toggle), StatusBar, AgentSession, GlobalTabBar, CommandPalette, TerminalTabs
- **E2E Phase A scenarios** — 7 human-authored test scenarios (22 tests) in `agent-scenarios.test.ts`: app structural integrity, settings panel, agent pane initial state, terminal tab management, command palette, project focus/tab switching, agent prompt submission (graceful Claude CLI skip)
- **E2E test fixtures**`tests/e2e/fixtures.ts`: creates isolated temp environments with data/config dirs, git repos, and groups.json. `createTestFixture()`, `createMultiProjectFixture()`, `destroyTestFixture()`
- **E2E results store**`tests/e2e/results-db.ts`: JSON-based test run/step tracking (pivoted from better-sqlite3 due to Node 25 native compile failure)
- **E2E Phase B scenarios** — 6 multi-project + LLM-judged test scenarios in `phase-b.test.ts`: multi-project grid rendering, independent tab switching, status bar fleet state, LLM-judged agent response quality, LLM-judged code generation, context tab verification
- **LLM judge helper**`tests/e2e/llm-judge.ts`: dual-mode judge (CLI first, API fallback). CLI backend spawns `claude` with `--output-format text` (unsets CLAUDECODE). API backend uses raw fetch to Anthropic. Backend selectable via `LLM_JUDGE_BACKEND` env var. Structured verdicts (pass/fail + reasoning + confidence), `assertWithJudge()` with configurable min confidence threshold
- **E2E testing documentation**`docs/e2e-testing.md`: comprehensive guide covering all 3 pillars (test fixtures, test mode, LLM judge), spec phases A-C, CI integration, WebKit2GTK pitfalls, troubleshooting
- **E2E CI workflow**`.github/workflows/e2e.yml`: 3 jobs (vitest, cargo, e2e), xvfb-run for headless WebKit2GTK, path-filtered triggers on v2 source changes, LLM-judged tests gated on `ANTHROPIC_API_KEY` secret availability
### Fixed
- **E2E fixture env propagation**`tauri:options.env` does not reliably set process-level env vars for Rust `std::env::var()`. Added `process.env` injection at module scope in wdio.conf.js so fixture groups.json is loaded instead of real user config
- **LLM judge CLI context pollution** — Claude CLI loaded project CLAUDE.md files causing model to refuse JSON output. Fixed by running judge from `cwd: /tmp` with `--setting-sources user` and `--system-prompt` flags
- **E2E mocha timeout** — Increased global mocha timeout from 60s to 180s. Agent-running tests (B4/B5) need 120s+ for Claude CLI round-trip
- **E2E test suite — 27 failures fixed** across 3 spec files: bterminal.test.ts (22 — stale v2 CSS selectors, v3 tab order/count, JS-dispatched KeyboardEvent for Ctrl+K, idempotent palette open/close, backdrop click close, scrollIntoView for below-fold settings, scoped theme dropdown selectors), agent-scenarios.test.ts (3 — JS click for settings button, programmatic focus check, graceful 40s agent timeout with skip), phase-b.test.ts (2 — waitUntil for project box render, conditional null handling for burn-rate/cost elements). 82 E2E passing, 0 failing, 4 skipped
- **AgentPane.svelte missing closing `>`** — div tag with data-testid attributes was missing closing angle bracket, causing template parse issues
### Changed
- **WebDriverIO config** — TCP readiness probe replaces blind 2s sleep for tauri-driver startup (200ms interval, 10s deadline). Added BTERMINAL_TEST=1 passthrough in capabilities
### Security
- `claude_read_skill` path traversal: added `canonicalize()` + `starts_with()` validation to prevent reading arbitrary files via crafted skill paths (commands/claude.rs)
- **Sidecar env allowlist hardening** — added `ANTHROPIC_*` to Rust-level `strip_provider_env_var()` as defense-in-depth (Claude CLI uses credentials file, not env for auth). Dual-layer stripping documented: Rust layer (first checkpoint) + JS runner layer (per-provider)
- **Plugin sandbox hardening** — 13 shadowed globals in `new Function()` sandbox (window, document, fetch, globalThis, self, XMLHttpRequest, WebSocket, Function, importScripts, require, process, Deno, __TAURI__, __TAURI_INTERNALS__). `this` bound to undefined via `.call()`. 35 tests covering all shadows, permissions, and lifecycle. Known escape vectors documented in JSDoc
- **WAL checkpoint** — periodic `PRAGMA wal_checkpoint(TRUNCATE)` every 5 minutes on sessions.db + btmsg.db to prevent unbounded WAL growth under sustained multi-agent load. 2 tests
- **TLS support for bterminal-relay** — optional `--tls-cert` and `--tls-key` CLI args. Server wraps TCP streams with native-tls. Client already supports `wss://` URLs. Generic handler refactor avoids code duplication
- **Landlock fallback logging** — improved warning message with kernel version requirement (6.2+) and documented 3 enforcement states
### Fixed
- **btmsg.rs column index mismatch**`get_agents()` used `SELECT a.*` with positional index 7 for `status`, but column 7 is actually `system_prompt`. Converted all query functions in btmsg.rs and bttask.rs from positional to named column access (`row.get("column_name")`). Added SQL aliases for JOIN columns
- **btmsg-bridge.ts camelCase mismatch**`BtmsgAgent` and `BtmsgMessage` TypeScript interfaces used snake_case fields (`group_id`, `unread_count`, `from_agent`) but Rust `#[serde(rename_all = "camelCase")]` sends camelCase. Fixed interfaces + all consumers (CommsTab.svelte)
- **GroupAgentsPanel event propagation** — toggleAgent button click propagated to parent card click handler (`setActiveProject`). Added `e.stopPropagation()`
- **ArchitectureTab PlantUML encoding**`rawDeflate()` was a no-op, `encode64()` did hex encoding. Collapsed into single `plantumlEncode()` using PlantUML's `~h` hex encoding
- **TestingTab Tauri 2.x asset URL** — used `asset://localhost/` (Tauri 1.x). Fixed to `convertFileSrc()` from `@tauri-apps/api/core`
- **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)
- **Subagent delegation not triggering** — Manager system prompt had no documentation of Agent tool / delegation capability. Added "Multi-Agent Delegation" section with usage examples and guidelines. Also inject `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` env var for Manager agents
- **Gitignore ignoring source code** — root `.gitignore` `plugins/` rule matched `v2/src/lib/plugins/` (source code). Narrowed to `/plugins/` and `/v2/plugins/` (runtime dirs only)
### Added
- **Reviewer agent role** — Tier 1 specialist with reviewer workflow in `agent-prompts.ts` (8-step process: inbox → review-queue → analyze → verdict → status update → review-log → report). Rust `bttask.rs` auto-posts to `#review-queue` btmsg channel on task→review transition via `notify_review_channel()` + `ensure_review_channels()` (idempotent). `reviewQueueDepth` in `attention-scorer.ts` (10pts/task, cap 50). `review_queue_count()` Rust function + Tauri command + `reviewQueueCount()` IPC bridge. ProjectBox: 'Tasks' tab for reviewer (reuses TaskBoardTab), 10s review queue polling → `setReviewQueueDepth()` in health store. 7 new vitest + 4 new cargo tests. 388 vitest + 76 cargo total
- **Auto-wake Manager scheduler**`wake-scheduler.svelte.ts` + `wake-scorer.ts` with 3 user-selectable strategies: persistent (Manager stays running, resume prompt with fleet context), on-demand (fresh session per wake), smart (threshold-gated on-demand, default). 6 wake signals from tribunal S-3 hybrid: AttentionSpike(1.0), ContextPressureCluster(0.9), BurnRateAnomaly(0.8), TaskQueuePressure(0.7), ReviewBacklog(0.6), PeriodicFloor(0.1). Settings UI: strategy segmented button + threshold slider in Manager agent cards. `GroupAgentConfig` extended with `wakeStrategy` + `wakeThreshold` fields. 24 tests in wake-scorer.test.ts. 381 vitest + 72 cargo total
- **Dashboard metrics panel**`MetricsPanel.svelte` new ProjectBox tab ('metrics', PERSISTED-LAZY, all projects). Live view: fleet aggregates (running/idle/stalled + burn rate), project health grid (status, burn rate, context %, idle, tokens, cost, turns, model, conflicts, attention), task board summary (5 kanban columns polled every 10s), cross-project attention queue. History view: 5 switchable SVG sparkline charts (cost/tokens/turns/tools/duration) with area fill, stats row (last/avg/max/min), recent sessions table. 25 tests in MetricsPanel.test.ts. 357 vitest + 72 cargo total
### Changed
- **Branded types for GroupId/AgentId (SOLID Phase 3b)** — Extended `types/ids.ts` with GroupId and AgentId branded types. Applied to ~40 sites: groups.ts interfaces (ProjectConfig.id, GroupConfig.id, GroupAgentConfig.id, GroupsFile.activeGroupId), btmsg-bridge.ts (5 interfaces, 15 function params), bttask-bridge.ts (Task/TaskComment, 6 params), groups-bridge.ts (AgentMessageRecord, ProjectAgentState, SessionMetric), 3 Svelte components (GroupAgentsPanel, TaskBoardTab, SettingsTab). agentToProject() uses `as unknown as ProjectId` cast for domain crossing. 12 tests in ids.test.ts. 332 vitest + 72 cargo total
- **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`
- **btmsg/bttask WAL mode** — added SQLite WAL journal mode + 5s busy_timeout to both `btmsg.rs` and `bttask.rs` `open_db()` for safe concurrent access from Python CLIs + Rust backend
### Added
- **Regression tests for btmsg/bttask bug fixes** — 49 new tests: btmsg.rs (8, in-memory SQLite with named column access regression for status vs system_prompt), bttask.rs (7, named column access + serde camelCase), sidecar strip_provider_env_var (8, all prefix combinations), btmsg-bridge.test.ts (17, camelCase fields + IPC commands), bttask-bridge.test.ts (10, camelCase + IPC), plantuml-encode.test.ts (7, hex encoding algorithm). Total: 327 vitest + 72 cargo
- **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

230
CLAUDE.md Normal file
View file

@ -0,0 +1,230 @@
# 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 + Production Readiness): 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/Reviewer with role-specific tabs, btmsg inter-agent messaging, bttask kanban task board with optimistic locking). Production features: sidecar crash recovery/supervision, FTS5 full-text search, plugin system (sandboxed, 35 tests), Landlock sandboxing, secrets management (system keyring), OS + in-app notifications, keyboard-first UX (18+ palette commands), agent health monitoring + dead letter queue, audit logging, error classification. Hardening: TLS relay support, WAL checkpoint (5min), subagent delegation fix.
- **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 |
| `docs/e2e-testing.md` | E2E testing facility: fixtures, test mode, LLM judge, spec phases, CI |
| `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/` | 16 domain command modules (pty, agent, watcher, session, persistence, knowledge, claude, groups, files, remote, misc, bttask, notifications, plugins, search, secrets) |
| `v2/src-tauri/src/btmsg.rs` | Agent messaging backend (agents, DMs, channels, contacts ACL, heartbeats, dead_letter_queue, audit_log; SQLite WAL mode, named column access) |
| `v2/src-tauri/src/bttask.rs` | Task board backend (list, create, update status with optimistic locking, delete, comments, review_queue_count; shared btmsg.db) |
| `v2/src-tauri/src/search.rs` | FTS5 full-text search (SearchDb, 3 virtual tables: search_messages/tasks/btmsg, index/search/rebuild) |
| `v2/src-tauri/src/secrets.rs` | SecretsManager (keyring crate, linux-native/libsecret, store/get/delete/list with metadata tracking) |
| `v2/src-tauri/src/plugins.rs` | Plugin discovery (scan config dir for plugin.json, path-traversal-safe file reading, permission validation) |
| `v2/src-tauri/src/notifications.rs` | Desktop notifications (notify-rust, graceful fallback if daemon unavailable) |
| `v2/bterminal-core/src/supervisor.rs` | SidecarSupervisor (auto-restart, exponential backoff 1s-30s, 5 retries, SidecarHealth enum, 17 tests) |
| `v2/bterminal-core/src/sandbox.rs` | Landlock sandbox (SandboxConfig RW/RO paths, pre_exec() integration, kernel 6.2+ graceful fallback) |
| `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/scripts/test-all.sh` | Unified test runner: vitest + cargo + optional E2E (--e2e flag) |
| `v2/tests/e2e/wdio.conf.js` | WebDriverIO config (tauri-driver lifecycle, TCP probe, test env vars) |
| `v2/tests/e2e/fixtures.ts` | E2E test fixture generator (isolated temp dirs, git repos, groups.json) |
| `v2/tests/e2e/results-db.ts` | JSON test results store (run/step tracking, no native deps) |
| `v2/tests/e2e/specs/bterminal.test.ts` | E2E smoke tests (CSS class selectors, 50+ tests) |
| `v2/tests/e2e/specs/agent-scenarios.test.ts` | Phase A E2E scenarios (data-testid selectors, 7 scenarios, 22 tests) |
| `v2/tests/e2e/specs/phase-b.test.ts` | Phase B E2E scenarios (multi-project, LLM-judged assertions, 6 scenarios) |
| `v2/tests/e2e/llm-judge.ts` | LLM judge helper (Claude API assertions, confidence thresholds) |
| `.github/workflows/e2e.yml` | CI: unit + cargo + E2E tests (xvfb-run, path-filtered, LLM tests gated on secret) |
| `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/wake-scorer.ts` | Pure wake signal evaluation (6 signals, 24 tests) |
| `v2/src/lib/types/wake.ts` | WakeStrategy, WakeSignal, WakeEvaluation, WakeContext types |
| `v2/src/lib/stores/wake-scheduler.svelte.ts` | Manager auto-wake scheduler (3 strategies, per-manager timers) |
| `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` | Notification store (toast + history, 6 NotificationTypes, unread badge, max 100 history) |
| `v2/src/lib/stores/plugins.svelte.ts` | Plugin store (command registry, event bus, loadAllPlugins/unloadAllPlugins) |
| `v2/src/lib/adapters/audit-bridge.ts` | Audit log IPC adapter (logAuditEvent, getAuditLog, AuditEntry, AuditEventType) |
| `v2/src/lib/adapters/notifications-bridge.ts` | Desktop notification IPC wrapper (sendDesktopNotification) |
| `v2/src/lib/adapters/plugins-bridge.ts` | Plugin discovery IPC wrapper (discoverPlugins, readPluginFile) |
| `v2/src/lib/adapters/search-bridge.ts` | FTS5 search IPC wrapper (initSearch, searchAll, rebuildIndex, indexMessage) |
| `v2/src/lib/adapters/secrets-bridge.ts` | Secrets IPC wrapper (storeSecret, getSecret, deleteSecret, listSecrets, hasKeyring) |
| `v2/src/lib/utils/error-classifier.ts` | API error classification (6 types: rate_limit/auth/quota/overloaded/network/unknown, retry logic, 20 tests) |
| `v2/src/lib/plugins/plugin-host.ts` | Sandboxed plugin runtime (new Function(), permission-gated API, load/unload lifecycle) |
| `v2/src/lib/components/Agent/UsageMeter.svelte` | Compact inline usage meter (color thresholds 50/75/90%, hover tooltip) |
| `v2/src/lib/components/Notifications/NotificationCenter.svelte` | Bell icon + dropdown notification panel (unread badge, history, mark read/clear) |
| `v2/src/lib/components/Workspace/AuditLogTab.svelte` | Manager audit log tab (filter by type+agent, 5s auto-refresh, max 200 entries) |
| `v2/src/lib/components/Workspace/SearchOverlay.svelte` | FTS5 search overlay (Ctrl+Shift+F, Spotlight-style, 300ms debounce, grouped results) |
| `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/components/Workspace/MetricsPanel.svelte` | Dashboard metrics panel (live health + task counts + history sparklines, 25 tests) |
| `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/btmsg-bridge.test.ts` | Vitest tests for btmsg bridge (17 tests: camelCase, IPC commands) |
| `v2/src/lib/adapters/bttask-bridge.test.ts` | Vitest tests for bttask bridge (10 tests: camelCase, IPC commands) |
| `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-full, FTS5), dirs, notify, serde, tokio, tokio-tungstenite, futures-util, tracing, tracing-subscriber, opentelemetry, opentelemetry_sdk, opentelemetry-otlp, tracing-opentelemetry, tauri-plugin-updater, tauri-plugin-dialog, notify-rust, keyring (linux-native)
- Rust deps (bterminal-core): portable-pty, uuid, serde, serde_json, log, landlock
- 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:all # All tests (vitest + cargo)
cd v2 && npm run test:all:e2e # All tests + E2E (needs built binary)
cd v2 && npm run test # Vitest only (frontend)
cd v2 && npm run test:cargo # Cargo only (backend)
cd v2 && npm run test:e2e # E2E only (WebDriverIO)
# 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)

126
README.md
View file

@ -1,21 +1,28 @@
# BTerminal
Terminal with session panel (MobaXterm-style), built with GTK 3 + VTE. Catppuccin Mocha theme.
GTK 3 terminal with SSH & Claude Code session management, macros, and a cross-session context database. 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)
## Features
- **SSH sessions** — saved configs (host, port, user, key, folder, color), CRUD with side panel
- **Claude Code sessions** — saved Claude Code configs with sudo, resume, skip-permissions and initial prompt
- **SSH macros** — multi-step macros (text, key, delay) assigned to sessions, run from sidebar
- **Tabs** — multiple terminals in tabs, Ctrl+T new, Ctrl+Shift+W close, Ctrl+PageUp/Down switch
- **Sudo askpass** — Claude Code with sudo: password entered once, temporary askpass helper, auto-cleanup
- **Folder grouping** — SSH and Claude Code sessions can be grouped in folders on the sidebar
- **ctx — Context manager** — SQLite-based cross-session context database for Claude Code projects
- **Catppuccin Mocha** — full theme: terminal, sidebar, tabs, session colors
- **SSH sessions** — saved configs (host, port, user, key, folder, color), one-click connect from sidebar
- **Claude Code sessions** — saved configs with sudo askpass, resume, skip-permissions and initial prompt
- **SSH macros** — multi-step automation (text, key press, delay) bound to sessions, runnable from sidebar
- **Tabs** — multiple terminals in tabs with reordering, auto-close and shell respawn
- **Folder grouping** — organize both SSH and Claude Code sessions in collapsible sidebar folders
- **Session colors** — 10 Catppuccin accent colors for quick visual identification
- **Sudo askpass** — temporary helper for Claude Code sudo mode: password entered once, auto-cleanup on exit
- **Catppuccin Mocha** — full theme across terminal, sidebar, tabs, dialogs and scrollbars
### Context Manager
- **ctx CLI** — SQLite-based tool for persistent context across Claude Code sessions
- **Ctx Manager panel** — sidebar tab for browsing, editing and managing all project contexts
- **Ctx Setup Wizard** — step-by-step project setup with auto-detection from README and CLAUDE.md generation
- **Import / Export** — selective import and export of projects, entries, summaries and shared context via JSON with checkbox tree UI
## Installation
@ -30,9 +37,23 @@ The installer will:
2. Copy files to `~/.local/share/bterminal/`
3. Create symlinks: `bterminal` and `ctx` in `~/.local/bin/`
4. Initialize context database at `~/.claude-context/context.db`
5. Add desktop entry to application menu
5. Add desktop entry and icon 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
@ -46,23 +67,37 @@ bterminal
## Context Manager (ctx)
`ctx` is a SQLite-based tool for managing persistent context across Claude Code sessions.
`ctx` is a SQLite-based tool for managing persistent context across Claude Code sessions. It uses FTS5 full-text search and WAL journal mode.
```bash
ctx init myproject "Project description" /path/to/project
ctx get myproject # Load full context (shared + project)
ctx get myproject # Load project context
ctx get myproject --shared # Include shared context
ctx set myproject key "value" # Save a context entry
ctx shared set preferences "value" # Save shared context (available in all projects)
ctx append myproject key "more" # Append to existing entry
ctx shared set preferences "value" # Save shared context (all projects)
ctx summary myproject "What was done" # Save session summary
ctx search "query" # Full-text search across everything
ctx list # List all projects
ctx history myproject # Show session history
ctx export # Export all data as JSON
ctx delete myproject [key] # Delete project or entry
ctx --help # All commands
```
### Ctx Manager Panel
The sidebar **Ctx** tab provides a GUI for the context database:
- Browse all projects and their entries in a tree view
- View entry values and project details in the detail pane
- Add, edit and delete projects and entries
- **Export** — select specific projects, entries, summaries and shared context to save as JSON
- **Import** — load a JSON file, preview contents with checkboxes, optionally overwrite existing entries
### Integration with Claude Code
Add a `CLAUDE.md` to your project root:
Add a `CLAUDE.md` to your project root (the Ctx Setup Wizard can generate this automatically):
```markdown
On session start, load context:
@ -80,8 +115,8 @@ Config files in `~/.config/bterminal/`:
| File | Description |
|------|-------------|
| `sessions.json` | Saved SSH sessions + macros |
| `claude_sessions.json` | Saved Claude Code configs |
| `sessions.json` | SSH sessions and macros |
| `claude_sessions.json` | Claude Code session configs |
Context database: `~/.claude-context/context.db`
@ -95,14 +130,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

30
TODO.md
View file

@ -1,14 +1,28 @@
# 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.
### Migration to agent-orchestrator
- [ ] **Review Dexter's 13 feature commits** -- hib_changes rebased onto dexter_changes (55ba8d0). Need to review: Aider provider, splash screen, provider/model unification, Tier 2 btmsg access, auto-wake on btmsg. Then push hib_changes and start working from agent-orchestrator repo.
- [ ] **CLAUDE.md Commit Zero** -- Update agent-orchestrator's CLAUDE.md to reflect rebrand + new features. Update docs/ accordingly.
- [ ] **Switch primary development to agent-orchestrator** -- After review + CLAUDE.md update, develop on hib_changes in agent-orchestrator. BTerminal repo stays for v1 production only.
### v3.1 Remaining
- [ ] **Multi-machine real-world testing** -- TLS added to relay. Needs real 2-machine test. Multi-machine UI not surfaced in v3, code exists in bridges/stores only.
- [ ] **Certificate pinning** -- TLS encryption done (v3.0). Pin cert hash in RemoteManager for v3.1.
- [ ] **Agent Teams real-world testing** -- Subagent delegation prompt fix done + env var injection. Needs real multi-agent session to verify Manager spawns child agents.
- [ ] **Plugin sandbox migration** -- `new Function()` has inherent escape vectors (prototype walking, arguments.callee.constructor). Consider Web Worker isolation for v3.2.
- [ ] **Soak test** -- Run 4-hour soak with 6+ agents across 3+ projects. Monitor memory, SQLite WAL size, xterm.js instances.
- [ ] **E2E agent tests hang in nested Claude sessions** -- B4/B5 phase-b tests timeout when run from within a Claude Code session (sidecar spawns Claude CLI which hangs in git repo context). Works in CI. Investigate process isolation or session nesting guard.
## Completed
(none yet)
- [x] **E2E fixture + judge hardening** -- Fixed fixture env propagation (process.env injection, tauri:options.env unreliable), LLM judge CLI context isolation (--setting-sources user, cwd /tmp, --system-prompt), mocha timeout 180s. Confirmed fixture fakes project list. Agent tests CI-only (nested Claude limitation). | Done: 2026-03-12
- [x] **LLM judge refactor + E2E docs** -- Refactored llm-judge.ts to dual-mode (CLI first, API fallback), env-configurable via LLM_JUDGE_BACKEND. Wrote comprehensive docs/e2e-testing.md covering fixtures, test mode, LLM judge, all spec phases, CI, troubleshooting. 444 vitest + 151 cargo + 109 E2E. | Done: 2026-03-12
- [x] **v3 Hardening Sprint** -- Fixed subagent delegation (prompt + env var), added TLS to relay, WAL checkpoint (5min), Landlock logging, plugin sandbox tests (35), gitignore fix. Phase C E2E tests (27 new, 3 pre-existing fixes). 444 vitest + 151 cargo + 109 E2E. | Done: 2026-03-12
- [x] **v3 Production Readiness — ALL tribunal items** -- Implemented all 13 features from tribunal assessment: sidecar supervisor, notifications, secrets, keyboard UX, agent health, search, plugins, sandbox, error classifier, audit log, team agent orchestration, optimistic locking, usage meter. 409 vitest + 109 cargo. | Done: 2026-03-12
- [x] **Unified test runner + testing gate rule** -- Created v2/scripts/test-all.sh (vitest + cargo + optional E2E), added npm scripts (test:all, test:all:e2e, test:cargo), added .claude/rules/20-testing-gate.md requiring full suite after major changes. | Done: 2026-03-12
- [x] **E2E testing — Phase B+ & test fixes** -- Phase B: LLM judge (llm-judge.ts, claude-haiku-4-5), 6 multi-project scenarios, CI workflow (3 jobs). Test fixes: 27 failures across 3 spec files. 388 vitest + 68 cargo + 82 E2E (0 fail, 4 skip). | Done: 2026-03-12
- [x] **Reviewer agent role** -- Tier 1 specialist with role='reviewer'. Reviewer workflow in agent-prompts.ts (8-step process). #review-queue/#review-log auto-channels. reviewQueueDepth in attention scoring (10pts/task, cap 50). 388 vitest + 76 cargo. | Done: 2026-03-12
- [x] **Auto-wake Manager** -- wake-scheduler.svelte.ts + wake-scorer.ts (24 tests). 3 strategies: persistent/on-demand/smart. 6 signals. Settings UI. 381 vitest + 72 cargo. | Done: 2026-03-12
- [x] **Dashboard metrics panel** -- MetricsPanel.svelte: live health + task board summary + SVG sparkline history. 25 tests. 357 vitest + 72 cargo. | Done: 2026-03-12

1
agent-orchestrator Submodule

@ -0,0 +1 @@
Subproject commit 55ba8d0969b4c9e34e47fe621ea4812528441365

File diff suppressed because it is too large Load diff

1183
btmsg Executable file

File diff suppressed because it is too large Load diff

729
bttask Executable file
View file

@ -0,0 +1,729 @@
#!/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')),
version INTEGER DEFAULT 1,
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);
""")
# Migration: add version column if missing (for existing databases)
cursor = db.execute("PRAGMA table_info(tasks)")
columns = [row[1] for row in cursor.fetchall()]
if 'version' not in columns:
db.execute("ALTER TABLE tasks ADD COLUMN version INTEGER DEFAULT 1")
db.commit()
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']
current_version = task['version'] if task['version'] is not None else 1
cursor = db.execute(
"UPDATE tasks SET status = ?, version = version + 1, updated_at = datetime('now') "
"WHERE id = ? AND version = ?",
(new_status, task['id'], current_version)
)
if cursor.rowcount == 0:
print(f"{C_RED}Error: Task was modified by another agent (version conflict).{C_RESET}")
print(f"{C_DIM}Re-fetch the task and try again.{C_RESET}")
db.close()
sys.exit(1)
# 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()
new_version = current_version + 1
print(f"{C_GREEN}✓ [{short_id(task['id'])}] {format_state(old_status)} → {format_state(new_status)} {C_DIM}(v{new_version}){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()

60
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")
@ -104,19 +103,18 @@ def cmd_init(args):
def cmd_get(args):
"""Get full context for a project (shared + project-specific + recent summaries)."""
"""Get full context for a project (project-specific + recent summaries).
Use --shared flag to also include shared context."""
if len(args) < 1:
print("Usage: ctx get <project>")
print("Usage: ctx get <project> [--shared]")
sys.exit(1)
project = args[0]
show_shared = "--shared" in args
db = get_db()
# Session info
session = db.execute("SELECT * FROM sessions WHERE name = ?", (project,)).fetchone()
# Shared context
shared = db.execute("SELECT key, value FROM shared ORDER BY key").fetchall()
# Project context
contexts = db.execute(
"SELECT key, value FROM contexts WHERE project = ? ORDER BY key", (project,)
@ -138,11 +136,13 @@ def cmd_get(args):
print(f"PROJECT: {project} (not registered, use: ctx init)")
print("=" * 60)
if shared:
print("\n--- Shared Context ---")
for row in shared:
print(f"\n[{row['key']}]")
print(row["value"])
if show_shared:
shared = db.execute("SELECT key, value FROM shared ORDER BY key").fetchall()
if shared:
print("\n--- Shared Context ---")
for row in shared:
print(f"\n[{row['key']}]")
print(row["value"])
if contexts:
print(f"\n--- {project} Context ---")
@ -156,8 +156,8 @@ def cmd_get(args):
print(f"\n[{row['created_at']}]")
print(row["summary"])
if not shared and not contexts and not summaries:
print("\nNo context stored yet. Use 'ctx set' or 'ctx shared set' to add.")
if not contexts and not summaries:
print("\nNo context stored yet. Use 'ctx set' to add project context.")
db.close()
@ -279,7 +279,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 +306,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(
@ -433,7 +445,7 @@ def print_help():
print("ctx — Cross-session context manager for Claude Code\n")
print("Commands:")
print(" init <project> <desc> [dir] Register a new project")
print(" get <project> Load full context (shared + project)")
print(" get <project> [--shared] Load project context (optionally with shared)")
print(" set <project> <key> <value> Set project context entry")
print(" append <project> <key> <val> Append to existing entry")
print(" shared get|set|delete Manage shared context")

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,31 @@ 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) |
### Testing
| Document | Description |
|----------|-------------|
| [e2e-testing.md](e2e-testing.md) | E2E testing facility: fixtures, test mode, LLM judge, spec phases, CI |
### 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) |

282
docs/e2e-testing.md Normal file
View file

@ -0,0 +1,282 @@
# E2E Testing Facility
BTerminal's end-to-end testing uses **WebDriverIO + tauri-driver** to drive the real Tauri application through WebKit2GTK's inspector protocol. The facility has three pillars:
1. **Test Fixtures** — isolated fake environments with dummy projects
2. **Test Mode** — app-level env vars that disable watchers and redirect data/config paths
3. **LLM Judge** — Claude-powered semantic assertions for evaluating agent behavior
## Quick Start
```bash
# Run all tests (vitest + cargo + E2E)
cd v2 && npm run test:all:e2e
# Run E2E only (requires pre-built debug binary)
cd v2 && SKIP_BUILD=1 npm run test:e2e
# Build debug binary separately (faster iteration)
cd v2 && cargo tauri build --debug --no-bundle
# Run with LLM judge via CLI (default, auto-detected)
cd v2 && npm run test:e2e
# Force LLM judge to use API instead of CLI
cd v2 && LLM_JUDGE_BACKEND=api ANTHROPIC_API_KEY=sk-... npm run test:e2e
```
## Prerequisites
| Dependency | Purpose | Install |
|-----------|---------|---------|
| Rust + Cargo | Build Tauri backend | [rustup.rs](https://rustup.rs) |
| Node.js 20+ | Frontend + test runner | `mise install node` |
| tauri-driver | WebDriver bridge to WebKit2GTK | `cargo install tauri-driver` |
| X11 display | WebKit2GTK needs a display | Real X, or `xvfb-run` in CI |
| Claude CLI | LLM judge (optional) | [claude.ai/download](https://claude.ai/download) |
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ WebDriverIO (mocha runner) │
│ specs/*.test.ts │
│ └─ browser.execute() → DOM queries + assertions │
│ └─ assertWithJudge() → LLM semantic evaluation │
├─────────────────────────────────────────────────────────┤
│ tauri-driver (port 4444) │
│ WebDriver protocol ↔ WebKit2GTK inspector │
├─────────────────────────────────────────────────────────┤
│ BTerminal debug binary │
│ BTERMINAL_TEST=1 (disables watchers, wake scheduler) │
│ BTERMINAL_TEST_DATA_DIR → isolated SQLite DBs │
│ BTERMINAL_TEST_CONFIG_DIR → test groups.json │
└─────────────────────────────────────────────────────────┘
```
## Pillar 1: Test Fixtures (`fixtures.ts`)
The fixture generator creates isolated temporary environments so tests never touch real user data. Each fixture includes:
- **Temp root dir** under `/tmp/bterminal-e2e-{timestamp}/`
- **Data dir** — empty, SQLite databases created at runtime
- **Config dir** — contains a generated `groups.json` with test projects
- **Project dir** — a real git repo with `README.md` and `hello.py` (for agent testing)
### Single-Project Fixture
```typescript
import { createTestFixture, destroyTestFixture } from '../fixtures';
const fixture = createTestFixture('my-test');
// fixture.rootDir → /tmp/my-test-1710234567890/
// fixture.dataDir → /tmp/my-test-1710234567890/data/
// fixture.configDir → /tmp/my-test-1710234567890/config/
// fixture.projectDir → /tmp/my-test-1710234567890/test-project/
// fixture.env → { BTERMINAL_TEST: '1', BTERMINAL_TEST_DATA_DIR: '...', BTERMINAL_TEST_CONFIG_DIR: '...' }
// The test project is a git repo with:
// README.md — "# Test Project\n\nA simple test project for BTerminal E2E tests."
// hello.py — "def greet(name: str) -> str:\n return f\"Hello, {name}!\""
// Both committed as "initial commit"
// groups.json contains one group "Test Group" with one project pointing at projectDir
// Cleanup when done:
destroyTestFixture(fixture);
```
### Multi-Project Fixture
```typescript
import { createMultiProjectFixture } from '../fixtures';
const fixture = createMultiProjectFixture(3); // 3 separate git repos
// Creates project-0, project-1, project-2 under fixture.rootDir
// Each is a git repo with README.md
// groups.json has one group "Multi Project Group" with all 3 projects
```
### Fixture Environment Variables
Pass `fixture.env` to the app to redirect all data/config paths:
| Variable | Effect |
|----------|--------|
| `BTERMINAL_TEST=1` | Disables file watchers, wake scheduler, enables `is_test_mode` |
| `BTERMINAL_TEST_DATA_DIR` | Redirects `sessions.db` and `btmsg.db` storage |
| `BTERMINAL_TEST_CONFIG_DIR` | Redirects `groups.json` config loading |
## Pillar 2: Test Mode
When `BTERMINAL_TEST=1` is set:
- **Rust backend**: `watcher.rs` and `fs_watcher.rs` skip file watchers
- **Frontend**: `is_test_mode` Tauri command returns true, wake scheduler disabled via `disableWakeScheduler()`
- **Data isolation**: `BTERMINAL_TEST_DATA_DIR` / `BTERMINAL_TEST_CONFIG_DIR` override default paths
The WebDriverIO config (`wdio.conf.js`) passes these env vars via `tauri:options.env` in capabilities.
## Pillar 3: LLM Judge (`llm-judge.ts`)
The LLM judge enables semantic assertions — evaluating whether agent output "looks right" rather than exact string matching. Useful for testing AI agent responses where exact output is non-deterministic.
### Dual Backend
The judge supports two backends, auto-detected or explicitly set:
| Backend | How it works | Requires |
|---------|-------------|----------|
| `cli` (default) | Spawns `claude` CLI with `--output-format text` | Claude CLI installed |
| `api` | Raw `fetch` to `https://api.anthropic.com/v1/messages` | `ANTHROPIC_API_KEY` env var |
**Auto-detection order**: CLI first → API fallback → skip test.
**Override**: Set `LLM_JUDGE_BACKEND=cli` or `LLM_JUDGE_BACKEND=api`.
### API
```typescript
import { isJudgeAvailable, judge, assertWithJudge } from '../llm-judge';
// Check availability (CLI or API key present)
if (!isJudgeAvailable()) {
this.skip(); // graceful skip in mocha
return;
}
// Basic judge call
const verdict = await judge(
'The output should contain a file listing with at least one filename', // criteria
actualOutput, // actual
'Agent was asked to list files in a directory containing README.md', // context (optional)
);
// verdict: { pass: boolean, reasoning: string, confidence: number }
// With confidence threshold (default 0.7)
const verdict = await assertWithJudge(
'Response should describe the greet function',
agentMessages,
{ context: 'hello.py contains def greet(name)', minConfidence: 0.8 },
);
```
### How It Works
1. Builds a structured prompt with criteria, actual output, and optional context
2. Asks Claude (Haiku) to evaluate as a test assertion judge
3. Expects JSON response: `{"pass": true/false, "reasoning": "...", "confidence": 0.0-1.0}`
4. Validates and returns structured `JudgeVerdict`
The CLI backend unsets `CLAUDECODE` env var to avoid nested session errors when running inside Claude Code.
## Test Spec Files
| File | Phase | Tests | Focus |
|------|-------|-------|-------|
| `bterminal.test.ts` | Smoke | ~50 | Basic UI rendering, CSS class selectors |
| `agent-scenarios.test.ts` | A | 22 | `data-testid` selectors, 7 deterministic scenarios |
| `phase-b.test.ts` | B | ~15 | Multi-project grid, LLM-judged agent responses |
| `phase-c.test.ts` | C | 27 | Hardening features (palette, search, notifications, keyboard, settings, health, metrics, context, files) |
### Phase A: Deterministic Agent Scenarios
Uses `data-testid` attributes for reliable selectors. Tests app structure, project rendering, and agent pane states without live agent interaction.
### Phase B: Multi-Project + LLM Judge
Tests multi-project grid rendering, independent tab switching, status bar fleet state. LLM-judged tests (B4, B5) send real prompts to agents and evaluate response quality — these require Claude CLI or API key and are skipped otherwise.
### Phase C: Production Hardening
Tests v3 hardening features: command palette commands (C1), search overlay (C2), notification center (C3), keyboard navigation (C4), settings panel (C5), project health indicators (C6), metrics tab (C7), context tab (C8), files tab with editor (C9), LLM-judged settings (C10), LLM-judged status bar (C11).
## Test Results Tracking (`results-db.ts`)
A lightweight JSON store for tracking test runs and individual step results:
```typescript
import { ResultsDb } from '../results-db';
const db = new ResultsDb(); // writes to v2/test-results/results.json
db.startRun('run-001', 'v2-mission-control', 'abc123');
db.recordStep({
run_id: 'run-001',
scenario_name: 'B4',
step_name: 'should send prompt and get meaningful response',
status: 'passed',
duration_ms: 15000,
error_message: null,
screenshot_path: null,
agent_cost_usd: 0.003,
});
db.finishRun('run-001', 'passed', 45000);
```
## CI Integration (`.github/workflows/e2e.yml`)
The CI pipeline runs on push/PR with path-filtered triggers:
1. **Unit tests**`npm run test` (vitest)
2. **Cargo tests**`cargo test` (with `env -u BTERMINAL_TEST` to prevent env leakage)
3. **E2E tests**`xvfb-run npm run test:e2e` (virtual framebuffer for headless WebKit2GTK)
LLM-judged tests are gated on the `ANTHROPIC_API_KEY` secret — they skip gracefully in forks or when the secret is absent.
## Writing New Tests
### Adding a New Scenario
1. Pick the appropriate spec file (or create a new phase file)
2. Use `data-testid` selectors where possible (more stable than CSS classes)
3. For DOM queries, use `browser.execute()` to run JS in the app context
4. For semantic assertions, use `assertWithJudge()` with clear criteria
### Common Helpers
All spec files share similar helper patterns:
```typescript
// Get project IDs
const ids: string[] = await browser.execute(() => {
const boxes = document.querySelectorAll('[data-testid="project-box"]');
return Array.from(boxes).map(b => b.getAttribute('data-project-id') ?? '').filter(Boolean);
});
// Focus a project
await browser.execute((id) => {
const box = document.querySelector(`[data-project-id="${id}"]`);
const header = box?.querySelector('.project-header');
if (header) (header as HTMLElement).click();
}, projectId);
// Switch tab in a project
await browser.execute((id, idx) => {
const box = document.querySelector(`[data-project-id="${id}"]`);
const tabs = box?.querySelectorAll('[data-testid="project-tabs"] .ptab');
if (tabs && tabs[idx]) (tabs[idx] as HTMLElement).click();
}, projectId, tabIndex);
```
### WebDriverIO Config (`wdio.conf.js`)
Key settings:
- **Single session**: `maxInstances: 1` — tauri-driver can't handle parallel sessions
- **Lifecycle**: `onPrepare` builds debug binary, `beforeSession` spawns tauri-driver with TCP readiness probe, `afterSession` kills tauri-driver
- **Timeouts**: 60s per test (mocha), 10s waitfor, 30s connection retry
- **Skip build**: Set `SKIP_BUILD=1` to reuse existing binary
## Troubleshooting
| Problem | Solution |
|---------|----------|
| "Callback was not called before unload" | Stale binary — rebuild with `cargo tauri build --debug --no-bundle` |
| Tests hang on startup | Kill stale `tauri-driver` processes: `pkill -f tauri-driver` |
| All tests skip LLM judge | Install Claude CLI or set `ANTHROPIC_API_KEY` |
| SIGUSR2 / exit code 144 | Stale tauri-driver on port 4444 — kill and retry |
| `BTERMINAL_TEST` leaking to cargo | Run cargo tests with `env -u BTERMINAL_TEST cargo test` |
| No display available | Use `xvfb-run` or ensure X11/Wayland display is set |

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)

1076
docs/v3-progress.md Normal file

File diff suppressed because it is too large Load diff

110
docs/v3-release-notes.md Normal file
View file

@ -0,0 +1,110 @@
# BTerminal v3.0 Release Notes
## Mission Control — Multi-Project AI Agent Orchestration
BTerminal v3.0 is a ground-up redesign of the terminal interface, built for managing multiple AI agent sessions across multiple projects simultaneously. The Mission Control dashboard replaces the single-pane terminal with a full orchestration workspace.
### What's New
**Mission Control Dashboard**
- VSCode-style layout: icon sidebar + expandable settings drawer + project grid + status bar
- Per-project boxes with 11 tab types (Model, Docs, Context, Files, SSH, Memory, Metrics, Tasks, Architecture, Selenium, Tests)
- Command palette (Ctrl+K) with 18+ commands across 6 categories
- Keyboard-first navigation: Alt+1-5 project jump, Ctrl+H/L vi-nav, Ctrl+Shift+1-9 tab switch
- 17 themes in 3 groups (Catppuccin, Editor, Deep Dark)
**Multi-Agent Orchestration**
- 4 Tier 1 management roles: Manager, Architect, Tester, Reviewer
- btmsg: inter-agent messaging (DMs, channels, contacts ACL, heartbeats, dead letter queue)
- bttask: kanban task board (5 columns, optimistic locking, review queue auto-notifications)
- Agent prompt generator with role-specific workflows and tool documentation
- Manager subagent delegation via Claude Agent SDK teams
- Auto-wake scheduler: 3 strategies (persistent, on-demand, smart) with 6 wake signals
**Multi-Provider Support**
- Claude Code (primary), OpenAI Codex, Ollama
- Provider-specific sidecar runners with unified message adapter layer
- Per-project provider selection with capability-gated UI
**Session Continuity**
- SQLite persistence for agent sessions, messages, and cost tracking
- Session anchors: preserve important turns through context compaction
- Auto-anchoring on first compaction (observation-masked, reasoning preserved)
- Configurable anchor budget (2K20K tokens)
**Dashboard Metrics**
- Real-time fleet overview: running/idle/stalled counts, burn rate ($/hr)
- Per-project health: activity state, context pressure, file conflicts, attention scoring
- Historical sparklines for cost, tokens, turns, tools, and duration
- Attention queue with priority-scored cards (click to focus)
**File Management**
- VSCode-style directory tree with CodeMirror 6 editor (15 language modes)
- PDF viewer (pdfjs-dist, multi-page, zoom 0.5x3x)
- CSV table viewer (RFC 4180, delimiter auto-detect, sortable columns)
- Filesystem watcher for external write conflict detection
**Terminal**
- xterm.js with Canvas addon (WebKit2GTK compatible)
- Agent preview pane (read-only view of agent activity)
- SSH session management (native PTY, no library required)
- Worktree isolation per project (optional)
### Production Readiness
**Reliability**
- Sidecar crash recovery: auto-restart with exponential backoff (1s30s, 5 retries)
- WAL checkpoint: periodic TRUNCATE every 5 minutes (sessions.db + btmsg.db)
- Error classification: 6 types with actionable messages and retry logic
- Optimistic locking for concurrent task board updates
**Security**
- Landlock sandbox: kernel 6.2+ filesystem restriction for sidecar processes
- Plugin sandbox: 13 shadowed globals, strict mode, frozen API, permission-gated
- Secrets management: system keyring (libsecret), no plaintext fallback
- TLS support for bterminal-relay (optional `--tls-cert`/`--tls-key`)
- Sidecar environment stripping: dual-layer (Rust + JS) credential isolation
- Audit logging: agent events, task changes, wake events, prompt injections
**Observability**
- OpenTelemetry: tracing + OTLP export to Tempo (optional)
- FTS5 full-text search across messages, tasks, and agent comms
- Agent health monitoring: heartbeats, stale detection, dead letter queue
- Desktop + in-app notifications with history
### Multi-Machine (Early Access)
bterminal-relay enables running agent sessions across multiple Linux machines via WebSocket. TLS encryption is supported. This feature is architecturally complete but not yet surfaced in the v3 UI — available for advanced users via the relay binary and bridges.
**v3.1 roadmap:** Certificate pinning, UI integration, real-world multi-machine testing.
### Test Coverage
| Suite | Tests | Status |
|-------|-------|--------|
| Vitest (frontend) | 444 | Pass |
| Cargo (backend) | 151 | Pass |
| E2E (WebDriverIO) | 109 | Pass |
| **Total** | **704** | **All passing** |
### Breaking Changes from v2
- Layout system replaced by workspace store (project groups)
- Configuration moved from sessions.json to groups.json
- App.svelte rewritten (VSCode-style sidebar replaces TilingGrid)
- Settings moved from modal dialog to sidebar drawer tab
### Requirements
- Linux x86_64
- Kernel 6.2+ recommended (for Landlock sandbox enforcement)
- libsecret / DBUS session (for secrets management)
- Node.js 20+ and Rust 1.77+ (build from source)
- Claude CLI installed (`~/.local/bin/claude` or system path)
### Known Limitations
- Maximum 4 active xterm.js instances (WebKit2GTK memory constraint)
- Plugin sandbox uses `new Function()` — best-effort, not a security boundary
- Multi-machine UI not yet integrated into Mission Control
- Agent Teams delegation requires complex prompts to trigger reliably

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"]
}

6780
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,15 @@
[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"] }
dirs = "5"
landlock = "0.4"

View file

@ -0,0 +1,209 @@
// AppConfig — centralized path resolution for all BTerminal subsystems.
// In production, paths resolve via dirs:: crate defaults.
// In test mode (BTERMINAL_TEST=1), paths resolve from env var overrides:
// BTERMINAL_TEST_DATA_DIR → replaces dirs::data_dir()/bterminal
// BTERMINAL_TEST_CONFIG_DIR → replaces dirs::config_dir()/bterminal
// BTERMINAL_TEST_CTX_DIR → replaces ~/.claude-context
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct AppConfig {
/// Data directory for btmsg.db, sessions.db (default: ~/.local/share/bterminal)
pub data_dir: PathBuf,
/// Config directory for groups.json (default: ~/.config/bterminal)
pub config_dir: PathBuf,
/// ctx database path (default: ~/.claude-context/context.db)
pub ctx_db_path: PathBuf,
/// Memora database path (default: ~/.local/share/memora/memories.db)
pub memora_db_path: PathBuf,
/// Whether we are in test mode
pub test_mode: bool,
}
impl AppConfig {
/// Build config from environment. In test mode, uses BTERMINAL_TEST_*_DIR env vars.
pub fn from_env() -> Self {
let test_mode = std::env::var("BTERMINAL_TEST").map_or(false, |v| v == "1");
let data_dir = std::env::var("BTERMINAL_TEST_DATA_DIR")
.ok()
.filter(|_| test_mode)
.map(PathBuf::from)
.unwrap_or_else(|| {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("bterminal")
});
let config_dir = std::env::var("BTERMINAL_TEST_CONFIG_DIR")
.ok()
.filter(|_| test_mode)
.map(PathBuf::from)
.unwrap_or_else(|| {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("bterminal")
});
let ctx_db_path = std::env::var("BTERMINAL_TEST_CTX_DIR")
.ok()
.filter(|_| test_mode)
.map(|d| PathBuf::from(d).join("context.db"))
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_default()
.join(".claude-context")
.join("context.db")
});
let memora_db_path = if test_mode {
// In test mode, memora is optional — use data_dir/memora/memories.db
data_dir.join("memora").join("memories.db")
} else {
dirs::data_dir()
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_default()
.join(".local/share")
})
.join("memora")
.join("memories.db")
};
Self {
data_dir,
config_dir,
ctx_db_path,
memora_db_path,
test_mode,
}
}
/// Path to btmsg.db (shared between btmsg and bttask)
pub fn btmsg_db_path(&self) -> PathBuf {
self.data_dir.join("btmsg.db")
}
/// Path to sessions.db
pub fn sessions_db_dir(&self) -> &PathBuf {
&self.data_dir
}
/// Path to groups.json
pub fn groups_json_path(&self) -> PathBuf {
self.config_dir.join("groups.json")
}
/// Path to plugins directory
pub fn plugins_dir(&self) -> PathBuf {
self.config_dir.join("plugins")
}
/// Whether running in test mode (BTERMINAL_TEST=1)
pub fn is_test_mode(&self) -> bool {
self.test_mode
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
// Serialize all tests that mutate env vars to prevent race conditions.
// Rust runs tests in parallel; set_var/remove_var are process-global.
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn test_production_paths_use_dirs() {
let _lock = ENV_LOCK.lock().unwrap();
// Without BTERMINAL_TEST=1, paths should use dirs:: defaults
std::env::remove_var("BTERMINAL_TEST");
std::env::remove_var("BTERMINAL_TEST_DATA_DIR");
std::env::remove_var("BTERMINAL_TEST_CONFIG_DIR");
std::env::remove_var("BTERMINAL_TEST_CTX_DIR");
let config = AppConfig::from_env();
assert!(!config.is_test_mode());
// Should end with "bterminal" for data and config
assert!(config.data_dir.ends_with("bterminal"));
assert!(config.config_dir.ends_with("bterminal"));
assert!(config.ctx_db_path.ends_with("context.db"));
assert!(config.memora_db_path.ends_with("memories.db"));
}
#[test]
fn test_btmsg_db_path() {
let _lock = ENV_LOCK.lock().unwrap();
std::env::remove_var("BTERMINAL_TEST");
let config = AppConfig::from_env();
let path = config.btmsg_db_path();
assert!(path.ends_with("btmsg.db"));
assert!(path.parent().unwrap().ends_with("bterminal"));
}
#[test]
fn test_groups_json_path() {
let _lock = ENV_LOCK.lock().unwrap();
std::env::remove_var("BTERMINAL_TEST");
let config = AppConfig::from_env();
let path = config.groups_json_path();
assert!(path.ends_with("groups.json"));
}
#[test]
fn test_test_mode_uses_overrides() {
let _lock = ENV_LOCK.lock().unwrap();
std::env::set_var("BTERMINAL_TEST", "1");
std::env::set_var("BTERMINAL_TEST_DATA_DIR", "/tmp/bt-test-data");
std::env::set_var("BTERMINAL_TEST_CONFIG_DIR", "/tmp/bt-test-config");
std::env::set_var("BTERMINAL_TEST_CTX_DIR", "/tmp/bt-test-ctx");
let config = AppConfig::from_env();
assert!(config.is_test_mode());
assert_eq!(config.data_dir, PathBuf::from("/tmp/bt-test-data"));
assert_eq!(config.config_dir, PathBuf::from("/tmp/bt-test-config"));
assert_eq!(config.ctx_db_path, PathBuf::from("/tmp/bt-test-ctx/context.db"));
assert_eq!(config.btmsg_db_path(), PathBuf::from("/tmp/bt-test-data/btmsg.db"));
assert_eq!(config.groups_json_path(), PathBuf::from("/tmp/bt-test-config/groups.json"));
// Cleanup
std::env::remove_var("BTERMINAL_TEST");
std::env::remove_var("BTERMINAL_TEST_DATA_DIR");
std::env::remove_var("BTERMINAL_TEST_CONFIG_DIR");
std::env::remove_var("BTERMINAL_TEST_CTX_DIR");
}
#[test]
fn test_test_mode_without_overrides_uses_defaults() {
let _lock = ENV_LOCK.lock().unwrap();
std::env::set_var("BTERMINAL_TEST", "1");
std::env::remove_var("BTERMINAL_TEST_DATA_DIR");
std::env::remove_var("BTERMINAL_TEST_CONFIG_DIR");
std::env::remove_var("BTERMINAL_TEST_CTX_DIR");
let config = AppConfig::from_env();
assert!(config.is_test_mode());
// Without override vars, falls back to dirs:: defaults
assert!(config.data_dir.ends_with("bterminal"));
std::env::remove_var("BTERMINAL_TEST");
}
#[test]
fn test_test_mode_memora_in_data_dir() {
let _lock = ENV_LOCK.lock().unwrap();
std::env::set_var("BTERMINAL_TEST", "1");
std::env::set_var("BTERMINAL_TEST_DATA_DIR", "/tmp/bt-test-data");
let config = AppConfig::from_env();
assert_eq!(
config.memora_db_path,
PathBuf::from("/tmp/bt-test-data/memora/memories.db")
);
std::env::remove_var("BTERMINAL_TEST");
std::env::remove_var("BTERMINAL_TEST_DATA_DIR");
}
}

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,6 @@
pub mod config;
pub mod event;
pub mod pty;
pub mod sandbox;
pub mod sidecar;
pub mod supervisor;

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,277 @@
// Landlock-based filesystem sandboxing for sidecar processes.
//
// Landlock is a Linux Security Module (LSM) available since kernel 5.13.
// It restricts filesystem access for the calling process and all its children.
// Applied via pre_exec() on the sidecar child process before exec.
//
// Restrictions can only be tightened after application — never relaxed.
// The sidecar is long-lived and handles queries for multiple projects,
// so we apply the union of all project paths at sidecar start time.
use std::path::PathBuf;
use landlock::{
Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr,
RulesetStatus, ABI,
};
/// Target Landlock ABI version. V3 requires kernel 6.2+ (we run 6.12+).
/// Falls back gracefully on older kernels via best-effort mode.
const TARGET_ABI: ABI = ABI::V3;
/// Configuration for Landlock filesystem sandboxing.
#[derive(Debug, Clone)]
pub struct SandboxConfig {
/// Directories with full read+write+execute access (project CWDs, worktrees, tmp)
pub rw_paths: Vec<PathBuf>,
/// Directories with read-only access (system libs, runtimes, config)
pub ro_paths: Vec<PathBuf>,
/// Whether sandboxing is enabled
pub enabled: bool,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
rw_paths: Vec::new(),
ro_paths: Vec::new(),
enabled: false,
}
}
}
impl SandboxConfig {
/// Build a sandbox config for a set of project directories.
///
/// `project_cwds` — directories that need read+write access (one per project).
/// `worktree_roots` — optional worktree directories (one per project that uses worktrees).
///
/// System paths (runtimes, libraries, /etc) are added as read-only automatically.
pub fn for_projects(project_cwds: &[&str], worktree_roots: &[&str]) -> Self {
let mut rw = Vec::new();
for cwd in project_cwds {
rw.push(PathBuf::from(cwd));
}
for wt in worktree_roots {
rw.push(PathBuf::from(wt));
}
// Temp dir for sidecar scratch files
rw.push(std::env::temp_dir());
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root"));
let ro = vec![
PathBuf::from("/usr"), // system binaries + libraries
PathBuf::from("/lib"), // shared libraries
PathBuf::from("/lib64"), // 64-bit shared libraries
PathBuf::from("/etc"), // system configuration (read only)
PathBuf::from("/proc"), // process info (Landlock V3+ handles this)
PathBuf::from("/dev"), // device nodes (stdin/stdout/stderr, /dev/null, urandom)
PathBuf::from("/bin"), // essential binaries (symlink to /usr/bin on most distros)
PathBuf::from("/sbin"), // essential system binaries
home.join(".local"), // ~/.local/bin (claude CLI, user-installed tools)
home.join(".deno"), // Deno runtime cache
home.join(".nvm"), // Node.js version manager
home.join(".config"), // XDG config (claude profiles, bterminal config)
home.join(".claude"), // Claude CLI data (worktrees, skills, settings)
];
Self {
rw_paths: rw,
ro_paths: ro,
enabled: true,
}
}
/// Build a sandbox config for a single project directory.
pub fn for_project(cwd: &str, worktree: Option<&str>) -> Self {
let worktrees: Vec<&str> = worktree.into_iter().collect();
Self::for_projects(&[cwd], &worktrees)
}
/// Apply Landlock restrictions to the current process.
///
/// This must be called in the child process (e.g., via `pre_exec`) BEFORE exec.
/// Once applied, restrictions are inherited by all child processes and cannot be relaxed.
///
/// Returns:
/// - `Ok(true)` if Landlock was applied and enforced
/// - `Ok(false)` if the kernel does not support Landlock (graceful degradation)
/// - `Err(msg)` on configuration or syscall errors
pub fn apply(&self) -> Result<bool, String> {
if !self.enabled {
return Ok(false);
}
let access_all = AccessFs::from_all(TARGET_ABI);
let access_read = AccessFs::from_read(TARGET_ABI);
// Create ruleset handling all filesystem access types
let mut ruleset = Ruleset::default()
.handle_access(access_all)
.map_err(|e| format!("Landlock: failed to handle access: {e}"))?
.create()
.map_err(|e| format!("Landlock: failed to create ruleset: {e}"))?;
// Add read+write rules for project directories and tmp
for path in &self.rw_paths {
if path.exists() {
let fd = PathFd::new(path)
.map_err(|e| format!("Landlock: PathFd failed for {}: {e}", path.display()))?;
ruleset = ruleset
.add_rule(PathBeneath::new(fd, access_all))
.map_err(|e| {
format!("Landlock: add_rule (rw) failed for {}: {e}", path.display())
})?;
} else {
log::warn!(
"Landlock: skipping non-existent rw path: {}",
path.display()
);
}
}
// Add read-only rules for system paths
for path in &self.ro_paths {
if path.exists() {
let fd = PathFd::new(path)
.map_err(|e| format!("Landlock: PathFd failed for {}: {e}", path.display()))?;
ruleset = ruleset
.add_rule(PathBeneath::new(fd, access_read))
.map_err(|e| {
format!("Landlock: add_rule (ro) failed for {}: {e}", path.display())
})?;
}
// Silently skip non-existent read-only paths (e.g., /lib64 on some systems)
}
// Enforce the ruleset on this thread (and inherited by children)
let status = ruleset
.restrict_self()
.map_err(|e| format!("Landlock: restrict_self failed: {e}"))?;
// Landlock enforcement states:
// - Enforced: kernel 6.2+ with ABI V3 (full filesystem restriction)
// - NotEnforced: kernel 5.136.1 (Landlock exists but ABI too old for V3)
// - Error (caught above): kernel <5.13 (no Landlock LSM available)
let enforced = status.ruleset != RulesetStatus::NotEnforced;
if enforced {
log::info!("Landlock sandbox applied ({} rw, {} ro paths)", self.rw_paths.len(), self.ro_paths.len());
} else {
log::warn!(
"Landlock not enforced — sidecar runs without filesystem restrictions. \
Kernel 6.2+ required for enforcement."
);
}
Ok(enforced)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_is_disabled() {
let config = SandboxConfig::default();
assert!(!config.enabled);
assert!(config.rw_paths.is_empty());
assert!(config.ro_paths.is_empty());
}
#[test]
fn test_for_project_single_cwd() {
let config = SandboxConfig::for_project("/home/user/myproject", None);
assert!(config.enabled);
assert!(config.rw_paths.contains(&PathBuf::from("/home/user/myproject")));
assert!(config.rw_paths.contains(&std::env::temp_dir()));
// No worktree path added
assert!(!config
.rw_paths
.iter()
.any(|p| p.to_string_lossy().contains("worktree")));
}
#[test]
fn test_for_project_with_worktree() {
let config = SandboxConfig::for_project(
"/home/user/myproject",
Some("/home/user/myproject/.claude/worktrees/abc123"),
);
assert!(config.enabled);
assert!(config.rw_paths.contains(&PathBuf::from("/home/user/myproject")));
assert!(config.rw_paths.contains(&PathBuf::from(
"/home/user/myproject/.claude/worktrees/abc123"
)));
}
#[test]
fn test_for_projects_multiple_cwds() {
let config = SandboxConfig::for_projects(
&["/home/user/project-a", "/home/user/project-b"],
&["/home/user/project-a/.claude/worktrees/s1"],
);
assert!(config.enabled);
assert!(config.rw_paths.contains(&PathBuf::from("/home/user/project-a")));
assert!(config.rw_paths.contains(&PathBuf::from("/home/user/project-b")));
assert!(config.rw_paths.contains(&PathBuf::from(
"/home/user/project-a/.claude/worktrees/s1"
)));
// tmp always present
assert!(config.rw_paths.contains(&std::env::temp_dir()));
}
#[test]
fn test_ro_paths_include_system_dirs() {
let config = SandboxConfig::for_project("/tmp/test", None);
let ro_strs: Vec<String> = config.ro_paths.iter().map(|p| p.display().to_string()).collect();
assert!(ro_strs.iter().any(|p| p == "/usr"), "missing /usr");
assert!(ro_strs.iter().any(|p| p == "/lib"), "missing /lib");
assert!(ro_strs.iter().any(|p| p == "/etc"), "missing /etc");
assert!(ro_strs.iter().any(|p| p == "/proc"), "missing /proc");
assert!(ro_strs.iter().any(|p| p == "/dev"), "missing /dev");
assert!(ro_strs.iter().any(|p| p == "/bin"), "missing /bin");
}
#[test]
fn test_ro_paths_include_runtime_dirs() {
let config = SandboxConfig::for_project("/tmp/test", None);
let home = dirs::home_dir().unwrap();
assert!(config.ro_paths.contains(&home.join(".local")));
assert!(config.ro_paths.contains(&home.join(".deno")));
assert!(config.ro_paths.contains(&home.join(".nvm")));
assert!(config.ro_paths.contains(&home.join(".config")));
assert!(config.ro_paths.contains(&home.join(".claude")));
}
#[test]
fn test_disabled_apply_returns_false() {
let config = SandboxConfig::default();
assert_eq!(config.apply().unwrap(), false);
}
#[test]
fn test_rw_paths_count() {
// Single project: cwd + tmp = 2
let config = SandboxConfig::for_project("/tmp/test", None);
assert_eq!(config.rw_paths.len(), 2);
// With worktree: cwd + worktree + tmp = 3
let config = SandboxConfig::for_project("/tmp/test", Some("/tmp/wt"));
assert_eq!(config.rw_paths.len(), 3);
}
#[test]
fn test_for_projects_empty() {
let config = SandboxConfig::for_projects(&[], &[]);
assert!(config.enabled);
// Only tmp dir in rw
assert_eq!(config.rw_paths.len(), 1);
assert_eq!(config.rw_paths[0], std::env::temp_dir());
}
}

View file

@ -0,0 +1,495 @@
// 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};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread;
use crate::event::EventSink;
use crate::sandbox::SandboxConfig;
#[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>,
/// Extra env vars forwarded to sidecar processes (e.g. BTERMINAL_TEST=1 for test isolation)
pub env_overrides: std::collections::HashMap<String, String>,
/// Landlock filesystem sandbox configuration (Linux 5.13+, applied via pre_exec)
pub sandbox: SandboxConfig,
}
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: Mutex<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: Mutex::new(config),
}
}
/// Update the sandbox configuration. Takes effect on next sidecar (re)start.
pub fn set_sandbox(&self, sandbox: SandboxConfig) {
self.config.lock().unwrap().sandbox = sandbox;
}
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 config = self.config.lock().unwrap();
let cmd = self.resolve_sidecar_command_with_config(&config)?;
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 command = Command::new(&cmd.program);
command
.args(&cmd.args)
.env_clear()
.envs(clean_env)
.envs(config.env_overrides.iter().map(|(k, v)| (k.as_str(), v.as_str())))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
// Apply Landlock sandbox in child process before exec (Linux only).
// Restrictions are inherited by all child processes (provider CLIs).
#[cfg(unix)]
if config.sandbox.enabled {
let sandbox = config.sandbox.clone();
unsafe {
command.pre_exec(move || {
sandbox.apply().map(|enforced| {
if !enforced {
log::warn!("Landlock sandbox not enforced in sidecar child");
}
}).map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
});
}
}
// Drop config lock before spawn (pre_exec closure owns the sandbox clone)
drop(config);
let mut child = command
.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 config = self.config.lock().unwrap();
let runner_exists = config
.search_paths
.iter()
.any(|base| base.join("dist").join(&runner_name).exists());
drop(config);
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_with_config(&self, config: &SidecarConfig) -> Result<SidecarCommand, String> {
Self::resolve_sidecar_for_provider_with_config(config, "claude")
}
/// Resolve a sidecar command for a specific provider's runner file.
fn resolve_sidecar_for_provider_with_config(config: &SidecarConfig, 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 &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).
/// First line of defense: strips provider-specific prefixes to prevent nesting detection
/// and credential leakage. JS runners apply a second layer of provider-specific stripping.
///
/// Stripped prefixes: CLAUDE*, CODEX*, OLLAMA*, ANTHROPIC_*
/// Whitelisted: CLAUDE_CODE_EXPERIMENTAL_* (feature flags like agent teams)
///
/// Note: OPENAI_* is NOT stripped here because the Codex runner needs OPENAI_API_KEY
/// from the environment (it re-injects it after its own stripping). If Codex support
/// moves to extraEnv-based key injection, add OPENAI to this list.
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")
|| key.starts_with("ANTHROPIC_")
{
return false;
}
true
}
impl Drop for SidecarManager {
fn drop(&mut self) {
let _ = self.shutdown();
}
}
#[cfg(test)]
mod tests {
use super::*;
// ---- strip_provider_env_var unit tests ----
#[test]
fn test_keeps_normal_env_vars() {
assert!(strip_provider_env_var("HOME"));
assert!(strip_provider_env_var("PATH"));
assert!(strip_provider_env_var("USER"));
assert!(strip_provider_env_var("SHELL"));
assert!(strip_provider_env_var("TERM"));
assert!(strip_provider_env_var("XDG_DATA_HOME"));
assert!(strip_provider_env_var("RUST_LOG"));
}
#[test]
fn test_strips_claude_vars() {
assert!(!strip_provider_env_var("CLAUDE_CONFIG_DIR"));
assert!(!strip_provider_env_var("CLAUDE_SESSION_ID"));
assert!(!strip_provider_env_var("CLAUDECODE"));
assert!(!strip_provider_env_var("CLAUDE_API_KEY"));
}
#[test]
fn test_whitelists_claude_code_experimental() {
assert!(strip_provider_env_var("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"));
assert!(strip_provider_env_var("CLAUDE_CODE_EXPERIMENTAL_TOOLS"));
assert!(strip_provider_env_var("CLAUDE_CODE_EXPERIMENTAL_SOMETHING_NEW"));
}
#[test]
fn test_strips_codex_vars() {
assert!(!strip_provider_env_var("CODEX_API_KEY"));
assert!(!strip_provider_env_var("CODEX_SESSION"));
assert!(!strip_provider_env_var("CODEX_CONFIG"));
}
#[test]
fn test_strips_ollama_vars() {
assert!(!strip_provider_env_var("OLLAMA_HOST"));
assert!(!strip_provider_env_var("OLLAMA_MODELS"));
assert!(!strip_provider_env_var("OLLAMA_NUM_PARALLEL"));
}
#[test]
fn test_strips_anthropic_vars() {
// ANTHROPIC_* vars stripped at Rust layer (defense in depth)
// Claude CLI has its own auth via credentials file
assert!(!strip_provider_env_var("ANTHROPIC_API_KEY"));
assert!(!strip_provider_env_var("ANTHROPIC_BASE_URL"));
assert!(!strip_provider_env_var("ANTHROPIC_LOG"));
}
#[test]
fn test_keeps_openai_vars() {
// OPENAI_* vars are NOT stripped by the Rust layer
// (they're stripped in the JS codex-runner layer instead)
assert!(strip_provider_env_var("OPENAI_API_KEY"));
assert!(strip_provider_env_var("OPENAI_BASE_URL"));
}
#[test]
fn test_env_filtering_integration() {
let test_env = vec![
("HOME", "/home/user"),
("PATH", "/usr/bin"),
("CLAUDE_CONFIG_DIR", "/tmp/claude"),
("CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS", "1"),
("CODEX_API_KEY", "sk-test"),
("OLLAMA_HOST", "localhost"),
("ANTHROPIC_API_KEY", "sk-ant-xxx"),
("OPENAI_API_KEY", "sk-openai-xxx"),
("RUST_LOG", "debug"),
("BTMSG_AGENT_ID", "a1"),
];
let kept: Vec<&str> = test_env
.iter()
.filter(|(k, _)| strip_provider_env_var(k))
.map(|(k, _)| *k)
.collect();
assert!(kept.contains(&"HOME"));
assert!(kept.contains(&"PATH"));
assert!(kept.contains(&"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS"));
assert!(kept.contains(&"RUST_LOG"));
assert!(kept.contains(&"BTMSG_AGENT_ID"));
// OPENAI_* passes through Rust layer (Codex runner needs it)
assert!(kept.contains(&"OPENAI_API_KEY"));
// These are stripped:
assert!(!kept.contains(&"CLAUDE_CONFIG_DIR"));
assert!(!kept.contains(&"CODEX_API_KEY"));
assert!(!kept.contains(&"OLLAMA_HOST"));
assert!(!kept.contains(&"ANTHROPIC_API_KEY"));
}
}

View file

@ -0,0 +1,684 @@
// Sidecar crash recovery and supervision.
// Wraps a SidecarManager with automatic restart, exponential backoff,
// and health status tracking. Emits `sidecar-health-changed` events.
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use crate::event::EventSink;
use crate::sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager};
/// Health status of the supervised sidecar process.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum SidecarHealth {
Healthy,
Degraded {
restart_count: u32,
},
Failed {
#[serde(default)]
last_error: String,
},
}
/// Configuration for supervisor restart behavior.
#[derive(Debug, Clone)]
pub struct SupervisorConfig {
/// Maximum restart attempts before entering Failed state (default: 5)
pub max_retries: u32,
/// Base backoff in milliseconds, doubled each retry (default: 1000, cap: 30000)
pub backoff_base_ms: u64,
/// Maximum backoff in milliseconds (default: 30000)
pub backoff_cap_ms: u64,
/// Stable operation duration before restart_count resets (default: 5 minutes)
pub stability_window: Duration,
}
impl Default for SupervisorConfig {
fn default() -> Self {
Self {
max_retries: 5,
backoff_base_ms: 1000,
backoff_cap_ms: 30_000,
stability_window: Duration::from_secs(300),
}
}
}
/// Internal state shared between the supervisor and its event interceptor.
struct SupervisorState {
health: SidecarHealth,
restart_count: u32,
last_crash_time: Option<Instant>,
last_start_time: Option<Instant>,
}
impl SupervisorState {
fn new() -> Self {
Self {
health: SidecarHealth::Healthy,
restart_count: 0,
last_crash_time: None,
last_start_time: None,
}
}
}
/// Compute exponential backoff: base_ms * 2^attempt, capped at cap_ms.
fn compute_backoff(base_ms: u64, attempt: u32, cap_ms: u64) -> Duration {
let backoff = base_ms.saturating_mul(1u64.checked_shl(attempt).unwrap_or(u64::MAX));
Duration::from_millis(backoff.min(cap_ms))
}
/// EventSink wrapper that intercepts `sidecar-exited` events and triggers
/// supervisor restart logic, while forwarding all other events unchanged.
struct SupervisorSink {
outer_sink: Arc<dyn EventSink>,
state: Arc<Mutex<SupervisorState>>,
config: SupervisorConfig,
sidecar_config: SidecarConfig,
}
impl EventSink for SupervisorSink {
fn emit(&self, event: &str, payload: serde_json::Value) {
if event == "sidecar-exited" {
self.handle_exit();
} else {
self.outer_sink.emit(event, payload);
}
}
}
impl SupervisorSink {
fn handle_exit(&self) {
let (should_restart, backoff, restart_count) = {
let mut state = self.state.lock().unwrap();
// Check if stable operation has elapsed since last start — reset counter
if let Some(start_time) = state.last_start_time {
if start_time.elapsed() >= self.config.stability_window {
log::info!(
"Sidecar ran stable for {:?}, resetting restart count",
start_time.elapsed()
);
state.restart_count = 0;
}
}
state.restart_count += 1;
state.last_crash_time = Some(Instant::now());
let count = state.restart_count;
if count > self.config.max_retries {
let error = format!("Exceeded max retries ({})", self.config.max_retries);
log::error!("Sidecar supervisor: {}", error);
state.health = SidecarHealth::Failed {
last_error: error.clone(),
};
self.emit_health(&state.health);
// Forward the original exited event so frontend knows
self.outer_sink
.emit("sidecar-exited", serde_json::Value::Null);
return;
}
state.health = SidecarHealth::Degraded {
restart_count: count,
};
self.emit_health(&state.health);
let backoff = compute_backoff(
self.config.backoff_base_ms,
count - 1,
self.config.backoff_cap_ms,
);
(true, backoff, count)
};
if !should_restart {
return;
}
log::warn!(
"Sidecar crashed (attempt {}/{}), restarting in {:?}",
restart_count,
self.config.max_retries,
backoff
);
// Restart on a background thread to avoid blocking the stdout reader
let outer_sink = self.outer_sink.clone();
let state = self.state.clone();
let sidecar_config = self.sidecar_config.clone();
let supervisor_state = self.state.clone();
let stability_window = self.config.stability_window;
let max_retries = self.config.max_retries;
let backoff_base_ms = self.config.backoff_base_ms;
let backoff_cap_ms = self.config.backoff_cap_ms;
std::thread::spawn(move || {
std::thread::sleep(backoff);
// Create a new SidecarManager that shares our supervisor sink.
// We need a new interceptor sink to capture the next exit event.
let new_state = state.clone();
let new_outer = outer_sink.clone();
let new_sidecar_config = sidecar_config.clone();
let interceptor: Arc<dyn EventSink> = Arc::new(SupervisorSink {
outer_sink: new_outer.clone(),
state: new_state.clone(),
config: SupervisorConfig {
max_retries,
backoff_base_ms,
backoff_cap_ms,
stability_window,
},
sidecar_config: new_sidecar_config.clone(),
});
let new_manager = SidecarManager::new(interceptor, new_sidecar_config);
match new_manager.start() {
Ok(()) => {
let mut s = supervisor_state.lock().unwrap();
s.last_start_time = Some(Instant::now());
log::info!("Sidecar restarted successfully (attempt {})", restart_count);
// Note: we cannot replace the manager reference in the outer
// SidecarSupervisor from here. The restart creates a new manager
// that handles its own lifecycle. The outer manager reference
// becomes stale. This is acceptable because:
// 1. The new manager's stdout reader will emit through our sink chain
// 2. The old manager's child process is already dead
// For a more sophisticated approach, the supervisor would need
// interior mutability on the manager reference. We do that below.
}
Err(e) => {
log::error!("Sidecar restart failed: {}", e);
let mut s = supervisor_state.lock().unwrap();
s.health = SidecarHealth::Failed {
last_error: e.clone(),
};
// Emit health change + forward exited
drop(s);
let health = SidecarHealth::Failed { last_error: e };
emit_health_event(&new_outer, &health);
new_outer
.emit("sidecar-exited", serde_json::Value::Null);
}
}
});
}
fn emit_health(&self, health: &SidecarHealth) {
emit_health_event(&self.outer_sink, health);
}
}
fn emit_health_event(sink: &Arc<dyn EventSink>, health: &SidecarHealth) {
let payload = serde_json::to_value(health).unwrap_or(serde_json::Value::Null);
sink.emit("sidecar-health-changed", payload);
}
/// Supervised sidecar process with automatic crash recovery.
///
/// Wraps a `SidecarManager` and intercepts exit events to perform automatic
/// restarts with exponential backoff. Tracks health status and emits
/// `sidecar-health-changed` events.
pub struct SidecarSupervisor {
manager: Arc<Mutex<SidecarManager>>,
state: Arc<Mutex<SupervisorState>>,
outer_sink: Arc<dyn EventSink>,
#[allow(dead_code)]
supervisor_config: SupervisorConfig,
#[allow(dead_code)]
sidecar_config: SidecarConfig,
}
impl SidecarSupervisor {
pub fn new(
sink: Arc<dyn EventSink>,
sidecar_config: SidecarConfig,
supervisor_config: SupervisorConfig,
) -> Self {
let state = Arc::new(Mutex::new(SupervisorState::new()));
let interceptor: Arc<dyn EventSink> = Arc::new(SupervisorSink {
outer_sink: sink.clone(),
state: state.clone(),
config: supervisor_config.clone(),
sidecar_config: sidecar_config.clone(),
});
let manager = SidecarManager::new(interceptor, sidecar_config.clone());
Self {
manager: Arc::new(Mutex::new(manager)),
state,
outer_sink: sink,
supervisor_config,
sidecar_config,
}
}
/// Start the supervised sidecar process.
pub fn start(&self) -> Result<(), String> {
let manager = self.manager.lock().unwrap();
let result = manager.start();
if result.is_ok() {
let mut state = self.state.lock().unwrap();
state.last_start_time = Some(Instant::now());
state.health = SidecarHealth::Healthy;
}
result
}
/// Send a raw JSON message to the sidecar.
pub fn send_message(&self, msg: &serde_json::Value) -> Result<(), String> {
self.manager.lock().unwrap().send_message(msg)
}
/// Send an agent query to the sidecar.
pub fn query(&self, options: &AgentQueryOptions) -> Result<(), String> {
self.manager.lock().unwrap().query(options)
}
/// Stop a specific agent session.
pub fn stop_session(&self, session_id: &str) -> Result<(), String> {
self.manager.lock().unwrap().stop_session(session_id)
}
/// Check if the sidecar is ready to accept queries.
pub fn is_ready(&self) -> bool {
self.manager.lock().unwrap().is_ready()
}
/// Shut down the sidecar process.
pub fn shutdown(&self) -> Result<(), String> {
let mut state = self.state.lock().unwrap();
state.health = SidecarHealth::Healthy;
state.restart_count = 0;
drop(state);
self.manager.lock().unwrap().shutdown()
}
/// Get the current health status.
pub fn health(&self) -> SidecarHealth {
self.state.lock().unwrap().health.clone()
}
/// Get the current restart count.
pub fn restart_count(&self) -> u32 {
self.state.lock().unwrap().restart_count
}
/// Manually reset the supervisor state (e.g., after user intervention).
pub fn reset(&self) {
let mut state = self.state.lock().unwrap();
state.health = SidecarHealth::Healthy;
state.restart_count = 0;
state.last_crash_time = None;
emit_health_event(&self.outer_sink, &state.health);
}
}
impl Drop for SidecarSupervisor {
fn drop(&mut self) {
let _ = self.shutdown();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU32, Ordering};
// ---- compute_backoff tests ----
#[test]
fn test_backoff_base_case() {
let d = compute_backoff(1000, 0, 30_000);
assert_eq!(d, Duration::from_millis(1000));
}
#[test]
fn test_backoff_exponential() {
assert_eq!(compute_backoff(1000, 1, 30_000), Duration::from_millis(2000));
assert_eq!(compute_backoff(1000, 2, 30_000), Duration::from_millis(4000));
assert_eq!(compute_backoff(1000, 3, 30_000), Duration::from_millis(8000));
assert_eq!(compute_backoff(1000, 4, 30_000), Duration::from_millis(16000));
}
#[test]
fn test_backoff_capped() {
assert_eq!(compute_backoff(1000, 5, 30_000), Duration::from_millis(30_000));
assert_eq!(compute_backoff(1000, 10, 30_000), Duration::from_millis(30_000));
}
#[test]
fn test_backoff_overflow_safe() {
// Very large attempt should not panic, just cap
assert_eq!(compute_backoff(1000, 63, 30_000), Duration::from_millis(30_000));
assert_eq!(compute_backoff(1000, 100, 30_000), Duration::from_millis(30_000));
}
#[test]
fn test_backoff_custom_base() {
assert_eq!(compute_backoff(500, 0, 10_000), Duration::from_millis(500));
assert_eq!(compute_backoff(500, 1, 10_000), Duration::from_millis(1000));
assert_eq!(compute_backoff(500, 5, 10_000), Duration::from_millis(10_000));
}
// ---- SidecarHealth serialization tests ----
#[test]
fn test_health_serialize_healthy() {
let h = SidecarHealth::Healthy;
let json = serde_json::to_value(&h).unwrap();
assert_eq!(json["status"], "healthy");
}
#[test]
fn test_health_serialize_degraded() {
let h = SidecarHealth::Degraded { restart_count: 3 };
let json = serde_json::to_value(&h).unwrap();
assert_eq!(json["status"], "degraded");
assert_eq!(json["restart_count"], 3);
}
#[test]
fn test_health_serialize_failed() {
let h = SidecarHealth::Failed {
last_error: "process killed".to_string(),
};
let json = serde_json::to_value(&h).unwrap();
assert_eq!(json["status"], "failed");
assert_eq!(json["last_error"], "process killed");
}
#[test]
fn test_health_deserialize_roundtrip() {
let cases = vec![
SidecarHealth::Healthy,
SidecarHealth::Degraded { restart_count: 2 },
SidecarHealth::Failed {
last_error: "OOM".to_string(),
},
];
for h in cases {
let json = serde_json::to_string(&h).unwrap();
let back: SidecarHealth = serde_json::from_str(&json).unwrap();
assert_eq!(h, back);
}
}
// ---- SupervisorConfig defaults ----
#[test]
fn test_supervisor_config_defaults() {
let cfg = SupervisorConfig::default();
assert_eq!(cfg.max_retries, 5);
assert_eq!(cfg.backoff_base_ms, 1000);
assert_eq!(cfg.backoff_cap_ms, 30_000);
assert_eq!(cfg.stability_window, Duration::from_secs(300));
}
// ---- SupervisorState tests ----
#[test]
fn test_initial_state() {
let state = SupervisorState::new();
assert_eq!(state.health, SidecarHealth::Healthy);
assert_eq!(state.restart_count, 0);
assert!(state.last_crash_time.is_none());
assert!(state.last_start_time.is_none());
}
// ---- Event interception tests (using mock sink) ----
/// Mock EventSink that records emitted events.
struct MockSink {
events: Mutex<Vec<(String, serde_json::Value)>>,
exit_count: AtomicU32,
}
impl MockSink {
fn new() -> Self {
Self {
events: Mutex::new(Vec::new()),
exit_count: AtomicU32::new(0),
}
}
fn events(&self) -> Vec<(String, serde_json::Value)> {
self.events.lock().unwrap().clone()
}
fn health_events(&self) -> Vec<SidecarHealth> {
self.events
.lock()
.unwrap()
.iter()
.filter(|(name, _)| name == "sidecar-health-changed")
.filter_map(|(_, payload)| serde_json::from_value(payload.clone()).ok())
.collect()
}
}
impl EventSink for MockSink {
fn emit(&self, event: &str, payload: serde_json::Value) {
if event == "sidecar-exited" {
self.exit_count.fetch_add(1, Ordering::SeqCst);
}
self.events
.lock()
.unwrap()
.push((event.to_string(), payload));
}
}
#[test]
fn test_non_exit_events_forwarded() {
let outer = Arc::new(MockSink::new());
let state = Arc::new(Mutex::new(SupervisorState::new()));
let sink = SupervisorSink {
outer_sink: outer.clone(),
state,
config: SupervisorConfig::default(),
sidecar_config: SidecarConfig {
search_paths: vec![],
env_overrides: Default::default(),
sandbox: Default::default(),
},
};
let payload = serde_json::json!({"type": "ready"});
sink.emit("sidecar-message", payload.clone());
let events = outer.events();
assert_eq!(events.len(), 1);
assert_eq!(events[0].0, "sidecar-message");
assert_eq!(events[0].1, payload);
}
#[test]
fn test_exit_triggers_degraded_health() {
let outer = Arc::new(MockSink::new());
let state = Arc::new(Mutex::new(SupervisorState::new()));
let sink = SupervisorSink {
outer_sink: outer.clone(),
state: state.clone(),
config: SupervisorConfig {
max_retries: 5,
backoff_base_ms: 100,
backoff_cap_ms: 1000,
stability_window: Duration::from_secs(300),
},
sidecar_config: SidecarConfig {
search_paths: vec![],
env_overrides: Default::default(),
sandbox: Default::default(),
},
};
// Simulate exit
sink.emit("sidecar-exited", serde_json::Value::Null);
let s = state.lock().unwrap();
assert_eq!(s.restart_count, 1);
assert!(s.last_crash_time.is_some());
match &s.health {
SidecarHealth::Degraded { restart_count } => assert_eq!(*restart_count, 1),
other => panic!("Expected Degraded, got {:?}", other),
}
// Should have emitted health-changed event
let health_events = outer.health_events();
assert_eq!(health_events.len(), 1);
assert_eq!(
health_events[0],
SidecarHealth::Degraded { restart_count: 1 }
);
}
#[test]
fn test_exit_exceeding_max_retries_fails() {
let outer = Arc::new(MockSink::new());
let state = Arc::new(Mutex::new(SupervisorState {
health: SidecarHealth::Degraded { restart_count: 5 },
restart_count: 5,
last_crash_time: Some(Instant::now()),
last_start_time: Some(Instant::now()),
}));
let sink = SupervisorSink {
outer_sink: outer.clone(),
state: state.clone(),
config: SupervisorConfig {
max_retries: 5,
..SupervisorConfig::default()
},
sidecar_config: SidecarConfig {
search_paths: vec![],
env_overrides: Default::default(),
sandbox: Default::default(),
},
};
// This is attempt 6, which exceeds max_retries=5
sink.emit("sidecar-exited", serde_json::Value::Null);
let s = state.lock().unwrap();
assert_eq!(s.restart_count, 6);
match &s.health {
SidecarHealth::Failed { last_error } => {
assert!(last_error.contains("Exceeded max retries"));
}
other => panic!("Expected Failed, got {:?}", other),
}
// Should have emitted health-changed with Failed + forwarded sidecar-exited
let events = outer.events();
let health_changed = events
.iter()
.filter(|(name, _)| name == "sidecar-health-changed")
.count();
let exited = events
.iter()
.filter(|(name, _)| name == "sidecar-exited")
.count();
assert_eq!(health_changed, 1);
assert_eq!(exited, 1); // Forwarded after max retries
}
#[test]
fn test_stability_window_resets_count() {
let outer = Arc::new(MockSink::new());
// Simulate: started 6 minutes ago, ran stable
let state = Arc::new(Mutex::new(SupervisorState {
health: SidecarHealth::Degraded { restart_count: 3 },
restart_count: 3,
last_crash_time: Some(Instant::now() - Duration::from_secs(400)),
last_start_time: Some(Instant::now() - Duration::from_secs(360)),
}));
let sink = SupervisorSink {
outer_sink: outer.clone(),
state: state.clone(),
config: SupervisorConfig {
max_retries: 5,
stability_window: Duration::from_secs(300), // 5 min
backoff_base_ms: 100,
backoff_cap_ms: 1000,
},
sidecar_config: SidecarConfig {
search_paths: vec![],
env_overrides: Default::default(),
sandbox: Default::default(),
},
};
sink.emit("sidecar-exited", serde_json::Value::Null);
let s = state.lock().unwrap();
// Count was reset to 0 then incremented to 1
assert_eq!(s.restart_count, 1);
match &s.health {
SidecarHealth::Degraded { restart_count } => assert_eq!(*restart_count, 1),
other => panic!("Expected Degraded(1), got {:?}", other),
}
}
#[test]
fn test_multiple_crashes_increment_count() {
let outer = Arc::new(MockSink::new());
let state = Arc::new(Mutex::new(SupervisorState::new()));
let sink = SupervisorSink {
outer_sink: outer.clone(),
state: state.clone(),
config: SupervisorConfig {
max_retries: 10,
backoff_base_ms: 100,
backoff_cap_ms: 1000,
stability_window: Duration::from_secs(300),
},
sidecar_config: SidecarConfig {
search_paths: vec![],
env_overrides: Default::default(),
sandbox: Default::default(),
},
};
for i in 1..=3 {
sink.emit("sidecar-exited", serde_json::Value::Null);
let s = state.lock().unwrap();
assert_eq!(s.restart_count, i);
}
let health_events = outer.health_events();
assert_eq!(health_events.len(), 3);
assert_eq!(
health_events[2],
SidecarHealth::Degraded { restart_count: 3 }
);
}
#[test]
fn test_health_equality() {
assert_eq!(SidecarHealth::Healthy, SidecarHealth::Healthy);
assert_eq!(
SidecarHealth::Degraded { restart_count: 2 },
SidecarHealth::Degraded { restart_count: 2 }
);
assert_ne!(
SidecarHealth::Degraded { restart_count: 1 },
SidecarHealth::Degraded { restart_count: 2 }
);
assert_ne!(SidecarHealth::Healthy, SidecarHealth::Failed {
last_error: String::new(),
});
}
}

View file

@ -0,0 +1,24 @@
[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"] }
tokio-native-tls = "0.3"
native-tls = "0.2"
futures-util = "0.3"
clap = { version = "4", features = ["derive"] }
uuid = { version = "1", features = ["v4"] }

View file

@ -0,0 +1,441 @@
// 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,
/// TLS certificate file (PEM format). Enables wss:// when provided with --tls-key.
#[arg(long)]
tls_cert: Option<String>,
/// TLS private key file (PEM format). Required when --tls-cert is provided.
#[arg(long)]
tls_key: Option<String>,
/// 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) },
});
}
}
/// Build a native-tls TLS acceptor from PEM cert and key files.
fn build_tls_acceptor(cert_path: &str, key_path: &str) -> Result<tokio_native_tls::TlsAcceptor, String> {
let cert_pem = std::fs::read(cert_path)
.map_err(|e| format!("Failed to read TLS cert '{}': {}", cert_path, e))?;
let key_pem = std::fs::read(key_path)
.map_err(|e| format!("Failed to read TLS key '{}': {}", key_path, e))?;
let identity = native_tls::Identity::from_pkcs8(&cert_pem, &key_pem)
.map_err(|e| format!("Failed to parse TLS identity (cert+key): {e}"))?;
let tls_acceptor = native_tls::TlsAcceptor::builder(identity)
.min_protocol_version(Some(native_tls::Protocol::Tlsv12))
.build()
.map_err(|e| format!("Failed to build TLS acceptor: {e}"))?;
Ok(tokio_native_tls::TlsAcceptor::from(tls_acceptor))
}
#[tokio::main]
async fn main() {
env_logger::init();
let cli = Cli::parse();
// Validate TLS args
let tls_acceptor = match (&cli.tls_cert, &cli.tls_key) {
(Some(cert), Some(key)) => {
let acceptor = build_tls_acceptor(cert, key).expect("TLS setup failed");
log::info!("TLS enabled (cert: {cert}, key: {key})");
Some(Arc::new(acceptor))
}
(Some(_), None) | (None, Some(_)) => {
eprintln!("Error: --tls-cert and --tls-key must both be provided");
std::process::exit(1);
}
(None, None) => {
if !cli.insecure {
log::warn!("Running without TLS. Use --tls-cert/--tls-key for encrypted connections, or --insecure to suppress this warning.");
}
None
}
};
let addr = SocketAddr::from(([0, 0, 0, 0], cli.port));
let listener = TcpListener::bind(&addr).await.expect("Failed to bind");
let protocol = if tls_acceptor.is_some() { "wss" } else { "ws" };
log::info!("bterminal-relay listening on {protocol}://{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,
env_overrides: std::collections::HashMap::new(),
sandbox: Default::default(),
};
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();
let tls = tls_acceptor.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 Some(tls_acceptor) = tls {
// TLS path: wrap TCP stream with TLS, then upgrade to WebSocket
match tls_acceptor.accept(stream).await {
Ok(tls_stream) => {
if let Err(e) = handle_tls_connection(tls_stream, peer, &token, &sidecar_config, &auth_failures).await {
log::error!("TLS connection error from {peer}: {e}");
}
}
Err(e) => {
log::error!("TLS handshake failed from {peer}: {e}");
}
}
} else {
// Plain WebSocket path
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> {
let ws_stream = accept_ws_with_auth(stream, expected_token, peer, auth_failures).await?;
run_ws_session(ws_stream, peer, sidecar_config).await
}
async fn handle_tls_connection(
stream: tokio_native_tls::TlsStream<TcpStream>,
peer: SocketAddr,
expected_token: &str,
sidecar_config: &SidecarConfig,
auth_failures: &tokio::sync::Mutex<std::collections::HashMap<SocketAddr, (u32, std::time::Instant)>>,
) -> Result<(), String> {
let ws_stream = accept_ws_with_auth(stream, expected_token, peer, auth_failures).await?;
run_ws_session(ws_stream, peer, sidecar_config).await
}
/// Accept a WebSocket connection with Bearer token auth validation.
async fn accept_ws_with_auth<S>(
stream: S,
expected_token: &str,
peer: SocketAddr,
auth_failures: &tokio::sync::Mutex<std::collections::HashMap<SocketAddr, (u32, std::time::Instant)>>,
) -> Result<tokio_tungstenite::WebSocketStream<S>, String>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
let expected = format!("Bearer {expected_token}");
tokio_tungstenite::accept_hdr_async(stream, |req: &http::Request<()>, response: http::Response<()>| {
let auth = req.headers().get("authorization").and_then(|v| v.to_str().ok());
match auth {
Some(value) if value == expected => Ok(response),
_ => {
Err(http::Response::builder()
.status(http::StatusCode::UNAUTHORIZED)
.body(Some("Invalid token".to_string()))
.unwrap())
}
}
})
.await
.map_err(|e| {
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}")
})
}
/// Run the WebSocket session (managers, event forwarding, command processing).
async fn run_ws_session<S>(
ws_stream: tokio_tungstenite::WebSocketStream<S>,
peer: SocketAddr,
sidecar_config: &SidecarConfig,
) -> Result<(), String>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{
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

63
v2/package.json Normal file
View file

@ -0,0 +1,63 @@
{
"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:cargo": "cd src-tauri && cargo test",
"test:e2e": "wdio run tests/e2e/wdio.conf.js",
"test:all": "bash scripts/test-all.sh",
"test:all:e2e": "bash scripts/test-all.sh --e2e",
"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"
}
}

114
v2/scripts/test-all.sh Executable file
View file

@ -0,0 +1,114 @@
#!/usr/bin/env bash
# BTerminal — unified test runner
# Usage: ./scripts/test-all.sh [--e2e] [--verbose]
#
# Runs vitest (frontend) + cargo test (backend) by default.
# Pass --e2e to also run WebDriverIO E2E tests (requires built binary).
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
RESET='\033[0m'
V2_DIR="$(cd "$(dirname "$0")/.." && pwd)"
RUN_E2E=false
VERBOSE=false
FAILED=()
for arg in "$@"; do
case "$arg" in
--e2e) RUN_E2E=true ;;
--verbose|-v) VERBOSE=true ;;
--help|-h)
echo "Usage: $0 [--e2e] [--verbose]"
echo " --e2e Also run WebDriverIO E2E tests (requires built binary)"
echo " --verbose Show full test output instead of summary"
exit 0
;;
*) echo "Unknown option: $arg"; exit 1 ;;
esac
done
step() {
echo -e "\n${CYAN}${BOLD}━━━ $1 ━━━${RESET}"
}
pass() {
echo -e "${GREEN}$1${RESET}"
}
fail() {
echo -e "${RED}$1${RESET}"
FAILED+=("$1")
}
# --- Vitest (frontend) ---
step "Vitest (frontend unit tests)"
if $VERBOSE; then
(cd "$V2_DIR" && npm run test) && pass "Vitest" || fail "Vitest"
else
if OUTPUT=$(cd "$V2_DIR" && npm run test 2>&1); then
SUMMARY=$(echo "$OUTPUT" | grep -E "Tests|Test Files" | tail -2)
echo "$SUMMARY"
pass "Vitest"
else
echo "$OUTPUT" | tail -20
fail "Vitest"
fi
fi
# --- Cargo test (backend) ---
step "Cargo test (Rust backend)"
if $VERBOSE; then
(cd "$V2_DIR/src-tauri" && cargo test) && pass "Cargo test" || fail "Cargo test"
else
if OUTPUT=$(cd "$V2_DIR/src-tauri" && cargo test 2>&1); then
SUMMARY=$(echo "$OUTPUT" | grep -E "test result:|running" | head -5)
echo "$SUMMARY"
pass "Cargo test"
else
echo "$OUTPUT" | tail -20
fail "Cargo test"
fi
fi
# --- E2E (WebDriverIO) ---
if $RUN_E2E; then
step "E2E tests (WebDriverIO + tauri-driver)"
# Check for built binary
BINARY=$(find "$V2_DIR/src-tauri/target" -name "bterminal*" -type f -executable -path "*/release/*" 2>/dev/null | head -1)
if [ -z "$BINARY" ]; then
echo -e "${YELLOW}⚠ No release binary found. Run 'npm run tauri build' first.${RESET}"
fail "E2E (no binary)"
else
if $VERBOSE; then
(cd "$V2_DIR" && npm run test:e2e) && pass "E2E" || fail "E2E"
else
if OUTPUT=$(cd "$V2_DIR" && npm run test:e2e 2>&1); then
SUMMARY=$(echo "$OUTPUT" | grep -E "passing|failing|skipped" | tail -3)
echo "$SUMMARY"
pass "E2E"
else
echo "$OUTPUT" | tail -30
fail "E2E"
fi
fi
fi
else
echo -e "\n${YELLOW}Skipping E2E tests (pass --e2e to include)${RESET}"
fi
# --- Summary ---
echo -e "\n${BOLD}━━━ Summary ━━━${RESET}"
if [ ${#FAILED[@]} -eq 0 ]; then
echo -e "${GREEN}${BOLD}All test suites passed.${RESET}"
exit 0
else
echo -e "${RED}${BOLD}Failed suites: ${FAILED[*]}${RESET}"
exit 1
fi

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

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

@ -0,0 +1,45 @@
[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-full"] }
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"
keyring = { version = "3", features = ["linux-native"] }
notify-rust = "4"
[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

1783
v2/src-tauri/src/btmsg.rs Normal file

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,766 @@
// bttask — Read access to task board SQLite tables in btmsg.db
// Tasks table created by bttask CLI, shared DB with btmsg
// Path configurable via init() for test isolation.
use rusqlite::{params, Connection, OpenFlags};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::OnceLock;
static DB_PATH: OnceLock<PathBuf> = OnceLock::new();
/// Set the bttask database path. Must be called before any db access.
/// Called from lib.rs setup with AppConfig-resolved path.
pub fn init(path: PathBuf) {
let _ = DB_PATH.set(path);
}
fn db_path() -> PathBuf {
DB_PATH.get().cloned().unwrap_or_else(|| {
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());
}
let conn = Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_WRITE)
.map_err(|e| format!("Failed to open btmsg.db: {e}"))?;
conn.query_row("PRAGMA journal_mode=WAL", [], |_| Ok(()))
.map_err(|e| format!("Failed to set WAL mode: {e}"))?;
conn.pragma_update(None, "busy_timeout", 5000)
.map_err(|e| format!("Failed to set busy_timeout: {e}"))?;
// Migration: add version column if missing
let has_version: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('tasks') WHERE name='version'",
[],
|row| row.get(0),
)
.unwrap_or(0);
if has_version == 0 {
conn.execute("ALTER TABLE tasks ADD COLUMN version INTEGER DEFAULT 1", [])
.map_err(|e| format!("Migration (version column) failed: {e}"))?;
}
Ok(conn)
}
#[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,
pub version: i64,
}
#[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, version
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("id")?,
title: row.get("title")?,
description: row.get::<_, String>("description").unwrap_or_default(),
status: row.get::<_, String>("status").unwrap_or_else(|_| "todo".into()),
priority: row.get::<_, String>("priority").unwrap_or_else(|_| "medium".into()),
assigned_to: row.get("assigned_to")?,
created_by: row.get("created_by")?,
group_id: row.get("group_id")?,
parent_task_id: row.get("parent_task_id")?,
sort_order: row.get::<_, i32>("sort_order").unwrap_or(0),
created_at: row.get::<_, String>("created_at").unwrap_or_default(),
updated_at: row.get::<_, String>("updated_at").unwrap_or_default(),
version: row.get::<_, i64>("version").unwrap_or(1),
})
})
.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("id")?,
task_id: row.get("task_id")?,
agent_id: row.get("agent_id")?,
content: row.get("content")?,
created_at: row.get::<_, String>("created_at").unwrap_or_default(),
})
})
.map_err(|e| format!("Query error: {e}"))?;
rows.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row error: {e}"))
}
/// Update task status with optimistic locking.
/// `expected_version` must match the current version in the database.
/// Returns the new version on success.
/// When transitioning to 'review', auto-posts to #review-queue channel if it exists.
pub fn update_task_status(task_id: &str, status: &str, expected_version: i64) -> Result<i64, String> {
let valid = ["todo", "progress", "review", "done", "blocked"];
if !valid.contains(&status) {
return Err(format!("Invalid status '{}'. Valid: {:?}", status, valid));
}
let db = open_db()?;
// Fetch task info before update (for channel notification)
let task_title: Option<(String, String)> = if status == "review" {
db.query_row(
"SELECT title, group_id FROM tasks WHERE id = ?1",
params![task_id],
|row| Ok((row.get::<_, String>("title")?, row.get::<_, String>("group_id")?)),
).ok()
} else {
None
};
let rows_affected = db.execute(
"UPDATE tasks SET status = ?1, version = version + 1, updated_at = datetime('now')
WHERE id = ?2 AND version = ?3",
params![status, task_id, expected_version],
)
.map_err(|e| format!("Update error: {e}"))?;
if rows_affected == 0 {
return Err("Task was modified by another agent (version conflict)".into());
}
let new_version = expected_version + 1;
// Auto-post to #review-queue channel on review transition
if let Some((title, group_id)) = task_title {
notify_review_channel(&db, &group_id, task_id, &title);
}
Ok(new_version)
}
/// Post a notification to #review-queue channel (best-effort, never fails the parent operation)
fn notify_review_channel(db: &Connection, group_id: &str, task_id: &str, title: &str) {
// Find #review-queue channel for this group
let channel_id: Option<String> = db
.query_row(
"SELECT id FROM channels WHERE name = 'review-queue' AND group_id = ?1",
params![group_id],
|row| row.get(0),
)
.ok();
let channel_id = match channel_id {
Some(id) => id,
None => {
// Auto-create #review-queue channel
match ensure_review_channels(db, group_id) {
Some(id) => id,
None => return, // Give up silently
}
}
};
let msg_id = uuid::Uuid::new_v4().to_string();
let content = format!("📋 Task ready for review: **{}** (`{}`)", title, task_id);
let _ = db.execute(
"INSERT INTO channel_messages (id, channel_id, from_agent, content) VALUES (?1, ?2, 'system', ?3)",
params![msg_id, channel_id, content],
);
}
/// Ensure #review-queue and #review-log channels exist for a group.
/// Returns the review-queue channel ID if created/found.
fn ensure_review_channels(db: &Connection, group_id: &str) -> Option<String> {
// Create channels only if they don't already exist
for name in &["review-queue", "review-log"] {
let exists: bool = db
.query_row(
"SELECT COUNT(*) > 0 FROM channels WHERE name = ?1 AND group_id = ?2",
params![name, group_id],
|row| row.get(0),
)
.unwrap_or(false);
if !exists {
let id = uuid::Uuid::new_v4().to_string();
let _ = db.execute(
"INSERT INTO channels (id, name, group_id, created_by) VALUES (?1, ?2, ?3, 'system')",
params![id, name, group_id],
);
}
}
// Return the review-queue channel ID
db.query_row(
"SELECT id FROM channels WHERE name = 'review-queue' AND group_id = ?1",
params![group_id],
|row| row.get(0),
)
.ok()
}
/// Count tasks in 'review' status for a group
pub fn review_queue_count(group_id: &str) -> Result<i64, String> {
let db = open_db()?;
db.query_row(
"SELECT COUNT(*) FROM tasks WHERE group_id = ?1 AND status = 'review'",
params![group_id],
|row| row.get(0),
)
.map_err(|e| format!("Query error: {e}"))
}
/// 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(())
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn test_db() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE 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')),
version INTEGER DEFAULT 1
);
CREATE TABLE 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'))
);
CREATE TABLE channels (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
group_id TEXT NOT NULL,
created_by TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE channel_messages (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
from_agent TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);",
)
.unwrap();
conn
}
// ---- REGRESSION: list_tasks named column access ----
#[test]
fn test_list_tasks_named_column_access() {
let conn = test_db();
conn.execute(
"INSERT INTO tasks (id, title, description, status, priority, assigned_to, created_by, group_id, sort_order)
VALUES ('t1', 'Fix bug', 'Critical fix', 'progress', 'high', 'a1', 'admin', 'g1', 1)",
[],
).unwrap();
conn.execute(
"INSERT INTO tasks (id, title, description, status, priority, assigned_to, created_by, group_id, sort_order)
VALUES ('t2', 'Add tests', '', 'todo', 'medium', NULL, 'a1', 'g1', 2)",
[],
).unwrap();
let mut stmt = conn.prepare(
"SELECT id, title, description, status, priority, assigned_to,
created_by, group_id, parent_task_id, sort_order,
created_at, updated_at, version
FROM tasks WHERE group_id = ?1
ORDER BY sort_order ASC, created_at DESC",
).unwrap();
let tasks: Vec<Task> = stmt.query_map(params!["g1"], |row| {
Ok(Task {
id: row.get("id")?,
title: row.get("title")?,
description: row.get::<_, String>("description").unwrap_or_default(),
status: row.get::<_, String>("status").unwrap_or_else(|_| "todo".into()),
priority: row.get::<_, String>("priority").unwrap_or_else(|_| "medium".into()),
assigned_to: row.get("assigned_to")?,
created_by: row.get("created_by")?,
group_id: row.get("group_id")?,
parent_task_id: row.get("parent_task_id")?,
sort_order: row.get::<_, i32>("sort_order").unwrap_or(0),
created_at: row.get::<_, String>("created_at").unwrap_or_default(),
updated_at: row.get::<_, String>("updated_at").unwrap_or_default(),
version: row.get::<_, i64>("version").unwrap_or(1),
})
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].id, "t1");
assert_eq!(tasks[0].title, "Fix bug");
assert_eq!(tasks[0].status, "progress");
assert_eq!(tasks[0].priority, "high");
assert_eq!(tasks[0].assigned_to, Some("a1".to_string()));
assert_eq!(tasks[0].sort_order, 1);
assert_eq!(tasks[1].id, "t2");
assert_eq!(tasks[1].assigned_to, None);
assert_eq!(tasks[1].parent_task_id, None);
}
// ---- REGRESSION: task_comments named column access ----
#[test]
fn test_task_comments_named_column_access() {
let conn = test_db();
conn.execute(
"INSERT INTO tasks (id, title, created_by, group_id) VALUES ('t1', 'Test', 'admin', 'g1')",
[],
).unwrap();
conn.execute(
"INSERT INTO task_comments (id, task_id, agent_id, content) VALUES ('c1', 't1', 'a1', 'Working on it')",
[],
).unwrap();
conn.execute(
"INSERT INTO task_comments (id, task_id, agent_id, content) VALUES ('c2', 't1', 'a2', 'Looks good')",
[],
).unwrap();
let mut stmt = conn.prepare(
"SELECT id, task_id, agent_id, content, created_at
FROM task_comments WHERE task_id = ?1
ORDER BY created_at ASC",
).unwrap();
let comments: Vec<TaskComment> = stmt.query_map(params!["t1"], |row| {
Ok(TaskComment {
id: row.get("id")?,
task_id: row.get("task_id")?,
agent_id: row.get("agent_id")?,
content: row.get("content")?,
created_at: row.get::<_, String>("created_at").unwrap_or_default(),
})
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(comments.len(), 2);
assert_eq!(comments[0].agent_id, "a1");
assert_eq!(comments[0].content, "Working on it");
assert_eq!(comments[1].agent_id, "a2");
}
// ---- serde camelCase serialization ----
#[test]
fn test_task_serializes_to_camel_case() {
let task = Task {
id: "t1".into(),
title: "Test".into(),
description: "desc".into(),
status: "todo".into(),
priority: "high".into(),
assigned_to: Some("a1".into()),
created_by: "admin".into(),
group_id: "g1".into(),
parent_task_id: None,
sort_order: 0,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
version: 1,
};
let json = serde_json::to_value(&task).unwrap();
assert!(json.get("assignedTo").is_some(), "expected camelCase 'assignedTo'");
assert!(json.get("createdBy").is_some(), "expected camelCase 'createdBy'");
assert!(json.get("groupId").is_some(), "expected camelCase 'groupId'");
assert!(json.get("parentTaskId").is_some(), "expected camelCase 'parentTaskId'");
assert!(json.get("sortOrder").is_some(), "expected camelCase 'sortOrder'");
assert!(json.get("createdAt").is_some(), "expected camelCase 'createdAt'");
assert!(json.get("updatedAt").is_some(), "expected camelCase 'updatedAt'");
// Ensure no snake_case leaks
assert!(json.get("assigned_to").is_none());
assert!(json.get("created_by").is_none());
assert!(json.get("group_id").is_none());
}
#[test]
fn test_task_comment_serializes_to_camel_case() {
let comment = TaskComment {
id: "c1".into(),
task_id: "t1".into(),
agent_id: "a1".into(),
content: "note".into(),
created_at: "2026-01-01".into(),
};
let json = serde_json::to_value(&comment).unwrap();
assert!(json.get("taskId").is_some(), "expected camelCase 'taskId'");
assert!(json.get("agentId").is_some(), "expected camelCase 'agentId'");
assert!(json.get("createdAt").is_some(), "expected camelCase 'createdAt'");
assert!(json.get("task_id").is_none());
}
// ---- update_task_status validation ----
#[test]
fn test_update_task_status_rejects_invalid() {
// Can't call update_task_status directly (uses open_db), but we can test the validation logic
let valid = ["todo", "progress", "review", "done", "blocked"];
assert!(valid.contains(&"todo"));
assert!(valid.contains(&"done"));
assert!(!valid.contains(&"invalid"));
assert!(!valid.contains(&"cancelled"));
}
// ---- Review channel auto-creation ----
#[test]
fn test_ensure_review_channels_creates_both() {
let conn = test_db();
let result = ensure_review_channels(&conn, "g1");
assert!(result.is_some(), "should return review-queue channel ID");
// Verify both channels exist
let queue_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM channels WHERE name = 'review-queue' AND group_id = 'g1'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(queue_count, 1);
let log_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM channels WHERE name = 'review-log' AND group_id = 'g1'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(log_count, 1);
}
#[test]
fn test_ensure_review_channels_idempotent() {
let conn = test_db();
let id1 = ensure_review_channels(&conn, "g1").unwrap();
let id2 = ensure_review_channels(&conn, "g1").unwrap();
assert_eq!(id1, id2, "should return same channel ID on repeated calls");
// Verify no duplicates
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM channels WHERE name = 'review-queue' AND group_id = 'g1'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn test_notify_review_channel_posts_message() {
let conn = test_db();
// Insert a task
conn.execute(
"INSERT INTO tasks (id, title, created_by, group_id) VALUES ('t1', 'Fix login bug', 'admin', 'g1')",
[],
).unwrap();
// Trigger notification (should auto-create channel)
notify_review_channel(&conn, "g1", "t1", "Fix login bug");
// Verify message was posted
let msg_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM channel_messages cm
JOIN channels c ON cm.channel_id = c.id
WHERE c.name = 'review-queue' AND c.group_id = 'g1'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(msg_count, 1);
// Verify message content
let content: String = conn
.query_row(
"SELECT cm.content FROM channel_messages cm
JOIN channels c ON cm.channel_id = c.id
WHERE c.name = 'review-queue'",
[],
|row| row.get(0),
)
.unwrap();
assert!(content.contains("Fix login bug"));
assert!(content.contains("t1"));
}
// ---- Review queue count ----
#[test]
fn test_review_queue_count_via_sql() {
let conn = test_db();
// Insert tasks with various statuses
conn.execute(
"INSERT INTO tasks (id, title, status, created_by, group_id) VALUES ('t1', 'A', 'review', 'admin', 'g1')",
[],
).unwrap();
conn.execute(
"INSERT INTO tasks (id, title, status, created_by, group_id) VALUES ('t2', 'B', 'review', 'admin', 'g1')",
[],
).unwrap();
conn.execute(
"INSERT INTO tasks (id, title, status, created_by, group_id) VALUES ('t3', 'C', 'progress', 'admin', 'g1')",
[],
).unwrap();
conn.execute(
"INSERT INTO tasks (id, title, status, created_by, group_id) VALUES ('t4', 'D', 'review', 'admin', 'g2')",
[],
).unwrap();
// Count review tasks for g1
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM tasks WHERE group_id = ?1 AND status = 'review'",
params!["g1"],
|row| row.get(0),
)
.unwrap();
assert_eq!(count, 2, "should count only review tasks in g1");
// Count review tasks for g2
let count_g2: i64 = conn
.query_row(
"SELECT COUNT(*) FROM tasks WHERE group_id = ?1 AND status = 'review'",
params!["g2"],
|row| row.get(0),
)
.unwrap();
assert_eq!(count_g2, 1, "should count only review tasks in g2");
}
// ---- Optimistic locking (version column) ----
#[test]
fn test_version_column_defaults_to_1() {
let conn = test_db();
conn.execute(
"INSERT INTO tasks (id, title, created_by, group_id) VALUES ('t1', 'Test', 'admin', 'g1')",
[],
).unwrap();
let version: i64 = conn
.query_row("SELECT version FROM tasks WHERE id = 't1'", [], |row| row.get(0))
.unwrap();
assert_eq!(version, 1);
}
#[test]
fn test_optimistic_lock_success() {
let conn = test_db();
conn.execute(
"INSERT INTO tasks (id, title, status, created_by, group_id) VALUES ('t1', 'Test', 'todo', 'admin', 'g1')",
[],
).unwrap();
// Update with correct version (1)
let rows = conn.execute(
"UPDATE tasks SET status = 'progress', version = version + 1, updated_at = datetime('now')
WHERE id = 't1' AND version = 1",
[],
).unwrap();
assert_eq!(rows, 1, "should affect 1 row");
let new_version: i64 = conn
.query_row("SELECT version FROM tasks WHERE id = 't1'", [], |row| row.get(0))
.unwrap();
assert_eq!(new_version, 2);
}
#[test]
fn test_optimistic_lock_conflict() {
let conn = test_db();
conn.execute(
"INSERT INTO tasks (id, title, status, created_by, group_id) VALUES ('t1', 'Test', 'todo', 'admin', 'g1')",
[],
).unwrap();
// First update succeeds
conn.execute(
"UPDATE tasks SET status = 'progress', version = version + 1, updated_at = datetime('now')
WHERE id = 't1' AND version = 1",
[],
).unwrap();
// Second update with stale version (1) should affect 0 rows
let rows = conn.execute(
"UPDATE tasks SET status = 'review', version = version + 1, updated_at = datetime('now')
WHERE id = 't1' AND version = 1",
[],
).unwrap();
assert_eq!(rows, 0, "stale version should affect 0 rows");
// Task should still be in 'progress' state
let status: String = conn
.query_row("SELECT status FROM tasks WHERE id = 't1'", [], |row| row.get(0))
.unwrap();
assert_eq!(status, "progress");
}
#[test]
fn test_version_in_list_tasks_query() {
let conn = test_db();
conn.execute(
"INSERT INTO tasks (id, title, created_by, group_id, sort_order) VALUES ('t1', 'V1', 'admin', 'g1', 1)",
[],
).unwrap();
// Bump version to 3
conn.execute("UPDATE tasks SET version = 3 WHERE id = 't1'", []).unwrap();
let mut stmt = conn.prepare(
"SELECT id, title, description, status, priority, assigned_to,
created_by, group_id, parent_task_id, sort_order,
created_at, updated_at, version
FROM tasks WHERE group_id = ?1",
).unwrap();
let tasks: Vec<Task> = stmt.query_map(params!["g1"], |row| {
Ok(Task {
id: row.get("id")?,
title: row.get("title")?,
description: row.get::<_, String>("description").unwrap_or_default(),
status: row.get::<_, String>("status").unwrap_or_else(|_| "todo".into()),
priority: row.get::<_, String>("priority").unwrap_or_else(|_| "medium".into()),
assigned_to: row.get("assigned_to")?,
created_by: row.get("created_by")?,
group_id: row.get("group_id")?,
parent_task_id: row.get("parent_task_id")?,
sort_order: row.get::<_, i32>("sort_order").unwrap_or(0),
created_at: row.get::<_, String>("created_at").unwrap_or_default(),
updated_at: row.get::<_, String>("updated_at").unwrap_or_default(),
version: row.get::<_, i64>("version").unwrap_or(1),
})
}).unwrap().collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].version, 3);
}
#[test]
fn test_version_serializes_to_camel_case() {
let task = Task {
id: "t1".into(),
title: "Test".into(),
description: "".into(),
status: "todo".into(),
priority: "medium".into(),
assigned_to: None,
created_by: "admin".into(),
group_id: "g1".into(),
parent_task_id: None,
sort_order: 0,
created_at: "2026-01-01".into(),
updated_at: "2026-01-01".into(),
version: 5,
};
let json = serde_json::to_value(&task).unwrap();
assert_eq!(json.get("version").unwrap(), 5);
}
}

View file

@ -0,0 +1,58 @@
use tauri::State;
use crate::AppState;
use crate::sidecar::AgentQueryOptions;
use bterminal_core::sandbox::SandboxConfig;
#[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()
}
/// Update sidecar sandbox configuration and restart to apply.
/// `project_cwds` — directories needing read+write access.
/// `worktree_roots` — optional worktree directories.
/// `enabled` — whether Landlock sandboxing is active.
#[tauri::command]
#[tracing::instrument(skip(state))]
pub fn agent_set_sandbox(
state: State<'_, AppState>,
project_cwds: Vec<String>,
worktree_roots: Vec<String>,
enabled: bool,
) -> Result<(), String> {
let cwd_refs: Vec<&str> = project_cwds.iter().map(|s| s.as_str()).collect();
let wt_refs: Vec<&str> = worktree_roots.iter().map(|s| s.as_str()).collect();
let mut sandbox = SandboxConfig::for_projects(&cwd_refs, &wt_refs);
sandbox.enabled = enabled;
state.sidecar_manager.set_sandbox(sandbox);
// Restart sidecar so Landlock restrictions take effect on the new process
if state.sidecar_manager.is_ready() {
state.sidecar_manager.restart()?;
}
Ok(())
}

View file

@ -0,0 +1,120 @@
use crate::btmsg;
use crate::groups;
#[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)
}
/// Register all agents from a GroupsFile into the btmsg database.
/// Creates/updates agent records, sets up contact permissions, ensures review channels.
#[tauri::command]
pub fn btmsg_register_agents(config: groups::GroupsFile) -> Result<(), String> {
btmsg::register_agents_from_groups(&config)
}
// ---- Heartbeat monitoring ----
#[tauri::command]
pub fn btmsg_record_heartbeat(agent_id: String) -> Result<(), String> {
btmsg::record_heartbeat(&agent_id)
}
#[tauri::command]
pub fn btmsg_get_stale_agents(group_id: String, threshold_secs: i64) -> Result<Vec<String>, String> {
btmsg::get_stale_agents(&group_id, threshold_secs)
}
// ---- Dead letter queue ----
#[tauri::command]
pub fn btmsg_get_dead_letters(group_id: String, limit: i32) -> Result<Vec<btmsg::DeadLetter>, String> {
btmsg::get_dead_letters(&group_id, limit)
}
#[tauri::command]
pub fn btmsg_clear_dead_letters(group_id: String) -> Result<(), String> {
btmsg::clear_dead_letters(&group_id)
}
// ---- Audit log ----
#[tauri::command]
pub fn audit_log_event(agent_id: String, event_type: String, detail: String) -> Result<(), String> {
btmsg::log_audit_event(&agent_id, &event_type, &detail)
}
#[tauri::command]
pub fn audit_log_list(group_id: String, limit: i32, offset: i32) -> Result<Vec<btmsg::AuditEntry>, String> {
btmsg::get_audit_log(&group_id, limit, offset)
}
#[tauri::command]
pub fn audit_log_for_agent(agent_id: String, limit: i32) -> Result<Vec<btmsg::AuditEntry>, String> {
btmsg::get_audit_log_for_agent(&agent_id, limit)
}

View file

@ -0,0 +1,43 @@
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, version: i64) -> Result<i64, String> {
bttask::update_task_status(&task_id, &status, version)
}
#[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)
}
#[tauri::command]
pub fn bttask_review_queue_count(group_id: String) -> Result<i64, String> {
bttask::review_queue_count(&group_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,46 @@
// 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 is_test_mode() -> bool {
std::env::var("BTERMINAL_TEST").map_or(false, |v| v == "1")
}
#[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,17 @@
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;
pub mod notifications;
pub mod search;
pub mod plugins;
pub mod secrets;

View file

@ -0,0 +1,8 @@
// Notification commands — desktop notification via notify-rust
use crate::notifications;
#[tauri::command]
pub fn notify_desktop(title: String, body: String, urgency: String) -> Result<(), String> {
notifications::send_desktop_notification(&title, &body, &urgency)
}

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,20 @@
// Plugin discovery and file access commands
use crate::AppState;
use crate::plugins;
#[tauri::command]
pub fn plugins_discover(state: tauri::State<'_, AppState>) -> Vec<plugins::PluginMeta> {
let plugins_dir = state.app_config.plugins_dir();
plugins::discover_plugins(&plugins_dir)
}
#[tauri::command]
pub fn plugin_read_file(
state: tauri::State<'_, AppState>,
plugin_id: String,
filename: String,
) -> Result<String, String> {
let plugins_dir = state.app_config.plugins_dir();
plugins::read_plugin_file(&plugins_dir, &plugin_id, &filename)
}

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
}

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