Compare commits
323 commits
master
...
v2-mission
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d0d5c5f07 | ||
|
|
719496853e | ||
|
|
92d8ee2c03 | ||
|
|
09463810c4 | ||
|
|
5815943861 | ||
|
|
31fed163d0 | ||
|
|
a7077c7987 | ||
|
|
f9ec78ce1e | ||
|
|
af670871ed | ||
|
|
6938e8c3a9 | ||
|
|
c9927a41e6 | ||
|
|
4c0d27aca3 | ||
|
|
78afb0e552 | ||
|
|
f555186843 | ||
|
|
a8a10ee4af | ||
|
|
ee198a2fdb | ||
|
|
65973fbf06 | ||
|
|
a3185656eb | ||
|
|
05c9e1abbb | ||
|
|
661f092fb2 | ||
|
|
2aec5889f8 | ||
|
|
8754b64ee3 | ||
|
|
e46b9e06d1 | ||
|
|
83c6711cd6 | ||
|
|
cd774ab4bd | ||
|
|
c304a8c06b | ||
|
|
bbb5f24cf9 | ||
|
|
c193db49a8 | ||
|
|
3cb65fd5e5 | ||
|
|
0fe43de357 | ||
|
|
5c31668760 | ||
|
|
b2932273ba | ||
|
|
5dd7df03cb | ||
|
|
7cb5cddc7c | ||
|
|
944b48ff13 | ||
|
|
b2c379516c | ||
|
|
548478f115 | ||
|
|
243faafd9e | ||
|
|
c5188757ad | ||
|
|
2e29ba5d9a | ||
|
|
9ce7c35325 | ||
|
|
e3594074d2 | ||
|
|
c43c83fbe6 | ||
|
|
5e4357e4ac | ||
|
|
c4c673a4b0 | ||
|
|
c6c38b91c6 | ||
|
|
2746b34f83 | ||
|
|
4097253921 | ||
|
|
d1a4d9f220 | ||
|
|
323bb1b040 | ||
|
|
61f01e22b8 | ||
|
|
c774f352ee | ||
|
|
5576392d4b | ||
|
|
6ca3ffdb8d | ||
|
|
d9d67b2bc6 | ||
|
|
0742309595 | ||
|
|
f928abd6ce | ||
|
|
46df7949a7 | ||
|
|
ce389a2a39 | ||
|
|
70ebbff699 | ||
|
|
e41d237745 | ||
|
|
a12f2bec7b | ||
|
|
8678e3474d | ||
|
|
93c2cdf434 | ||
|
|
32f6d7eadf | ||
|
|
2ca7756a74 | ||
|
|
0c28f204c7 | ||
|
|
14808a97e9 | ||
|
|
a158ed9544 | ||
|
|
1331d094b3 | ||
|
|
f2dcedc460 | ||
|
|
485b279659 | ||
|
|
e1025a0a8a | ||
|
|
44610f3177 | ||
|
|
dc0ffb6dbf | ||
|
|
af3cd45324 | ||
|
|
c3d2e1daee | ||
|
|
889adcb004 | ||
|
|
a06b9d5053 | ||
|
|
3f4f2d70af | ||
|
|
f2a7d385d6 | ||
|
|
7ba63db101 | ||
|
|
584a38d096 | ||
|
|
9c94272ca7 | ||
|
|
450756f540 | ||
|
|
54b1c60810 | ||
|
|
af369f30d2 | ||
|
|
4d93b77f6a | ||
|
|
30c21256bc | ||
|
|
b1bc5d18a4 | ||
|
|
fc7fe3180e | ||
|
|
4ac0336e72 | ||
|
|
6b420a6a1f | ||
|
|
267087937f | ||
|
|
0139f482b5 | ||
|
|
be504cadcf | ||
|
|
f3f740a8fe | ||
|
|
ad7e24e40d | ||
|
|
8309896e7d | ||
|
|
3e34fda59a | ||
|
|
4ae7ca6634 | ||
|
|
643ab0a6b6 | ||
|
|
0da53e7390 | ||
|
|
0d9c473a06 | ||
|
|
64e040ebfe | ||
|
|
3e60516544 | ||
|
|
ccce2b6005 | ||
|
|
a9e94fc154 | ||
|
|
8f4faaafa3 | ||
|
|
11b00f18f8 | ||
|
|
1efcb13869 | ||
|
|
d8d7ad16f3 | ||
|
|
ab4e8b7e06 | ||
|
|
a74d3a74d3 | ||
|
|
199873781b | ||
|
|
378c59bb97 | ||
|
|
929f54e195 | ||
|
|
64ad4d2e58 | ||
|
|
9d9cc75b28 | ||
|
|
b19aa632c8 | ||
|
|
d1ce031624 | ||
|
|
e5d9f51df7 | ||
|
|
6b239c5ce5 | ||
|
|
13ac45c203 | ||
|
|
38b8447ae6 | ||
|
|
05191127ea | ||
|
|
82fb618c76 | ||
|
|
8e00e0ef8c | ||
|
|
1b61f10532 | ||
|
|
42094eac2a | ||
|
|
072316d63f | ||
|
|
f4ec2f3762 | ||
|
|
d898586181 | ||
|
|
979c8883f3 | ||
|
|
9c7618cad1 | ||
|
|
6e24adcd56 | ||
|
|
86fa55bc3e | ||
|
|
61cd33f393 | ||
|
|
e54ea1dbbc | ||
|
|
3bb972fc01 | ||
|
|
0ffbd93b8b | ||
|
|
cfe3abcf00 | ||
|
|
ea44719685 | ||
|
|
e2a406a167 | ||
|
|
a111ba0b9d | ||
|
|
59606e067f | ||
|
|
18c62cc462 | ||
|
|
260a21c66a | ||
|
|
6744e1beaf | ||
|
|
f5f3e0d63e | ||
|
|
0a51fc4b02 | ||
|
|
5a9f67fcb6 | ||
|
|
cd438c2cf3 | ||
|
|
91aa711ef3 | ||
|
|
b6ca086371 | ||
|
|
134a7bd8ff | ||
|
|
e92e54d6c2 | ||
|
|
92b513dd9d | ||
|
|
c6fda19170 | ||
|
|
ac5b3c4adc | ||
|
|
7bc4a70b06 | ||
|
|
4674a4779d | ||
|
|
28f7867dc6 | ||
|
|
fa9ca415be | ||
|
|
6585208233 | ||
|
|
74ce1ee083 | ||
|
|
b9b5ef9cb3 | ||
|
|
8bdd9d6fcc | ||
|
|
2eb323fba8 | ||
|
|
4c02b87e33 | ||
|
|
50a9ad40fa | ||
|
|
d12cbffda7 | ||
|
|
13fe598742 | ||
|
|
bfbdb2cc18 | ||
|
|
3059475ab7 | ||
|
|
3c3a8ab54e | ||
|
|
7fc87a9567 | ||
|
|
9738776bae | ||
|
|
dba6a88a28 | ||
|
|
a69022756a | ||
|
|
fd9f55faff | ||
|
|
3f1638c98b | ||
|
|
044f891c3a | ||
|
|
9ec7e560ae | ||
|
|
4bdb74721d | ||
|
|
73ca780b54 | ||
|
|
4f2b8b3183 | ||
|
|
f50811cfdb | ||
|
|
e37c85e294 | ||
|
|
0f0ea3fb59 | ||
|
|
957f4c20f6 | ||
|
|
d903904d52 | ||
|
|
c008e2c5f2 | ||
|
|
684af68ed9 | ||
|
|
d67ab7eeaf | ||
|
|
85952346f8 | ||
|
|
e677a6aa6a | ||
|
|
d2fd9fb6e3 | ||
|
|
90c1fb94e2 | ||
|
|
975f03e75d | ||
|
|
308664a4c9 | ||
|
|
0e5fcd766b | ||
|
|
e8c43b002c | ||
|
|
caeb450eca | ||
|
|
be4df01302 | ||
|
|
319c92fc68 | ||
|
|
f2aa514845 | ||
|
|
e2fda3f742 | ||
|
|
5c657d0daa | ||
|
|
b8001dc56c | ||
|
|
2a93574d1f | ||
|
|
b1efe8e48d | ||
|
|
a53d629b61 | ||
|
|
c9115158c2 | ||
|
|
642508e9ea | ||
|
|
a64ab2e55f | ||
|
|
99282e833a | ||
|
|
50eef73429 | ||
|
|
27cc50fb9c | ||
|
|
97860c3db1 | ||
|
|
3ecc4f02d1 | ||
|
|
906e967aa0 | ||
|
|
820467c029 | ||
|
|
87dd8cb09d | ||
|
|
4424a90f89 | ||
|
|
3776a3ba65 | ||
|
|
b1b34f8195 | ||
|
|
36af9dd1d2 | ||
|
|
fa7d0bd915 | ||
|
|
47492aa637 | ||
|
|
1279981bf9 | ||
|
|
9af7ac3f68 | ||
|
|
d38adc017a | ||
|
|
37d211e9a7 | ||
|
|
edaf5fcdb6 | ||
|
|
4a46ef42ab | ||
|
|
54edb43e8b | ||
|
|
ff2d354219 | ||
|
|
1ba818e7a5 | ||
|
|
3b2c1353fa | ||
|
|
6ea1ed1dfd | ||
|
|
67c416dc10 | ||
|
|
ab0811ca2b | ||
|
|
86da302aa6 | ||
|
|
160712de50 | ||
|
|
e0056f811f | ||
|
|
9766a480ed | ||
|
|
4f29582aac | ||
|
|
a11e7f9d2c | ||
|
|
ab79dac4b3 | ||
|
|
293bed6dc5 | ||
|
|
03e9f34e07 | ||
|
|
761070251f | ||
|
|
17c5f9b88a | ||
|
|
ff49e7e176 | ||
|
|
768db420d3 | ||
|
|
d35b3dc7fc | ||
|
|
14b62da729 | ||
|
|
a3d9933221 | ||
|
|
f97e7391a9 | ||
|
|
2409642925 | ||
|
|
658dc4715e | ||
|
|
e7c957d650 | ||
|
|
d5eb08ed42 | ||
|
|
af0eb362e6 | ||
|
|
bb530edd28 | ||
|
|
323703caba | ||
|
|
fdd1884015 | ||
|
|
ce79ae671a | ||
|
|
4c06b5f121 | ||
|
|
71100da125 | ||
|
|
218570ac35 | ||
|
|
b0cce7ae4f | ||
|
|
0a17c09a46 | ||
|
|
5503340e87 | ||
|
|
0b39133d66 | ||
|
|
cf37b572cf | ||
|
|
f894c2862c | ||
|
|
250ea17d3e | ||
|
|
04a7a4bb94 | ||
|
|
86fbe3e762 | ||
|
|
fc429a5095 | ||
|
|
097b4b2ee7 | ||
|
|
90efeea507 | ||
|
|
b7f77d8f60 | ||
|
|
a34687f844 | ||
|
|
07fc52b958 | ||
|
|
d021061b8a | ||
|
|
020dc20d4f | ||
|
|
a2bc8838b4 | ||
|
|
035d4186fa | ||
|
|
f349f3bb14 | ||
|
|
c15fe7d912 | ||
|
|
35a515db25 | ||
|
|
7e6e777713 | ||
|
|
f27543d8d8 | ||
|
|
1d028c67f7 | ||
|
|
4db7ccff60 | ||
|
|
4f2614186d | ||
|
|
173c55cb2b | ||
|
|
67875a1f70 | ||
|
|
643eb15697 | ||
|
|
d7a1dca40d | ||
|
|
be24d07c65 | ||
|
|
cd1271adf0 | ||
|
|
bdb87978a9 | ||
|
|
5ca035d438 | ||
|
|
da6d7272ee | ||
|
|
af1516ed2b | ||
|
|
c24e540080 | ||
|
|
314c6d77aa | ||
|
|
f928501075 | ||
|
|
f0ec44f6a6 | ||
|
|
54b0d44bc1 | ||
|
|
05ad0db092 | ||
|
|
bfd4021909 | ||
|
|
bb0e9283fc | ||
|
|
f15e60be60 | ||
|
|
406683799b | ||
|
|
9bd15b359b | ||
|
|
bb93bd1c9a | ||
|
|
89a98adb61 | ||
|
|
758d626fab |
|
|
@ -3,7 +3,10 @@
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
- v1 is a single-file Python app (`bterminal.py`). Changes are localized.
|
- 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.
|
- Consult Memora (tag: `bterminal`) before making architectural changes.
|
||||||
|
|
||||||
## Documentation References
|
## Documentation References
|
||||||
|
|
@ -12,21 +15,81 @@
|
||||||
- Implementation phases: [docs/phases.md](../docs/phases.md)
|
- Implementation phases: [docs/phases.md](../docs/phases.md)
|
||||||
- Research findings: [docs/findings.md](../docs/findings.md)
|
- Research findings: [docs/findings.md](../docs/findings.md)
|
||||||
- Progress log: [docs/progress.md](../docs/progress.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
|
## Rules
|
||||||
|
|
||||||
- Do not modify v1 code (`bterminal.py`) unless explicitly asked — it is production-stable.
|
- 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.
|
- v2/v3 work goes on the `v2-mission-control` branch, not master.
|
||||||
- All v2 architecture decisions must reference `docs/task_plan.md` Decisions Log.
|
- 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.
|
- When adding new decisions, append to the Decisions Log table with date.
|
||||||
- Update `docs/progress.md` after each significant work session.
|
- Update `docs/progress.md` after each significant work session.
|
||||||
|
|
||||||
## Key Technical Constraints
|
## Key Technical Constraints
|
||||||
|
|
||||||
- WebKit2GTK has no WebGL — xterm.js must use Canvas addon explicitly.
|
- 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`).
|
- 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).
|
||||||
- Node.js sidecar communicates via stdio NDJSON, not sockets.
|
- 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.
|
||||||
- Maximum 4 active xterm.js instances to avoid WebKit2GTK memory issues.
|
- 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.5x–3x; 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 5–60 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
|
## 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 |
|
| 15 | `memora.md` | Persistent memory across sessions |
|
||||||
| 16 | `sub-agents.md` | When to use sub-agents and team agents |
|
| 16 | `sub-agents.md` | When to use sub-agents and team agents |
|
||||||
| 17 | `document-imports.md` | Resolve @ imports in CLAUDE.md before acting |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
- 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
|
## After Every Change
|
||||||
|
|
||||||
- Run the test suite, report results, fix failures before continuing.
|
- Run the test suite, report results, fix failures before continuing.
|
||||||
|
|
|
||||||
12
.claude/rules/18-preexisting-issues.md
Normal 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.
|
||||||
17
.claude/rules/51-theme-integration.md
Normal 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)`.
|
||||||
10
.claude/rules/52-no-implicit-push.md
Normal 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.
|
||||||
17
.claude/rules/53-relative-units.md
Normal 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.
|
||||||
32
.claude/rules/54-testing-gate.md
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -2,3 +2,13 @@ __pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
|
v2/target/
|
||||||
|
debug/
|
||||||
|
/plugins/
|
||||||
|
/v2/plugins/
|
||||||
|
projects/
|
||||||
|
.playwright-mcp/
|
||||||
|
.audit/
|
||||||
|
.tribunal/
|
||||||
|
.vscode/
|
||||||
|
.local/
|
||||||
|
|
|
||||||
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "agent-orchestrator"]
|
||||||
|
path = agent-orchestrator
|
||||||
|
url = git@github.com:DexterFromLab/agent-orchestrator.git
|
||||||
483
CHANGELOG.md
|
|
@ -8,10 +8,493 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### 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 (5–60 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-1–PA-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.5x–3x, 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.3–3.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
|
- 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
|
- Research documentation covering Agent SDK, xterm.js performance, Tauri ecosystem, and ultrawide layout patterns
|
||||||
- Phased implementation plan (6 phases, MVP = Phases 1-4)
|
- Phased implementation plan (6 phases, MVP = Phases 1-4)
|
||||||
- Error handling and testing strategy for v2
|
- Error handling and testing strategy for v2
|
||||||
- Documentation structure in `docs/` (task_plan, phases, findings, progress)
|
- Documentation structure in `docs/` (task_plan, phases, findings, progress)
|
||||||
|
- 17 operational rules in `.claude/rules/`
|
||||||
- TODO.md for tracking active work
|
- TODO.md for tracking active work
|
||||||
- `.claude/CLAUDE.md` behavioral guide for Claude sessions
|
- `.claude/CLAUDE.md` behavioral guide for Claude sessions
|
||||||
|
- VS Code workspace configuration with Peacock color
|
||||||
|
|
|
||||||
230
CLAUDE.md
Normal 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.5x–3x, 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
|
|
@ -1,21 +1,28 @@
|
||||||
# BTerminal
|
# 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **SSH sessions** — saved configs (host, port, user, key, folder, color), CRUD with side panel
|
- **SSH sessions** — saved configs (host, port, user, key, folder, color), one-click connect from sidebar
|
||||||
- **Claude Code sessions** — saved Claude Code configs with sudo, resume, skip-permissions and initial prompt
|
- **Claude Code sessions** — saved configs with sudo askpass, resume, skip-permissions and initial prompt
|
||||||
- **SSH macros** — multi-step macros (text, key, delay) assigned to sessions, run from sidebar
|
- **SSH macros** — multi-step automation (text, key press, delay) bound to sessions, runnable from sidebar
|
||||||
- **Tabs** — multiple terminals in tabs, Ctrl+T new, Ctrl+Shift+W close, Ctrl+PageUp/Down switch
|
- **Tabs** — multiple terminals in tabs with reordering, auto-close and shell respawn
|
||||||
- **Sudo askpass** — Claude Code with sudo: password entered once, temporary askpass helper, auto-cleanup
|
- **Folder grouping** — organize both SSH and Claude Code sessions in collapsible sidebar folders
|
||||||
- **Folder grouping** — SSH and Claude Code sessions can be grouped in folders on the sidebar
|
- **Session colors** — 10 Catppuccin accent colors for quick visual identification
|
||||||
- **ctx — Context manager** — SQLite-based cross-session context database for Claude Code projects
|
- **Sudo askpass** — temporary helper for Claude Code sudo mode: password entered once, auto-cleanup on exit
|
||||||
- **Catppuccin Mocha** — full theme: terminal, sidebar, tabs, session colors
|
- **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
|
## Installation
|
||||||
|
|
||||||
|
|
@ -30,9 +37,23 @@ The installer will:
|
||||||
2. Copy files to `~/.local/share/bterminal/`
|
2. Copy files to `~/.local/share/bterminal/`
|
||||||
3. Create symlinks: `bterminal` and `ctx` in `~/.local/bin/`
|
3. Create symlinks: `bterminal` and `ctx` in `~/.local/bin/`
|
||||||
4. Initialize context database at `~/.claude-context/context.db`
|
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
|
```bash
|
||||||
sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-vte-2.91
|
sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-vte-2.91
|
||||||
|
|
@ -46,23 +67,37 @@ bterminal
|
||||||
|
|
||||||
## Context Manager (ctx)
|
## 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
|
```bash
|
||||||
ctx init myproject "Project description" /path/to/project
|
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 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 summary myproject "What was done" # Save session summary
|
||||||
ctx search "query" # Full-text search across everything
|
ctx search "query" # Full-text search across everything
|
||||||
ctx list # List all projects
|
ctx list # List all projects
|
||||||
ctx history myproject # Show session history
|
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 --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
|
### 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
|
```markdown
|
||||||
On session start, load context:
|
On session start, load context:
|
||||||
|
|
@ -80,8 +115,8 @@ Config files in `~/.config/bterminal/`:
|
||||||
|
|
||||||
| File | Description |
|
| File | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `sessions.json` | Saved SSH sessions + macros |
|
| `sessions.json` | SSH sessions and macros |
|
||||||
| `claude_sessions.json` | Saved Claude Code configs |
|
| `claude_sessions.json` | Claude Code session configs |
|
||||||
|
|
||||||
Context database: `~/.claude-context/context.db`
|
Context database: `~/.claude-context/context.db`
|
||||||
|
|
||||||
|
|
@ -95,14 +130,65 @@ Context database: `~/.claude-context/context.db`
|
||||||
| `Ctrl+Shift+V` | Paste |
|
| `Ctrl+Shift+V` | Paste |
|
||||||
| `Ctrl+PageUp/Down` | Previous/next tab |
|
| `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
|
## Documentation
|
||||||
|
|
||||||
| Document | Description |
|
| Document | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| [docs/task_plan.md](docs/task_plan.md) | v2 architecture decisions, error handling, testing strategy |
|
| [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/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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
30
TODO.md
|
|
@ -1,14 +1,28 @@
|
||||||
# BTerminal — TODO
|
# BTerminal -- TODO
|
||||||
|
|
||||||
## Active
|
## 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).
|
### Migration to agent-orchestrator
|
||||||
- [ ] **Phase 2: Terminal Pane + Layout** — CSS Grid tiling, xterm.js with Canvas addon, PTY via portable-pty, SSH/shell/Claude CLI support.
|
- [ ] **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.
|
||||||
- [ ] **Phase 3: Agent SDK Integration** — Node.js sidecar, SDK message adapter, structured agent panes with tool call cards.
|
- [ ] **CLAUDE.md Commit Zero** -- Update agent-orchestrator's CLAUDE.md to reflect rebrand + new features. Update docs/ accordingly.
|
||||||
- [ ] **Phase 4: Session Management + Markdown** — SQLite persistence, session CRUD, file watcher, markdown rendering. MVP ship after this phase.
|
- [ ] **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.
|
||||||
- [ ] **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.
|
### 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
|
## 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
|
||||||
1676
bterminal.py
729
bttask
Executable 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()
|
||||||
34
ctx
|
|
@ -8,15 +8,14 @@ Usage: ctx <command> [args]
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
import os
|
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
DB_PATH = Path.home() / ".claude-context" / "context.db"
|
DB_PATH = Path.home() / ".claude-context" / "context.db"
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
|
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
db = sqlite3.connect(str(DB_PATH))
|
db = sqlite3.connect(str(DB_PATH))
|
||||||
db.row_factory = sqlite3.Row
|
db.row_factory = sqlite3.Row
|
||||||
db.execute("PRAGMA journal_mode=WAL")
|
db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
|
@ -104,19 +103,18 @@ def cmd_init(args):
|
||||||
|
|
||||||
|
|
||||||
def cmd_get(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:
|
if len(args) < 1:
|
||||||
print("Usage: ctx get <project>")
|
print("Usage: ctx get <project> [--shared]")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
project = args[0]
|
project = args[0]
|
||||||
|
show_shared = "--shared" in args
|
||||||
db = get_db()
|
db = get_db()
|
||||||
|
|
||||||
# Session info
|
# Session info
|
||||||
session = db.execute("SELECT * FROM sessions WHERE name = ?", (project,)).fetchone()
|
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
|
# Project context
|
||||||
contexts = db.execute(
|
contexts = db.execute(
|
||||||
"SELECT key, value FROM contexts WHERE project = ? ORDER BY key", (project,)
|
"SELECT key, value FROM contexts WHERE project = ? ORDER BY key", (project,)
|
||||||
|
|
@ -138,6 +136,8 @@ def cmd_get(args):
|
||||||
print(f"PROJECT: {project} (not registered, use: ctx init)")
|
print(f"PROJECT: {project} (not registered, use: ctx init)")
|
||||||
print("=" * 60)
|
print("=" * 60)
|
||||||
|
|
||||||
|
if show_shared:
|
||||||
|
shared = db.execute("SELECT key, value FROM shared ORDER BY key").fetchall()
|
||||||
if shared:
|
if shared:
|
||||||
print("\n--- Shared Context ---")
|
print("\n--- Shared Context ---")
|
||||||
for row in shared:
|
for row in shared:
|
||||||
|
|
@ -156,8 +156,8 @@ def cmd_get(args):
|
||||||
print(f"\n[{row['created_at']}]")
|
print(f"\n[{row['created_at']}]")
|
||||||
print(row["summary"])
|
print(row["summary"])
|
||||||
|
|
||||||
if not shared and not contexts and not summaries:
|
if not contexts and not summaries:
|
||||||
print("\nNo context stored yet. Use 'ctx set' or 'ctx shared set' to add.")
|
print("\nNo context stored yet. Use 'ctx set' to add project context.")
|
||||||
|
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
@ -279,7 +279,11 @@ def cmd_history(args):
|
||||||
print("Usage: ctx history <project> [limit]")
|
print("Usage: ctx history <project> [limit]")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
project = args[0]
|
project = args[0]
|
||||||
|
try:
|
||||||
limit = int(args[1]) if len(args) > 1 else 10
|
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()
|
db = get_db()
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"SELECT summary, created_at FROM summaries WHERE project = ? ORDER BY created_at DESC LIMIT ?",
|
"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)
|
query = " ".join(args)
|
||||||
db = get_db()
|
db = get_db()
|
||||||
|
|
||||||
# Search project contexts
|
# Search project contexts (FTS5 MATCH can fail on malformed query syntax)
|
||||||
|
try:
|
||||||
results_ctx = db.execute(
|
results_ctx = db.execute(
|
||||||
"SELECT project, key, value FROM contexts_fts WHERE contexts_fts MATCH ?",
|
"SELECT project, key, value FROM contexts_fts WHERE contexts_fts MATCH ?",
|
||||||
(query,),
|
(query,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
print(f"Invalid search query: '{query}' (FTS5 syntax error)")
|
||||||
|
db.close()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# Search shared contexts
|
# Search shared contexts
|
||||||
|
try:
|
||||||
results_shared = db.execute(
|
results_shared = db.execute(
|
||||||
"SELECT key, value FROM shared_fts WHERE shared_fts MATCH ?", (query,)
|
"SELECT key, value FROM shared_fts WHERE shared_fts MATCH ?", (query,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
results_shared = []
|
||||||
|
|
||||||
# Search summaries (simple LIKE since no FTS on summaries)
|
# Search summaries (simple LIKE since no FTS on summaries)
|
||||||
results_sum = db.execute(
|
results_sum = db.execute(
|
||||||
|
|
@ -433,7 +445,7 @@ def print_help():
|
||||||
print("ctx — Cross-session context manager for Claude Code\n")
|
print("ctx — Cross-session context manager for Claude Code\n")
|
||||||
print("Commands:")
|
print("Commands:")
|
||||||
print(" init <project> <desc> [dir] Register a new project")
|
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(" set <project> <key> <value> Set project context entry")
|
||||||
print(" append <project> <key> <val> Append to existing entry")
|
print(" append <project> <key> <val> Append to existing entry")
|
||||||
print(" shared get|set|delete Manage shared context")
|
print(" shared get|set|delete Manage shared context")
|
||||||
|
|
|
||||||
27
docker/tempo/docker-compose.yaml
Normal 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:
|
||||||
11
docker/tempo/grafana-datasources.yaml
Normal 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
|
|
@ -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
|
||||||
|
|
@ -11,3 +11,31 @@ description: "Project documentation index"
|
||||||
Project documentation lives here.
|
Project documentation lives here.
|
||||||
|
|
||||||
> This directory is maintained automatically. When features are added or changed, corresponding documentation is updated.
|
> 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
|
|
@ -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 |
|
||||||
|
|
@ -132,17 +132,17 @@ Zellij uses WASM plugins for extensibility:
|
||||||
|
|
||||||
## 6. Frontend Framework Choice
|
## 6. Frontend Framework Choice
|
||||||
|
|
||||||
### Why Solid.js
|
### Why Svelte 5 (revised from initial Solid.js choice)
|
||||||
- **Fine-grained reactivity** — updates only the DOM nodes that changed, not the component tree
|
- **Fine-grained reactivity** — $state/$derived runes match Solid's signals model
|
||||||
- **No VDOM** — critical when we have 4-8 panes each streaming data
|
- **No VDOM** — critical when we have 4-8 panes each streaming data
|
||||||
- **Small bundle** — ~7KB vs React's ~40KB
|
- **Small bundle** — ~5KB runtime vs React's ~40KB
|
||||||
- **JSX familiar** — easy for anyone who knows React
|
- **Larger ecosystem** — more component libraries, xterm.js wrappers, better tooling
|
||||||
- **Signals** — perfect for streaming agent state
|
- **Better TypeScript support** — improved in Svelte 5
|
||||||
|
|
||||||
### Alternative: Svelte
|
### Why NOT Solid.js (initial choice, revised)
|
||||||
- Also no VDOM, also reactive, slightly larger community
|
- Ecosystem too small for production use
|
||||||
- Slightly more ceremony for stores/state management
|
- Fewer component libraries and integrations
|
||||||
- Would also work, personal preference
|
- Svelte 5 runes eliminated the ceremony gap
|
||||||
|
|
||||||
### NOT React
|
### NOT React
|
||||||
- VDOM reconciliation across 4-8 simultaneously updating panes = CPU waste
|
- VDOM reconciliation across 4-8 simultaneously updating panes = CPU waste
|
||||||
|
|
|
||||||
323
docs/multi-machine.md
Normal 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)
|
||||||
313
docs/phases.md
|
|
@ -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`
|
- [x] Create feature branch `v2-mission-control`
|
||||||
- [ ] Initialize Tauri 2.x project with Svelte 5 frontend
|
- [x] Initialize Tauri 2.x project with Svelte 5 frontend
|
||||||
- [ ] Project structure (see below)
|
- [x] Project structure (see below)
|
||||||
- [ ] Basic Tauri window with Catppuccin Mocha CSS variables
|
- [x] Basic Tauri window with Catppuccin Mocha CSS variables
|
||||||
- [ ] Verify Tauri builds and launches on target system
|
- [x] Verify Tauri builds and launches on target system
|
||||||
- [ ] Set up dev scripts (dev, build, lint)
|
- [x] Set up dev scripts (dev, build, lint)
|
||||||
|
|
||||||
### File Structure
|
### File Structure
|
||||||
```
|
```
|
||||||
|
|
@ -20,42 +20,68 @@ bterminal-v2/
|
||||||
src/
|
src/
|
||||||
main.rs # Tauri app entry
|
main.rs # Tauri app entry
|
||||||
pty.rs # PTY management (portable-pty, not plugin)
|
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
|
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
|
Cargo.toml
|
||||||
src/
|
src/
|
||||||
App.svelte # Root layout
|
App.svelte # Root layout + detached pane mode
|
||||||
lib/
|
lib/
|
||||||
components/
|
components/
|
||||||
Layout/
|
Layout/
|
||||||
TilingGrid.svelte # Dynamic tiling manager
|
TilingGrid.svelte # Dynamic tiling manager
|
||||||
PaneContainer.svelte # Individual pane wrapper
|
PaneContainer.svelte # Individual pane wrapper
|
||||||
PaneHeader.svelte # Pane title bar with controls
|
|
||||||
Terminal/
|
Terminal/
|
||||||
TerminalPane.svelte # xterm.js terminal pane
|
TerminalPane.svelte # xterm.js terminal pane (theme-aware)
|
||||||
Agent/
|
Agent/
|
||||||
AgentPane.svelte # SDK agent structured output
|
AgentPane.svelte # SDK agent structured output
|
||||||
AgentTree.svelte # Subagent tree visualization
|
AgentTree.svelte # Subagent tree visualization (SVG)
|
||||||
ToolCallCard.svelte # Individual tool call display
|
|
||||||
Markdown/
|
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/
|
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/
|
stores/
|
||||||
sessions.ts # Session state ($state runes)
|
sessions.svelte.ts # Session state ($state runes)
|
||||||
agents.ts # Active agent tracking
|
agents.svelte.ts # Active agent tracking
|
||||||
layout.ts # Pane layout state
|
layout.svelte.ts # Pane layout state
|
||||||
|
notifications.svelte.ts # Toast notification state
|
||||||
|
theme.svelte.ts # Catppuccin theme flavor state
|
||||||
adapters/
|
adapters/
|
||||||
sdk-messages.ts # SDK message abstraction layer
|
sdk-messages.ts # SDK message abstraction layer
|
||||||
pty-bridge.ts # PTY IPC wrapper
|
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/
|
styles/
|
||||||
catppuccin.css # Theme CSS variables
|
catppuccin.css # Theme CSS variables (Mocha defaults)
|
||||||
|
themes.ts # All 4 Catppuccin flavor definitions
|
||||||
app.css
|
app.css
|
||||||
sidecar/
|
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
|
package.json # Agent SDK dependency
|
||||||
esbuild.config.ts # Bundle to single file
|
|
||||||
package.json
|
package.json
|
||||||
svelte.config.js
|
svelte.config.js
|
||||||
vite.config.ts
|
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)
|
### Layout (responsive)
|
||||||
|
|
||||||
|
|
@ -86,92 +112,219 @@ bterminal-v2/
|
||||||
+--------+-------------------------+
|
+--------+-------------------------+
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] CSS Grid layout with sidebar + main area + optional right panel
|
- [x] CSS Grid layout with sidebar + main area + optional right panel
|
||||||
- [ ] Responsive breakpoints (ultrawide / standard / narrow)
|
- [x] Responsive breakpoints (ultrawide / standard / narrow)
|
||||||
- [ ] Pane resize via drag handles (use CSS `resize` or lightweight lib)
|
- [x] Pane resize via drag handles (splitter overlays in TilingGrid with mouse drag, min/max 10%/90%)
|
||||||
- [ ] Layout presets: 1-col, 2-col, 3-col, 2x2, master+stack
|
- [x] Layout presets: 1-col, 2-col, 3-col, 2x2, master+stack
|
||||||
- [ ] Save/restore layout to SQLite
|
- [ ] Save/restore layout to SQLite (Phase 4)
|
||||||
- [ ] Keyboard: Ctrl+1-4 focus pane, Ctrl+Shift+arrows move
|
- [x] Keyboard: Ctrl+1-4 focus pane, Ctrl+N new terminal
|
||||||
|
|
||||||
### Terminal
|
### Terminal
|
||||||
- [ ] xterm.js with Canvas addon (explicit — no WebGL dependency)
|
- [x] xterm.js with Canvas addon (explicit — no WebGL dependency)
|
||||||
- [ ] Catppuccin Mocha theme for xterm.js
|
- [x] Catppuccin Mocha theme for xterm.js
|
||||||
- [ ] PTY spawn from Rust (portable-pty), stream to frontend via Tauri events
|
- [x] PTY spawn from Rust (portable-pty), stream to frontend via Tauri events
|
||||||
- [ ] Terminal resize -> PTY resize
|
- [x] Terminal resize -> PTY resize (100ms debounce)
|
||||||
- [ ] Copy/paste (Ctrl+Shift+C/V)
|
- [x] Copy/paste (Ctrl+Shift+C/V) — via attachCustomKeyEventHandler
|
||||||
- [ ] SSH session: spawn `ssh` command in PTY
|
- [x] SSH session: spawn `ssh` command in PTY (via shell args)
|
||||||
- [ ] Local shell: spawn user's $SHELL
|
- [x] Local shell: spawn user's $SHELL
|
||||||
- [ ] Claude Code CLI: spawn `claude` in PTY (fallback mode)
|
- [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.
|
**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()`
|
### Backend
|
||||||
- [ ] Sidecar communication: Rust spawns Node.js, stdio NDJSON
|
- [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)
|
||||||
- [ ] Sidecar lifecycle: spawn on demand, detect crash, restart
|
- [x] Sidecar communication: Rust spawns Node.js, stdio NDJSON
|
||||||
- [ ] SDK message adapter (abstraction layer)
|
- [x] Sidecar lifecycle: auto-start on app launch, shutdown on exit
|
||||||
- [ ] Agent pane: renders structured messages
|
- [x] Sidecar lifecycle: detect crash, offer restart in UI (agent_restart command + restart button)
|
||||||
- Text -> markdown rendered
|
- [x] Tauri commands: agent_query, agent_stop, agent_ready, agent_restart
|
||||||
- Tool calls -> collapsible cards (tool name + input + output)
|
|
||||||
- Subagent spawn -> tree node + optional new pane
|
### Frontend
|
||||||
- Errors -> highlighted error card
|
- [x] SDK message adapter: parses stream-json into 9 typed AgentMessage types (abstraction layer)
|
||||||
- Cost/tokens -> pane header metrics
|
- [x] Agent bridge: Tauri IPC adapter (invoke + event listeners)
|
||||||
- [ ] Auto-scroll with scroll-lock on user scroll-up
|
- [x] Agent dispatcher: singleton routing sidecar events to store, crash detection
|
||||||
- [ ] Agent status indicator (running/thinking/waiting/done/error)
|
- [x] Agent store: session state, message history, cost tracking (Svelte 5 $state)
|
||||||
- [ ] Start/stop/cancel agent from UI
|
- [x] Agent pane: renders structured messages
|
||||||
- [ ] Session resume (SDK `resume: sessionId`)
|
- [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.
|
**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
|
### Sessions
|
||||||
- [ ] SQLite persistence for sessions (rusqlite)
|
- [x] SQLite persistence for sessions (rusqlite with bundled feature)
|
||||||
- [ ] Session types: SSH, Claude CLI, Agent SDK, Local Shell
|
- [x] Session types: terminal, agent, markdown (SSH via terminal args)
|
||||||
- [ ] Session CRUD in sidebar
|
- [x] Session CRUD: save, delete, update_title, touch (last_used_at)
|
||||||
- [ ] Session groups/folders
|
- [x] Session groups/folders — group_name column, setPaneGroup, grouped sidebar with collapsible headers
|
||||||
- [ ] Remember last layout on restart
|
- [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
|
### Markdown Viewer
|
||||||
- [ ] File watcher (notify crate) -> Tauri events -> frontend
|
- [x] File watcher (notify crate v6) -> Tauri events -> frontend
|
||||||
- [ ] Markdown rendering (marked.js or remark)
|
- [x] Markdown rendering (marked.js)
|
||||||
- [ ] Syntax highlighting (Shiki)
|
- [x] Syntax highlighting (Shiki) — added in Phase 5 (highlight.ts, 13 preloaded languages)
|
||||||
- [ ] Open from sidebar or from agent output file references
|
- [x] Open from sidebar (file picker button "M")
|
||||||
- [ ] Debounce file watcher (200ms)
|
- [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.
|
**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)
|
- [x] Agent tree visualization (SVG, compact horizontal layout) — AgentTree.svelte + agent-tree.ts utility
|
||||||
- [ ] Click tree node -> focus agent pane
|
- [x] Click tree node -> scroll to message (handleTreeNodeClick in AgentPane, scrollIntoView smooth)
|
||||||
- [ ] Aggregate cost per subtree
|
- [x] Aggregate cost per subtree (subtreeCost displayed in yellow below each tree node label)
|
||||||
- [ ] Global status bar (total cost, active agents, uptime)
|
- [x] Terminal copy/paste (Ctrl+Shift+C/V via attachCustomKeyEventHandler)
|
||||||
- [ ] Notification system (agent done, error)
|
- [x] Terminal theme hot-swap (onThemeChange callback registry in theme.svelte.ts, TerminalPane subscribes)
|
||||||
- [ ] Global keyboard shortcuts
|
- [x] Pane drag-resize handles (splitter overlays in TilingGrid with mouse drag)
|
||||||
- [ ] Settings dialog
|
- [x] Session resume (follow-up prompt, resume_session_id to SDK)
|
||||||
- [ ] ctx integration (port from v1)
|
- [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)
|
- [x] install-v2.sh — build-from-source installer with dependency checks (Node.js 20+, Rust 1.77+, system libs)
|
||||||
- [ ] AppImage build (single file, works everywhere)
|
- Checks: WebKit2GTK, GTK3, GLib, libayatana-appindicator, librsvg, openssl, build-essential, pkg-config, curl, wget, FUSE
|
||||||
- [ ] .deb package (Debian/Ubuntu)
|
- Prompts to install missing packages via apt
|
||||||
- [ ] GitHub Actions CI for building releases
|
- Builds with `npx tauri build`, installs binary as `bterminal-v2` in `~/.local/bin/`
|
||||||
- [ ] Auto-update mechanism (Tauri updater)
|
- Creates desktop entry and installs SVG icon
|
||||||
- [ ] Migrate bterminal.svg icon
|
- [x] Tauri bundle configuration — targets: `["deb", "appimage"]`, category: DeveloperTool
|
||||||
- [ ] README update
|
- .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
|
### System Requirements
|
||||||
- Node.js 20+ (for Agent SDK sidecar)
|
- Node.js 20+ (for Agent SDK sidecar)
|
||||||
|
- Rust 1.77+ (for building from source)
|
||||||
- WebKit2GTK 4.1+ (Tauri runtime)
|
- WebKit2GTK 4.1+ (Tauri runtime)
|
||||||
- Linux x86_64 (primary target)
|
- 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
|
|
@ -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
|
||||||
272
docs/progress.md
|
|
@ -1,37 +1,249 @@
|
||||||
# BTerminal v2 — Progress Log
|
# 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)
|
### Session: 2026-03-09 — AgentPane + MarkdownPane UI Redesign
|
||||||
- [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)
|
#### Tribunal-Elected Design (S-3-R4, 88% confidence)
|
||||||
- Decision: **Tauri 2.x + Solid.js + Claude Agent SDK + xterm.js**
|
- [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
|
||||||
- Rationale documented in task_plan.md Phase 0
|
- [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)
|
### Session: 2026-03-06 (continued) — Sidecar Env Var Bug Fix
|
||||||
- [x] Spawned devil's advocate agent to attack the plan
|
|
||||||
- [x] Identified 5 fatal/critical issues:
|
#### CLAUDE* Environment Variable Leak (critical fix)
|
||||||
1. Node.js sidecar requirement unacknowledged
|
- [x] Diagnosed silent hang in agent sessions when BTerminal launched from Claude Code terminal
|
||||||
2. SDK 0.2.x instability — need abstraction layer
|
- [x] Root cause: Claude Code sets ~8 CLAUDE* env vars for nesting/sandbox detection
|
||||||
3. Three-tier observation overengineered → simplified to two-tier
|
- [x] Fixed both sidecar runners to filter out all keys starting with 'CLAUDE'
|
||||||
4. Solid.js ecosystem too small → switched to Svelte 5
|
|
||||||
5. Missing: packaging, error handling, testing, responsive design
|
### Session: 2026-03-06 (continued) — Sidecar SDK Migration
|
||||||
- [x] Revised plan (Rev 2) incorporating all corrections
|
|
||||||
- [x] Added error handling strategy table
|
#### Migration from CLI Spawning to Agent SDK
|
||||||
- [x] Added testing strategy table
|
- [x] Diagnosed root cause: claude CLI v2.1.69 hangs with piped stdio (bug #6775)
|
||||||
- [x] Defined MVP boundary (Phases 1-4)
|
- [x] Migrated both runners to @anthropic-ai/claude-agent-sdk query() function
|
||||||
- [x] Added responsive layout requirement (1920px degraded mode)
|
- [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.3–3.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
|
### Next Steps
|
||||||
- [ ] Present plan to user for review and decision
|
- [ ] Real-world relay testing (2 machines)
|
||||||
- [ ] Create feature branch
|
- [ ] TLS/certificate pinning for relay connections
|
||||||
- [ ] Begin Phase 1: Project scaffolding
|
- [ ] Test agent teams with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
|
||||||
|
|
|
||||||
62
docs/provider-adapter/findings.md
Normal 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. |
|
||||||
95
docs/provider-adapter/progress.md
Normal 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).
|
||||||
134
docs/provider-adapter/task_plan.md
Normal 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
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
## Goal
|
## 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.
|
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)
|
- 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
|
- 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.
|
- **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.
|
||||||
- **Future:** Could replace Node.js with Deno (single binary, no npm) for better packaging.
|
- **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
|
### SDK Abstraction Layer
|
||||||
|
|
||||||
|
|
@ -103,10 +104,11 @@ When SDK changes its message format, only the adapter needs updating.
|
||||||
|
|
||||||
## Implementation Phases
|
## 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)
|
- **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 |
|
| 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 |
|
| 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 |
|
| 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
|
## 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.
|
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?** Remote agents via WebSocket. Phase 7+ feature.
|
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?** Experimental Anthropic feature. Natural fit but adds complexity. Phase 7+.
|
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.
|
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
|
## Error Handling Strategy
|
||||||
|
|
@ -155,4 +188,9 @@ See [phases.md](phases.md) for the full phased implementation plan (Phases 1-6).
|
||||||
|
|
||||||
## Errors Encountered
|
## 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
|
|
@ -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
110
docs/v3-release-notes.md
Normal 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 (2K–20K 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.5x–3x)
|
||||||
|
- 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 (1s–30s, 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
||||||
6780
v2/Cargo.lock
generated
Normal file
3
v2/Cargo.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[workspace]
|
||||||
|
members = ["src-tauri", "bterminal-core", "bterminal-relay"]
|
||||||
|
resolver = "2"
|
||||||
47
v2/README.md
Normal 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)
|
||||||
|
```
|
||||||
15
v2/bterminal-core/Cargo.toml
Normal 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"
|
||||||
209
v2/bterminal-core/src/config.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
5
v2/bterminal-core/src/event.rs
Normal 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);
|
||||||
|
}
|
||||||
6
v2/bterminal-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
pub mod config;
|
||||||
|
pub mod event;
|
||||||
|
pub mod pty;
|
||||||
|
pub mod sandbox;
|
||||||
|
pub mod sidecar;
|
||||||
|
pub mod supervisor;
|
||||||
173
v2/bterminal-core/src/pty.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
277
v2/bterminal-core/src/sandbox.rs
Normal 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.13–6.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
495
v2/bterminal-core/src/sidecar.rs
Normal 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
684
v2/bterminal-core/src/supervisor.rs
Normal 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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
24
v2/bterminal-relay/Cargo.toml
Normal 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"] }
|
||||||
441
v2/bterminal-relay/src/main.rs
Normal 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
|
|
@ -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
63
v2/package.json
Normal 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
|
|
@ -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
|
||||||
209
v2/sidecar/agent-runner-deno.ts
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target/
|
||||||
|
/gen/schemas
|
||||||
45
v2/src-tauri/Cargo.toml
Normal 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
|
|
@ -0,0 +1,3 @@
|
||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
12
v2/src-tauri/capabilities/default.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
v2/src-tauri/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
v2/src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
v2/src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
v2/src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
BIN
v2/src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
v2/src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
v2/src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
v2/src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
v2/src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
v2/src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
v2/src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
v2/src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
v2/src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
v2/src-tauri/icons/icon.icns
Normal file
BIN
v2/src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
v2/src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
1783
v2/src-tauri/src/btmsg.rs
Normal file
766
v2/src-tauri/src/bttask.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
v2/src-tauri/src/commands/agent.rs
Normal 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(())
|
||||||
|
}
|
||||||
120
v2/src-tauri/src/commands/btmsg.rs
Normal 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)
|
||||||
|
}
|
||||||
43
v2/src-tauri/src/commands/bttask.rs
Normal 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)
|
||||||
|
}
|
||||||
158
v2/src-tauri/src/commands/claude.rs
Normal 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}"))
|
||||||
|
}
|
||||||
130
v2/src-tauri/src/commands/files.rs
Normal 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()))
|
||||||
|
}
|
||||||
16
v2/src-tauri/src/commands/groups.rs
Normal 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)
|
||||||
|
}
|
||||||
67
v2/src-tauri/src/commands/knowledge.rs
Normal 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)
|
||||||
|
}
|
||||||
46
v2/src-tauri/src/commands/misc.rs
Normal 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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
17
v2/src-tauri/src/commands/mod.rs
Normal 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;
|
||||||
8
v2/src-tauri/src/commands/notifications.rs
Normal 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)
|
||||||
|
}
|
||||||
109
v2/src-tauri/src/commands/persistence.rs
Normal 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)
|
||||||
|
}
|
||||||
20
v2/src-tauri/src/commands/plugins.rs
Normal 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)
|
||||||
|
}
|
||||||
33
v2/src-tauri/src/commands/pty.rs
Normal 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)
|
||||||
|
}
|
||||||
65
v2/src-tauri/src/commands/remote.rs
Normal 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
|
||||||
|
}
|
||||||