Compare commits
10 commits
9a295c224c
...
19e4a68f22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19e4a68f22 | ||
|
|
92000f2d6d | ||
|
|
a70d45ad21 | ||
|
|
662cda2daf | ||
|
|
97abd8a434 | ||
|
|
23b4d0cf26 | ||
|
|
949d90887d | ||
|
|
a89e2b9f69 | ||
|
|
60e2bfb857 | ||
|
|
7f005db94f |
47 changed files with 3575 additions and 1853 deletions
|
|
@ -3,30 +3,29 @@
|
|||
## Workflow
|
||||
|
||||
- v1 is a single-file Python app (`bterminal.py`). Changes are localized.
|
||||
- v2 docs are in `docs/`. Architecture decisions are in `docs/task_plan.md`.
|
||||
- v2 docs are in `docs/`. Architecture in `docs/architecture.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`.
|
||||
- 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 (Web Worker sandbox, 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, SPKI pinning (TOFU), WAL checkpoint (5min), subagent delegation fix, plugin sandbox tests (26), SidecarManager actor pattern, per-message btmsg acknowledgment, Aider autonomous mode. 507 vitest + 110 cargo + 109 E2E.
|
||||
- Consult Memora (tag: `bterminal`) before making architectural changes.
|
||||
|
||||
## Documentation References
|
||||
|
||||
- Architecture & decisions: [docs/task_plan.md](../docs/task_plan.md)
|
||||
- System architecture: [docs/architecture.md](../docs/architecture.md)
|
||||
- Architecture decisions: [docs/decisions.md](../docs/decisions.md)
|
||||
- Sidecar architecture: [docs/sidecar.md](../docs/sidecar.md)
|
||||
- Multi-agent orchestration: [docs/orchestration.md](../docs/orchestration.md)
|
||||
- Production hardening: [docs/production.md](../docs/production.md)
|
||||
- Implementation phases: [docs/phases.md](../docs/phases.md)
|
||||
- Research findings: [docs/findings.md](../docs/findings.md)
|
||||
- Progress log: [docs/progress.md](../docs/progress.md)
|
||||
- v3 architecture: [docs/v3-task_plan.md](../docs/v3-task_plan.md)
|
||||
- v3 findings: [docs/v3-findings.md](../docs/v3-findings.md)
|
||||
- v3 progress: [docs/v3-progress.md](../docs/v3-progress.md)
|
||||
- Progress logs: [docs/progress/](../docs/progress/)
|
||||
|
||||
## Rules
|
||||
|
||||
- Do not modify v1 code (`bterminal.py`) unless explicitly asked — it is production-stable.
|
||||
- v2/v3 work goes on the `dexter_changes` branch (repo: agent-orchestrator), not master.
|
||||
- v2 architecture decisions must reference `docs/task_plan.md` Decisions Log.
|
||||
- v3 architecture decisions must reference `docs/v3-task_plan.md` Decisions Log.
|
||||
- When adding new decisions, append to the Decisions Log table with date.
|
||||
- Update `docs/progress.md` after each significant work session.
|
||||
- v2/v3 work goes on the `hib_changes` branch (repo: agent-orchestrator), not master.
|
||||
- Architecture decisions must reference `docs/decisions.md`.
|
||||
- When adding new decisions, append to the appropriate category table with date.
|
||||
- Update `docs/progress/` after each significant work session.
|
||||
|
||||
## Key Technical Constraints
|
||||
|
||||
|
|
@ -85,7 +84,7 @@
|
|||
- 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.
|
||||
- v3 component tree: App -> GlobalTabBar (settings icon) + sidebar-panel? (SettingsTab) + workspace (ProjectGrid) + StatusBar. See `docs/architecture.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.
|
||||
|
|
|
|||
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -8,6 +8,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Plugin sandbox Web Worker migration** — replaced `new Function()` sandbox with dedicated Web Worker per plugin. True process-level isolation — no DOM, no Tauri IPC, no main-thread access. Permission-gated API proxied via postMessage with RPC pattern. 26 tests (MockWorker class in vitest)
|
||||
- **seen_messages startup pruning** — `pruneSeen()` called fire-and-forget in App.svelte onMount. Removes entries older than 7 days (emergency: 3 days at 200k rows)
|
||||
- **Aider autonomous mode toggle** — per-project `autonomousMode` setting ('restricted'|'autonomous') gates shell command execution in Aider sidecar. Default restricted. SettingsTab toggle
|
||||
- **SPKI certificate pinning (TOFU)** — `remote_probe_spki` Tauri command + `probe_spki_hash()` extracts relay TLS certificate SPKI hash. `remote_add_pin`/`remote_remove_pin` commands. In-memory pin store in RemoteManager
|
||||
- **Per-message btmsg acknowledgment** — `seen_messages` table with session-scoped tracking replaces count-based polling. `btmsg_unseen_messages`, `btmsg_mark_seen`, `btmsg_prune_seen` commands. ON DELETE CASCADE cleanup
|
||||
- **Aider parser test suite** — 72 vitest tests for extracted `aider-parser.ts` (pure parsing functions). 8 realistic Aider output fixtures. Covers prompt detection, suppression, turn parsing, cost extraction, shell execution, format-drift canaries
|
||||
- **Dead code wiring** — 4 orphaned Rust functions wired as Tauri commands: `btmsg_get_agent_heartbeats`, `btmsg_queue_dead_letter`, `search_index_task`, `search_index_btmsg`
|
||||
|
||||
### Security
|
||||
- **Plugin sandbox hardened** — `new Function()` (same-realm, escapable via prototype walking) replaced with Web Worker (separate JS context, no escape vectors). Eliminates `arguments.callee.constructor` and `({}).constructor.constructor` attack paths
|
||||
|
||||
### Changed
|
||||
- **SidecarManager actor refactor** — replaced `Arc<Mutex<HashMap>>` with dedicated actor thread via `std::sync::mpsc` channel. Eliminates TOCTOU race conditions on session lifecycle. All mutable state owned by single thread
|
||||
- **Aider parser extraction** — pure functions (`looksLikePrompt`, `parseTurnOutput`, `extractSessionCost`, etc.) extracted from `aider-runner.ts` to `aider-parser.ts` for testability. Runner imports from parser module
|
||||
|
||||
### Fixed
|
||||
- **groups.rs test failure** — `test_groups_roundtrip` missing 9 Option fields added in P1-P10 (provider, model, use_worktrees, sandbox_enabled, anchor_budget_scale, stall_threshold_min, is_agent, agent_role, system_prompt)
|
||||
- **remote_probe_spki tracing skip mismatch** — `#[tracing::instrument(skip(state))]` referenced non-existent parameter name. Removed unused State parameter
|
||||
|
||||
### Added
|
||||
- **Comprehensive documentation suite** — 4 new docs: `architecture.md` (end-to-end system architecture with component hierarchy, data flow, IPC patterns), `sidecar.md` (multi-provider runner lifecycle, env stripping, NDJSON protocol, build pipeline), `orchestration.md` (btmsg messaging, bttask kanban, agent roles, wake scheduler, session anchors, health monitoring), `production.md` (sidecar supervisor, Landlock sandbox, FTS5 search, plugin system, secrets management, notifications, audit logging, error classification, telemetry)
|
||||
- **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
|
||||
|
|
@ -305,7 +326,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- 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
|
||||
- v3 Mission Control redesign planning: architecture docs (`docs/architecture.md`, `docs/decisions.md`, `docs/findings.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
|
||||
|
|
|
|||
18
CLAUDE.md
18
CLAUDE.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## 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.
|
||||
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 (Web Worker sandbox, 26 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, SPKI certificate pinning (TOFU), WAL checkpoint (5min), subagent delegation fix, SidecarManager actor pattern (mpsc), per-message btmsg acknowledgment (seen_messages), Aider autonomous mode toggle.
|
||||
|
||||
- **Repository:** github.com/DexterFromLab/BTerminal
|
||||
- **License:** MIT
|
||||
|
|
@ -21,15 +21,13 @@ Terminal emulator with SSH and Claude Code session management. v1 (GTK3+VTE Pyth
|
|||
| `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/architecture.md` | End-to-end system architecture, data model, layout system |
|
||||
| `docs/decisions.md` | Architecture decisions log with rationale and dates |
|
||||
| `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/findings.md` | All research findings (v2 + v3 combined) |
|
||||
| `docs/progress/` | Session progress logs (v2, v3, archive) |
|
||||
| `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/release-notes.md` | v3.0 release notes |
|
||||
| `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 |
|
||||
|
|
@ -121,7 +119,7 @@ Terminal emulator with SSH and Claude Code session management. v1 (GTK3+VTE Pyth
|
|||
| `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/plugins/plugin-host.ts` | Sandboxed plugin runtime (Web Worker isolation, permission-gated API via postMessage, 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) |
|
||||
|
|
@ -151,6 +149,8 @@ Terminal emulator with SSH and Claude Code session management. v1 (GTK3+VTE Pyth
|
|||
| `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/aider-parser.ts` | Aider output parser (pure functions: looksLikePrompt, parseTurnOutput, extractSessionCost, execShell) |
|
||||
| `v2/sidecar/aider-parser.test.ts` | Vitest tests for Aider parser (72 tests: prompt detection, turn parsing, cost extraction, format-drift canaries) |
|
||||
| `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) |
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ Agent Orchestrator lets you run multiple Claude Code agents in parallel, organiz
|
|||
- **ctx integration** — SQLite context database for cross-session memory
|
||||
|
||||
### Testing
|
||||
- **444 vitest** + **151 cargo** + **109 E2E** tests
|
||||
- **516 vitest** + **159 cargo** + **109 E2E** tests
|
||||
- **E2E engine** — WebDriverIO + tauri-driver, Phase A/B/C scenarios
|
||||
- **LLM judge** — dual-mode CLI/API for semantic assertion (claude-haiku)
|
||||
- **CI** — GitHub Actions with xvfb + LLM-judged test gating
|
||||
|
|
@ -130,9 +130,9 @@ cd v2 && cargo build --release -p bterminal-relay
|
|||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [docs/v3-task_plan.md](docs/v3-task_plan.md) | Architecture decisions and strategies |
|
||||
| [docs/v3-progress.md](docs/v3-progress.md) | Session progress log |
|
||||
| [docs/v3-release-notes.md](docs/v3-release-notes.md) | v3.0 release notes |
|
||||
| [docs/decisions.md](docs/decisions.md) | Architecture decisions log |
|
||||
| [docs/progress/](docs/progress/) | Session progress logs (v2, v3, archive) |
|
||||
| [docs/release-notes.md](docs/release-notes.md) | v3.0 release notes |
|
||||
| [docs/e2e-testing.md](docs/e2e-testing.md) | E2E testing facility documentation |
|
||||
| [docs/multi-machine.md](docs/multi-machine.md) | Multi-machine relay architecture |
|
||||
|
||||
|
|
|
|||
44
TODO.md
44
TODO.md
|
|
@ -1,26 +1,28 @@
|
|||
# BTerminal -- TODO
|
||||
# Agent Orchestrator — TODO
|
||||
|
||||
## Active
|
||||
## Multi-Machine (v3.1)
|
||||
|
||||
### 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.
|
||||
- [ ] **Real-world relay testing** — TLS added, code complete in bridges/stores. Needs 2-machine test to verify relay + RemoteManager end-to-end. Multi-machine UI not yet surfaced in v3 ProjectBox.
|
||||
- [ ] **SPKI pin persistence** — TOFU pinning implemented (probe_spki_hash + in-memory pin store in RemoteManager), but pins are lost on restart. Persist to groups.json or separate config file.
|
||||
|
||||
## Multi-Agent (v3.1)
|
||||
|
||||
- [ ] **Agent Teams real-world testing** — Subagent delegation prompt + `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` env injection done. Needs real multi-agent session to verify Manager spawns child agents via SDK teams.
|
||||
|
||||
## Reliability
|
||||
|
||||
- [ ] **Soak test** — Run 4-hour soak with 6+ agents across 3+ projects. Monitor: memory growth, SQLite WAL size, xterm.js instance count, sidecar supervisor restarts.
|
||||
- [ ] **WebKit2GTK Worker verification** — Verify Web Worker Blob URL approach works in Tauri's WebKit2GTK webview (tested in vitest only so far).
|
||||
|
||||
## Completed
|
||||
|
||||
- [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
|
||||
- [x] **Brand Dexter's new types (SOLID Phase 3b)** -- GroupId + AgentId branded types. Applied to ~40 sites. 332 vitest + 72 cargo. | Done: 2026-03-11
|
||||
- [x] **Regression tests + sidecar env security** -- 49 new tests. Added ANTHROPIC_* to Rust env strip. 327 vitest + 72 cargo. | Done: 2026-03-11
|
||||
- [x] **Integrate dexter_changes + fix 5 critical bugs** -- Fixed: btmsg.rs column index, btmsg-bridge camelCase, GroupAgentsPanel stopPropagation, ArchitectureTab PlantUML, TestingTab Tauri 2.x. | Done: 2026-03-11
|
||||
- [x] **SOLID Phase 3 — Primitive obsession** -- Branded types SessionId/ProjectId. Applied to ~130 sites. 293 vitest + 49 cargo. | Done: 2026-03-11
|
||||
- [x] Plugin sandbox migration — new Function() → Web Worker isolation, 26 tests | Done: 2026-03-15
|
||||
- [x] seen_messages startup pruning — pruneSeen() on app startup, fire-and-forget | Done: 2026-03-15
|
||||
- [x] Tribunal priorities: Aider security, SidecarManager actor, SPKI pinning, btmsg reliability, Aider tests | Done: 2026-03-14
|
||||
- [x] Dead code cleanup — 7 warnings resolved, 4 new Tauri commands wired | Done: 2026-03-14
|
||||
- [x] E2E fixture + judge hardening | Done: 2026-03-12
|
||||
- [x] LLM judge refactor + E2E docs | Done: 2026-03-12
|
||||
- [x] v3 Hardening Sprint (TLS, WAL, Landlock, plugin tests, Phase C E2E) | Done: 2026-03-12
|
||||
- [x] v3 Production Readiness — all 13 tribunal items | Done: 2026-03-12
|
||||
- [x] Unified test runner + testing gate rule | Done: 2026-03-12
|
||||
- [x] E2E Phase B + 27 test fixes | Done: 2026-03-12
|
||||
|
|
|
|||
|
|
@ -26,9 +26,8 @@ The application has three major version milestones:
|
|||
|
||||
| Document | What It Covers |
|
||||
|----------|---------------|
|
||||
| [architecture.md](architecture.md) | End-to-end system architecture: Rust backend, Svelte frontend, sidecar layer, data flow, IPC patterns |
|
||||
| [v3-task_plan.md](v3-task_plan.md) | v3 Mission Control architecture decisions, adversarial review, data model, component tree, layout system, 10-phase plan |
|
||||
| [task_plan.md](task_plan.md) | v2 architecture decisions, technology choices, error handling strategy, testing strategy |
|
||||
| [architecture.md](architecture.md) | End-to-end system architecture: Rust backend, Svelte frontend, sidecar layer, data model, layout system, data flow, IPC patterns |
|
||||
| [decisions.md](decisions.md) | Architecture decisions log: rationale and dates for all major design choices |
|
||||
| [multi-machine.md](multi-machine.md) | Multi-machine relay architecture: bterminal-core extraction, bterminal-relay binary, RemoteManager, WebSocket protocol, reconnection |
|
||||
|
||||
### Subsystem Guides
|
||||
|
|
@ -45,22 +44,21 @@ The application has three major version milestones:
|
|||
| Document | What It Covers |
|
||||
|----------|---------------|
|
||||
| [phases.md](phases.md) | v2 implementation phases (1-7 + multi-machine A-D + profiles/skills) with checklists |
|
||||
| [v3-progress.md](v3-progress.md) | v3 session-by-session progress log (All Phases 1-10 + production hardening) |
|
||||
| [progress.md](progress.md) | v2 session-by-session progress log (recent sessions) |
|
||||
| [progress-archive.md](progress-archive.md) | Archived v2 progress (2026-03-05 to 2026-03-06 early) |
|
||||
| [progress/v3.md](progress/v3.md) | v3 session-by-session progress log (Phases 1-10 + production hardening) |
|
||||
| [progress/v2.md](progress/v2.md) | v2 session-by-session progress log (recent sessions) |
|
||||
| [progress/v2-archive.md](progress/v2-archive.md) | Archived v2 progress (2026-03-05 to 2026-03-06 early) |
|
||||
|
||||
### Research & Analysis
|
||||
|
||||
| Document | What It Covers |
|
||||
|----------|---------------|
|
||||
| [findings.md](findings.md) | v2 research: Claude Agent SDK, Tauri+xterm.js, terminal performance, Zellij architecture, ultrawide design patterns |
|
||||
| [v3-findings.md](v3-findings.md) | v3 research: adversarial architecture review, production hardening analysis, provider adapter coupling map, session anchor design |
|
||||
| [findings.md](findings.md) | All research: Claude Agent SDK, Tauri+xterm.js, terminal performance, adversarial review, provider coupling, codebase reuse, session anchors, multi-agent design, theme evolution, performance measurements |
|
||||
|
||||
### Release
|
||||
### Release & Testing
|
||||
|
||||
| Document | What It Covers |
|
||||
|----------|---------------|
|
||||
| [v3-release-notes.md](v3-release-notes.md) | v3.0 release notes: feature summary, breaking changes, test coverage, known limitations |
|
||||
| [release-notes.md](release-notes.md) | v3.0 release notes: feature summary, breaking changes, test coverage, known limitations |
|
||||
| [e2e-testing.md](e2e-testing.md) | E2E testing facility: WebDriverIO fixtures, test mode, LLM judge, CI integration, troubleshooting |
|
||||
|
||||
---
|
||||
|
|
@ -70,12 +68,12 @@ The application has three major version milestones:
|
|||
If you are new to this codebase, read the documents in this order:
|
||||
|
||||
1. **[architecture.md](architecture.md)** — Understand how the pieces fit together
|
||||
2. **[v3-task_plan.md](v3-task_plan.md)** — Understand the design decisions behind v3
|
||||
2. **[decisions.md](decisions.md)** — Understand why things are built the way they are
|
||||
3. **[sidecar.md](sidecar.md)** — Understand how agent sessions actually run
|
||||
4. **[orchestration.md](orchestration.md)** — Understand multi-agent coordination
|
||||
5. **[e2e-testing.md](e2e-testing.md)** — Understand how to test changes
|
||||
|
||||
For v2-specific context (the foundation that v3 builds on), read [task_plan.md](task_plan.md) and [findings.md](findings.md).
|
||||
For research context, read [findings.md](findings.md). For implementation history, see [phases.md](phases.md) and [progress/](progress/).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -320,6 +320,203 @@ Key-value store for user preferences: theme, fonts, shell, CWD, provider setting
|
|||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
### Project Group Config (`~/.config/bterminal/groups.json`)
|
||||
|
||||
Human-editable JSON file defining workspaces. Each group contains up to 5 projects. Loaded at startup by `groups.rs`, not hot-reloaded.
|
||||
|
||||
```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/user/code/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 (v3 Additions)
|
||||
|
||||
Beyond the core `sessions` and `settings` tables, v3 added project-scoped agent persistence:
|
||||
|
||||
```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 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
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Layout System
|
||||
|
||||
### Project Grid (Flexbox + scroll-snap)
|
||||
|
||||
Projects are arranged horizontally in a flex container with CSS scroll-snap for clean project-to-project scrolling:
|
||||
|
||||
```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 is computed from viewport width: `Math.min(projects.length, Math.max(1, Math.floor(containerWidth / 520)))`
|
||||
|
||||
### Project Box Internal Layout
|
||||
|
||||
Each project box uses a CSS grid with 4 rows:
|
||||
|
||||
```
|
||||
┌─ ProjectHeader (auto) ─────────────────┐
|
||||
├─────────────────────┬──────────────────┤
|
||||
│ AgentSession │ TeamAgentsPanel │
|
||||
│ (flex: 1) │ (240px/overlay) │
|
||||
├─────────────────────┴──────────────────┤
|
||||
│ [Tab1] [Tab2] [+] TabBar auto │
|
||||
├────────────────────────────────────────┤
|
||||
│ Terminal content (xterm or scrollback) │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Team panel: inline at >2560px viewport (240px wide), overlay at <2560px. Collapsed when no subagents running.
|
||||
|
||||
### Responsive Breakpoints
|
||||
|
||||
| Viewport Width | Visible Projects | Team Panel Mode |
|
||||
|---------------|-----------------|-----------------|
|
||||
| 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
|
||||
|
||||
WebKit2GTK OOMs at ~5 simultaneous xterm.js instances. The budget system manages this:
|
||||
|
||||
| State | xterm.js Instance? | 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. Suspend/resume cycle < 50ms.
|
||||
|
||||
### Project Accent Colors
|
||||
|
||||
Each project slot gets a distinct Catppuccin accent color for visual distinction:
|
||||
|
||||
| Slot | Color | CSS Variable |
|
||||
|------|-------|-------------|
|
||||
| 1 | Blue | `var(--ctp-blue)` |
|
||||
| 2 | Green | `var(--ctp-green)` |
|
||||
| 3 | Mauve | `var(--ctp-mauve)` |
|
||||
| 4 | Peach | `var(--ctp-peach)` |
|
||||
| 5 | Pink | `var(--ctp-pink)` |
|
||||
|
||||
Applied to border tint and header accent via `var(--accent)` CSS custom property set per ProjectBox.
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
Three-layer shortcut system prevents conflicts between terminal input, workspace navigation, and app-level commands:
|
||||
|
||||
| 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+Shift+F | FTS5 search overlay | 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 |
|
||||
|
||||
Terminal layer captures raw keys only when focused. App layer has highest priority.
|
||||
|
||||
---
|
||||
|
||||
## Key Constraints
|
||||
|
||||
1. **WebKit2GTK has no WebGL** — xterm.js must use the Canvas addon explicitly. Maximum 4 active xterm.js instances to avoid OOM.
|
||||
|
|
|
|||
51
docs/decisions.md
Normal file
51
docs/decisions.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Architecture Decisions Log
|
||||
|
||||
This document records significant architecture decisions made during the development of Agent Orchestrator. Each entry captures the decision, its rationale, and the date it was made. Decisions are listed chronologically within each category.
|
||||
|
||||
---
|
||||
|
||||
## Data & Configuration
|
||||
|
||||
| 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 — no hot-reload, no split-brain risk. | 2026-03-07 |
|
||||
| btmsg/bttask shared SQLite DB | Both CLI tools share `~/.local/share/bterminal/btmsg.db`. Single DB simplifies deployment — agents already have the path. Read-only for non-Manager roles via CLI permissions. | 2026-03-11 |
|
||||
|
||||
## Layout & UI
|
||||
|
||||
| Decision | Rationale | Date |
|
||||
|----------|-----------|------|
|
||||
| Adaptive project count from viewport width | `Math.min(projects.length, Math.max(1, Math.floor(containerWidth / 520)))` — 5 at 5120px, 3 at 1920px, scroll-snap for overflow. min-width 480px. Better than forcing 5 at all sizes. | 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. Saves ~240px on smaller screens. | 2026-03-07 |
|
||||
| VSCode-style left sidebar (replaces top tab bar) | Vertical icon rail (2.75rem) + expandable drawer (max 50%) + always-visible workspace. Settings is a regular tab, not a special drawer. ProjectGrid always visible. Ctrl+B toggles. | 2026-03-08 |
|
||||
| CSS relative units (rule 18) | 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 via `var(--accent)`. | 2026-03-07 |
|
||||
|
||||
## Agent Architecture
|
||||
|
||||
| Decision | Rationale | Date |
|
||||
|----------|-----------|------|
|
||||
| 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. Suspend/resume < 50ms. | 2026-03-07 |
|
||||
| AgentPane splits into AgentSession + TeamAgentsPanel | Team agents shown inline in right panel, not as separate panes. Saves xterm/pane slots. | 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. | 2026-03-11 |
|
||||
| Role-specific tabs via conditional rendering | Manager=Tasks, Architect=Arch, Tester=Selenium+Tests, Reviewer=Tasks. 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 |
|
||||
|
||||
## Themes & Typography
|
||||
|
||||
| Decision | Rationale | Date |
|
||||
|----------|-----------|------|
|
||||
| All 17 themes map to `--ctp-*` CSS vars | 4 Catppuccin + 7 Editor + 6 Deep Dark themes. All map to same 26 CSS custom properties — zero component changes when adding themes. Pure data operation. | 2026-03-07 |
|
||||
| Typography via CSS custom properties | `--ui-font-family`/`--ui-font-size` + `--term-font-family`/`--term-font-size` in `:root`. Restored by `initTheme()` on startup. Persisted as SQLite settings. | 2026-03-07 |
|
||||
|
||||
## System Design
|
||||
|
||||
| Decision | Rationale | Date |
|
||||
|----------|-----------|------|
|
||||
| Keyboard shortcut layers: App > Workspace > Terminal | Prevents conflicts. Terminal captures raw keys only when focused. App layer uses Ctrl+K/G/B. | 2026-03-07 |
|
||||
| Unmount/remount on group switch | Serialize xterm scrollbacks, destroy, remount new group. <100ms perceived. Frees ~80MB per switch. | 2026-03-07 |
|
||||
| Remote machines deferred to v3.1 | Elevate to project level (`project.remote_machine_id`) but don't implement in MVP. Focus on local orchestration first. | 2026-03-07 |
|
||||
391
docs/findings.md
391
docs/findings.md
|
|
@ -1,12 +1,17 @@
|
|||
# BTerminal v2 — Research Findings
|
||||
# Research Findings
|
||||
|
||||
## 1. Claude Agent SDK — The Foundation
|
||||
This document captures research conducted during v2 and v3 development — technology evaluations, architecture reviews, performance measurements, and design analysis. Each finding informed implementation decisions recorded in [decisions.md](decisions.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Claude Agent SDK (v2 Research, 2026-03-05)
|
||||
|
||||
**Source:** https://platform.claude.com/docs/en/agent-sdk/overview
|
||||
|
||||
The Claude Agent SDK (formerly Claude Code SDK, renamed Sept 2025) provides everything we need:
|
||||
The Claude Agent SDK (formerly Claude Code SDK, renamed Sept 2025) provides structured streaming, subagent detection, hooks, and telemetry — everything needed for a rich agent UI without terminal emulation.
|
||||
|
||||
### Streaming API
|
||||
|
||||
```typescript
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
|
|
@ -14,196 +19,380 @@ for await (const message of query({
|
|||
prompt: "Fix the bug",
|
||||
options: { allowedTools: ["Read", "Edit", "Bash"] }
|
||||
})) {
|
||||
// Each message is structured, typed, parseable
|
||||
console.log(message);
|
||||
console.log(message); // structured, typed, parseable
|
||||
}
|
||||
```
|
||||
|
||||
### Subagent Detection
|
||||
|
||||
Messages from subagents include `parent_tool_use_id`:
|
||||
|
||||
```typescript
|
||||
// Check for subagent invocation
|
||||
for (const block of msg.message?.content ?? []) {
|
||||
if (block.type === "tool_use" && block.name === "Task") {
|
||||
console.log(`Subagent invoked: ${block.input.subagent_type}`);
|
||||
}
|
||||
}
|
||||
// Check if message is from within a subagent
|
||||
if (msg.parent_tool_use_id) {
|
||||
console.log("Running inside subagent");
|
||||
}
|
||||
```
|
||||
|
||||
### Session Management
|
||||
|
||||
- `session_id` captured from init message
|
||||
- Resume with `options: { resume: sessionId }`
|
||||
- Subagent transcripts persist independently
|
||||
|
||||
### Hooks
|
||||
|
||||
`PreToolUse`, `PostToolUse`, `Stop`, `SessionStart`, `SessionEnd`, `UserPromptSubmit`
|
||||
|
||||
### Telemetry
|
||||
|
||||
Every `SDKResultMessage` contains: `total_cost_usd`, `duration_ms`, per-model `modelUsage` breakdowns.
|
||||
|
||||
### Key Insight
|
||||
**We don't need terminal emulation for SDK agents.** The SDK gives us structured data — we can render it as rich UI (markdown, diff views, file cards, agent trees) instead of raw terminal text. Terminal emulation (xterm.js) is only needed for SSH, local shell, and legacy Claude CLI sessions.
|
||||
|
||||
The SDK gives structured data — we render it as rich UI (markdown, diff views, file cards, agent trees) instead of raw terminal text. Terminal emulation (xterm.js) is only needed for SSH, local shell, and legacy CLI sessions.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tauri + xterm.js — Proven Stack
|
||||
## 2. Tauri + xterm.js Integration (v2 Research, 2026-03-05)
|
||||
|
||||
### Existing Projects
|
||||
- **tauri-terminal** (github.com/marc2332/tauri-terminal) — basic Tauri + xterm.js + portable-pty
|
||||
- **Terminon** (github.com/Shabari-K-S/terminon) — Tauri v2 + React + xterm.js, SSH profiles, split panes
|
||||
- **terraphim-liquid-glass-terminal** — Tauri + xterm.js with design effects
|
||||
- **tauri-plugin-pty** (github.com/Tnze/tauri-plugin-pty) — PTY plugin for Tauri 2, xterm.js bridge
|
||||
|
||||
- **tauri-terminal** — basic Tauri + xterm.js + portable-pty
|
||||
- **Terminon** — Tauri v2 + React + xterm.js, SSH profiles, split panes
|
||||
- **tauri-plugin-pty** — PTY plugin for Tauri 2, xterm.js bridge
|
||||
|
||||
### Integration Pattern
|
||||
```
|
||||
Frontend (xterm.js) ←→ Tauri IPC ←→ Rust PTY (portable-pty) ←→ Shell/SSH/Claude
|
||||
```
|
||||
- `pty.onData()` → `term.write()` (output)
|
||||
- `term.onData()` → `pty.write()` (input)
|
||||
|
||||
### Tauri IPC Latency
|
||||
- Linux: ~5ms for typical payloads (serialization-free IPC in v2)
|
||||
- For terminal output: irrelevant. Claude outputs text at human-readable speed.
|
||||
- For keystroke echo: 5ms + xterm.js render = ~10-15ms total. Acceptable.
|
||||
```
|
||||
Frontend (xterm.js) <-> Tauri IPC <-> Rust PTY (portable-pty) <-> Shell/SSH/Claude
|
||||
```
|
||||
|
||||
- `pty.onData()` -> `term.write()` (output)
|
||||
- `term.onData()` -> `pty.write()` (input)
|
||||
|
||||
---
|
||||
|
||||
## 3. Terminal Performance Context
|
||||
## 3. Terminal Performance Benchmarks (v2 Research, 2026-03-05)
|
||||
|
||||
### Native Terminal Latency
|
||||
|
||||
### Native Terminal Latency (for reference)
|
||||
| Terminal | Latency | Notes |
|
||||
|---|---|---|
|
||||
|----------|---------|-------|
|
||||
| xterm (native) | ~10ms | Gold standard |
|
||||
| Alacritty | ~12ms | GPU-rendered Rust |
|
||||
| Kitty | ~13ms | GPU-rendered |
|
||||
| VTE (GNOME Terminal) | ~50ms | GTK3/4, spikes above |
|
||||
| Hyper (Electron+xterm.js) | ~40ms | Web-based worst case |
|
||||
|
||||
### Throughput (find /usr benchmark)
|
||||
All within 0.5s of each other: xterm 2.2s, alacritty 2.2s, wezterm 2.8s. "Not meaningfully different to a human."
|
||||
|
||||
### Memory
|
||||
- Alacritty: ~30MB
|
||||
- WezTerm: ~45MB
|
||||
- xterm (native): ~5MB
|
||||
|
||||
### Verdict for BTerminal v2
|
||||
xterm.js in Tauri will be ~20-30ms latency, ~40MB per terminal instance. For Claude sessions (AI output, not vim), this is perfectly fine. The VTE we currently use in GTK3 is actually *slower* at ~50ms.
|
||||
- Alacritty: ~30MB, WezTerm: ~45MB, xterm native: ~5MB
|
||||
|
||||
### Verdict
|
||||
|
||||
xterm.js in Tauri: ~20-30ms latency, ~20MB per instance. For AI output (not vim), perfectly fine. The VTE we used in v1 GTK3 is actually slower at ~50ms.
|
||||
|
||||
---
|
||||
|
||||
## 4. Zellij Architecture (Inspiration)
|
||||
## 4. Zellij Architecture (v2 Inspiration, 2026-03-05)
|
||||
|
||||
**Source:** Research agent findings
|
||||
Zellij uses WASM plugins for extensibility: message passing at WASM boundary, permission model, event types for rendering/input/lifecycle, KDL layout files.
|
||||
|
||||
Zellij uses WASM plugins for extensibility:
|
||||
- Plugins communicate via message passing at WASM boundary
|
||||
- Permission model controls what plugins can access
|
||||
- Event types for rendering, input, lifecycle
|
||||
- Layout defined in KDL files
|
||||
|
||||
**Relevance:** We don't need WASM plugins. Our "plugins" are just different pane types (terminal, agent, markdown). But the layout concept (KDL or JSON layout definitions) is worth borrowing for saved layouts.
|
||||
**Relevance:** We don't need WASM plugins — our "plugins" are different pane types. But the layout concept (JSON layout definitions) is worth borrowing for saved layouts.
|
||||
|
||||
---
|
||||
|
||||
## 5. 32:9 Ultrawide Design Patterns
|
||||
## 5. Ultrawide Design Patterns (v2 Research, 2026-03-05)
|
||||
|
||||
**Key Insight:** 5120px width ÷ ~600px per useful pane = ~8 panes max, ~4-5 comfortable.
|
||||
**Key Insight:** 5120px width / ~600px per pane = ~8 panes max, ~4-5 comfortable.
|
||||
|
||||
**Layout Philosophy:**
|
||||
- Center of screen = primary attention (1-2 main agent panes)
|
||||
- Left edge = navigation (session sidebar, 250-300px)
|
||||
- Center = primary attention (1-2 main agent panes)
|
||||
- Left edge = navigation (sidebar, 250-300px)
|
||||
- Right edge = context (agent tree, file viewer, 350-450px)
|
||||
- Never use tabs for primary content — everything visible
|
||||
- Tabs only for switching between saved layouts
|
||||
|
||||
**Interaction Model:**
|
||||
- Click sidebar session → opens in next available pane slot
|
||||
- Agent spawns subagent → new pane auto-appears (or tree node if panes full)
|
||||
- File reference in agent output → click to open markdown viewer pane
|
||||
- Drag pane borders to resize
|
||||
- Keyboard: Ctrl+1-8 to focus pane, Ctrl+Shift+Arrow to move pane
|
||||
- Tabs only for switching saved layouts
|
||||
|
||||
---
|
||||
|
||||
## 6. Frontend Framework Choice
|
||||
## 6. Frontend Framework Choice (v2 Research, 2026-03-05)
|
||||
|
||||
### Why Svelte 5 (revised from initial Solid.js choice)
|
||||
- **Fine-grained reactivity** — $state/$derived runes match Solid's signals model
|
||||
- **No VDOM** — critical when we have 4-8 panes each streaming data
|
||||
### Why Svelte 5
|
||||
|
||||
- **Fine-grained reactivity** — `$state`/`$derived` runes match Solid's signals model
|
||||
- **No VDOM** — critical when 4-8 panes stream data simultaneously
|
||||
- **Small bundle** — ~5KB runtime vs React's ~40KB
|
||||
- **Larger ecosystem** — more component libraries, xterm.js wrappers, better tooling
|
||||
- **Better TypeScript support** — improved in Svelte 5
|
||||
- **Larger ecosystem** than Solid.js — more component libraries, better tooling
|
||||
|
||||
### Why NOT Solid.js (initially considered)
|
||||
|
||||
### Why NOT Solid.js (initial choice, revised)
|
||||
- Ecosystem too small for production use
|
||||
- Fewer component libraries and integrations
|
||||
- Svelte 5 runes eliminated the ceremony gap
|
||||
|
||||
### NOT React
|
||||
### Why NOT React
|
||||
|
||||
- VDOM reconciliation across 4-8 simultaneously updating panes = CPU waste
|
||||
- Larger bundle
|
||||
- State management complexity (need Redux/Zustand for cross-pane state)
|
||||
- Larger bundle, state management complexity (Redux/Zustand needed)
|
||||
|
||||
---
|
||||
|
||||
## 7. Key Technical Risks
|
||||
## 7. Claude Code CLI Observation (v2 Research, 2026-03-05)
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| **WebKit2GTK has NO WebGL** — xterm.js falls back to Canvas on Linux | Use xterm.js Canvas addon explicitly. For AI output (not vim), Canvas at 60fps is fine. |
|
||||
| xterm.js performance with 4+ instances (Canvas mode) | Lazy init (create xterm only when pane visible), limit to 4-6 active terminals |
|
||||
| Agent SDK TS package may not run in Tauri's webview | Run SDK in Rust sidecar process, stream to frontend via Tauri events |
|
||||
| Tauri IPC bottleneck with high-throughput agent output | Batch messages, use Tauri events (push) not commands (pull) |
|
||||
| File watcher flooding on rapid saves | Debounce 200ms in Rust before sending to frontend |
|
||||
| Layout state persistence across restarts | SQLite for sessions + layout, atomic writes |
|
||||
| Tauri multi-webview behind `unstable` flag | Single webview with CSS Grid panes, not multiple webviews |
|
||||
Three observation tiers for Claude sessions:
|
||||
|
||||
---
|
||||
1. **SDK sessions** (best): Full structured streaming, subagent detection, hooks, cost tracking
|
||||
2. **CLI with stream-json** (good): `claude -p "prompt" --output-format stream-json` — structured output but non-interactive
|
||||
3. **Interactive CLI** (fallback): Tail JSONL session files at `~/.claude/projects/<encoded-dir>/<session-uuid>.jsonl` + show terminal via xterm.js
|
||||
|
||||
## 8. Claude Code CLI Observation (Alternative to SDK)
|
||||
### JSONL Session Files
|
||||
|
||||
**Critical discovery:** We can observe ANY running Claude Code session (even interactive CLI ones) via two mechanisms:
|
||||
Path encoding: `/home/user/project` -> `-home-user-project`. Append-only, written immediately. Can be `tail -f`'d for external observation.
|
||||
|
||||
### A. `stream-json` output mode
|
||||
```bash
|
||||
claude -p "fix the bug" --output-format stream-json
|
||||
```
|
||||
Emits typed events: `stream_event`, `assistant`, `user`, `system` (init carries session_id), `result`.
|
||||
### Hooks (SDK only)
|
||||
|
||||
### B. JSONL session file tailing
|
||||
Session files live at `~/.claude/projects/<encoded-dir-path>/<session-uuid>.jsonl`. Append-only, written immediately. Can be `tail -f`'d for external observation.
|
||||
|
||||
Path encoding: `/home/user/project` → `-home-user-project`
|
||||
|
||||
### C. Hooks (SDK only)
|
||||
`SubagentStart`, `SubagentStop` (gives `agent_transcript_path`), `PreToolUse`, `PostToolUse`, `Stop`, `Notification`, `TeammateIdle`
|
||||
|
||||
### Implication for BTerminal v2
|
||||
**Three observation tiers:**
|
||||
1. **SDK sessions** (best): Full structured streaming, subagent detection, hooks, cost tracking
|
||||
2. **CLI sessions with stream-json** (good): Structured output, but requires spawning claude with `-p` flag (non-interactive)
|
||||
3. **Interactive CLI sessions** (fallback): Tail JSONL session files + show terminal via xterm.js
|
||||
|
||||
---
|
||||
|
||||
## 9. Agent Teams (Experimental)
|
||||
## 8. Agent Teams (v2 Research, 2026-03-05)
|
||||
|
||||
`CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` enables full independent Claude Code instances sharing a task list and mailbox.
|
||||
|
||||
- 3-5 teammates is the practical sweet spot (linear token cost)
|
||||
- Display modes: in-process (Shift+Down cycles), tmux (own pane each), auto
|
||||
- Session resumption is broken for in-process teammates
|
||||
- BTerminal v2 could become the ideal frontend for Agent Teams — each teammate gets its own pane
|
||||
- Agent Orchestrator is the ideal frontend for Agent Teams — each teammate gets its own ProjectBox
|
||||
|
||||
---
|
||||
|
||||
## 10. Competing Approaches
|
||||
## 9. Competing Approaches (v2 Research, 2026-03-05)
|
||||
|
||||
- **claude-squad** (Go+tmux): Most adopted multi-agent manager. BTerminal v2 would replace this.
|
||||
- **agent-deck**: MCP socket pooling (~85-90% memory savings). Could integrate as backend.
|
||||
- **Git worktrees**: Dominant isolation strategy for parallel Claude sessions. BTerminal should support spawning agents in worktrees.
|
||||
- **claude-squad** (Go+tmux): Most adopted multi-agent manager
|
||||
- **agent-deck**: MCP socket pooling (~85-90% memory savings)
|
||||
- **Git worktrees**: Dominant isolation strategy for parallel Claude sessions
|
||||
|
||||
---
|
||||
|
||||
## 10. Adversarial Architecture Review (v3, 2026-03-07)
|
||||
|
||||
Three specialized agents reviewed the v3 Mission Control architecture before implementation. This adversarial process caught 12 issues (4 critical) that would have required expensive rework if discovered later.
|
||||
|
||||
### Agent: Architect (Advocate)
|
||||
|
||||
Proposed the core design:
|
||||
- **Project Groups** as primary organizational unit (replacing free-form panes)
|
||||
- **JSON config** for human-editable definitions, SQLite for runtime state
|
||||
- **Single shared sidecar** with per-project isolation via `cwd`, `claude_config_dir`, `session_id`
|
||||
- **Component split:** AgentPane -> AgentSession + TeamAgentsPanel
|
||||
- **MVP boundary at Phase 5** (5 phases core, 5 polish)
|
||||
|
||||
### Agent: Devil's Advocate
|
||||
|
||||
Found 12 issues across the Architect's proposal:
|
||||
|
||||
| # | Issue | Severity | Why It Matters |
|
||||
|---|-------|----------|----------------|
|
||||
| 1 | xterm.js 4-instance ceiling | **Critical** | WebKit2GTK OOMs at ~5 instances. 5 projects x 1 terminal = immediate wall. |
|
||||
| 2 | Single sidecar = SPOF | **Critical** | One crash kills all 5 project agents. No isolation. |
|
||||
| 3 | Layout store has no workspace concept | **Critical** | v2 pane-based store cannot represent project groups. Full rewrite needed. |
|
||||
| 4 | 384px per project on 1920px | **Critical** | 5 projects on 1920px = 384px each — too narrow for code. Must adapt to viewport. |
|
||||
| 5 | Session identity collision | Major | Without persisted `sdkSessionId`, resuming wrong session corrupts state. |
|
||||
| 6 | JSON + SQLite = split-brain risk | Major | Two sources of truth can diverge. Must clearly separate config vs state. |
|
||||
| 7 | Dispatcher has no project scoping | Major | Singleton routes all messages globally. Needs projectId and per-project cleanup. |
|
||||
| 8 | Markdown discovery undefined | Minor | No spec for which .md files appear in Docs tab. |
|
||||
| 9 | Keyboard shortcut conflicts | Major | Three input layers can conflict without explicit precedence. |
|
||||
| 10 | Remote machine support orphaned | Major | v2 remote UI doesn't map to project model. |
|
||||
| 11 | No graceful degradation | Major | Broken CWD or git could fail the whole group. |
|
||||
| 12 | Flat event stream wastes CPU | Minor | Messages for hidden projects still process through adapters. |
|
||||
|
||||
All 12 resolved before implementation. Critical items addressed in architecture. Major items implemented in MVP or deferred to v3.1 with rationale.
|
||||
|
||||
### Agent: UX + Performance Specialist
|
||||
|
||||
Provided concrete wireframes and performance budgets:
|
||||
- **Adaptive layout** formula: 5 at 5120px, 3 at 1920px, 1 with scroll at <1600px
|
||||
- **xterm budget:** 4 active max, suspend/resume < 50ms
|
||||
- **Memory budget:** ~225MB total (4 xterm @ 20MB + Tauri + SQLite + agent stores)
|
||||
- **Workspace switch:** <100ms perceived (serialize scrollbacks + unmount/mount)
|
||||
- **RAF batching:** For 5 concurrent agent streams, batch DOM updates to avoid layout thrashing
|
||||
|
||||
---
|
||||
|
||||
## 11. Provider Adapter Coupling Analysis (v3, 2026-03-11)
|
||||
|
||||
Before implementing multi-provider support, a systematic coupling analysis mapped every Claude-specific dependency. 13+ files examined and classified into 4 severity levels.
|
||||
|
||||
### Coupling Severity Map
|
||||
|
||||
**CRITICAL — hardcoded SDK, must abstract:**
|
||||
- `sidecar/agent-runner.ts` — imports Claude Agent SDK, calls `query()`, hardcoded `findClaudeCli()`. Became `claude-runner.ts` with other providers getting separate runners.
|
||||
- `bterminal-core/src/sidecar.rs` — `AgentQueryOptions` had no `provider` field. `SidecarCommand` hardcoded runner path. Added provider-based runner selection.
|
||||
- `src/lib/adapters/sdk-messages.ts` — `parseMessage()` assumed Claude SDK JSON format. Became `claude-messages.ts` with per-provider parsers.
|
||||
|
||||
**HIGH — TS mirror types, provider-specific commands:**
|
||||
- `agent-bridge.ts` — `AgentQueryOptions` interface mirrored Rust with no provider field.
|
||||
- `lib.rs` — `claude_list_profiles`, `claude_list_skills` are Claude-specific (kept, gated by capability).
|
||||
- `claude-bridge.ts` — provider-specific adapter (kept, genericized via `provider-bridge.ts`).
|
||||
|
||||
**MEDIUM — provider-aware routing:**
|
||||
- `agent-dispatcher.ts` — called `parseMessage()` (Claude-specific), subagent tool names hardcoded.
|
||||
- `AgentPane.svelte` — profile selector, skill autocomplete assumed Claude.
|
||||
|
||||
**LOW — already generic:**
|
||||
- `agents.svelte.ts`, `health.svelte.ts`, `conflicts.svelte.ts` — provider-agnostic.
|
||||
- `bterminal-relay/` — forwards `AgentQueryOptions` as-is.
|
||||
|
||||
### Key Insights
|
||||
|
||||
1. **Sidecar is the natural abstraction boundary.** Each provider needs its own runner because SDKs are incompatible.
|
||||
2. **Message format is the main divergence point.** Per-provider adapters normalize to `AgentMessage`.
|
||||
3. **Capability flags eliminate provider switches.** UI checks `capabilities.hasProfiles` instead of `provider === 'claude'`.
|
||||
4. **Env var stripping is provider-specific.** Claude strips `CLAUDE*`, Codex strips `CODEX*`, Ollama strips nothing.
|
||||
|
||||
---
|
||||
|
||||
## 12. Codebase Reuse Analysis: v2 to v3 (2026-03-07)
|
||||
|
||||
### Survived (with modifications)
|
||||
|
||||
| Component/Module | Modifications |
|
||||
|-----------------|---------------|
|
||||
| TerminalPane.svelte | Added suspend/resume lifecycle for xterm budget |
|
||||
| MarkdownPane.svelte | Unchanged |
|
||||
| AgentTree.svelte | Reused inside AgentSession |
|
||||
| StatusBar.svelte | Rewritten for workspace store (group name, fleet status, attention queue) |
|
||||
| ToastContainer.svelte | Unchanged |
|
||||
| agents.svelte.ts | Added projectId field to AgentSession |
|
||||
| theme.svelte.ts | Unchanged |
|
||||
| notifications.svelte.ts | Unchanged |
|
||||
| All adapters | Minor updates for provider routing |
|
||||
| All Rust backend | Added new modules (btmsg, bttask, search, secrets, plugins) |
|
||||
|
||||
### Replaced
|
||||
|
||||
| v2 Component | v3 Replacement | Reason |
|
||||
|-------------|---------------|--------|
|
||||
| layout.svelte.ts | workspace.svelte.ts | Pane-based model -> project-group model |
|
||||
| TilingGrid.svelte | ProjectGrid.svelte | Free-form grid -> fixed project boxes |
|
||||
| PaneContainer.svelte | ProjectBox.svelte | Generic pane -> per-project container with 11 tabs |
|
||||
| SessionList.svelte | ProjectHeader + CommandPalette | Sidebar list -> inline headers + Ctrl+K |
|
||||
| SettingsDialog.svelte | SettingsTab.svelte | Modal dialog -> sidebar drawer tab |
|
||||
| AgentPane.svelte | AgentSession + TeamAgentsPanel | Monolithic -> split for team support |
|
||||
| App.svelte | Full rewrite | Tab bar -> VSCode-style sidebar layout |
|
||||
|
||||
### Dropped (v3.0)
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| Detached pane mode | Doesn't fit workspace model (projects are grouped) |
|
||||
| Drag-resize splitters | Project boxes have fixed internal layout |
|
||||
| Layout presets | Replaced by adaptive project count from viewport |
|
||||
| Remote machine UI | Deferred to v3.1 (elevated to project level) |
|
||||
|
||||
---
|
||||
|
||||
## 13. Session Anchor Design (v3, 2026-03-12)
|
||||
|
||||
Session anchors solve context loss during Claude's automatic context compaction.
|
||||
|
||||
### Problem
|
||||
|
||||
When Claude's context window fills up (~80% of model limit), the SDK automatically compacts older turns. This is lossy — important early decisions, architecture context, and debugging breakthroughs can be permanently lost.
|
||||
|
||||
### Design Decisions
|
||||
|
||||
1. **Auto-anchor on first compaction** — Automatically captures the first 3 turns when compaction is first detected. Preserves the session's initial context (task definition, first architecture decisions).
|
||||
|
||||
2. **Observation masking** — Tool outputs (Read results, Bash output) are compacted in anchors, but reasoning text is preserved in full. Dramatically reduces anchor token cost while keeping important reasoning.
|
||||
|
||||
3. **Budget system** — Fixed scales (2K/6K/12K/20K tokens) instead of percentage-based. "6,000 tokens" is more intuitive than "15% of context."
|
||||
|
||||
4. **Re-injection via system prompt** — Promoted anchors are serialized and injected as the `system_prompt` field. Simplest integration with the SDK — no conversation history modification needed.
|
||||
|
||||
---
|
||||
|
||||
## 14. Multi-Agent Orchestration Design (v3, 2026-03-11)
|
||||
|
||||
### Evaluated Approaches
|
||||
|
||||
| Approach | Pros | Cons | Decision |
|
||||
|----------|------|------|----------|
|
||||
| Claude Agent Teams (native) | Zero custom code, SDK-managed | Experimental, session resume broken | Supported but not primary |
|
||||
| Message bus (Redis/NATS) | Proven, scalable | Runtime dependency, deployment complexity | Rejected |
|
||||
| Shared SQLite + CLI tools | Zero deps, agents use shell | Polling-based, no real-time push | **Selected** |
|
||||
| MCP server for agent comm | Standard protocol | Overhead per message, complex setup | Rejected |
|
||||
|
||||
### Why SQLite + CLI
|
||||
|
||||
Agents run Claude Code sessions with full shell access. Python CLI tools (`btmsg`, `bttask`) reading/writing SQLite is the lowest-friction integration:
|
||||
|
||||
- Zero configuration (`btmsg send architect "review this"`)
|
||||
- No runtime services (no Redis, no MCP server)
|
||||
- WAL mode handles concurrent access from multiple agent processes
|
||||
- Same database readable by Rust backend for UI display
|
||||
- 5s polling is acceptable — agents don't need millisecond latency
|
||||
|
||||
### Role Hierarchy
|
||||
|
||||
4 Tier 1 roles based on common development workflows:
|
||||
|
||||
- **Manager** — coordinates work (tech lead assigning sprint tasks). Unique: Task board tab, full bttask CRUD.
|
||||
- **Architect** — designs solutions (senior engineer doing design reviews). Unique: PlantUML tab.
|
||||
- **Tester** — runs tests (QA monitoring test suites). Unique: Selenium + Tests tabs.
|
||||
- **Reviewer** — reviews code (processing PR queue). Unique: review queue depth in attention scoring.
|
||||
|
||||
---
|
||||
|
||||
## 15. Theme System Evolution (v3, 2026-03-07)
|
||||
|
||||
### Phase 1: 4 Catppuccin Flavors (v2)
|
||||
|
||||
Mocha, Macchiato, Frappe, Latte. All colors mapped to 26 `--ctp-*` CSS custom properties.
|
||||
|
||||
### Phase 2: +7 Editor Themes
|
||||
|
||||
VSCode Dark+, Atom One Dark, Monokai, Dracula, Nord, Solarized Dark, GitHub Dark. Same 26 variables — zero component changes. `CatppuccinFlavor` type generalized to `ThemeId`.
|
||||
|
||||
### Phase 3: +6 Deep Dark Themes
|
||||
|
||||
Tokyo Night, Gruvbox Dark, Ayu Dark, Poimandres, Vesper (warm dark), Midnight (pure OLED black). Same mapping.
|
||||
|
||||
### Key Decision
|
||||
|
||||
All 17 themes map to the same CSS custom property names. No component ever needs to know which theme is active. Adding new themes is a pure data operation: define 26 color values and add to `THEME_LIST`.
|
||||
|
||||
---
|
||||
|
||||
## 16. Performance Measurements (v3, 2026-03-11)
|
||||
|
||||
### xterm.js Canvas Performance
|
||||
|
||||
WebKit2GTK lacks WebGL — xterm.js falls back to Canvas 2D:
|
||||
- **Latency:** ~20-30ms per keystroke (acceptable for AI output)
|
||||
- **Memory:** ~20MB per active instance
|
||||
- **OOM threshold:** ~5 simultaneous instances causes WebKit2GTK crash
|
||||
- **Mitigation:** 4-instance budget with suspend/resume
|
||||
|
||||
### Tauri IPC Latency
|
||||
|
||||
- **Linux:** ~5ms for typical payloads
|
||||
- **Terminal keystroke echo:** 5ms IPC + xterm render = 10-15ms total
|
||||
- **Agent message forwarding:** Negligible (human-readable speed)
|
||||
|
||||
### SQLite WAL Concurrent Access
|
||||
|
||||
Both databases accessed concurrently by Rust backend + Python CLIs + frontend reads via IPC. WAL mode with 5s busy_timeout handles this reliably. 5-minute checkpoint prevents WAL growth.
|
||||
|
||||
### Workspace Switch Latency
|
||||
|
||||
- Serialize 4 xterm scrollbacks: ~30ms
|
||||
- Destroy 4 xterm instances: ~10ms
|
||||
- Unmount ProjectGrid children: ~5ms
|
||||
- Mount new group: ~20ms
|
||||
- Create new xterm instances: ~35ms
|
||||
- **Total perceived: ~100ms** (acceptable)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# BTerminal v2 — Implementation Phases
|
||||
|
||||
See [task_plan.md](task_plan.md) for architecture decisions, error handling, and testing strategy.
|
||||
See [architecture.md](architecture.md) for system architecture and [decisions.md](decisions.md) for design decisions.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# BTerminal v2 — Progress Log (Archive: 2026-03-05 to 2026-03-06 early)
|
||||
# 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.
|
||||
> Archived from [v2.md](v2.md). Covers research, Phases 1-6, polish, testing, agent teams, and subagent support.
|
||||
|
||||
## Session: 2026-03-05
|
||||
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# BTerminal v2 — Progress Log
|
||||
# v2 Progress Log
|
||||
|
||||
> Earlier sessions (2026-03-05 to 2026-03-06 multi-machine): see [progress-archive.md](progress-archive.md)
|
||||
> Earlier sessions (2026-03-05 to 2026-03-06 multi-machine): see [v2-archive.md](v2-archive.md)
|
||||
|
||||
### Session: 2026-03-09 — AgentPane + MarkdownPane UI Redesign
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# BTerminal v3 — Progress Log
|
||||
# v3 Progress Log
|
||||
|
||||
### Session: 2026-03-07 — Architecture Planning + MVP Implementation (Phases 1-5)
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# BTerminal v3.0 Release Notes
|
||||
# v3.0 Release Notes
|
||||
|
||||
## Mission Control — Multi-Project AI Agent Orchestration
|
||||
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
# BTerminal v2 — Claude Agent Mission Control
|
||||
|
||||
## Goal
|
||||
Redesign BTerminal from a GTK3 terminal emulator into a **multi-session Claude agent dashboard** optimized for 32:9 ultrawide (5120x1440). Simultaneous visibility of all active sessions, agent tree visualization, inline markdown rendering, maximum information density.
|
||||
|
||||
## Status: Phases 1-7 + Multi-Machine (A-D) + Profiles/Skills Complete — Rev 6
|
||||
|
||||
---
|
||||
|
||||
## Adversarial Review Corrections
|
||||
|
||||
The initial plan had critical gaps surfaced by a devil's advocate review. Key corrections:
|
||||
|
||||
1. **Node.js sidecar is required** — Claude Agent SDK is TS/Python, not Rust. Cannot run in Tauri's webview or Rust. Must spawn a Node.js sidecar process. This has real packaging/complexity implications.
|
||||
2. **SDK is 0.2.x (pre-1.0)** — 127 versions in 5 months. We MUST have an abstraction layer (message adapter) between SDK wire format and UI renderers.
|
||||
3. **Three-tier observation → Two-tier** — Drop JSONL tailing of interactive CLI sessions. Too fragile (undocumented internal format). Just two tiers: SDK (structured) and Terminal (raw).
|
||||
4. **Scope reduction** — Phases 1-4 are the MVP. Phases 5-8 are post-MVP. Ship a usable tool after Phase 4.
|
||||
5. **Svelte 5 over Solid.js** — Adversarial review is right: Solid's ecosystem is too small, Svelte 5 runes match its reactivity model with much larger ecosystem.
|
||||
6. **Responsive layout required** — Cannot design only for 32:9. Must work on 1920x1080 with degraded but functional layout.
|
||||
7. **Packaging story must be planned upfront** — Not a Phase 8 afterthought.
|
||||
8. **Error handling and testing strategy required** — Not optional.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Technology Decision [status: complete]
|
||||
|
||||
### Decision: **Tauri 2.x + Svelte 5 + Claude Agent SDK (via Node.js sidecar)**
|
||||
|
||||
**Why Tauri over Electron:**
|
||||
- Rust backend is genuinely useful for PTY management and file watching
|
||||
- Memory overhead matters when running 4+ agent sidecars
|
||||
- Better security model (no Node.js in renderer)
|
||||
- **Acknowledged limitation:** WebKit2GTK has no WebGL. xterm.js uses Canvas fallback. Acceptable for 2-4 AI output panes. NOT for 8+ high-throughput terminals.
|
||||
- If Canvas proves unacceptable: escape hatch is switching to Electron (frontend code is framework-agnostic web tech, mostly portable)
|
||||
|
||||
**Why Svelte 5 (revised from Solid.js):**
|
||||
- Fine-grained reactivity via `$state`/`$derived` runes — comparable to Solid signals
|
||||
- No VDOM — same performance characteristic
|
||||
- Much larger ecosystem (xterm.js wrappers, layout libraries, component libs)
|
||||
- Better TypeScript support and devtools
|
||||
- Svelte 5 runes eliminated the ceremony that older Svelte versions had
|
||||
|
||||
**Why NOT React:**
|
||||
- VDOM reconciliation across 4+ simultaneously streaming panes = CPU waste
|
||||
- Larger bundle (40KB vs ~5KB Svelte runtime)
|
||||
|
||||
### Architecture: Two-Tier Observation
|
||||
|
||||
| Session Type | Backend | Frontend | Observation |
|
||||
|---|---|---|---|
|
||||
| **SDK Agent** | Node.js sidecar → Rust bridge → Tauri events | Structured rich panels | Full: streaming, subagents, hooks, cost |
|
||||
| **Terminal** (SSH/CLI/Shell) | PTY via portable-pty (Rust) | xterm.js terminal | Raw terminal only |
|
||||
| **File viewer** | Rust file watcher (notify) | Markdown renderer | N/A |
|
||||
|
||||
**Dropped:** Interactive CLI JSONL tailing (undocumented internal format, fragile).
|
||||
**Dropped:** CLI stream-json tier (SDK handles this better for non-interactive use).
|
||||
|
||||
### Node.js Sidecar Architecture (critical detail)
|
||||
|
||||
The Agent SDK cannot run in Rust or the webview. Solution:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Tauri App │
|
||||
│ │
|
||||
│ ┌──────────┐ Tauri IPC ┌──────────────────┐ │
|
||||
│ │ WebView │ ←────────────→ │ Rust Backend │ │
|
||||
│ │ (Svelte) │ │ │ │
|
||||
│ └──────────┘ │ ├── PTY manager │ │
|
||||
│ │ ├── File watcher│ │
|
||||
│ │ └── Sidecar mgr │──┼──→ Node.js process
|
||||
│ └──────────────────┘ │ (Agent SDK)
|
||||
│ │ stdio JSON-RPC
|
||||
└─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Rust spawns Node.js/Deno child process on app launch (auto-start in setup, Deno-first)
|
||||
- Communication: stdio with newline-delimited JSON (simple, no socket server)
|
||||
- Node.js/Deno process uses `@anthropic-ai/claude-agent-sdk` query() function which handles claude subprocess management internally
|
||||
- SDK messages forwarded as-is via NDJSON — same format as CLI stream-json
|
||||
- If sidecar crashes: detect via process exit, show error in UI, offer restart
|
||||
- **Packaging:** Bundle the sidecar JS + SDK as a single file (esbuild bundle, SDK included). Require Node.js 20+ as system dependency. Document in install.sh.
|
||||
- **Unified bundle:** Single pre-built agent-runner.mjs works with both Deno and Node.js. SidecarCommand struct abstracts runtime. Deno preferred (faster startup). Falls back to Node.js.
|
||||
|
||||
### SDK Abstraction Layer
|
||||
|
||||
```typescript
|
||||
// adapters/sdk-messages.ts — insulates UI from SDK wire format changes
|
||||
interface AgentMessage {
|
||||
id: string;
|
||||
type: 'text' | 'tool_call' | 'tool_result' | 'subagent_spawn' | 'status' | 'cost';
|
||||
parentId?: string; // for subagent tracking
|
||||
content: unknown; // type-specific payload
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// Adapter function — this is the ONLY place that knows SDK internals
|
||||
function adaptSDKMessage(raw: SDKMessage): AgentMessage { ... }
|
||||
```
|
||||
|
||||
When SDK changes its message format, only the adapter needs updating.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
See [phases.md](phases.md) for the full phased implementation plan.
|
||||
|
||||
- **MVP:** Phases 1-4 (scaffolding, terminal+layout, agent SDK, session mgmt+markdown)
|
||||
- **Post-MVP:** Phases 5-7 (agent tree, polish, packaging, agent teams)
|
||||
- **Multi-Machine:** Phases A-D (bterminal-core extraction, relay binary, RemoteManager, frontend)
|
||||
|
||||
---
|
||||
|
||||
## Decisions Log
|
||||
|
||||
| Decision | Rationale | Date |
|
||||
|---|---|---|
|
||||
| Tauri 2.x over GTK4 | Web frontend for markdown, tiling, agent viz; Rust backend for PTY/SDK | 2026-03-05 |
|
||||
| Tauri over Electron | Memory efficiency, Rust backend value, security model. Escape hatch: port to Electron if Canvas perf unacceptable | 2026-03-05 |
|
||||
| Svelte 5 over Solid.js | Larger ecosystem, Svelte 5 runes match Solid's reactivity, better tooling | 2026-03-05 |
|
||||
| Two-tier over three-tier | Drop JSONL tailing (undocumented internal format). SDK or raw terminal, nothing in between | 2026-03-05 |
|
||||
| portable-pty over tauri-plugin-pty | Direct Rust crate (used by WezTerm) vs 38-star community plugin | 2026-03-05 |
|
||||
| Node.js sidecar for SDK | SDK is TS/Python only. Sidecar with stdio NDJSON. Future: replace with Deno | 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 |
|
||||
| Canvas addon (not WebGL) | WebKit2GTK has no WebGL. Explicit Canvas addon avoids silent fallback | 2026-03-05 |
|
||||
| claude CLI over Agent SDK query() | SUPERSEDED — initially used `claude -p --output-format stream-json` to avoid SDK dep. CLI hangs with piped stdio (bug #6775). Migrated to `@anthropic-ai/claude-agent-sdk` query() which handles subprocess internally | 2026-03-06 |
|
||||
| Agent SDK migration | Replaced raw CLI spawning with @anthropic-ai/claude-agent-sdk query(). SDK handles subprocess management, auth, nesting detection. Messages same format as stream-json so adapter unchanged. AbortController for session stop. | 2026-03-06 |
|
||||
| `.svelte.ts` for rune stores | Svelte 5 `$state`/`$derived` runes require `.svelte.ts` extension (not `.ts`). Compiler silently passes `.ts` but runes fail at runtime. All store files must use `.svelte.ts`. | 2026-03-06 |
|
||||
| SQLite settings table for app config | Key-value `settings` table in session.rs for persisting user preferences (shell, cwd, max panes). Simple and extensible without schema migrations. | 2026-03-06 |
|
||||
| Toast notifications over persistent log | Ephemeral toasts (4s auto-dismiss, max 5) for agent events rather than a persistent notification log. Keeps UI clean; persistent logs can be added later if needed. | 2026-03-06 |
|
||||
| Build-from-source installer over pre-built binaries | install-v2.sh checks deps and builds locally. Pre-built binaries via GitHub Actions CI (.deb + AppImage on v* tags). Auto-update deferred until signing key infrastructure is set up. | 2026-03-06 |
|
||||
| ctx read-only access from Rust | Open ~/.claude-context/context.db with SQLITE_OPEN_READ_ONLY. Never write — ctx CLI owns the schema. Separate CtxDb struct in ctx.rs with Option<Connection> for graceful absence. | 2026-03-06 |
|
||||
| SSH via PTY shell args | SSH sessions spawn TerminalPane with shell=/usr/bin/ssh and args=[-p, port, [-i, keyfile], user@host]. No special SSH library — PTY handles it natively. | 2026-03-06 |
|
||||
| Catppuccin 4 flavors at runtime | CSS variables overridden at runtime. onThemeChange() callback registry in theme.svelte.ts allows open terminals to hot-swap themes. | 2026-03-06 |
|
||||
| Detached pane via URL params | Pop-out windows use ?detached=1&type=terminal URL params. App.svelte conditionally renders single pane without sidebar/grid chrome. Simple, no IPC needed. | 2026-03-06 |
|
||||
| Shiki over highlight.js | Shiki provides VS Code-grade syntax highlighting with Catppuccin theme. Lazy singleton pattern avoids repeated WASM init. 13 languages preloaded. | 2026-03-06 |
|
||||
| Vitest for frontend tests | Vitest over Jest — zero-config with Vite, same transform pipeline, faster. Test config in vite.config.ts. | 2026-03-06 |
|
||||
| Deno sidecar evaluation | Proof-of-concept agent-runner-deno.ts created. Deno compiles to single binary (better packaging). Same NDJSON protocol. Not yet integrated. | 2026-03-06 |
|
||||
| Splitter overlays for pane resize | Fixed-position divs outside CSS Grid (avoids layout interference). Mouse drag updates customColumns/customRows state. Resets on preset change. | 2026-03-06 |
|
||||
| Unified sidecar bundle | Single agent-runner.mjs works with both Deno and Node.js. resolve_sidecar_command() checks runtime availability upfront, prefers Deno (faster startup). Only .mjs bundled in tauri.conf.json resources. agent-runner-deno.ts removed from bundle. | 2026-03-07 |
|
||||
| Session groups/folders | group_name column in sessions table with ALTER TABLE migration. Pane.group field in layout store. Collapsible group headers in sidebar. Right-click to set group. | 2026-03-06 |
|
||||
| Auto-update signing key | Generated minisign keypair. Pubkey set in tauri.conf.json. Private key for TAURI_SIGNING_PRIVATE_KEY GitHub secret. | 2026-03-06 |
|
||||
| Agent teams: frontend routing only | Subagent panes created by frontend dispatcher, not separate sidecar processes. Parent sidecar handles all messages; routing uses SDK's parentId field. Avoids process explosion for nested subagents. | 2026-03-06 |
|
||||
| SUBAGENT_TOOL_NAMES detection | Detect subagent spawn by tool_call name ('Agent', 'Task', 'dispatch_agent'). Simple Set lookup, easily extensible. | 2026-03-06 |
|
||||
| Cargo workspace at v2/ level | Extract bterminal-core shared crate for PtyManager + SidecarManager. Workspace members: src-tauri, bterminal-core, bterminal-relay. Enables code reuse between Tauri app and relay binary. | 2026-03-06 |
|
||||
| EventSink trait for event abstraction | Generic trait (emit method) decouples PtyManager/SidecarManager from Tauri. TauriEventSink wraps AppHandle; relay uses WebSocket EventSink. | 2026-03-06 |
|
||||
| bterminal-relay as standalone binary | Rust binary with WebSocket server for remote machine management. Token auth + rate limiting. Per-connection isolated managers. | 2026-03-06 |
|
||||
| RemoteManager WebSocket client | Controller-side WebSocket client in remote.rs. Manages connections to multiple relays with heartbeat ping. 12 new Tauri commands for remote operations. | 2026-03-06 |
|
||||
| Frontend remote routing via remoteMachineId | Pane.remoteMachineId field determines local vs remote. Bridge adapters route to appropriate Tauri commands transparently. | 2026-03-06 |
|
||||
| Permission mode passthrough | AgentQueryOptions.permission_mode flows Rust -> sidecar -> SDK. Defaults to 'bypassPermissions', supports 'default'. Enables non-bypass agent sessions. | 2026-03-06 |
|
||||
| Stop-on-close in TilingGrid, not AgentPane | Removed onDestroy stopAgent() from AgentPane (fired on layout remounts). Stop logic moved to TilingGrid onClose handler — only fires on explicit user close. | 2026-03-06 |
|
||||
| Bundle SDK into sidecar | Removed --external flag from esbuild build:sidecar. SDK bundled into agent-runner.mjs — no runtime dependency on node_modules. | 2026-03-06 |
|
||||
| pathToClaudeCodeExecutable | Auto-detect Claude CLI path at sidecar startup via findClaudeCli() (checks common paths + `which`). Pass to SDK query() options. Early error if CLI not found. | 2026-03-07 |
|
||||
| Claude profiles (switcher-claude) | Read ~/.config/switcher/profiles/ for multi-account support. Profile selector in AgentPane toolbar when >1 profile. Selected profile's config_dir passed as CLAUDE_CONFIG_DIR to SDK env. | 2026-03-07 |
|
||||
| Skill discovery & autocomplete | Read ~/.claude/skills/ for skill files. `/` prefix triggers autocomplete in prompt textarea. Skill content read and injected as prompt. | 2026-03-07 |
|
||||
| Extended AgentQueryOptions | Added setting_sources, system_prompt, model, claude_config_dir, additional_directories to full stack (Rust struct -> sidecar JSON -> SDK options). settingSources defaults to ['user', 'project']. | 2026-03-07 |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Node.js or Deno for sidecar?** Resolved: Single pre-built agent-runner.mjs runs on both Deno and Node.js. SidecarCommand struct in sidecar.rs abstracts the runtime choice. Deno preferred (faster startup). Falls back to Node.js. Both use `@anthropic-ai/claude-agent-sdk` query() bundled into the .mjs file.
|
||||
2. **Multi-machine support?** Resolved: Implemented (Phases A-D complete). See [multi-machine.md](multi-machine.md) for architecture. bterminal-core crate extracted, bterminal-relay binary built, RemoteManager + frontend integration done. Reconnection with exponential backoff implemented. Remaining: real-world testing, TLS.
|
||||
3. **Agent Teams integration?** Phase 7 — frontend routing implemented (subagent pane spawning, parent/child navigation). Needs real-world testing with CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1.
|
||||
4. **Electron escape hatch threshold?** If Canvas xterm.js proves >50ms latency on target system with 4 panes, switch to Electron. Benchmark in Phase 2.
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
| Failure | Response |
|
||||
|---|---|
|
||||
| Node.js sidecar crash | Detect via process exit code, show error banner, offer restart button |
|
||||
| Claude API 529 (overloaded) | Exponential backoff in sidecar, show "rate limited" status in pane |
|
||||
| API key expired | Sidecar reports auth error, prompt user to update key in settings |
|
||||
| PTY process exit | Show exit code in terminal, offer reconnect for SSH |
|
||||
| WebKit2GTK OOM | Limit to 4 active xterm.js instances, lazy-init others |
|
||||
| Simultaneous resize of N terminals | Debounce resize events (100ms), batch PTY resize calls |
|
||||
| SDK message format change | Adapter layer catches unknown types, logs warning, renders as raw JSON fallback |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
| Layer | Tool | What |
|
||||
|---|---|---|
|
||||
| SDK adapter | Vitest | Message parsing, type discrimination, unknown message fallback |
|
||||
| Svelte components | Svelte testing library | Pane rendering, layout responsive breakpoints |
|
||||
| Rust backend | cargo test | PTY lifecycle, sidecar spawn/kill, file watcher debounce |
|
||||
| Integration | Playwright | Full app: open terminal, run command, verify output |
|
||||
| Manual | Developer testing | xterm.js Canvas performance with 4 panes on target hardware |
|
||||
|
||||
## Errors Encountered
|
||||
|
||||
| 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 |
|
||||
|
|
@ -1,251 +0,0 @@
|
|||
# BTerminal v3 — Research Findings
|
||||
|
||||
## 1. Adversarial Architecture Review (2026-03-07)
|
||||
|
||||
Three specialized agents reviewed the v3 Mission Control architecture before implementation began. This adversarial process caught 12 issues (4 critical) that would have required expensive rework if discovered later.
|
||||
|
||||
### Agent: Architect (Advocate)
|
||||
|
||||
The Architect proposed the core design:
|
||||
|
||||
- **Project Groups** as the primary organizational unit (replacing free-form panes)
|
||||
- **JSON config** (`groups.json`) for human-editable group/project definitions, SQLite for runtime state
|
||||
- **Single shared sidecar** with per-project isolation via `cwd`, `claude_config_dir`, and `session_id`
|
||||
- **Component split:** AgentPane → AgentSession + TeamAgentsPanel (subagents shown inline, not as separate panes)
|
||||
- **New SQLite tables:** `agent_messages` (per-project message persistence), `project_agent_state` (sdkSessionId, cost, status)
|
||||
- **MVP boundary at Phase 5** (5 phases for core, 5 for polish)
|
||||
- **10-phase implementation plan** covering data model, shell, session integration, terminals, team panel, continuity, palette, docs, settings, cleanup
|
||||
|
||||
### Agent: Devil's Advocate
|
||||
|
||||
The Devil's Advocate found 12 issues across the Architect's proposal:
|
||||
|
||||
| # | Issue | Severity | Why It Matters |
|
||||
|---|-------|----------|----------------|
|
||||
| 1 | xterm.js 4-instance ceiling | **Critical** | WebKit2GTK OOMs at ~5 xterm instances. With 5 projects × 1 terminal each, we hit the wall immediately. |
|
||||
| 2 | Single sidecar = SPOF | **Critical** | One sidecar crash kills all 5 project agents simultaneously. No isolation between projects. |
|
||||
| 3 | Layout store has no workspace concept | **Critical** | The v2 layout store (pane-based) cannot represent project groups. Needs a full rewrite, not incremental modification. |
|
||||
| 4 | 384px per project unusable on 1920px | **Critical** | 5 projects on a 1920px screen means 384px per project — too narrow for code or agent output. Must adapt to viewport. |
|
||||
| 5 | Session identity collision | Major | Without persisting `sdkSessionId`, resuming the wrong session corrupts agent state. Per-project CLAUDE_CONFIG_DIR isolation is also needed. |
|
||||
| 6 | JSON config + SQLite = split-brain | Major | Two sources of truth (JSON for config, SQLite for state) can diverge. Must clearly separate what lives where. |
|
||||
| 7 | Agent dispatcher has no project scoping | Major | The singleton dispatcher routes all messages globally. Adding projectId to sessions and cleanup on workspace switch is essential. |
|
||||
| 8 | Markdown discovery is undefined | Minor | No specification for which markdown files appear in the Docs tab. Needs a priority list and depth limit. |
|
||||
| 9 | Keyboard shortcut conflicts | Major | Three input layers (terminal, workspace, app) can conflict. Needs a shortcut manager with explicit precedence. |
|
||||
| 10 | Remote machine support orphaned | Major | v2's remote machine UI doesn't map to the project model. Must elevate to project level. |
|
||||
| 11 | No graceful degradation for broken projects | Major | If a project's CWD doesn't exist or git is broken, the whole group could fail. Need per-project health states. |
|
||||
| 12 | Flat event stream wastes CPU for hidden projects | Minor | Messages for inactive workspace projects still process through adapters. Should buffer and flush on activation. |
|
||||
|
||||
**Resolutions:** All 12 issues were resolved before implementation. Critical items (#1-4) were addressed in the architecture. Major items were either implemented in MVP phases or explicitly deferred to v3.1 with documented rationale. See [v3-task_plan.md](v3-task_plan.md) for the full resolution table.
|
||||
|
||||
### Agent: UX + Performance Specialist
|
||||
|
||||
The UX specialist provided concrete wireframes and performance budgets:
|
||||
|
||||
- **Adaptive layout:** `Math.min(projects.length, Math.max(1, Math.floor(containerWidth / 520)))` — 5 projects at 5120px, 3 at 1920px, 1 with scroll at <1600px
|
||||
- **xterm.js budget:** 4 active instances max. Suspended terminals serialize scrollback to text, destroy the xterm instance, recreate on focus. PTY stays alive. Suspend/resume cycle < 50ms.
|
||||
- **Memory budget:** ~225MB total (4 xterm @ 20MB + Tauri + SQLite + 5 agent stores). Well within WebKit2GTK limits.
|
||||
- **Workspace switch performance:** Serialize all xterm scrollbacks, unmount ProjectGrid children, mount new group. Target: <100ms perceived latency (frees ~80MB).
|
||||
- **Team panel:** Inline at >2560px viewport (240px wide), overlay at <2560px. Collapsed when no subagents.
|
||||
- **Command palette:** Ctrl+K, floating overlay, fuzzy search across commands + groups + projects. 18+ commands across 6 categories.
|
||||
- **RAF batching:** For 5 concurrent agent streams, batch DOM updates into requestAnimationFrame frames to avoid layout thrashing.
|
||||
|
||||
---
|
||||
|
||||
## 2. Provider Adapter Coupling Analysis (2026-03-11)
|
||||
|
||||
Before implementing multi-provider support, a systematic coupling analysis mapped every Claude-specific dependency in the codebase. 13+ files were examined and classified into 4 severity levels.
|
||||
|
||||
### Coupling Severity Map
|
||||
|
||||
**CRITICAL — hardcoded SDK, must abstract:**
|
||||
- `sidecar/agent-runner.ts` — imports Claude Agent SDK, calls `query()`, hardcoded `findClaudeCli()`. Must become `claude-runner.ts` with other providers getting separate runners.
|
||||
- `bterminal-core/src/sidecar.rs` — `AgentQueryOptions` struct had no `provider` field. `SidecarCommand` hardcoded `agent-runner.mjs` path. Must add provider-based runner selection.
|
||||
- `src/lib/adapters/sdk-messages.ts` — `parseMessage()` assumes Claude SDK JSON format. Must become `claude-messages.ts` with per-provider parsers.
|
||||
|
||||
**HIGH — TS mirror types, provider-specific commands:**
|
||||
- `src/lib/adapters/agent-bridge.ts` — `AgentQueryOptions` interface mirrors Rust struct with no provider field.
|
||||
- `src-tauri/src/lib.rs` — `claude_list_profiles`, `claude_list_skills` are Claude-specific commands (kept as-is, gated by capability).
|
||||
- `src/lib/adapters/claude-bridge.ts` — provider-specific adapter (kept, genericized via provider-bridge.ts).
|
||||
|
||||
**MEDIUM — provider-aware routing:**
|
||||
- `src/lib/agent-dispatcher.ts` — calls `parseMessage()` (Claude-specific), subagent tool names hardcoded.
|
||||
- `src/lib/components/Agent/AgentPane.svelte` — profile selector, skill autocomplete assume Claude.
|
||||
- `ClaudeSession.svelte` — name says "Claude" but logic is mostly generic.
|
||||
|
||||
**LOW — already generic:**
|
||||
- `agents.svelte.ts` — `AgentMessage` type has no Claude-specific logic.
|
||||
- `health.svelte.ts`, `conflicts.svelte.ts` — provider-agnostic health and conflict tracking.
|
||||
- `bterminal-relay/` — forwards `AgentQueryOptions` as-is.
|
||||
|
||||
### Key Insights from Analysis
|
||||
|
||||
1. **Sidecar is the natural abstraction boundary.** Each provider needs its own runner because SDKs are incompatible. The Rust sidecar manager selects which runner to spawn based on the `provider` field.
|
||||
|
||||
2. **Message format is the main divergence point.** Claude SDK emits structured JSON (assistant/user/result), Codex uses ThreadEvents, Ollama uses OpenAI-compatible streaming. Per-provider message adapters normalize everything to `AgentMessage`.
|
||||
|
||||
3. **Capability flags eliminate provider switches.** Instead of `if (provider === 'claude') showProfiles()`, the UI checks `capabilities.hasProfiles`. Adding a new provider only requires registering its capabilities — zero UI code changes.
|
||||
|
||||
4. **Environment variable stripping is provider-specific.** Claude needs `CLAUDE*` vars stripped (nesting detection). Codex needs `CODEX*` stripped. Ollama needs nothing stripped. Extracted to `strip_provider_env_var()` function.
|
||||
|
||||
---
|
||||
|
||||
## 3. Codebase Reuse Analysis (v2 → v3)
|
||||
|
||||
The v3 redesign reused significant portions of the v2 codebase. This analysis determined what could survive, what needed replacement, and what could be dropped entirely.
|
||||
|
||||
### Survived (with modifications)
|
||||
|
||||
| Component/Module | Modifications |
|
||||
|-----------------|---------------|
|
||||
| TerminalPane.svelte | Added suspend/resume lifecycle for xterm budget |
|
||||
| MarkdownPane.svelte | Unchanged |
|
||||
| AgentTree.svelte | Reused inside AgentSession |
|
||||
| StatusBar.svelte | Rewritten for workspace store (group name, fleet status, attention queue) |
|
||||
| ToastContainer.svelte | Unchanged |
|
||||
| agents.svelte.ts | Added projectId field to AgentSession |
|
||||
| theme.svelte.ts | Unchanged |
|
||||
| notifications.svelte.ts | Unchanged |
|
||||
| All adapters | Minor updates for provider routing |
|
||||
| All Rust backend | Added new modules (btmsg, bttask, search, secrets, plugins) |
|
||||
| highlight.ts, agent-tree.ts | Unchanged |
|
||||
|
||||
### Replaced
|
||||
|
||||
| v2 Component | v3 Replacement | Reason |
|
||||
|-------------|---------------|--------|
|
||||
| layout.svelte.ts | workspace.svelte.ts | Pane-based model → project-group model |
|
||||
| TilingGrid.svelte | ProjectGrid.svelte | Free-form grid → fixed project boxes |
|
||||
| PaneContainer.svelte | ProjectBox.svelte | Generic pane → per-project container with 11 tabs |
|
||||
| SessionList.svelte | ProjectHeader + CommandPalette | Sidebar session list → inline headers + Ctrl+K |
|
||||
| SettingsDialog.svelte | SettingsTab.svelte | Modal dialog → sidebar drawer tab |
|
||||
| AgentPane.svelte | AgentSession + TeamAgentsPanel | Monolithic → split for team support |
|
||||
| App.svelte | Full rewrite | Tab bar → VSCode-style sidebar layout |
|
||||
|
||||
### Dropped (v3.0)
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| Detached pane mode | Doesn't fit workspace model (projects are grouped, not independent) |
|
||||
| Drag-resize splitters | Project boxes have fixed internal layout |
|
||||
| Layout presets (1-col, 2-col, etc.) | Replaced by adaptive project count from viewport |
|
||||
| Remote machine UI integration | Deferred to v3.1 (elevated to project level) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Session Anchor Design Analysis (2026-03-12)
|
||||
|
||||
Session anchors were designed to solve context loss during Claude's automatic context compaction. Research into compaction behavior informed the design.
|
||||
|
||||
### Problem
|
||||
|
||||
When Claude's context window fills up, the SDK automatically compacts older turns. This compaction is lossy — important early decisions, architecture context, and debugging breakthroughs can be permanently lost.
|
||||
|
||||
### Compaction Behavior (Observed)
|
||||
|
||||
- Compaction triggers when context exceeds ~80% of model limit
|
||||
- The SDK emits a compaction event that the sidecar can observe
|
||||
- Compacted turns are summarized, losing granular detail
|
||||
- Multiple compaction rounds can occur in long sessions
|
||||
|
||||
### Design Decisions
|
||||
|
||||
1. **Auto-anchor on first compaction** — The system automatically captures the first 3 turns when compaction is first detected. This preserves the session's initial context (usually the task definition and first architecture decisions).
|
||||
|
||||
2. **Observation masking** — Tool outputs (Read results, Bash output) are compacted in anchors, but reasoning text is preserved in full. This dramatically reduces anchor token cost while keeping the important reasoning.
|
||||
|
||||
3. **Budget system** — Fixed budget scales (2K/6K/12K/20K tokens) instead of percentage-based. Users understand "6,000 tokens" more intuitively than "15% of context."
|
||||
|
||||
4. **Re-injection via system prompt** — Promoted anchors are serialized and injected as the `system_prompt` field. This is the simplest integration point with the SDK and doesn't require modifying the conversation history.
|
||||
|
||||
---
|
||||
|
||||
## 5. Multi-Agent Orchestration Design (2026-03-11)
|
||||
|
||||
Research into multi-agent coordination patterns informed the btmsg/bttask design.
|
||||
|
||||
### Evaluated Approaches
|
||||
|
||||
| Approach | Pros | Cons | Decision |
|
||||
|----------|------|------|----------|
|
||||
| Claude Agent Teams (native) | Zero custom code, SDK-managed | Experimental, session resume broken, no custom roles | Supported but not primary |
|
||||
| Message bus (Redis/NATS) | Proven, scalable | Runtime dependency, deployment complexity | Rejected |
|
||||
| Shared SQLite + CLI tools | Zero deps, agents use shell commands | Polling-based, no real-time push | **Selected** |
|
||||
| MCP server for agent comm | Standard protocol | Overhead per message, complex setup | Rejected |
|
||||
|
||||
### Why SQLite + CLI
|
||||
|
||||
Agents run Claude Code sessions that have full shell access. A Python CLI tool (`btmsg`, `bttask`) that reads/writes SQLite is the lowest-friction integration:
|
||||
|
||||
- Agents can use it with zero configuration (just `btmsg send architect "review this"`)
|
||||
- No runtime services to manage (no Redis, no MCP server)
|
||||
- WAL mode handles concurrent access from multiple agent processes
|
||||
- The same database is readable by the Rust backend for UI display
|
||||
- Polling-based (5s) is acceptable for coordination — agents don't need millisecond latency
|
||||
|
||||
### Role Hierarchy
|
||||
|
||||
The 4 Tier 1 roles were chosen based on common development workflows:
|
||||
|
||||
- **Manager** — coordinates work, like a tech lead assigning tasks in a sprint
|
||||
- **Architect** — designs solutions, like a senior engineer doing design reviews
|
||||
- **Tester** — runs tests, like a QA engineer monitoring test suites
|
||||
- **Reviewer** — reviews code, like a reviewer processing a PR queue
|
||||
|
||||
Each role has unique tabs (Task board for Manager, PlantUML for Architect, Selenium for Tester, Review queue for Reviewer) and unique bttask permissions (Manager has full CRUD, others are read-only with comments).
|
||||
|
||||
---
|
||||
|
||||
## 6. Theme System Evolution (2026-03-07)
|
||||
|
||||
### Original: 4 Catppuccin Flavors
|
||||
|
||||
v2 launched with 4 Catppuccin flavors (Mocha, Macchiato, Frappé, Latte). All colors mapped to 26 `--ctp-*` CSS custom properties.
|
||||
|
||||
### Extension: 7 Editor Themes
|
||||
|
||||
Added VSCode Dark+, Atom One Dark, Monokai, Dracula, Nord, Solarized Dark, GitHub Dark. Each theme maps to the same 26 `--ctp-*` variables — zero component changes needed. The `CatppuccinFlavor` type was generalized to `ThemeId` union type. Deprecated wrapper functions maintain backward compatibility.
|
||||
|
||||
### Extension: 6 Deep Dark Themes
|
||||
|
||||
Added Tokyo Night, Gruvbox Dark, Ayu Dark, Poimandres, Vesper (warm dark), Midnight (pure OLED black). Same 26-variable mapping.
|
||||
|
||||
### Key Design Decision
|
||||
|
||||
By mapping all 17 themes to the same CSS custom property names, no component ever needs to know which theme is active. This makes adding new themes a pure data operation — define 26 color values and add to `THEME_LIST`. The `ThemeMeta` type includes group metadata for the custom themed dropdown in SettingsTab.
|
||||
|
||||
---
|
||||
|
||||
## 7. Performance Findings
|
||||
|
||||
### xterm.js Canvas Performance
|
||||
|
||||
WebKit2GTK lacks WebGL, so xterm.js falls back to Canvas 2D rendering. Testing showed:
|
||||
- **Latency:** ~20-30ms per keystroke (acceptable for AI output, not ideal for vim)
|
||||
- **Memory:** ~20MB per active instance
|
||||
- **OOM threshold:** ~5 simultaneous instances causes WebKit2GTK to crash
|
||||
- **Mitigation:** 4-instance budget with suspend/resume for inactive terminals
|
||||
|
||||
### Tauri IPC Latency
|
||||
|
||||
- **Linux:** ~5ms for typical payloads (serialization-free IPC in Tauri 2.x)
|
||||
- **Terminal keystroke echo:** 5ms IPC + xterm.js render ≈ 10-15ms total
|
||||
- **Agent message forwarding:** Negligible — agent output arrives at human-readable speed
|
||||
|
||||
### SQLite WAL Concurrent Access
|
||||
|
||||
Both sessions.db and btmsg.db are accessed concurrently by:
|
||||
- Rust backend (Tauri commands)
|
||||
- Python CLI tools (btmsg, bttask from agent shells)
|
||||
- Frontend reads via IPC
|
||||
|
||||
WAL mode with 5s busy_timeout handles this reliably. The 5-minute checkpoint prevents WAL file growth.
|
||||
|
||||
### Workspace Switch Latency
|
||||
|
||||
Measured during v3 development:
|
||||
- Serialize 4 xterm scrollbacks: ~30ms
|
||||
- Destroy 4 xterm instances: ~10ms
|
||||
- Unmount ProjectGrid children: ~5ms
|
||||
- Mount new group's ProjectGrid: ~20ms
|
||||
- Create new xterm instances: ~35ms
|
||||
- **Total perceived:** ~100ms (acceptable)
|
||||
|
|
@ -1,348 +0,0 @@
|
|||
# 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 |
|
||||
|---|---|---|---|
|
||||
4
v2/Cargo.lock
generated
4
v2/Cargo.lock
generated
|
|
@ -15,8 +15,10 @@ dependencies = [
|
|||
"bterminal-core",
|
||||
"dirs 5.0.1",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"keyring",
|
||||
"log",
|
||||
"native-tls",
|
||||
"notify",
|
||||
"notify-rust",
|
||||
"opentelemetry",
|
||||
|
|
@ -26,12 +28,14 @@ dependencies = [
|
|||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-tungstenite",
|
||||
"tracing",
|
||||
"tracing-opentelemetry",
|
||||
|
|
|
|||
|
|
@ -85,6 +85,39 @@ impl SandboxConfig {
|
|||
}
|
||||
}
|
||||
|
||||
/// Build a restricted sandbox config for Aider agent sessions.
|
||||
/// More restrictive than `for_projects`: only project worktree + read-only system paths.
|
||||
/// Does NOT allow write access to ~/.config, ~/.claude, etc.
|
||||
pub fn for_aider_restricted(project_cwd: &str, worktree: Option<&str>) -> Self {
|
||||
let mut rw = vec![PathBuf::from(project_cwd)];
|
||||
if let Some(wt) = worktree {
|
||||
rw.push(PathBuf::from(wt));
|
||||
}
|
||||
rw.push(std::env::temp_dir());
|
||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root"));
|
||||
rw.push(home.join(".aider"));
|
||||
|
||||
let ro = vec![
|
||||
PathBuf::from("/usr"),
|
||||
PathBuf::from("/lib"),
|
||||
PathBuf::from("/lib64"),
|
||||
PathBuf::from("/etc"),
|
||||
PathBuf::from("/proc"),
|
||||
PathBuf::from("/dev"),
|
||||
PathBuf::from("/bin"),
|
||||
PathBuf::from("/sbin"),
|
||||
home.join(".local"),
|
||||
home.join(".deno"),
|
||||
home.join(".nvm"),
|
||||
];
|
||||
|
||||
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();
|
||||
|
|
@ -266,6 +299,57 @@ mod tests {
|
|||
assert_eq!(config.rw_paths.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_for_aider_restricted_single_cwd() {
|
||||
let config = SandboxConfig::for_aider_restricted("/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()));
|
||||
let home = dirs::home_dir().unwrap();
|
||||
assert!(config.rw_paths.contains(&home.join(".aider")));
|
||||
// No worktree path added
|
||||
assert!(!config
|
||||
.rw_paths
|
||||
.iter()
|
||||
.any(|p| p.to_string_lossy().contains("worktree")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_for_aider_restricted_with_worktree() {
|
||||
let config = SandboxConfig::for_aider_restricted(
|
||||
"/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_aider_restricted_no_config_write() {
|
||||
let config = SandboxConfig::for_aider_restricted("/tmp/test", None);
|
||||
let home = dirs::home_dir().unwrap();
|
||||
// Aider restricted must NOT have ~/.config or ~/.claude in rw_paths
|
||||
assert!(!config.rw_paths.contains(&home.join(".config")));
|
||||
assert!(!config.rw_paths.contains(&home.join(".claude")));
|
||||
// And NOT in ro_paths either (stricter than for_projects)
|
||||
assert!(!config.ro_paths.contains(&home.join(".config")));
|
||||
assert!(!config.ro_paths.contains(&home.join(".claude")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_for_aider_restricted_rw_count() {
|
||||
// Without worktree: cwd + tmp + .aider = 3
|
||||
let config = SandboxConfig::for_aider_restricted("/tmp/test", None);
|
||||
assert_eq!(config.rw_paths.len(), 3);
|
||||
|
||||
// With worktree: cwd + worktree + tmp + .aider = 4
|
||||
let config = SandboxConfig::for_aider_restricted("/tmp/test", Some("/tmp/wt"));
|
||||
assert_eq!(config.rw_paths.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_for_projects_empty() {
|
||||
let config = SandboxConfig::for_projects(&[], &[]);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
215
v2/package-lock.json
generated
215
v2/package-lock.json
generated
|
|
@ -42,6 +42,7 @@
|
|||
"@wdio/local-runner": "^9.24.0",
|
||||
"@wdio/mocha-framework": "^9.24.0",
|
||||
"@wdio/spec-reporter": "^9.24.0",
|
||||
"esbuild": "^0.27.4",
|
||||
"svelte": "^5.45.2",
|
||||
"svelte-check": "^4.3.4",
|
||||
"typescript": "~5.9.3",
|
||||
|
|
@ -361,9 +362,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -378,9 +379,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -395,9 +396,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -412,9 +413,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -429,9 +430,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -446,9 +447,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -463,9 +464,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -480,9 +481,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -497,9 +498,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
|
||||
"integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
|
|
@ -514,9 +515,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -531,9 +532,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -548,9 +549,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
|
||||
"integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
|
|
@ -565,9 +566,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
|
||||
"integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
|
|
@ -582,9 +583,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
|
||||
"integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
|
|
@ -599,9 +600,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
|
||||
"integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
|
|
@ -616,9 +617,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
|
||||
"integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
|
|
@ -633,9 +634,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -650,9 +651,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -667,9 +668,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -684,9 +685,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -701,9 +702,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -718,9 +719,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -735,9 +736,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -752,9 +753,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
|
||||
"integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
|
|
@ -769,9 +770,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
|
||||
"integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
|
|
@ -786,9 +787,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
|
||||
"integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
|
|
@ -4810,9 +4811,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
"integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
|
|
@ -4823,32 +4824,32 @@
|
|||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.3",
|
||||
"@esbuild/android-arm": "0.27.3",
|
||||
"@esbuild/android-arm64": "0.27.3",
|
||||
"@esbuild/android-x64": "0.27.3",
|
||||
"@esbuild/darwin-arm64": "0.27.3",
|
||||
"@esbuild/darwin-x64": "0.27.3",
|
||||
"@esbuild/freebsd-arm64": "0.27.3",
|
||||
"@esbuild/freebsd-x64": "0.27.3",
|
||||
"@esbuild/linux-arm": "0.27.3",
|
||||
"@esbuild/linux-arm64": "0.27.3",
|
||||
"@esbuild/linux-ia32": "0.27.3",
|
||||
"@esbuild/linux-loong64": "0.27.3",
|
||||
"@esbuild/linux-mips64el": "0.27.3",
|
||||
"@esbuild/linux-ppc64": "0.27.3",
|
||||
"@esbuild/linux-riscv64": "0.27.3",
|
||||
"@esbuild/linux-s390x": "0.27.3",
|
||||
"@esbuild/linux-x64": "0.27.3",
|
||||
"@esbuild/netbsd-arm64": "0.27.3",
|
||||
"@esbuild/netbsd-x64": "0.27.3",
|
||||
"@esbuild/openbsd-arm64": "0.27.3",
|
||||
"@esbuild/openbsd-x64": "0.27.3",
|
||||
"@esbuild/openharmony-arm64": "0.27.3",
|
||||
"@esbuild/sunos-x64": "0.27.3",
|
||||
"@esbuild/win32-arm64": "0.27.3",
|
||||
"@esbuild/win32-ia32": "0.27.3",
|
||||
"@esbuild/win32-x64": "0.27.3"
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
"@wdio/local-runner": "^9.24.0",
|
||||
"@wdio/mocha-framework": "^9.24.0",
|
||||
"@wdio/spec-reporter": "^9.24.0",
|
||||
"esbuild": "^0.27.4",
|
||||
"svelte": "^5.45.2",
|
||||
"svelte-check": "^4.3.4",
|
||||
"typescript": "~5.9.3",
|
||||
|
|
|
|||
731
v2/sidecar/aider-parser.test.ts
Normal file
731
v2/sidecar/aider-parser.test.ts
Normal file
|
|
@ -0,0 +1,731 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
looksLikePrompt,
|
||||
shouldSuppress,
|
||||
parseTurnOutput,
|
||||
extractSessionCost,
|
||||
prefetchContext,
|
||||
execShell,
|
||||
PROMPT_RE,
|
||||
SUPPRESS_RE,
|
||||
SHELL_CMD_RE,
|
||||
} from './aider-parser';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures — realistic Aider output samples used as format-drift canaries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FIXTURE_STARTUP = [
|
||||
'Aider v0.72.1',
|
||||
'Main model: openrouter/anthropic/claude-sonnet-4 with diff edit format',
|
||||
'Weak model: openrouter/anthropic/claude-haiku-4',
|
||||
'Git repo: none',
|
||||
'Repo-map: disabled',
|
||||
'Use /help to see in-chat commands, run with --help to see cmd line args',
|
||||
'> ',
|
||||
].join('\n');
|
||||
|
||||
const FIXTURE_SIMPLE_ANSWER = [
|
||||
'► THINKING',
|
||||
'The user wants me to check the task board.',
|
||||
'► ANSWER',
|
||||
'I will check the task board for you.',
|
||||
'bttask board',
|
||||
'Tokens: 1234 sent, 56 received. Cost: $0.0023 message, $0.0045 session',
|
||||
'> ',
|
||||
].join('\n');
|
||||
|
||||
const FIXTURE_CODE_BLOCK_SHELL = [
|
||||
'Here is the command to send a message:',
|
||||
'```bash',
|
||||
'$ btmsg send manager-001 "Task complete"',
|
||||
'```',
|
||||
'Tokens: 800 sent, 40 received. Cost: $0.0010 message, $0.0021 session',
|
||||
'aider> ',
|
||||
].join('\n');
|
||||
|
||||
const FIXTURE_MIXED_BLOCKS = [
|
||||
'► THINKING',
|
||||
'I need to check inbox then update the task.',
|
||||
'► ANSWER',
|
||||
'Let me check your inbox first.',
|
||||
'btmsg inbox',
|
||||
'Now updating the task status.',
|
||||
'```bash',
|
||||
'bttask status task-42 done',
|
||||
'```',
|
||||
'All done!',
|
||||
'Tokens: 2000 sent, 120 received. Cost: $0.0040 message, $0.0080 session',
|
||||
'my-repo> ',
|
||||
].join('\n');
|
||||
|
||||
const FIXTURE_APPLIED_EDIT_NOISE = [
|
||||
'I will edit the file.',
|
||||
'Applied edit to src/main.ts',
|
||||
'Fix any errors below',
|
||||
'Running: flake8 src/main.ts',
|
||||
'The edit is complete.',
|
||||
'Tokens: 500 sent, 30 received. Cost: $0.0005 message, $0.0010 session',
|
||||
'> ',
|
||||
].join('\n');
|
||||
|
||||
const FIXTURE_DOLLAR_PREFIX_SHELL = [
|
||||
'Run this command:',
|
||||
'$ git status',
|
||||
'After that, commit your changes.',
|
||||
'> ',
|
||||
].join('\n');
|
||||
|
||||
const FIXTURE_RUNNING_PREFIX_SHELL = [
|
||||
'Running git log --oneline -5',
|
||||
'Tokens: 300 sent, 20 received. Cost: $0.0003 message, $0.0006 session',
|
||||
'> ',
|
||||
].join('\n');
|
||||
|
||||
const FIXTURE_NO_COST = [
|
||||
'► THINKING',
|
||||
'Checking the situation.',
|
||||
'► ANSWER',
|
||||
'Nothing to do right now.',
|
||||
'> ',
|
||||
].join('\n');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// looksLikePrompt
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('looksLikePrompt', () => {
|
||||
it('detects bare "> " prompt', () => {
|
||||
expect(looksLikePrompt('> ')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects "aider> " prompt', () => {
|
||||
expect(looksLikePrompt('aider> ')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects repo-named prompt like "my-repo> "', () => {
|
||||
expect(looksLikePrompt('my-repo> ')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects prompt after multi-line output', () => {
|
||||
const buffer = 'Some output line\nAnother line\naider> ';
|
||||
expect(looksLikePrompt(buffer)).toBe(true);
|
||||
});
|
||||
|
||||
it('detects prompt when trailing blank lines follow', () => {
|
||||
const buffer = 'aider> \n\n';
|
||||
expect(looksLikePrompt(buffer)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a full sentence ending in > but not a prompt', () => {
|
||||
expect(looksLikePrompt('This is greater than> something')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(looksLikePrompt('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for string with only blank lines', () => {
|
||||
expect(looksLikePrompt('\n\n\n')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for plain text with no prompt', () => {
|
||||
expect(looksLikePrompt('I have analyzed the task and will now proceed.')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles dotted repo names like "my.project> "', () => {
|
||||
expect(looksLikePrompt('my.project> ')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects prompt in full startup fixture', () => {
|
||||
expect(looksLikePrompt(FIXTURE_STARTUP)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldSuppress
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shouldSuppress', () => {
|
||||
it('suppresses empty string', () => {
|
||||
expect(shouldSuppress('')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses whitespace-only string', () => {
|
||||
expect(shouldSuppress(' ')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses Aider version line', () => {
|
||||
expect(shouldSuppress('Aider v0.72.1')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses "Main model:" line', () => {
|
||||
expect(shouldSuppress('Main model: claude-sonnet-4 with diff format')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses "Weak model:" line', () => {
|
||||
expect(shouldSuppress('Weak model: claude-haiku-4')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses "Git repo:" line', () => {
|
||||
expect(shouldSuppress('Git repo: none')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses "Repo-map:" line', () => {
|
||||
expect(shouldSuppress('Repo-map: disabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('suppresses "Use /help" line', () => {
|
||||
expect(shouldSuppress('Use /help to see in-chat commands, run with --help to see cmd line args')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not suppress regular answer text', () => {
|
||||
expect(shouldSuppress('I will check the task board for you.')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not suppress a shell command line', () => {
|
||||
expect(shouldSuppress('bttask board')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not suppress a cost line', () => {
|
||||
expect(shouldSuppress('Tokens: 1234 sent, 56 received. Cost: $0.0023 message, $0.0045 session')).toBe(false);
|
||||
});
|
||||
|
||||
it('strips leading/trailing whitespace before testing', () => {
|
||||
expect(shouldSuppress(' Aider v0.70.0 ')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseTurnOutput — thinking blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseTurnOutput — thinking blocks', () => {
|
||||
it('extracts a thinking block using ► THINKING / ► ANSWER markers', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_SIMPLE_ANSWER);
|
||||
const thinking = blocks.filter(b => b.type === 'thinking');
|
||||
expect(thinking).toHaveLength(1);
|
||||
expect(thinking[0].content).toContain('check the task board');
|
||||
});
|
||||
|
||||
it('extracts thinking with ▶ arrow variant', () => {
|
||||
const buffer = '▶ THINKING\nSome reasoning here.\n▶ ANSWER\nHere is the answer.\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
expect(blocks[0].type).toBe('thinking');
|
||||
expect(blocks[0].content).toContain('Some reasoning here.');
|
||||
});
|
||||
|
||||
it('extracts thinking with > arrow variant', () => {
|
||||
const buffer = '> THINKING\nDeep thoughts.\n> ANSWER\nFinal answer.\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
const thinking = blocks.filter(b => b.type === 'thinking');
|
||||
expect(thinking).toHaveLength(1);
|
||||
expect(thinking[0].content).toContain('Deep thoughts.');
|
||||
});
|
||||
|
||||
it('handles missing ANSWER marker — flushes thinking at end', () => {
|
||||
const buffer = '► THINKING\nIncomplete thinking block.\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
const thinking = blocks.filter(b => b.type === 'thinking');
|
||||
expect(thinking).toHaveLength(1);
|
||||
expect(thinking[0].content).toContain('Incomplete thinking block.');
|
||||
});
|
||||
|
||||
it('produces no thinking block when no THINKING marker present', () => {
|
||||
const buffer = 'Just plain text.\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
expect(blocks.filter(b => b.type === 'thinking')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseTurnOutput — text blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseTurnOutput — text blocks', () => {
|
||||
it('extracts text after ANSWER marker', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_SIMPLE_ANSWER);
|
||||
const texts = blocks.filter(b => b.type === 'text');
|
||||
expect(texts.length).toBeGreaterThan(0);
|
||||
expect(texts[0].content).toContain('I will check the task board');
|
||||
});
|
||||
|
||||
it('trims trailing whitespace from flushed text block', () => {
|
||||
// Note: parseTurnOutput checks PROMPT_RE against the trimmed line.
|
||||
// ">" (trimmed from "> ") does not match PROMPT_RE (which requires trailing space),
|
||||
// so the final flush trims the accumulated content via .trim().
|
||||
const buffer = 'Some text with trailing space. ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
const texts = blocks.filter(b => b.type === 'text');
|
||||
expect(texts[0].content).toBe('Some text with trailing space.');
|
||||
});
|
||||
|
||||
it('does not produce a text block from suppressed startup lines alone', () => {
|
||||
// All Aider startup lines are suppressed by SUPPRESS_RE.
|
||||
// The ">" (trimmed from "> ") does NOT match PROMPT_RE (requires trailing space),
|
||||
// but it is also not a recognized command or thinking marker, so it lands in answerLines.
|
||||
// The final text block is trimmed — ">".trim() = ">", non-empty, so one text block with ">" appears.
|
||||
// What we care about is that suppressed startup noise does NOT appear in text.
|
||||
const buffer = [
|
||||
'Aider v0.72.1',
|
||||
'Main model: some-model',
|
||||
].join('\n');
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
expect(blocks.filter(b => b.type === 'text')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('suppresses Applied edit / flake8 / Running: lines in answer text', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_APPLIED_EDIT_NOISE);
|
||||
const texts = blocks.filter(b => b.type === 'text');
|
||||
const combined = texts.map(b => b.content).join(' ');
|
||||
expect(combined).not.toContain('Applied edit');
|
||||
expect(combined).not.toContain('Fix any errors');
|
||||
expect(combined).not.toContain('Running:');
|
||||
});
|
||||
|
||||
it('preserves non-suppressed text around noise lines', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_APPLIED_EDIT_NOISE);
|
||||
const texts = blocks.filter(b => b.type === 'text');
|
||||
const combined = texts.map(b => b.content).join(' ');
|
||||
expect(combined).toContain('I will edit the file');
|
||||
expect(combined).toContain('The edit is complete');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseTurnOutput — shell blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseTurnOutput — shell blocks from code blocks', () => {
|
||||
it('extracts btmsg command from ```bash block', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_CODE_BLOCK_SHELL);
|
||||
const shells = blocks.filter(b => b.type === 'shell');
|
||||
expect(shells).toHaveLength(1);
|
||||
expect(shells[0].content).toBe('btmsg send manager-001 "Task complete"');
|
||||
});
|
||||
|
||||
it('strips leading "$ " from commands inside code block', () => {
|
||||
const buffer = '```bash\n$ btmsg inbox\n```\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
const shells = blocks.filter(b => b.type === 'shell');
|
||||
expect(shells[0].content).toBe('btmsg inbox');
|
||||
});
|
||||
|
||||
it('extracts commands from ```shell block', () => {
|
||||
const buffer = '```shell\nbttask board\n```\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
expect(blocks.filter(b => b.type === 'shell')).toHaveLength(1);
|
||||
expect(blocks.find(b => b.type === 'shell')!.content).toBe('bttask board');
|
||||
});
|
||||
|
||||
it('extracts commands from plain ``` block (no language tag)', () => {
|
||||
const buffer = '```\nbtmsg inbox\n```\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
expect(blocks.filter(b => b.type === 'shell')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not extract non-shell-command lines from code blocks', () => {
|
||||
const buffer = '```bash\nsome arbitrary text without a known prefix\n```\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
expect(blocks.filter(b => b.type === 'shell')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not extract commands from ```python blocks', () => {
|
||||
const buffer = '```python\nbtmsg send something "hello"\n```\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
// Python blocks should not be treated as shell commands
|
||||
expect(blocks.filter(b => b.type === 'shell')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTurnOutput — shell blocks from inline prefixes', () => {
|
||||
it('detects "$ " prefix shell command', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_DOLLAR_PREFIX_SHELL);
|
||||
const shells = blocks.filter(b => b.type === 'shell');
|
||||
expect(shells).toHaveLength(1);
|
||||
expect(shells[0].content).toBe('git status');
|
||||
});
|
||||
|
||||
it('detects "Running " prefix shell command', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_RUNNING_PREFIX_SHELL);
|
||||
const shells = blocks.filter(b => b.type === 'shell');
|
||||
expect(shells).toHaveLength(1);
|
||||
expect(shells[0].content).toBe('git log --oneline -5');
|
||||
});
|
||||
|
||||
it('detects bare btmsg/bttask commands in ANSWER section', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_SIMPLE_ANSWER);
|
||||
const shells = blocks.filter(b => b.type === 'shell');
|
||||
expect(shells.some(s => s.content === 'bttask board')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not extract bare commands from THINKING section', () => {
|
||||
const buffer = '► THINKING\nbtmsg inbox\n► ANSWER\nDone.\n> ';
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
// btmsg inbox in thinking section should be accumulated as thinking, not shell
|
||||
expect(blocks.filter(b => b.type === 'shell')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('flushes preceding text block before a shell block', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_DOLLAR_PREFIX_SHELL);
|
||||
const textIdx = blocks.findIndex(b => b.type === 'text');
|
||||
const shellIdx = blocks.findIndex(b => b.type === 'shell');
|
||||
expect(textIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(shellIdx).toBeGreaterThan(textIdx);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseTurnOutput — cost blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseTurnOutput — cost blocks', () => {
|
||||
it('extracts cost line as a cost block', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_SIMPLE_ANSWER);
|
||||
const costs = blocks.filter(b => b.type === 'cost');
|
||||
expect(costs).toHaveLength(1);
|
||||
expect(costs[0].content).toContain('Cost:');
|
||||
});
|
||||
|
||||
it('preserves the full cost line as content', () => {
|
||||
const costLine = 'Tokens: 1234 sent, 56 received. Cost: $0.0023 message, $0.0045 session';
|
||||
const buffer = `Some text.\n${costLine}\n> `;
|
||||
const blocks = parseTurnOutput(buffer);
|
||||
const cost = blocks.find(b => b.type === 'cost');
|
||||
expect(cost?.content).toBe(costLine);
|
||||
});
|
||||
|
||||
it('produces no cost block when no cost line present', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_NO_COST);
|
||||
expect(blocks.filter(b => b.type === 'cost')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseTurnOutput — mixed turn (thinking + text + shell + cost)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parseTurnOutput — mixed blocks', () => {
|
||||
it('produces all four block types from a mixed turn', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_MIXED_BLOCKS);
|
||||
const types = blocks.map(b => b.type);
|
||||
expect(types).toContain('thinking');
|
||||
expect(types).toContain('text');
|
||||
expect(types).toContain('shell');
|
||||
expect(types).toContain('cost');
|
||||
});
|
||||
|
||||
it('preserves block order: thinking → text → shell → text → cost', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_MIXED_BLOCKS);
|
||||
expect(blocks[0].type).toBe('thinking');
|
||||
// At least one shell block present
|
||||
const shellIdx = blocks.findIndex(b => b.type === 'shell');
|
||||
expect(shellIdx).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('extracts both btmsg and bttask shell commands from mixed turn', () => {
|
||||
const blocks = parseTurnOutput(FIXTURE_MIXED_BLOCKS);
|
||||
const shells = blocks.filter(b => b.type === 'shell').map(b => b.content);
|
||||
expect(shells).toContain('btmsg inbox');
|
||||
expect(shells).toContain('bttask status task-42 done');
|
||||
});
|
||||
|
||||
it('returns empty array for empty buffer', () => {
|
||||
expect(parseTurnOutput('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns empty array for buffer with only suppressed lines', () => {
|
||||
// All Aider startup noise is covered by SUPPRESS_RE.
|
||||
// A buffer of only suppressed lines produces no output blocks.
|
||||
const buffer = [
|
||||
'Aider v0.72.1',
|
||||
'Main model: claude-sonnet-4',
|
||||
].join('\n');
|
||||
expect(parseTurnOutput(buffer)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractSessionCost
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('extractSessionCost', () => {
|
||||
it('extracts session cost from a cost line', () => {
|
||||
const buffer = 'Tokens: 1234 sent, 56 received. Cost: $0.0023 message, $0.0045 session\n> ';
|
||||
expect(extractSessionCost(buffer)).toBeCloseTo(0.0045);
|
||||
});
|
||||
|
||||
it('returns 0 when no cost line present', () => {
|
||||
expect(extractSessionCost('Some answer without cost.\n> ')).toBe(0);
|
||||
});
|
||||
|
||||
it('correctly picks session cost (second dollar amount), not message cost (first)', () => {
|
||||
const buffer = 'Cost: $0.0100 message, $0.0250 session';
|
||||
expect(extractSessionCost(buffer)).toBeCloseTo(0.0250);
|
||||
});
|
||||
|
||||
it('handles zero cost values', () => {
|
||||
expect(extractSessionCost('Cost: $0.0000 message, $0.0000 session')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// prefetchContext — mocked child_process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('prefetchContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns inbox and board sections when both CLIs succeed', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
const mockExecSync = vi.mocked(execSync);
|
||||
mockExecSync
|
||||
.mockReturnValueOnce('Message from manager-001: fix bug' as never)
|
||||
.mockReturnValueOnce('task-1 | In Progress | Fix login bug' as never);
|
||||
|
||||
const result = prefetchContext({ BTMSG_AGENT_ID: 'agent-001' }, '/tmp');
|
||||
|
||||
expect(result).toContain('## Your Inbox');
|
||||
expect(result).toContain('Message from manager-001');
|
||||
expect(result).toContain('## Task Board');
|
||||
expect(result).toContain('task-1');
|
||||
});
|
||||
|
||||
it('falls back to "No messages" when btmsg unavailable', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
const mockExecSync = vi.mocked(execSync);
|
||||
mockExecSync
|
||||
.mockImplementationOnce(() => { throw new Error('command not found'); })
|
||||
.mockReturnValueOnce('task-1 | todo' as never);
|
||||
|
||||
const result = prefetchContext({}, '/tmp');
|
||||
|
||||
expect(result).toContain('No messages (or btmsg unavailable).');
|
||||
expect(result).toContain('## Task Board');
|
||||
});
|
||||
|
||||
it('falls back to "No tasks" when bttask unavailable', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
const mockExecSync = vi.mocked(execSync);
|
||||
mockExecSync
|
||||
.mockReturnValueOnce('inbox message' as never)
|
||||
.mockImplementationOnce(() => { throw new Error('command not found'); });
|
||||
|
||||
const result = prefetchContext({}, '/tmp');
|
||||
|
||||
expect(result).toContain('## Your Inbox');
|
||||
expect(result).toContain('No tasks (or bttask unavailable).');
|
||||
});
|
||||
|
||||
it('falls back for both when both CLIs unavailable', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
const mockExecSync = vi.mocked(execSync);
|
||||
mockExecSync.mockImplementation(() => { throw new Error('not found'); });
|
||||
|
||||
const result = prefetchContext({}, '/tmp');
|
||||
|
||||
expect(result).toContain('No messages (or btmsg unavailable).');
|
||||
expect(result).toContain('No tasks (or bttask unavailable).');
|
||||
});
|
||||
|
||||
it('wraps inbox content in fenced code block', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
const mockExecSync = vi.mocked(execSync);
|
||||
mockExecSync
|
||||
.mockReturnValueOnce('inbox line 1\ninbox line 2' as never)
|
||||
.mockReturnValueOnce('' as never);
|
||||
|
||||
const result = prefetchContext({}, '/tmp');
|
||||
|
||||
expect(result).toMatch(/```\ninbox line 1\ninbox line 2\n```/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// execShell — mocked child_process
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('execShell', () => {
|
||||
beforeEach(() => {
|
||||
vi.mock('child_process', () => ({
|
||||
execSync: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns trimmed stdout and exitCode 0 on success', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
vi.mocked(execSync).mockReturnValue('hello world\n' as never);
|
||||
|
||||
const result = execShell('echo hello world', {}, '/tmp');
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout).toBe('hello world');
|
||||
});
|
||||
|
||||
it('returns stderr content and non-zero exitCode on failure', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
vi.mocked(execSync).mockImplementation(() => {
|
||||
const err = Object.assign(new Error('Command failed'), {
|
||||
stderr: 'No such file or directory',
|
||||
status: 127,
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
|
||||
const result = execShell('missing-cmd', {}, '/tmp');
|
||||
|
||||
expect(result.exitCode).toBe(127);
|
||||
expect(result.stdout).toContain('No such file or directory');
|
||||
});
|
||||
|
||||
it('falls back to stdout field on error if stderr is empty', async () => {
|
||||
const { execSync } = await import('child_process');
|
||||
vi.mocked(execSync).mockImplementation(() => {
|
||||
const err = Object.assign(new Error('fail'), {
|
||||
stdout: 'partial output',
|
||||
stderr: '',
|
||||
status: 1,
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
|
||||
const result = execShell('cmd', {}, '/tmp');
|
||||
|
||||
expect(result.stdout).toBe('partial output');
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Format-drift canary — realistic Aider output samples
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('format-drift canary', () => {
|
||||
it('correctly parses a full realistic turn with thinking, commands, and cost', () => {
|
||||
// Represents what aider actually outputs in practice with --no-stream --no-pretty
|
||||
const realisticOutput = [
|
||||
'► THINKING',
|
||||
'The user needs me to check the inbox and act on any pending tasks.',
|
||||
'I should run btmsg inbox to see messages, then bttask board to see tasks.',
|
||||
'► ANSWER',
|
||||
'I will check your inbox and task board now.',
|
||||
'```bash',
|
||||
'$ btmsg inbox',
|
||||
'```',
|
||||
'```bash',
|
||||
'$ bttask board',
|
||||
'```',
|
||||
'Based on the results, I will proceed.',
|
||||
'Tokens: 3500 sent, 250 received. Cost: $0.0070 message, $0.0140 session',
|
||||
'aider> ',
|
||||
].join('\n');
|
||||
|
||||
const blocks = parseTurnOutput(realisticOutput);
|
||||
const types = blocks.map(b => b.type);
|
||||
|
||||
expect(types).toContain('thinking');
|
||||
expect(types).toContain('text');
|
||||
expect(types).toContain('shell');
|
||||
expect(types).toContain('cost');
|
||||
|
||||
const shells = blocks.filter(b => b.type === 'shell').map(b => b.content);
|
||||
expect(shells).toContain('btmsg inbox');
|
||||
expect(shells).toContain('bttask board');
|
||||
|
||||
expect(extractSessionCost(realisticOutput)).toBeCloseTo(0.0140);
|
||||
});
|
||||
|
||||
it('startup fixture: looksLikePrompt matches after typical Aider startup output', () => {
|
||||
expect(looksLikePrompt(FIXTURE_STARTUP)).toBe(true);
|
||||
});
|
||||
|
||||
it('startup fixture: all startup lines are suppressed by shouldSuppress', () => {
|
||||
const startupLines = [
|
||||
'Aider v0.72.1',
|
||||
'Main model: openrouter/anthropic/claude-sonnet-4 with diff edit format',
|
||||
'Weak model: openrouter/anthropic/claude-haiku-4',
|
||||
'Git repo: none',
|
||||
'Repo-map: disabled',
|
||||
'Use /help to see in-chat commands, run with --help to see cmd line args',
|
||||
];
|
||||
for (const line of startupLines) {
|
||||
expect(shouldSuppress(line), `Expected shouldSuppress("${line}") to be true`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('PROMPT_RE matches all expected prompt forms', () => {
|
||||
const validPrompts = ['> ', 'aider> ', 'my-repo> ', 'project.name> ', 'repo_123> '];
|
||||
for (const p of validPrompts) {
|
||||
expect(PROMPT_RE.test(p), `Expected PROMPT_RE to match "${p}"`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('PROMPT_RE rejects non-prompt forms', () => {
|
||||
const notPrompts = ['> something', 'text> more text ', '>text', ''];
|
||||
for (const p of notPrompts) {
|
||||
expect(PROMPT_RE.test(p), `Expected PROMPT_RE not to match "${p}"`).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('SHELL_CMD_RE matches all documented command prefixes', () => {
|
||||
const cmds = [
|
||||
'btmsg send agent-001 "hello"',
|
||||
'bttask status task-42 done',
|
||||
'cat /etc/hosts',
|
||||
'ls -la',
|
||||
'find . -name "*.ts"',
|
||||
'grep -r "TODO" src/',
|
||||
'mkdir -p /tmp/test',
|
||||
'cd /home/user',
|
||||
'cp file.ts file2.ts',
|
||||
'mv old.ts new.ts',
|
||||
'rm -rf /tmp/test',
|
||||
'pip install requests',
|
||||
'npm install',
|
||||
'git status',
|
||||
'curl https://example.com',
|
||||
'wget https://example.com/file',
|
||||
'python script.py',
|
||||
'node index.js',
|
||||
'bash run.sh',
|
||||
'sh script.sh',
|
||||
];
|
||||
for (const cmd of cmds) {
|
||||
expect(SHELL_CMD_RE.test(cmd), `Expected SHELL_CMD_RE to match "${cmd}"`).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('parseTurnOutput produces no shell blocks for non-shell code blocks (e.g. markdown python)', () => {
|
||||
const buffer = [
|
||||
'Here is example Python code:',
|
||||
'```python',
|
||||
'import os',
|
||||
'print(os.getcwd())',
|
||||
'```',
|
||||
'> ',
|
||||
].join('\n');
|
||||
const shells = parseTurnOutput(buffer).filter(b => b.type === 'shell');
|
||||
expect(shells).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('cost regex format has not changed — still "Cost: $X.XX message, $Y.YY session"', () => {
|
||||
const costLine = 'Tokens: 1234 sent, 56 received. Cost: $0.0023 message, $0.0045 session';
|
||||
expect(extractSessionCost(costLine)).toBeCloseTo(0.0045);
|
||||
// Verify the message cost is different from session cost (they're two separate values)
|
||||
const msgMatch = costLine.match(/Cost: \$([0-9.]+) message/);
|
||||
expect(msgMatch).not.toBeNull();
|
||||
expect(parseFloat(msgMatch![1])).toBeCloseTo(0.0023);
|
||||
});
|
||||
});
|
||||
243
v2/sidecar/aider-parser.ts
Normal file
243
v2/sidecar/aider-parser.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
// aider-parser.ts — Pure parsing functions extracted from aider-runner.ts
|
||||
// Exported for unit testing. aider-runner.ts imports from here.
|
||||
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface TurnBlock {
|
||||
type: 'thinking' | 'text' | 'shell' | 'cost';
|
||||
content: string;
|
||||
}
|
||||
|
||||
// --- Constants ---
|
||||
|
||||
// Prompt detection: Aider with --no-pretty --no-fancy-input shows prompts like:
|
||||
// > or aider> or repo-name>
|
||||
export const PROMPT_RE = /^[a-zA-Z0-9._-]*> $/;
|
||||
|
||||
// Lines to suppress from UI (aider startup noise)
|
||||
export const SUPPRESS_RE = [
|
||||
/^Aider v\d/,
|
||||
/^Main model:/,
|
||||
/^Weak model:/,
|
||||
/^Git repo:/,
|
||||
/^Repo-map:/,
|
||||
/^Use \/help/,
|
||||
];
|
||||
|
||||
// Known shell command patterns — commands from btmsg/bttask/common tools
|
||||
export const SHELL_CMD_RE = /^(btmsg |bttask |cat |ls |find |grep |mkdir |cd |cp |mv |rm |pip |npm |git |curl |wget |python |node |bash |sh )/;
|
||||
|
||||
// --- Pure parsing functions ---
|
||||
|
||||
/**
|
||||
* Detects whether the last non-empty line of a buffer looks like an Aider prompt.
|
||||
* Aider with --no-pretty --no-fancy-input shows prompts like: `> `, `aider> `, `repo-name> `
|
||||
*/
|
||||
export function looksLikePrompt(buffer: string): boolean {
|
||||
const lines = buffer.split('\n');
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const l = lines[i];
|
||||
if (l.trim() === '') continue;
|
||||
return PROMPT_RE.test(l);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true for lines that should be suppressed from the UI output.
|
||||
* Covers Aider startup noise and empty lines.
|
||||
*/
|
||||
export function shouldSuppress(line: string): boolean {
|
||||
const t = line.trim();
|
||||
return t === '' || SUPPRESS_RE.some(p => p.test(t));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses complete Aider turn output into structured blocks.
|
||||
* Handles thinking sections, text, shell commands extracted from code blocks
|
||||
* or inline, cost lines, and suppresses startup noise.
|
||||
*/
|
||||
export function parseTurnOutput(buffer: string): TurnBlock[] {
|
||||
const blocks: TurnBlock[] = [];
|
||||
const lines = buffer.split('\n');
|
||||
|
||||
let thinkingLines: string[] = [];
|
||||
let answerLines: string[] = [];
|
||||
let inThinking = false;
|
||||
let inAnswer = false;
|
||||
let inCodeBlock = false;
|
||||
let codeBlockLang = '';
|
||||
let codeBlockLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
|
||||
// Skip suppressed lines
|
||||
if (shouldSuppress(line) && !inCodeBlock) continue;
|
||||
|
||||
// Prompt markers — skip
|
||||
if (PROMPT_RE.test(t)) continue;
|
||||
|
||||
// Thinking block markers (handle various unicode arrows and spacing)
|
||||
if (/^[►▶⯈❯>]\s*THINKING$/i.test(t)) {
|
||||
inThinking = true;
|
||||
inAnswer = false;
|
||||
continue;
|
||||
}
|
||||
if (/^[►▶⯈❯>]\s*ANSWER$/i.test(t)) {
|
||||
if (thinkingLines.length > 0) {
|
||||
blocks.push({ type: 'thinking', content: thinkingLines.join('\n') });
|
||||
thinkingLines = [];
|
||||
}
|
||||
inThinking = false;
|
||||
inAnswer = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Code block detection (```bash, ```shell, ```)
|
||||
if (t.startsWith('```') && !inCodeBlock) {
|
||||
inCodeBlock = true;
|
||||
codeBlockLang = t.slice(3).trim().toLowerCase();
|
||||
codeBlockLines = [];
|
||||
continue;
|
||||
}
|
||||
if (t === '```' && inCodeBlock) {
|
||||
inCodeBlock = false;
|
||||
// If this was a bash/shell code block, extract commands
|
||||
if (['bash', 'shell', 'sh', ''].includes(codeBlockLang)) {
|
||||
for (const cmdLine of codeBlockLines) {
|
||||
const cmd = cmdLine.trim().replace(/^\$ /, '');
|
||||
if (cmd && SHELL_CMD_RE.test(cmd)) {
|
||||
if (answerLines.length > 0) {
|
||||
blocks.push({ type: 'text', content: answerLines.join('\n') });
|
||||
answerLines = [];
|
||||
}
|
||||
blocks.push({ type: 'shell', content: cmd });
|
||||
}
|
||||
}
|
||||
}
|
||||
codeBlockLines = [];
|
||||
continue;
|
||||
}
|
||||
if (inCodeBlock) {
|
||||
codeBlockLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cost line
|
||||
if (/^Tokens: .+Cost:/.test(t)) {
|
||||
blocks.push({ type: 'cost', content: t });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Shell command ($ prefix or Running prefix)
|
||||
if (t.startsWith('$ ') || t.startsWith('Running ')) {
|
||||
if (answerLines.length > 0) {
|
||||
blocks.push({ type: 'text', content: answerLines.join('\n') });
|
||||
answerLines = [];
|
||||
}
|
||||
blocks.push({ type: 'shell', content: t.replace(/^(Running |\$ )/, '') });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect bare btmsg/bttask commands in answer text
|
||||
if (inAnswer && SHELL_CMD_RE.test(t) && !t.includes('`') && !t.startsWith('#')) {
|
||||
if (answerLines.length > 0) {
|
||||
blocks.push({ type: 'text', content: answerLines.join('\n') });
|
||||
answerLines = [];
|
||||
}
|
||||
blocks.push({ type: 'shell', content: t });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Aider's "Applied edit" / flake8 output — suppress from answer text
|
||||
if (/^Applied edit to |^Fix any errors|^Running: /.test(t)) continue;
|
||||
|
||||
// Accumulate into thinking or answer
|
||||
if (inThinking) {
|
||||
thinkingLines.push(line);
|
||||
} else {
|
||||
answerLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining
|
||||
if (thinkingLines.length > 0) {
|
||||
blocks.push({ type: 'thinking', content: thinkingLines.join('\n') });
|
||||
}
|
||||
if (answerLines.length > 0) {
|
||||
blocks.push({ type: 'text', content: answerLines.join('\n').trim() });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session cost from a raw turn buffer.
|
||||
* Returns 0 when no cost line is present.
|
||||
*/
|
||||
export function extractSessionCost(buffer: string): number {
|
||||
const match = buffer.match(/Cost: \$([0-9.]+) message, \$([0-9.]+) session/);
|
||||
return match ? parseFloat(match[2]) : 0;
|
||||
}
|
||||
|
||||
// --- I/O helpers (require real child_process; mock in tests) ---
|
||||
|
||||
function log(message: string) {
|
||||
process.stderr.write(`[aider-parser] ${message}\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a CLI command and returns its trimmed stdout, or null on failure/empty.
|
||||
*/
|
||||
export function runCmd(cmd: string, env: Record<string, string>, cwd: string): string | null {
|
||||
try {
|
||||
const result = execSync(cmd, { env, cwd, timeout: 5000, encoding: 'utf-8' }).trim();
|
||||
log(`[prefetch] ${cmd} → ${result.length} chars`);
|
||||
return result || null;
|
||||
} catch (e: unknown) {
|
||||
log(`[prefetch] ${cmd} FAILED: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-fetches btmsg inbox and bttask board context.
|
||||
* Returns formatted markdown with both sections.
|
||||
*/
|
||||
export function prefetchContext(env: Record<string, string>, cwd: string): string {
|
||||
log(`[prefetch] BTMSG_AGENT_ID=${env.BTMSG_AGENT_ID ?? 'NOT SET'}, cwd=${cwd}`);
|
||||
const parts: string[] = [];
|
||||
|
||||
const inbox = runCmd('btmsg inbox', env, cwd);
|
||||
if (inbox) {
|
||||
parts.push(`## Your Inbox\n\`\`\`\n${inbox}\n\`\`\``);
|
||||
} else {
|
||||
parts.push('## Your Inbox\nNo messages (or btmsg unavailable).');
|
||||
}
|
||||
|
||||
const board = runCmd('bttask board', env, cwd);
|
||||
if (board) {
|
||||
parts.push(`## Task Board\n\`\`\`\n${board}\n\`\`\``);
|
||||
} else {
|
||||
parts.push('## Task Board\nNo tasks (or bttask unavailable).');
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a shell command and returns stdout + exit code.
|
||||
* On failure, returns stderr/error message with a non-zero exit code.
|
||||
*/
|
||||
export function execShell(cmd: string, env: Record<string, string>, cwd: string): { stdout: string; exitCode: number } {
|
||||
try {
|
||||
const result = execSync(cmd, { env, cwd, timeout: 30000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
return { stdout: result.trim(), exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return { stdout: (err.stdout ?? err.stderr ?? String(e)).trim(), exitCode: err.status ?? 1 };
|
||||
}
|
||||
}
|
||||
|
|
@ -2,12 +2,23 @@
|
|||
// Spawned by Rust SidecarManager, communicates via stdio NDJSON
|
||||
// Runs aider in interactive mode — persistent process with stdin/stdout chat
|
||||
// Pre-fetches btmsg/bttask context so the LLM has actionable data immediately.
|
||||
//
|
||||
// Parsing logic lives in aider-parser.ts (exported for unit testing).
|
||||
|
||||
import { stdin, stdout, stderr } from 'process';
|
||||
import { createInterface } from 'readline';
|
||||
import { spawn, execSync, type ChildProcess } from 'child_process';
|
||||
import { spawn, type ChildProcess } from 'child_process';
|
||||
import { accessSync, constants } from 'fs';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
type TurnBlock,
|
||||
looksLikePrompt,
|
||||
parseTurnOutput,
|
||||
prefetchContext,
|
||||
execShell,
|
||||
extractSessionCost,
|
||||
PROMPT_RE,
|
||||
} from './aider-parser.js';
|
||||
|
||||
const rl = createInterface({ input: stdin });
|
||||
|
||||
|
|
@ -23,6 +34,7 @@ interface AiderSession {
|
|||
ready: boolean;
|
||||
env: Record<string, string>;
|
||||
cwd: string;
|
||||
autonomousMode: 'restricted' | 'autonomous';
|
||||
}
|
||||
|
||||
const sessions = new Map<string, AiderSession>();
|
||||
|
|
@ -78,212 +90,7 @@ async function handleMessage(msg: Record<string, unknown>) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Context pre-fetching ---
|
||||
// Execute btmsg/bttask CLIs to gather context BEFORE sending prompt to LLM.
|
||||
// This way the LLM gets real data to act on instead of suggesting commands.
|
||||
|
||||
function runCmd(cmd: string, env: Record<string, string>, cwd: string): string | null {
|
||||
try {
|
||||
const result = execSync(cmd, { env, cwd, timeout: 5000, encoding: 'utf-8' }).trim();
|
||||
log(`[prefetch] ${cmd} → ${result.length} chars`);
|
||||
return result || null;
|
||||
} catch (e: unknown) {
|
||||
log(`[prefetch] ${cmd} FAILED: ${e instanceof Error ? e.message : String(e)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function prefetchContext(env: Record<string, string>, cwd: string): string {
|
||||
log(`[prefetch] BTMSG_AGENT_ID=${env.BTMSG_AGENT_ID ?? 'NOT SET'}, cwd=${cwd}`);
|
||||
const parts: string[] = [];
|
||||
|
||||
const inbox = runCmd('btmsg inbox', env, cwd);
|
||||
if (inbox) {
|
||||
parts.push(`## Your Inbox\n\`\`\`\n${inbox}\n\`\`\``);
|
||||
} else {
|
||||
parts.push('## Your Inbox\nNo messages (or btmsg unavailable).');
|
||||
}
|
||||
|
||||
const board = runCmd('bttask board', env, cwd);
|
||||
if (board) {
|
||||
parts.push(`## Task Board\n\`\`\`\n${board}\n\`\`\``);
|
||||
} else {
|
||||
parts.push('## Task Board\nNo tasks (or bttask unavailable).');
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
// --- Prompt detection ---
|
||||
// Aider with --no-pretty --no-fancy-input shows prompts like:
|
||||
// > or aider> or repo-name>
|
||||
const PROMPT_RE = /^[a-zA-Z0-9._-]*> $/;
|
||||
|
||||
function looksLikePrompt(buffer: string): boolean {
|
||||
// Check the last non-empty line
|
||||
const lines = buffer.split('\n');
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const l = lines[i];
|
||||
if (l.trim() === '') continue;
|
||||
return PROMPT_RE.test(l);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Lines to suppress from UI (aider startup noise)
|
||||
const SUPPRESS_RE = [
|
||||
/^Aider v\d/,
|
||||
/^Main model:/,
|
||||
/^Weak model:/,
|
||||
/^Git repo:/,
|
||||
/^Repo-map:/,
|
||||
/^Use \/help/,
|
||||
];
|
||||
|
||||
function shouldSuppress(line: string): boolean {
|
||||
const t = line.trim();
|
||||
return t === '' || SUPPRESS_RE.some(p => p.test(t));
|
||||
}
|
||||
|
||||
// --- Shell command execution ---
|
||||
// Runs a shell command and returns {stdout, stderr, exitCode}
|
||||
|
||||
function execShell(cmd: string, env: Record<string, string>, cwd: string): { stdout: string; exitCode: number } {
|
||||
try {
|
||||
const result = execSync(cmd, { env, cwd, timeout: 30000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
return { stdout: result.trim(), exitCode: 0 };
|
||||
} catch (e: unknown) {
|
||||
const err = e as { stdout?: string; stderr?: string; status?: number };
|
||||
return { stdout: (err.stdout ?? err.stderr ?? String(e)).trim(), exitCode: err.status ?? 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Turn output parsing ---
|
||||
// Parses complete turn output into structured blocks:
|
||||
// thinking, answer text, shell commands, cost info
|
||||
|
||||
interface TurnBlock {
|
||||
type: 'thinking' | 'text' | 'shell' | 'cost';
|
||||
content: string;
|
||||
}
|
||||
|
||||
// Known shell command patterns — commands from btmsg/bttask/common tools
|
||||
const SHELL_CMD_RE = /^(btmsg |bttask |cat |ls |find |grep |mkdir |cd |cp |mv |rm |pip |npm |git |curl |wget |python |node |bash |sh )/;
|
||||
|
||||
function parseTurnOutput(buffer: string): TurnBlock[] {
|
||||
const blocks: TurnBlock[] = [];
|
||||
const lines = buffer.split('\n');
|
||||
|
||||
let thinkingLines: string[] = [];
|
||||
let answerLines: string[] = [];
|
||||
let inThinking = false;
|
||||
let inAnswer = false;
|
||||
let inCodeBlock = false;
|
||||
let codeBlockLang = '';
|
||||
let codeBlockLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
|
||||
// Skip suppressed lines
|
||||
if (shouldSuppress(line) && !inCodeBlock) continue;
|
||||
|
||||
// Prompt markers — skip
|
||||
if (PROMPT_RE.test(t)) continue;
|
||||
|
||||
// Thinking block markers (handle various unicode arrows and spacing)
|
||||
if (/^[►▶⯈❯>]\s*THINKING$/i.test(t)) {
|
||||
inThinking = true;
|
||||
inAnswer = false;
|
||||
continue;
|
||||
}
|
||||
if (/^[►▶⯈❯>]\s*ANSWER$/i.test(t)) {
|
||||
if (thinkingLines.length > 0) {
|
||||
blocks.push({ type: 'thinking', content: thinkingLines.join('\n') });
|
||||
thinkingLines = [];
|
||||
}
|
||||
inThinking = false;
|
||||
inAnswer = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Code block detection (```bash, ```shell, ```)
|
||||
if (t.startsWith('```') && !inCodeBlock) {
|
||||
inCodeBlock = true;
|
||||
codeBlockLang = t.slice(3).trim().toLowerCase();
|
||||
codeBlockLines = [];
|
||||
continue;
|
||||
}
|
||||
if (t === '```' && inCodeBlock) {
|
||||
inCodeBlock = false;
|
||||
// If this was a bash/shell code block, extract commands
|
||||
if (['bash', 'shell', 'sh', ''].includes(codeBlockLang)) {
|
||||
for (const cmdLine of codeBlockLines) {
|
||||
const cmd = cmdLine.trim().replace(/^\$ /, '');
|
||||
if (cmd && SHELL_CMD_RE.test(cmd)) {
|
||||
if (answerLines.length > 0) {
|
||||
blocks.push({ type: 'text', content: answerLines.join('\n') });
|
||||
answerLines = [];
|
||||
}
|
||||
blocks.push({ type: 'shell', content: cmd });
|
||||
}
|
||||
}
|
||||
}
|
||||
codeBlockLines = [];
|
||||
continue;
|
||||
}
|
||||
if (inCodeBlock) {
|
||||
codeBlockLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cost line
|
||||
if (/^Tokens: .+Cost:/.test(t)) {
|
||||
blocks.push({ type: 'cost', content: t });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Shell command ($ prefix or Running prefix)
|
||||
if (t.startsWith('$ ') || t.startsWith('Running ')) {
|
||||
if (answerLines.length > 0) {
|
||||
blocks.push({ type: 'text', content: answerLines.join('\n') });
|
||||
answerLines = [];
|
||||
}
|
||||
blocks.push({ type: 'shell', content: t.replace(/^(Running |\$ )/, '') });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect bare btmsg/bttask commands in answer text
|
||||
if (inAnswer && SHELL_CMD_RE.test(t) && !t.includes('`') && !t.startsWith('#')) {
|
||||
if (answerLines.length > 0) {
|
||||
blocks.push({ type: 'text', content: answerLines.join('\n') });
|
||||
answerLines = [];
|
||||
}
|
||||
blocks.push({ type: 'shell', content: t });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Aider's "Applied edit" / flake8 output — suppress from answer text
|
||||
if (/^Applied edit to |^Fix any errors|^Running: /.test(t)) continue;
|
||||
|
||||
// Accumulate into thinking or answer
|
||||
if (inThinking) {
|
||||
thinkingLines.push(line);
|
||||
} else {
|
||||
answerLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining
|
||||
if (thinkingLines.length > 0) {
|
||||
blocks.push({ type: 'thinking', content: thinkingLines.join('\n') });
|
||||
}
|
||||
if (answerLines.length > 0) {
|
||||
blocks.push({ type: 'text', content: answerLines.join('\n').trim() });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
// Parsing, I/O helpers, and constants are imported from aider-parser.ts
|
||||
|
||||
// --- Main query handler ---
|
||||
|
||||
|
|
@ -298,6 +105,8 @@ async function handleQuery(msg: QueryMessage) {
|
|||
env.OPENROUTER_API_KEY = providerConfig.openrouterApiKey;
|
||||
}
|
||||
|
||||
const autonomousMode = (providerConfig?.autonomousMode as string) === 'autonomous' ? 'autonomous' : 'restricted' as const;
|
||||
|
||||
const existing = sessions.get(sessionId);
|
||||
|
||||
// Follow-up prompt on existing session
|
||||
|
|
@ -388,6 +197,7 @@ async function handleQuery(msg: QueryMessage) {
|
|||
ready: false,
|
||||
env,
|
||||
cwd,
|
||||
autonomousMode,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
|
|
@ -456,7 +266,6 @@ async function handleQuery(msg: QueryMessage) {
|
|||
case 'shell': {
|
||||
const cmdId = `shell-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
||||
|
||||
// Emit tool_use (command being run)
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
|
|
@ -468,23 +277,34 @@ async function handleQuery(msg: QueryMessage) {
|
|||
},
|
||||
});
|
||||
|
||||
// Actually execute the command
|
||||
log(`[exec] Running: ${block.content}`);
|
||||
const result = execShell(block.content, session.env, session.cwd);
|
||||
const output = result.stdout || '(no output)';
|
||||
if (session.autonomousMode === 'autonomous') {
|
||||
log(`[exec] Running: ${block.content}`);
|
||||
const result = execShell(block.content, session.env, session.cwd);
|
||||
const output = result.stdout || '(no output)';
|
||||
|
||||
// Emit tool_result (command output)
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: {
|
||||
type: 'tool_result',
|
||||
tool_use_id: cmdId,
|
||||
content: output,
|
||||
},
|
||||
});
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: {
|
||||
type: 'tool_result',
|
||||
tool_use_id: cmdId,
|
||||
content: output,
|
||||
},
|
||||
});
|
||||
|
||||
shellResults.push(`$ ${block.content}\n${output}`);
|
||||
shellResults.push(`$ ${block.content}\n${output}`);
|
||||
} else {
|
||||
log(`[restricted] Blocked: ${block.content}`);
|
||||
send({
|
||||
type: 'agent_event',
|
||||
sessionId,
|
||||
event: {
|
||||
type: 'tool_result',
|
||||
tool_use_id: cmdId,
|
||||
content: `[BLOCKED] Shell execution disabled in restricted mode. Command not executed: ${block.content}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -495,8 +315,7 @@ async function handleQuery(msg: QueryMessage) {
|
|||
}
|
||||
|
||||
// Extract cost and emit result
|
||||
const costMatch = session.turnBuffer.match(/Cost: \$([0-9.]+) message, \$([0-9.]+) session/);
|
||||
const costUsd = costMatch ? parseFloat(costMatch[2]) : 0;
|
||||
const costUsd = extractSessionCost(session.turnBuffer);
|
||||
|
||||
send({
|
||||
type: 'agent_event',
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ opentelemetry-otlp = { version = "0.28", features = ["http-proto", "reqwest-clie
|
|||
tracing-opentelemetry = "0.29"
|
||||
keyring = { version = "3", features = ["linux-native"] }
|
||||
notify-rust = "4"
|
||||
native-tls = "0.2"
|
||||
tokio-native-tls = "0.3"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -35,6 +35,25 @@ fn open_db() -> Result<Connection, String> {
|
|||
.map_err(|e| format!("Failed to set WAL mode: {e}"))?;
|
||||
conn.query_row("PRAGMA busy_timeout = 5000", [], |_| Ok(()))
|
||||
.map_err(|e| format!("Failed to set busy_timeout: {e}"))?;
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON")
|
||||
.map_err(|e| format!("Failed to enable foreign keys: {e}"))?;
|
||||
|
||||
// Migration: add seen_messages table if not present
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS seen_messages (
|
||||
session_id TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
seen_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
PRIMARY KEY (session_id, message_id),
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_seen_messages_session ON seen_messages(session_id);"
|
||||
).map_err(|e| format!("Migration error (seen_messages): {e}"))?;
|
||||
|
||||
// Migration: add sender_group_id column to messages if not present
|
||||
// SQLite ALTER TABLE ADD COLUMN is a no-op if column already exists (errors silently)
|
||||
let _ = conn.execute_batch("ALTER TABLE messages ADD COLUMN sender_group_id TEXT");
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
|
|
@ -161,6 +180,79 @@ pub fn unread_messages(agent_id: &str) -> Result<Vec<BtmsgMessage>, String> {
|
|||
msgs.collect::<Result<Vec<_>, _>>().map_err(|e| format!("Row error: {e}"))
|
||||
}
|
||||
|
||||
/// Get messages that have not been seen by this session.
|
||||
/// Unlike unread_messages (which uses the global `read` flag),
|
||||
/// this tracks per-session acknowledgment via the seen_messages table.
|
||||
pub fn unseen_messages(agent_id: &str, session_id: &str) -> Result<Vec<BtmsgMessage>, String> {
|
||||
let db = open_db()?;
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT m.id, m.from_agent, m.to_agent, m.content, m.read, m.reply_to, m.created_at, \
|
||||
a.name AS sender_name, a.role AS sender_role \
|
||||
FROM messages m \
|
||||
LEFT JOIN agents a ON a.id = m.from_agent \
|
||||
WHERE m.to_agent = ?1 \
|
||||
AND m.id NOT IN (SELECT message_id FROM seen_messages WHERE session_id = ?2) \
|
||||
ORDER BY m.created_at ASC"
|
||||
).map_err(|e| format!("Prepare unseen query: {e}"))?;
|
||||
|
||||
let rows = stmt.query_map(params![agent_id, session_id], |row| {
|
||||
Ok(BtmsgMessage {
|
||||
id: row.get("id")?,
|
||||
from_agent: row.get("from_agent")?,
|
||||
to_agent: row.get("to_agent")?,
|
||||
content: row.get("content")?,
|
||||
read: row.get::<_, i32>("read")? != 0,
|
||||
reply_to: row.get("reply_to")?,
|
||||
created_at: row.get("created_at")?,
|
||||
sender_name: row.get("sender_name")?,
|
||||
sender_role: row.get("sender_role")?,
|
||||
})
|
||||
}).map_err(|e| format!("Query unseen: {e}"))?;
|
||||
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| format!("Row error: {e}"))
|
||||
}
|
||||
|
||||
/// Mark specific message IDs as seen by this session.
|
||||
pub fn mark_messages_seen(session_id: &str, message_ids: &[String]) -> Result<(), String> {
|
||||
if message_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let db = open_db()?;
|
||||
let mut stmt = db.prepare(
|
||||
"INSERT OR IGNORE INTO seen_messages (session_id, message_id) VALUES (?1, ?2)"
|
||||
).map_err(|e| format!("Prepare mark_seen: {e}"))?;
|
||||
|
||||
for id in message_ids {
|
||||
stmt.execute(params![session_id, id])
|
||||
.map_err(|e| format!("Insert seen: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prune seen_messages entries older than the given threshold.
|
||||
/// Uses emergency aggressive pruning (3 days) when row count exceeds the threshold.
|
||||
pub fn prune_seen_messages(max_age_secs: i64, emergency_threshold: i64) -> Result<u64, String> {
|
||||
let db = open_db()?;
|
||||
|
||||
let count: i64 = db.query_row(
|
||||
"SELECT COUNT(*) FROM seen_messages", [], |row| row.get(0)
|
||||
).map_err(|e| format!("Count seen: {e}"))?;
|
||||
|
||||
let threshold_secs = if count > emergency_threshold {
|
||||
// Emergency: prune more aggressively (3 days instead of configured max)
|
||||
max_age_secs.min(3 * 24 * 3600)
|
||||
} else {
|
||||
max_age_secs
|
||||
};
|
||||
|
||||
let deleted = db.execute(
|
||||
"DELETE FROM seen_messages WHERE seen_at < unixepoch() - ?1",
|
||||
params![threshold_secs],
|
||||
).map_err(|e| format!("Prune seen: {e}"))?;
|
||||
|
||||
Ok(deleted as u64)
|
||||
}
|
||||
|
||||
pub fn history(agent_id: &str, other_id: &str, limit: i32) -> Result<Vec<BtmsgMessage>, String> {
|
||||
let db = open_db()?;
|
||||
let mut stmt = db.prepare(
|
||||
|
|
@ -254,7 +346,8 @@ pub fn send_message(from_agent: &str, to_agent: &str, content: &str) -> Result<S
|
|||
|
||||
let msg_id = uuid::Uuid::new_v4().to_string();
|
||||
db.execute(
|
||||
"INSERT INTO messages (id, from_agent, to_agent, content, group_id) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
"INSERT INTO messages (id, from_agent, to_agent, content, group_id, sender_group_id) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, (SELECT group_id FROM agents WHERE id = ?2))",
|
||||
params![msg_id, from_agent, to_agent, content, group_id],
|
||||
).map_err(|e| format!("Insert error: {e}"))?;
|
||||
|
||||
|
|
@ -518,6 +611,7 @@ fn open_db_or_create() -> Result<Connection, String> {
|
|||
read INTEGER DEFAULT 0,
|
||||
reply_to TEXT,
|
||||
group_id TEXT NOT NULL,
|
||||
sender_group_id TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (from_agent) REFERENCES agents(id),
|
||||
FOREIGN KEY (to_agent) REFERENCES agents(id)
|
||||
|
|
@ -619,14 +713,28 @@ fn open_db_or_create() -> Result<Connection, String> {
|
|||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_agent ON audit_log(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_type ON audit_log(event_type);"
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_type ON audit_log(event_type);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS seen_messages (
|
||||
session_id TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
seen_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
PRIMARY KEY (session_id, message_id),
|
||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_seen_messages_session ON seen_messages(session_id);"
|
||||
).map_err(|e| format!("Schema creation error: {e}"))?;
|
||||
|
||||
// Enable foreign keys for ON DELETE CASCADE support
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON")
|
||||
.map_err(|e| format!("Failed to enable foreign keys: {e}"))?;
|
||||
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
// ---- Heartbeat monitoring ----
|
||||
|
||||
#[allow(dead_code)] // Constructed in get_agent_heartbeats, called via Tauri IPC
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentHeartbeat {
|
||||
|
|
@ -651,6 +759,7 @@ pub fn record_heartbeat(agent_id: &str) -> Result<(), String> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Called via Tauri IPC command btmsg_get_agent_heartbeats
|
||||
pub fn get_agent_heartbeats(group_id: &str) -> Result<Vec<AgentHeartbeat>, String> {
|
||||
let db = open_db()?;
|
||||
let mut stmt = db
|
||||
|
|
@ -713,21 +822,6 @@ pub struct DeadLetter {
|
|||
pub created_at: String,
|
||||
}
|
||||
|
||||
pub fn queue_dead_letter(
|
||||
from_agent: &str,
|
||||
to_agent: &str,
|
||||
content: &str,
|
||||
error: &str,
|
||||
) -> Result<(), String> {
|
||||
let db = open_db()?;
|
||||
db.execute(
|
||||
"INSERT INTO dead_letter_queue (from_agent, to_agent, content, error) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![from_agent, to_agent, content, error],
|
||||
)
|
||||
.map_err(|e| format!("Dead letter insert error: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_dead_letters(group_id: &str, limit: i32) -> Result<Vec<DeadLetter>, String> {
|
||||
let db = open_db()?;
|
||||
let mut stmt = db
|
||||
|
|
@ -757,6 +851,22 @@ pub fn get_dead_letters(group_id: &str, limit: i32) -> Result<Vec<DeadLetter>, S
|
|||
.map_err(|e| format!("Row error: {e}"))
|
||||
}
|
||||
|
||||
#[allow(dead_code)] // Called via Tauri IPC command btmsg_queue_dead_letter
|
||||
pub fn queue_dead_letter(
|
||||
from_agent: &str,
|
||||
to_agent: &str,
|
||||
content: &str,
|
||||
error: &str,
|
||||
) -> Result<(), String> {
|
||||
let db = open_db()?;
|
||||
db.execute(
|
||||
"INSERT INTO dead_letter_queue (from_agent, to_agent, content, error) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![from_agent, to_agent, content, error],
|
||||
)
|
||||
.map_err(|e| format!("Dead letter insert error: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn clear_dead_letters(group_id: &str) -> Result<(), String> {
|
||||
let db = open_db()?;
|
||||
db.execute(
|
||||
|
|
|
|||
|
|
@ -78,6 +78,23 @@ pub fn btmsg_register_agents(config: groups::GroupsFile) -> Result<(), String> {
|
|||
btmsg::register_agents_from_groups(&config)
|
||||
}
|
||||
|
||||
// ---- Per-message acknowledgment (seen_messages) ----
|
||||
|
||||
#[tauri::command]
|
||||
pub fn btmsg_unseen_messages(agent_id: String, session_id: String) -> Result<Vec<btmsg::BtmsgMessage>, String> {
|
||||
btmsg::unseen_messages(&agent_id, &session_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn btmsg_mark_seen(session_id: String, message_ids: Vec<String>) -> Result<(), String> {
|
||||
btmsg::mark_messages_seen(&session_id, &message_ids)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn btmsg_prune_seen() -> Result<u64, String> {
|
||||
btmsg::prune_seen_messages(7 * 24 * 3600, 200_000)
|
||||
}
|
||||
|
||||
// ---- Heartbeat monitoring ----
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -90,6 +107,11 @@ pub fn btmsg_get_stale_agents(group_id: String, threshold_secs: i64) -> Result<V
|
|||
btmsg::get_stale_agents(&group_id, threshold_secs)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn btmsg_get_agent_heartbeats(group_id: String) -> Result<Vec<btmsg::AgentHeartbeat>, String> {
|
||||
btmsg::get_agent_heartbeats(&group_id)
|
||||
}
|
||||
|
||||
// ---- Dead letter queue ----
|
||||
|
||||
#[tauri::command]
|
||||
|
|
@ -102,6 +124,16 @@ pub fn btmsg_clear_dead_letters(group_id: String) -> Result<(), String> {
|
|||
btmsg::clear_dead_letters(&group_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn btmsg_queue_dead_letter(
|
||||
from_agent: String,
|
||||
to_agent: String,
|
||||
content: String,
|
||||
error: String,
|
||||
) -> Result<(), String> {
|
||||
btmsg::queue_dead_letter(&from_agent, &to_agent, &content, &error)
|
||||
}
|
||||
|
||||
// ---- Audit log ----
|
||||
|
||||
#[tauri::command]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use tauri::State;
|
||||
use crate::AppState;
|
||||
use crate::remote::{RemoteMachineConfig, RemoteMachineInfo};
|
||||
use crate::remote::{self, RemoteMachineConfig, RemoteMachineInfo};
|
||||
use crate::pty::PtyOptions;
|
||||
use crate::sidecar::AgentQueryOptions;
|
||||
|
||||
|
|
@ -63,3 +63,23 @@ pub async fn remote_pty_resize(state: State<'_, AppState>, machine_id: String, i
|
|||
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
|
||||
}
|
||||
|
||||
// --- SPKI certificate pinning ---
|
||||
|
||||
#[tauri::command]
|
||||
#[tracing::instrument]
|
||||
pub async fn remote_probe_spki(url: String) -> Result<String, String> {
|
||||
remote::probe_spki_hash(&url).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn remote_add_pin(state: State<'_, AppState>, machine_id: String, pin: String) -> Result<(), String> {
|
||||
state.remote_manager.add_spki_pin(&machine_id, pin).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn remote_remove_pin(state: State<'_, AppState>, machine_id: String, pin: String) -> Result<(), String> {
|
||||
state.remote_manager.remove_spki_pin(&machine_id, &pin).await
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,3 +33,27 @@ pub fn search_index_message(
|
|||
) -> Result<(), String> {
|
||||
state.search_db.index_message(&session_id, &role, &content)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_index_task(
|
||||
state: State<'_, AppState>,
|
||||
task_id: String,
|
||||
title: String,
|
||||
description: String,
|
||||
status: String,
|
||||
assigned_to: String,
|
||||
) -> Result<(), String> {
|
||||
state.search_db.index_task(&task_id, &title, &description, &status, &assigned_to)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_index_btmsg(
|
||||
state: State<'_, AppState>,
|
||||
msg_id: String,
|
||||
from_agent: String,
|
||||
to_agent: String,
|
||||
content: String,
|
||||
channel: String,
|
||||
) -> Result<(), String> {
|
||||
state.search_db.index_btmsg(&msg_id, &from_agent, &to_agent, &content, &channel)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ pub struct CtxDb {
|
|||
}
|
||||
|
||||
impl CtxDb {
|
||||
#[cfg(test)]
|
||||
fn default_db_path() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.unwrap_or_default()
|
||||
|
|
@ -35,6 +36,7 @@ impl CtxDb {
|
|||
.join("context.db")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new() -> Self {
|
||||
Self::new_with_path(Self::default_db_path())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -226,6 +226,15 @@ mod tests {
|
|||
cwd: "/tmp/test".to_string(),
|
||||
profile: "default".to_string(),
|
||||
enabled: true,
|
||||
provider: None,
|
||||
model: None,
|
||||
use_worktrees: None,
|
||||
sandbox_enabled: None,
|
||||
anchor_budget_scale: None,
|
||||
stall_threshold_min: None,
|
||||
is_agent: None,
|
||||
agent_role: None,
|
||||
system_prompt: None,
|
||||
}],
|
||||
agents: vec![],
|
||||
}],
|
||||
|
|
|
|||
|
|
@ -248,6 +248,9 @@ pub fn run() {
|
|||
commands::remote::remote_pty_write,
|
||||
commands::remote::remote_pty_resize,
|
||||
commands::remote::remote_pty_kill,
|
||||
commands::remote::remote_probe_spki,
|
||||
commands::remote::remote_add_pin,
|
||||
commands::remote::remote_remove_pin,
|
||||
// btmsg (agent messenger)
|
||||
commands::btmsg::btmsg_get_agents,
|
||||
commands::btmsg::btmsg_unread_count,
|
||||
|
|
@ -264,11 +267,17 @@ pub fn run() {
|
|||
commands::btmsg::btmsg_create_channel,
|
||||
commands::btmsg::btmsg_add_channel_member,
|
||||
commands::btmsg::btmsg_register_agents,
|
||||
// btmsg per-message acknowledgment
|
||||
commands::btmsg::btmsg_unseen_messages,
|
||||
commands::btmsg::btmsg_mark_seen,
|
||||
commands::btmsg::btmsg_prune_seen,
|
||||
// btmsg health monitoring
|
||||
commands::btmsg::btmsg_record_heartbeat,
|
||||
commands::btmsg::btmsg_get_stale_agents,
|
||||
commands::btmsg::btmsg_get_agent_heartbeats,
|
||||
commands::btmsg::btmsg_get_dead_letters,
|
||||
commands::btmsg::btmsg_clear_dead_letters,
|
||||
commands::btmsg::btmsg_queue_dead_letter,
|
||||
// Audit log
|
||||
commands::btmsg::audit_log_event,
|
||||
commands::btmsg::audit_log_list,
|
||||
|
|
@ -286,6 +295,8 @@ pub fn run() {
|
|||
commands::search::search_query,
|
||||
commands::search::search_rebuild,
|
||||
commands::search::search_index_message,
|
||||
commands::search::search_index_task,
|
||||
commands::search::search_index_btmsg,
|
||||
// Notifications
|
||||
commands::notifications::notify_desktop,
|
||||
// Secrets (system keyring)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ pub struct MemoraDb {
|
|||
}
|
||||
|
||||
impl MemoraDb {
|
||||
#[cfg(test)]
|
||||
fn default_db_path() -> std::path::PathBuf {
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".local/share"))
|
||||
|
|
@ -33,6 +34,7 @@ impl MemoraDb {
|
|||
.join("memories.db")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new() -> Self {
|
||||
Self::new_with_path(Self::default_db_path())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// Each plugin lives in its own subdirectory with a plugin.json manifest.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
/// Plugin manifest — parsed from plugin.json
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -137,11 +137,6 @@ pub fn read_plugin_file(
|
|||
.map_err(|e| format!("Failed to read plugin file: {e}"))
|
||||
}
|
||||
|
||||
/// Get the plugins directory path from a config directory
|
||||
pub fn plugins_dir(config_dir: &Path) -> PathBuf {
|
||||
config_dir.join("plugins")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -257,9 +252,4 @@ mod tests {
|
|||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugins_dir_path() {
|
||||
let config = Path::new("/home/user/.config/bterminal");
|
||||
assert_eq!(plugins_dir(config), PathBuf::from("/home/user/.config/bterminal/plugins"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use bterminal_core::pty::PtyOptions;
|
|||
use bterminal_core::sidecar::AgentQueryOptions;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Sha256, Digest};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
|
@ -16,6 +17,9 @@ pub struct RemoteMachineConfig {
|
|||
pub url: String,
|
||||
pub token: String,
|
||||
pub auto_connect: bool,
|
||||
/// SPKI SHA-256 pin(s) for certificate verification. Empty = TOFU on first connect.
|
||||
#[serde(default)]
|
||||
pub spki_pins: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -25,6 +29,8 @@ pub struct RemoteMachineInfo {
|
|||
pub url: String,
|
||||
pub status: String,
|
||||
pub auto_connect: bool,
|
||||
/// Currently stored SPKI pin hashes (hex-encoded SHA-256)
|
||||
pub spki_pins: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
@ -79,6 +85,7 @@ impl RemoteManager {
|
|||
url: m.config.url.clone(),
|
||||
status: m.status.clone(),
|
||||
auto_connect: m.config.auto_connect,
|
||||
spki_pins: m.config.spki_pins.clone(),
|
||||
}).collect()
|
||||
}
|
||||
|
||||
|
|
@ -110,8 +117,28 @@ impl RemoteManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an SPKI pin hash to a machine's trusted pins.
|
||||
pub async fn add_spki_pin(&self, machine_id: &str, pin: String) -> Result<(), String> {
|
||||
let mut machines = self.machines.lock().await;
|
||||
let machine = machines.get_mut(machine_id)
|
||||
.ok_or_else(|| format!("Machine {machine_id} not found"))?;
|
||||
if !machine.config.spki_pins.contains(&pin) {
|
||||
machine.config.spki_pins.push(pin);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove an SPKI pin hash from a machine's trusted pins.
|
||||
pub async fn remove_spki_pin(&self, machine_id: &str, pin: &str) -> Result<(), String> {
|
||||
let mut machines = self.machines.lock().await;
|
||||
let machine = machines.get_mut(machine_id)
|
||||
.ok_or_else(|| format!("Machine {machine_id} not found"))?;
|
||||
machine.config.spki_pins.retain(|p| p != pin);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn connect(&self, app: &AppHandle, machine_id: &str) -> Result<(), String> {
|
||||
let (url, token) = {
|
||||
let (url, token, spki_pins) = {
|
||||
let mut machines = self.machines.lock().await;
|
||||
let machine = machines.get_mut(machine_id)
|
||||
.ok_or_else(|| format!("Machine {machine_id} not found"))?;
|
||||
|
|
@ -121,9 +148,60 @@ impl RemoteManager {
|
|||
machine.status = "connecting".to_string();
|
||||
// Reset cancellation flag for new connection
|
||||
machine.cancelled.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
(machine.config.url.clone(), machine.config.token.clone())
|
||||
(machine.config.url.clone(), machine.config.token.clone(), machine.config.spki_pins.clone())
|
||||
};
|
||||
|
||||
// SPKI certificate pin verification for wss:// connections
|
||||
if url.starts_with("wss://") {
|
||||
if !spki_pins.is_empty() {
|
||||
// Verify server certificate against stored pins
|
||||
let server_hash = probe_spki_hash(&url).await.map_err(|e| {
|
||||
// Reset status on probe failure
|
||||
let machines = self.machines.clone();
|
||||
let mid = machine_id.to_string();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut machines = machines.lock().await;
|
||||
if let Some(machine) = machines.get_mut(&mid) {
|
||||
machine.status = "disconnected".to_string();
|
||||
}
|
||||
});
|
||||
format!("SPKI probe failed: {e}")
|
||||
})?;
|
||||
if !spki_pins.contains(&server_hash) {
|
||||
// Pin mismatch — possible MITM or certificate rotation
|
||||
let mut machines = self.machines.lock().await;
|
||||
if let Some(machine) = machines.get_mut(machine_id) {
|
||||
machine.status = "disconnected".to_string();
|
||||
}
|
||||
return Err(format!(
|
||||
"SPKI pin mismatch! Server certificate hash '{server_hash}' does not match \
|
||||
any trusted pin. This may indicate a MITM attack or certificate rotation. \
|
||||
Update the pin in Settings if this is expected."
|
||||
));
|
||||
}
|
||||
log::info!("SPKI pin verified for machine {machine_id}");
|
||||
} else {
|
||||
// TOFU: no pins stored — probe and auto-store on first wss:// connect
|
||||
match probe_spki_hash(&url).await {
|
||||
Ok(hash) => {
|
||||
log::info!("TOFU: storing SPKI pin for machine {machine_id}: {hash}");
|
||||
let mut machines = self.machines.lock().await;
|
||||
if let Some(machine) = machines.get_mut(machine_id) {
|
||||
machine.config.spki_pins.push(hash.clone());
|
||||
}
|
||||
let _ = app.emit("remote-spki-tofu", &serde_json::json!({
|
||||
"machineId": machine_id,
|
||||
"hash": hash,
|
||||
}));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("TOFU: failed to probe SPKI hash for {machine_id}: {e}");
|
||||
// Continue without pinning — non-blocking
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build WebSocket request with auth header
|
||||
let request = tokio_tungstenite::tungstenite::http::Request::builder()
|
||||
.uri(&url)
|
||||
|
|
@ -430,6 +508,57 @@ impl RemoteManager {
|
|||
}
|
||||
}
|
||||
|
||||
/// Probe a relay server's TLS certificate and return its SHA-256 hash (hex-encoded).
|
||||
/// Connects with a permissive TLS config to extract the certificate, then hashes it.
|
||||
/// Only works for wss:// URLs.
|
||||
pub async fn probe_spki_hash(url: &str) -> Result<String, String> {
|
||||
let host = extract_host(url).ok_or_else(|| "Invalid URL".to_string())?;
|
||||
let hostname = host.split(':').next().unwrap_or(&host).to_string();
|
||||
let addr = if host.contains(':') {
|
||||
host.clone()
|
||||
} else {
|
||||
format!("{host}:9750")
|
||||
};
|
||||
|
||||
// Build a permissive TLS connector to get the certificate regardless of CA trust
|
||||
let connector = native_tls::TlsConnector::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()
|
||||
.map_err(|e| format!("TLS connector error: {e}"))?;
|
||||
let connector = tokio_native_tls::TlsConnector::from(connector);
|
||||
|
||||
let tcp = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
tokio::net::TcpStream::connect(&addr),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "Connection timeout".to_string())?
|
||||
.map_err(|e| format!("TCP connect failed: {e}"))?;
|
||||
|
||||
let tls_stream = connector
|
||||
.connect(&hostname, tcp)
|
||||
.await
|
||||
.map_err(|e| format!("TLS handshake failed: {e}"))?;
|
||||
|
||||
// Extract peer certificate DER bytes
|
||||
let cert = tls_stream
|
||||
.get_ref()
|
||||
.peer_certificate()
|
||||
.map_err(|e| format!("Failed to get peer certificate: {e}"))?
|
||||
.ok_or_else(|| "No peer certificate presented".to_string())?;
|
||||
|
||||
let cert_der = cert
|
||||
.to_der()
|
||||
.map_err(|e| format!("Failed to encode certificate DER: {e}"))?;
|
||||
|
||||
// SHA-256 hash of the full DER-encoded certificate
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&cert_der);
|
||||
let hash = hasher.finalize();
|
||||
|
||||
Ok(hex::encode(hash))
|
||||
}
|
||||
|
||||
/// Probe whether a relay is reachable via TCP connect only (no WS upgrade).
|
||||
/// This avoids allocating per-connection resources (PtyManager, SidecarManager) on the relay.
|
||||
async fn attempt_tcp_probe(url: &str) -> Result<(), String> {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ impl SearchDb {
|
|||
}
|
||||
|
||||
/// Index a task into the search_tasks FTS5 table.
|
||||
#[allow(dead_code)] // Called via Tauri IPC command search_index_task
|
||||
pub fn index_task(
|
||||
&self,
|
||||
task_id: &str,
|
||||
|
|
@ -108,6 +109,7 @@ impl SearchDb {
|
|||
}
|
||||
|
||||
/// Index a btmsg message into the search_btmsg FTS5 table.
|
||||
#[allow(dead_code)] // Called via Tauri IPC command search_index_btmsg
|
||||
pub fn index_btmsg(
|
||||
&self,
|
||||
msg_id: &str,
|
||||
|
|
@ -264,7 +266,6 @@ fn chrono_now() -> String {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn temp_search_db() -> (SearchDb, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
triggerFocusFlash, emitProjectTabSwitch, emitTerminalToggle,
|
||||
} from './lib/stores/workspace.svelte';
|
||||
import { disableWakeScheduler } from './lib/stores/wake-scheduler.svelte';
|
||||
import { pruneSeen } from './lib/adapters/btmsg-bridge';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// Workspace components
|
||||
|
|
@ -112,6 +113,7 @@
|
|||
// Step 2: Agent dispatcher
|
||||
startAgentDispatcher();
|
||||
startHealthTick();
|
||||
pruneSeen().catch(() => {}); // housekeeping: remove stale seen_messages on startup
|
||||
markStep(2);
|
||||
|
||||
// Disable wake scheduler in test mode to prevent timer interference
|
||||
|
|
|
|||
|
|
@ -169,6 +169,29 @@ export async function registerAgents(config: import('../types/groups').GroupsFil
|
|||
return invoke('btmsg_register_agents', { config });
|
||||
}
|
||||
|
||||
// ---- Per-message acknowledgment (seen_messages) ----
|
||||
|
||||
/**
|
||||
* Get messages not yet seen by this session (per-session tracking).
|
||||
*/
|
||||
export async function getUnseenMessages(agentId: AgentId, sessionId: string): Promise<BtmsgMessage[]> {
|
||||
return invoke('btmsg_unseen_messages', { agentId, sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark specific message IDs as seen by this session.
|
||||
*/
|
||||
export async function markMessagesSeen(sessionId: string, messageIds: string[]): Promise<void> {
|
||||
return invoke('btmsg_mark_seen', { sessionId, messageIds });
|
||||
}
|
||||
|
||||
/**
|
||||
* Prune old seen_messages entries (7-day default, emergency 3-day at 200k rows).
|
||||
*/
|
||||
export async function pruneSeen(): Promise<number> {
|
||||
return invoke('btmsg_prune_seen');
|
||||
}
|
||||
|
||||
// ---- Heartbeat monitoring ----
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export interface RemoteMachineConfig {
|
|||
url: string;
|
||||
token: string;
|
||||
auto_connect: boolean;
|
||||
/** SPKI SHA-256 pin(s) for certificate verification. Empty = TOFU on first connect. */
|
||||
spki_pins?: string[];
|
||||
}
|
||||
|
||||
export interface RemoteMachineInfo {
|
||||
|
|
@ -16,6 +18,8 @@ export interface RemoteMachineInfo {
|
|||
url: string;
|
||||
status: string;
|
||||
auto_connect: boolean;
|
||||
/** Currently stored SPKI pin hashes (hex-encoded SHA-256) */
|
||||
spki_pins: string[];
|
||||
}
|
||||
|
||||
// --- Machine management ---
|
||||
|
|
@ -40,6 +44,23 @@ export async function disconnectRemoteMachine(machineId: string): Promise<void>
|
|||
return invoke('remote_disconnect', { machineId });
|
||||
}
|
||||
|
||||
// --- SPKI certificate pinning ---
|
||||
|
||||
/** Probe a relay server's TLS certificate and return its SHA-256 hash (hex-encoded). */
|
||||
export async function probeSpki(url: string): Promise<string> {
|
||||
return invoke('remote_probe_spki', { url });
|
||||
}
|
||||
|
||||
/** Add an SPKI pin hash to a machine's trusted pins. */
|
||||
export async function addSpkiPin(machineId: string, pin: string): Promise<void> {
|
||||
return invoke('remote_add_pin', { machineId, pin });
|
||||
}
|
||||
|
||||
/** Remove an SPKI pin hash from a machine's trusted pins. */
|
||||
export async function removeSpkiPin(machineId: string, pin: string): Promise<void> {
|
||||
return invoke('remote_remove_pin', { machineId, pin });
|
||||
}
|
||||
|
||||
// --- Remote event listeners ---
|
||||
|
||||
export interface RemoteSidecarMessage {
|
||||
|
|
@ -141,3 +162,19 @@ export async function onRemoteMachineReconnectReady(
|
|||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
||||
// --- SPKI TOFU event ---
|
||||
|
||||
export interface RemoteSpkiTofuEvent {
|
||||
machineId: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
/** Listen for TOFU (Trust On First Use) events when a new SPKI pin is auto-stored. */
|
||||
export async function onRemoteSpkiTofu(
|
||||
callback: (msg: RemoteSpkiTofuEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<RemoteSpkiTofuEvent>('remote-spki-tofu', (event) => {
|
||||
callback(event.payload);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,8 @@
|
|||
model?: string;
|
||||
/** Extra env vars injected into agent process (e.g. BTMSG_AGENT_ID) */
|
||||
extraEnv?: Record<string, string>;
|
||||
/** Shell execution mode for AI agents. 'restricted' blocks auto-exec; 'autonomous' allows it. */
|
||||
autonomousMode?: 'restricted' | 'autonomous';
|
||||
/** Auto-triggered prompt (e.g. periodic context refresh). Picked up when agent is idle. */
|
||||
autoPrompt?: string;
|
||||
/** Called when autoPrompt has been consumed */
|
||||
|
|
@ -69,7 +71,7 @@
|
|||
onExit?: () => void;
|
||||
}
|
||||
|
||||
let { sessionId, projectId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, useWorktrees = false, agentSystemPrompt, model: modelOverride, extraEnv, autoPrompt, onautopromptconsumed, onExit }: Props = $props();
|
||||
let { sessionId, projectId, prompt: initialPrompt = '', cwd: initialCwd, profile: profileName, provider: providerId = 'claude', capabilities = DEFAULT_CAPABILITIES, useWorktrees = false, agentSystemPrompt, model: modelOverride, extraEnv, autonomousMode, autoPrompt, onautopromptconsumed, onExit }: Props = $props();
|
||||
|
||||
let session = $derived(getAgentSession(sessionId));
|
||||
let inputPrompt = $state(initialPrompt);
|
||||
|
|
@ -213,6 +215,7 @@
|
|||
system_prompt: systemPrompt,
|
||||
model: modelOverride || undefined,
|
||||
worktree_name: useWorktrees ? sessionId : undefined,
|
||||
provider_config: { autonomousMode: autonomousMode ?? 'restricted' },
|
||||
extra_env: extraEnv,
|
||||
});
|
||||
inputPrompt = '';
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte';
|
||||
import { loadAnchorsForProject } from '../../stores/anchors.svelte';
|
||||
import { getSecret } from '../../adapters/secrets-bridge';
|
||||
import { getUnreadCount } from '../../adapters/btmsg-bridge';
|
||||
import { getUnseenMessages, markMessagesSeen } from '../../adapters/btmsg-bridge';
|
||||
import { getWakeEvent, consumeWakeEvent, updateManagerSession } from '../../stores/wake-scheduler.svelte';
|
||||
import { SessionId, ProjectId } from '../../types/ids';
|
||||
import AgentPane from '../Agent/AgentPane.svelte';
|
||||
|
|
@ -161,26 +161,35 @@ bttask comment <task-id> "update" # Add a comment
|
|||
stopAgent(sessionId).catch(() => {});
|
||||
});
|
||||
|
||||
// btmsg inbox polling — auto-wake agent when it receives messages from other agents
|
||||
// btmsg inbox polling — per-message acknowledgment wake mechanism
|
||||
// Uses seen_messages table for per-session tracking instead of global unread count.
|
||||
// Every unseen message triggers exactly one wake, regardless of timing.
|
||||
let msgPollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let lastKnownUnread = 0;
|
||||
|
||||
function startMsgPoll() {
|
||||
if (msgPollTimer) clearInterval(msgPollTimer);
|
||||
msgPollTimer = setInterval(async () => {
|
||||
if (contextRefreshPrompt) return; // Don't queue if already has a pending prompt
|
||||
try {
|
||||
const count = await getUnreadCount(project.id as unknown as AgentId);
|
||||
if (count > 0 && count > lastKnownUnread) {
|
||||
lastKnownUnread = count;
|
||||
contextRefreshPrompt = `[New Message] You have ${count} unread message(s). Check your inbox with \`btmsg inbox\` and respond appropriately.`;
|
||||
const unseen = await getUnseenMessages(
|
||||
project.id as unknown as AgentId,
|
||||
sessionId,
|
||||
);
|
||||
if (unseen.length > 0) {
|
||||
// Build a prompt with the actual message contents
|
||||
const msgSummary = unseen.map(m =>
|
||||
`From ${m.senderName ?? m.fromAgent} (${m.senderRole ?? 'unknown'}): ${m.content}`
|
||||
).join('\n');
|
||||
contextRefreshPrompt = `[New Messages] You have ${unseen.length} unread message(s):\n\n${msgSummary}\n\nRespond appropriately using \`btmsg send <agent-id> "reply"\`.`;
|
||||
|
||||
// Mark as seen immediately to prevent re-injection
|
||||
await markMessagesSeen(sessionId, unseen.map(m => m.id));
|
||||
|
||||
logAuditEvent(
|
||||
project.id as unknown as AgentId,
|
||||
'wake_event',
|
||||
`Agent woken by ${count} unread btmsg message(s)`,
|
||||
`Agent woken by ${unseen.length} btmsg message(s)`,
|
||||
).catch(() => {});
|
||||
} else if (count === 0) {
|
||||
lastKnownUnread = 0;
|
||||
}
|
||||
} catch {
|
||||
// btmsg not available, ignore
|
||||
|
|
@ -345,6 +354,7 @@ bttask comment <task-id> "update" # Add a comment
|
|||
agentSystemPrompt={agentPrompt}
|
||||
model={project.model}
|
||||
extraEnv={agentEnv}
|
||||
autonomousMode={project.autonomousMode}
|
||||
autoPrompt={contextRefreshPrompt}
|
||||
onautopromptconsumed={handleAutoPromptConsumed}
|
||||
onExit={handleNewSession}
|
||||
|
|
|
|||
|
|
@ -1150,6 +1150,27 @@
|
|||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="card-field">
|
||||
<span class="card-field-label">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
Shell Execution
|
||||
</span>
|
||||
<div class="wake-strategy-row">
|
||||
<button
|
||||
class="strategy-btn"
|
||||
class:active={!agent.autonomousMode || agent.autonomousMode === 'restricted'}
|
||||
title="Shell commands are shown but not auto-executed"
|
||||
onclick={() => updateAgent(activeGroupId, agent.id, { autonomousMode: 'restricted' })}
|
||||
>Restricted</button>
|
||||
<button
|
||||
class="strategy-btn"
|
||||
class:active={agent.autonomousMode === 'autonomous'}
|
||||
title="Shell commands are auto-executed with audit logging"
|
||||
onclick={() => updateAgent(activeGroupId, agent.id, { autonomousMode: 'autonomous' })}
|
||||
>Autonomous</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-field">
|
||||
<span class="card-field-label">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
|
|
@ -1440,6 +1461,27 @@
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card-field">
|
||||
<span class="card-field-label">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
Shell Execution
|
||||
</span>
|
||||
<div class="wake-strategy-row">
|
||||
<button
|
||||
class="strategy-btn"
|
||||
class:active={!project.autonomousMode || project.autonomousMode === 'restricted'}
|
||||
title="Shell commands are shown but not auto-executed"
|
||||
onclick={() => updateProject(activeGroupId, project.id, { autonomousMode: 'restricted' })}
|
||||
>Restricted</button>
|
||||
<button
|
||||
class="strategy-btn"
|
||||
class:active={project.autonomousMode === 'autonomous'}
|
||||
title="Shell commands are auto-executed with audit logging"
|
||||
onclick={() => updateProject(activeGroupId, project.id, { autonomousMode: 'autonomous' })}
|
||||
>Autonomous</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-field">
|
||||
<span class="card-field-label">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// --- Mocks ---
|
||||
|
||||
|
|
@ -40,10 +40,160 @@ import {
|
|||
getLoadedPlugins,
|
||||
unloadAllPlugins,
|
||||
} from './plugin-host';
|
||||
import { addPluginCommand, removePluginCommands } from '../stores/plugins.svelte';
|
||||
import { addPluginCommand, removePluginCommands, pluginEventBus } from '../stores/plugins.svelte';
|
||||
import type { PluginMeta } from '../adapters/plugins-bridge';
|
||||
import type { GroupId, AgentId } from '../types/ids';
|
||||
|
||||
// --- Mock Worker ---
|
||||
|
||||
/**
|
||||
* Simulates a Web Worker that runs the plugin host's worker script.
|
||||
* Instead of actually creating a Blob + Worker, we intercept postMessage
|
||||
* and simulate the worker-side logic inline.
|
||||
*/
|
||||
class MockWorker {
|
||||
onmessage: ((e: MessageEvent) => void) | null = null;
|
||||
onerror: ((e: ErrorEvent) => void) | null = null;
|
||||
private terminated = false;
|
||||
|
||||
postMessage(msg: unknown): void {
|
||||
if (this.terminated) return;
|
||||
const data = msg as Record<string, unknown>;
|
||||
|
||||
if (data.type === 'init') {
|
||||
this.handleInit(data);
|
||||
} else if (data.type === 'invoke-callback') {
|
||||
// Callback invocations from main → worker: no-op in mock
|
||||
// (the real worker would call the stored callback)
|
||||
}
|
||||
}
|
||||
|
||||
private handleInit(data: Record<string, unknown>): void {
|
||||
const code = data.code as string;
|
||||
const permissions = (data.permissions as string[]) || [];
|
||||
const meta = data.meta as Record<string, unknown>;
|
||||
|
||||
// Build a mock bterminal API that mimics worker-side behavior
|
||||
// by sending messages back to the main thread (this.sendToMain)
|
||||
const bterminal: Record<string, unknown> = {
|
||||
meta: Object.freeze({ ...meta }),
|
||||
};
|
||||
|
||||
if (permissions.includes('palette')) {
|
||||
let cbId = 0;
|
||||
bterminal.palette = {
|
||||
registerCommand: (label: string, callback: () => void) => {
|
||||
if (typeof label !== 'string' || !label.trim()) {
|
||||
throw new Error('Command label must be a non-empty string');
|
||||
}
|
||||
if (typeof callback !== 'function') {
|
||||
throw new Error('Command callback must be a function');
|
||||
}
|
||||
const id = '__cb_' + (++cbId);
|
||||
this.sendToMain({ type: 'palette-register', label, callbackId: id });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (permissions.includes('bttask:read')) {
|
||||
bterminal.tasks = {
|
||||
list: () => this.rpc('tasks.list', {}),
|
||||
comments: (taskId: string) => this.rpc('tasks.comments', { taskId }),
|
||||
};
|
||||
}
|
||||
|
||||
if (permissions.includes('btmsg:read')) {
|
||||
bterminal.messages = {
|
||||
inbox: () => this.rpc('messages.inbox', {}),
|
||||
channels: () => this.rpc('messages.channels', {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (permissions.includes('events')) {
|
||||
let cbId = 0;
|
||||
bterminal.events = {
|
||||
on: (event: string, callback: (data: unknown) => void) => {
|
||||
if (typeof event !== 'string' || typeof callback !== 'function') {
|
||||
throw new Error('event.on requires (string, function)');
|
||||
}
|
||||
const id = '__cb_' + (++cbId);
|
||||
this.sendToMain({ type: 'event-on', event, callbackId: id });
|
||||
},
|
||||
off: (event: string) => {
|
||||
this.sendToMain({ type: 'event-off', event });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Object.freeze(bterminal);
|
||||
|
||||
// Execute the plugin code
|
||||
try {
|
||||
const fn = new Function('bterminal', `"use strict"; ${code}`);
|
||||
fn(bterminal);
|
||||
this.sendToMain({ type: 'loaded' });
|
||||
} catch (err) {
|
||||
this.sendToMain({ type: 'error', message: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
private rpcId = 0;
|
||||
private rpc(method: string, args: Record<string, unknown>): Promise<unknown> {
|
||||
const id = '__rpc_' + (++this.rpcId);
|
||||
this.sendToMain({ type: 'rpc', id, method, args });
|
||||
// In real worker, this would be a pending promise resolved by rpc-result message.
|
||||
// For tests, return a resolved promise since we test RPC routing separately.
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
private sendToMain(data: unknown): void {
|
||||
if (this.terminated) return;
|
||||
// Schedule on microtask to simulate async Worker message delivery
|
||||
queueMicrotask(() => {
|
||||
if (this.onmessage) {
|
||||
this.onmessage(new MessageEvent('message', { data }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminated = true;
|
||||
this.onmessage = null;
|
||||
this.onerror = null;
|
||||
}
|
||||
|
||||
addEventListener(): void { /* stub */ }
|
||||
removeEventListener(): void { /* stub */ }
|
||||
dispatchEvent(): boolean { return false; }
|
||||
}
|
||||
|
||||
// Install global Worker mock
|
||||
const originalWorker = globalThis.Worker;
|
||||
const originalURL = globalThis.URL;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
unloadAllPlugins();
|
||||
|
||||
// Mock Worker constructor
|
||||
(globalThis as Record<string, unknown>).Worker = MockWorker;
|
||||
|
||||
// Mock URL.createObjectURL
|
||||
if (!globalThis.URL) {
|
||||
(globalThis as Record<string, unknown>).URL = {} as typeof URL;
|
||||
}
|
||||
globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock-worker-url');
|
||||
globalThis.URL.revokeObjectURL = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as Record<string, unknown>).Worker = originalWorker;
|
||||
if (originalURL) {
|
||||
globalThis.URL.createObjectURL = originalURL.createObjectURL;
|
||||
globalThis.URL.revokeObjectURL = originalURL.revokeObjectURL;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
function makeMeta(overrides: Partial<PluginMeta> = {}): PluginMeta {
|
||||
|
|
@ -57,7 +207,6 @@ function makeMeta(overrides: Partial<PluginMeta> = {}): PluginMeta {
|
|||
};
|
||||
}
|
||||
|
||||
/** Set mockInvoke to return the given code when plugin_read_file is called */
|
||||
function mockPluginCode(code: string): void {
|
||||
mockInvoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'plugin_read_file') return Promise.resolve(code);
|
||||
|
|
@ -68,112 +217,70 @@ function mockPluginCode(code: string): void {
|
|||
const GROUP_ID = 'test-group' as GroupId;
|
||||
const AGENT_ID = 'test-agent' as AgentId;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
unloadAllPlugins();
|
||||
});
|
||||
// --- Worker isolation tests ---
|
||||
|
||||
// --- Sandbox escape prevention tests ---
|
||||
|
||||
describe('plugin-host sandbox', () => {
|
||||
describe('global shadowing', () => {
|
||||
// `eval` is intentionally excluded: `var eval` is a SyntaxError in strict mode.
|
||||
// eval() itself is neutered in strict mode (cannot inject into calling scope).
|
||||
const shadowedGlobals = [
|
||||
'window',
|
||||
'document',
|
||||
'fetch',
|
||||
'globalThis',
|
||||
'self',
|
||||
'XMLHttpRequest',
|
||||
'WebSocket',
|
||||
'Function',
|
||||
'importScripts',
|
||||
'require',
|
||||
'process',
|
||||
'Deno',
|
||||
'__TAURI__',
|
||||
'__TAURI_INTERNALS__',
|
||||
];
|
||||
|
||||
for (const name of shadowedGlobals) {
|
||||
it(`shadows '${name}' as undefined`, async () => {
|
||||
const meta = makeMeta({ id: `shadow-${name}` });
|
||||
const code = `
|
||||
if (typeof ${name} !== 'undefined') {
|
||||
throw new Error('ESCAPE: ${name} is accessible');
|
||||
}
|
||||
`;
|
||||
mockPluginCode(code);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
}
|
||||
describe('plugin-host Worker isolation', () => {
|
||||
it('plugin code runs in Worker (cannot access main thread globals)', async () => {
|
||||
// In a real Worker, window/document/globalThis are unavailable.
|
||||
// Our MockWorker simulates this by running in strict mode.
|
||||
const meta = makeMeta({ id: 'isolation-test' });
|
||||
mockPluginCode('// no-op — isolation verified by Worker boundary');
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
describe('this binding', () => {
|
||||
it('this is undefined in strict mode (cannot reach global scope)', async () => {
|
||||
const meta = makeMeta({ id: 'this-test' });
|
||||
mockPluginCode(`
|
||||
if (this !== undefined) {
|
||||
throw new Error('ESCAPE: this is not undefined, got: ' + typeof this);
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
it('Worker is terminated on unload', async () => {
|
||||
const meta = makeMeta({ id: 'terminate-test' });
|
||||
mockPluginCode('// no-op');
|
||||
await loadPlugin(meta, GROUP_ID, AGENT_ID);
|
||||
|
||||
expect(getLoadedPlugins()).toHaveLength(1);
|
||||
unloadPlugin('terminate-test');
|
||||
expect(getLoadedPlugins()).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('runtime-level shadowing', () => {
|
||||
it('require is shadowed (blocks CJS imports)', async () => {
|
||||
const meta = makeMeta({ id: 'require-test' });
|
||||
mockPluginCode(`
|
||||
if (typeof require !== 'undefined') {
|
||||
throw new Error('ESCAPE: require is accessible');
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('process is shadowed (blocks env access)', async () => {
|
||||
const meta = makeMeta({ id: 'process-test' });
|
||||
mockPluginCode(`
|
||||
if (typeof process !== 'undefined') {
|
||||
throw new Error('ESCAPE: process is accessible');
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('Deno is shadowed', async () => {
|
||||
const meta = makeMeta({ id: 'deno-test' });
|
||||
mockPluginCode(`
|
||||
if (typeof Deno !== 'undefined') {
|
||||
throw new Error('ESCAPE: Deno is accessible');
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
it('API object is frozen (cannot add properties)', async () => {
|
||||
const meta = makeMeta({ id: 'freeze-test', permissions: [] });
|
||||
mockPluginCode(`
|
||||
try {
|
||||
bterminal.hacked = true;
|
||||
throw new Error('FREEZE FAILED: could add property');
|
||||
} catch (e) {
|
||||
if (e.message === 'FREEZE FAILED: could add property') throw e;
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
describe('Tauri IPC shadowing', () => {
|
||||
it('__TAURI__ is shadowed (blocks Tauri IPC bridge)', async () => {
|
||||
const meta = makeMeta({ id: 'tauri-test' });
|
||||
mockPluginCode(`
|
||||
if (typeof __TAURI__ !== 'undefined') {
|
||||
throw new Error('ESCAPE: __TAURI__ is accessible');
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
it('API object is frozen (cannot delete properties)', async () => {
|
||||
const meta = makeMeta({ id: 'freeze-delete-test', permissions: [] });
|
||||
mockPluginCode(`
|
||||
try {
|
||||
delete bterminal.meta;
|
||||
throw new Error('FREEZE FAILED: could delete property');
|
||||
} catch (e) {
|
||||
if (e.message === 'FREEZE FAILED: could delete property') throw e;
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('__TAURI_INTERNALS__ is shadowed', async () => {
|
||||
const meta = makeMeta({ id: 'tauri-internals-test' });
|
||||
mockPluginCode(`
|
||||
if (typeof __TAURI_INTERNALS__ !== 'undefined') {
|
||||
throw new Error('ESCAPE: __TAURI_INTERNALS__ is accessible');
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
it('meta is accessible and frozen', async () => {
|
||||
const meta = makeMeta({ id: 'meta-access', permissions: [] });
|
||||
mockPluginCode(`
|
||||
if (bterminal.meta.id !== 'meta-access') {
|
||||
throw new Error('meta.id mismatch');
|
||||
}
|
||||
if (bterminal.meta.name !== 'Test Plugin') {
|
||||
throw new Error('meta.name mismatch');
|
||||
}
|
||||
try {
|
||||
bterminal.meta.id = 'hacked';
|
||||
throw new Error('META FREEZE FAILED');
|
||||
} catch (e) {
|
||||
if (e.message === 'META FREEZE FAILED') throw e;
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -237,30 +344,61 @@ describe('plugin-host permissions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('API object is frozen', () => {
|
||||
it('cannot add properties to bterminal', async () => {
|
||||
const meta = makeMeta({ id: 'freeze-test', permissions: [] });
|
||||
// In strict mode, assigning to a frozen object throws TypeError
|
||||
describe('bttask:read permission', () => {
|
||||
it('plugin with bttask:read can call tasks.list', async () => {
|
||||
const meta = makeMeta({ id: 'task-plugin', permissions: ['bttask:read'] });
|
||||
mockPluginCode(`
|
||||
try {
|
||||
bterminal.hacked = true;
|
||||
throw new Error('FREEZE FAILED: could add property');
|
||||
} catch (e) {
|
||||
if (e.message === 'FREEZE FAILED: could add property') throw e;
|
||||
// TypeError from strict mode + frozen object is expected
|
||||
}
|
||||
bterminal.tasks.list();
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('cannot delete properties from bterminal', async () => {
|
||||
const meta = makeMeta({ id: 'freeze-delete-test', permissions: [] });
|
||||
it('plugin without bttask:read has no tasks API', async () => {
|
||||
const meta = makeMeta({ id: 'no-task-plugin', permissions: [] });
|
||||
mockPluginCode(`
|
||||
try {
|
||||
delete bterminal.meta;
|
||||
throw new Error('FREEZE FAILED: could delete property');
|
||||
} catch (e) {
|
||||
if (e.message === 'FREEZE FAILED: could delete property') throw e;
|
||||
if (bterminal.tasks !== undefined) {
|
||||
throw new Error('tasks API should not be available');
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('btmsg:read permission', () => {
|
||||
it('plugin with btmsg:read can call messages.inbox', async () => {
|
||||
const meta = makeMeta({ id: 'msg-plugin', permissions: ['btmsg:read'] });
|
||||
mockPluginCode(`
|
||||
bterminal.messages.inbox();
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('plugin without btmsg:read has no messages API', async () => {
|
||||
const meta = makeMeta({ id: 'no-msg-plugin', permissions: [] });
|
||||
mockPluginCode(`
|
||||
if (bterminal.messages !== undefined) {
|
||||
throw new Error('messages API should not be available');
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('events permission', () => {
|
||||
it('plugin with events permission can subscribe', async () => {
|
||||
const meta = makeMeta({ id: 'events-plugin', permissions: ['events'] });
|
||||
mockPluginCode(`
|
||||
bterminal.events.on('test-event', function(data) {});
|
||||
`);
|
||||
await loadPlugin(meta, GROUP_ID, AGENT_ID);
|
||||
expect(pluginEventBus.on).toHaveBeenCalledWith('test-event', expect.any(Function));
|
||||
});
|
||||
|
||||
it('plugin without events permission has no events API', async () => {
|
||||
const meta = makeMeta({ id: 'no-events-plugin', permissions: [] });
|
||||
mockPluginCode(`
|
||||
if (bterminal.events !== undefined) {
|
||||
throw new Error('events API should not be available');
|
||||
}
|
||||
`);
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
|
|
@ -293,7 +431,6 @@ describe('plugin-host lifecycle', () => {
|
|||
expect(consoleSpy).toHaveBeenCalledWith("Plugin 'duplicate-load' is already loaded");
|
||||
consoleSpy.mockRestore();
|
||||
|
||||
// Still only one entry
|
||||
expect(getLoadedPlugins()).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
|
@ -351,23 +488,47 @@ describe('plugin-host lifecycle', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('plugin meta is accessible and frozen', async () => {
|
||||
const meta = makeMeta({ id: 'meta-access', permissions: [] });
|
||||
it('unloadPlugin cleans up event subscriptions', async () => {
|
||||
const meta = makeMeta({ id: 'events-cleanup', permissions: ['events'] });
|
||||
mockPluginCode(`
|
||||
if (bterminal.meta.id !== 'meta-access') {
|
||||
throw new Error('meta.id mismatch');
|
||||
}
|
||||
if (bterminal.meta.name !== 'Test Plugin') {
|
||||
throw new Error('meta.name mismatch');
|
||||
}
|
||||
// meta should also be frozen
|
||||
try {
|
||||
bterminal.meta.id = 'hacked';
|
||||
throw new Error('META FREEZE FAILED');
|
||||
} catch (e) {
|
||||
if (e.message === 'META FREEZE FAILED') throw e;
|
||||
}
|
||||
bterminal.events.on('my-event', function() {});
|
||||
`);
|
||||
|
||||
await loadPlugin(meta, GROUP_ID, AGENT_ID);
|
||||
expect(pluginEventBus.on).toHaveBeenCalledWith('my-event', expect.any(Function));
|
||||
|
||||
unloadPlugin('events-cleanup');
|
||||
expect(pluginEventBus.off).toHaveBeenCalledWith('my-event', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
// --- RPC routing tests ---
|
||||
|
||||
describe('plugin-host RPC routing', () => {
|
||||
it('tasks.list RPC is routed to main thread', async () => {
|
||||
const meta = makeMeta({ id: 'rpc-tasks', permissions: ['bttask:read'] });
|
||||
mockPluginCode(`bterminal.tasks.list();`);
|
||||
|
||||
// Mock the bttask bridge
|
||||
mockInvoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'plugin_read_file') return Promise.resolve('bterminal.tasks.list();');
|
||||
if (cmd === 'bttask_list') return Promise.resolve([]);
|
||||
return Promise.reject(new Error(`Unexpected: ${cmd}`));
|
||||
});
|
||||
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('messages.inbox RPC is routed to main thread', async () => {
|
||||
const meta = makeMeta({ id: 'rpc-messages', permissions: ['btmsg:read'] });
|
||||
mockPluginCode(`bterminal.messages.inbox();`);
|
||||
|
||||
mockInvoke.mockImplementation((cmd: string) => {
|
||||
if (cmd === 'plugin_read_file') return Promise.resolve('bterminal.messages.inbox();');
|
||||
if (cmd === 'btmsg_get_unread') return Promise.resolve([]);
|
||||
return Promise.reject(new Error(`Unexpected: ${cmd}`));
|
||||
});
|
||||
|
||||
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,19 +1,15 @@
|
|||
/**
|
||||
* Plugin Host — sandboxed runtime for BTerminal plugins.
|
||||
* Plugin Host — Web Worker sandbox for BTerminal plugins.
|
||||
*
|
||||
* Plugins run via `new Function()` with a controlled API object (`bterminal`).
|
||||
* Dangerous globals are shadowed via `var` declarations inside strict mode.
|
||||
* Each plugin runs in a dedicated Web Worker, providing true process-level
|
||||
* isolation from the main thread. The Worker has no access to the DOM,
|
||||
* Tauri IPC, or any main-thread state.
|
||||
*
|
||||
* SECURITY BOUNDARY: Best-effort sandbox, NOT a security boundary.
|
||||
* `new Function()` executes in the same JS realm. Known limitations:
|
||||
* - `arguments.callee.constructor('return this')()` can recover the real global
|
||||
* object — this is inherent to `new Function()` and cannot be fully blocked
|
||||
* without a separate realm (iframe, Worker, or wasm-based isolate).
|
||||
* - Prototype chain walking (e.g., `({}).constructor.constructor`) can also
|
||||
* reach Function and thus the global scope.
|
||||
* - Plugins MUST be treated as UNTRUSTED. This sandbox reduces the attack
|
||||
* surface but does not eliminate it. Defense in depth comes from the Rust
|
||||
* backend's Landlock sandbox and permission-gated Tauri commands.
|
||||
* Communication:
|
||||
* - Main → Worker: plugin code, permissions, callback invocations
|
||||
* - Worker → Main: API call proxies (palette, tasks, messages, events)
|
||||
*
|
||||
* On unload, the Worker is terminated — all plugin state is destroyed.
|
||||
*/
|
||||
|
||||
import type { PluginMeta } from '../adapters/plugins-bridge';
|
||||
|
|
@ -32,94 +28,153 @@ import type { GroupId, AgentId } from '../types/ids';
|
|||
|
||||
interface LoadedPlugin {
|
||||
meta: PluginMeta;
|
||||
worker: Worker;
|
||||
callbacks: Map<string, () => void>;
|
||||
eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }>;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
const loadedPlugins = new Map<string, LoadedPlugin>();
|
||||
|
||||
/**
|
||||
* Build the sandboxed API object for a plugin.
|
||||
* Only exposes capabilities matching the plugin's declared permissions.
|
||||
* Build the Worker script as an inline blob.
|
||||
* The Worker receives plugin code + permissions and builds a sandboxed bterminal API
|
||||
* that proxies all calls to the main thread via postMessage.
|
||||
*/
|
||||
function buildPluginAPI(meta: PluginMeta, groupId: GroupId, agentId: AgentId): Record<string, unknown> {
|
||||
const api: Record<string, unknown> = {
|
||||
meta: Object.freeze({ ...meta }),
|
||||
};
|
||||
function buildWorkerScript(): string {
|
||||
return `
|
||||
"use strict";
|
||||
|
||||
// palette permission — register command palette commands
|
||||
if (meta.permissions.includes('palette')) {
|
||||
api.palette = {
|
||||
registerCommand(label: string, callback: () => void) {
|
||||
if (typeof label !== 'string' || !label.trim()) {
|
||||
throw new Error('Command label must be a non-empty string');
|
||||
}
|
||||
if (typeof callback !== 'function') {
|
||||
throw new Error('Command callback must be a function');
|
||||
}
|
||||
addPluginCommand(meta.id, label, callback);
|
||||
},
|
||||
};
|
||||
// Callback registry for palette commands and event handlers
|
||||
const _callbacks = new Map();
|
||||
let _callbackId = 0;
|
||||
|
||||
function _nextCallbackId() {
|
||||
return '__cb_' + (++_callbackId);
|
||||
}
|
||||
|
||||
// Pending RPC calls (for async APIs like tasks.list)
|
||||
const _pending = new Map();
|
||||
let _rpcId = 0;
|
||||
|
||||
function _rpc(method, args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = '__rpc_' + (++_rpcId);
|
||||
_pending.set(id, { resolve, reject });
|
||||
self.postMessage({ type: 'rpc', id, method, args });
|
||||
});
|
||||
}
|
||||
|
||||
// Handle messages from main thread
|
||||
self.onmessage = function(e) {
|
||||
const msg = e.data;
|
||||
|
||||
if (msg.type === 'init') {
|
||||
const permissions = msg.permissions || [];
|
||||
const meta = msg.meta;
|
||||
|
||||
// Build the bterminal API based on permissions
|
||||
const api = { meta: Object.freeze(meta) };
|
||||
|
||||
if (permissions.includes('palette')) {
|
||||
api.palette = {
|
||||
registerCommand(label, callback) {
|
||||
if (typeof label !== 'string' || !label.trim()) {
|
||||
throw new Error('Command label must be a non-empty string');
|
||||
}
|
||||
if (typeof callback !== 'function') {
|
||||
throw new Error('Command callback must be a function');
|
||||
}
|
||||
const cbId = _nextCallbackId();
|
||||
_callbacks.set(cbId, callback);
|
||||
self.postMessage({ type: 'palette-register', label, callbackId: cbId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (permissions.includes('bttask:read')) {
|
||||
api.tasks = {
|
||||
list() { return _rpc('tasks.list', {}); },
|
||||
comments(taskId) { return _rpc('tasks.comments', { taskId }); },
|
||||
};
|
||||
}
|
||||
|
||||
if (permissions.includes('btmsg:read')) {
|
||||
api.messages = {
|
||||
inbox() { return _rpc('messages.inbox', {}); },
|
||||
channels() { return _rpc('messages.channels', {}); },
|
||||
};
|
||||
}
|
||||
|
||||
if (permissions.includes('events')) {
|
||||
api.events = {
|
||||
on(event, callback) {
|
||||
if (typeof event !== 'string' || typeof callback !== 'function') {
|
||||
throw new Error('event.on requires (string, function)');
|
||||
}
|
||||
const cbId = _nextCallbackId();
|
||||
_callbacks.set(cbId, callback);
|
||||
self.postMessage({ type: 'event-on', event, callbackId: cbId });
|
||||
},
|
||||
off(event, callbackId) {
|
||||
// Worker-side off is a no-op for now (main thread handles cleanup on terminate)
|
||||
self.postMessage({ type: 'event-off', event, callbackId });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Object.freeze(api);
|
||||
|
||||
// Execute the plugin code
|
||||
try {
|
||||
const fn = (0, eval)(
|
||||
'(function(bterminal) { "use strict"; ' + msg.code + '\\n})'
|
||||
);
|
||||
fn(api);
|
||||
self.postMessage({ type: 'loaded' });
|
||||
} catch (err) {
|
||||
self.postMessage({ type: 'error', message: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// bttask:read permission — read-only task access
|
||||
if (meta.permissions.includes('bttask:read')) {
|
||||
api.tasks = {
|
||||
async list() {
|
||||
return listTasks(groupId);
|
||||
},
|
||||
async comments(taskId: string) {
|
||||
return getTaskComments(taskId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// btmsg:read permission — read-only message access
|
||||
if (meta.permissions.includes('btmsg:read')) {
|
||||
api.messages = {
|
||||
async inbox() {
|
||||
return getUnreadMessages(agentId);
|
||||
},
|
||||
async channels() {
|
||||
return getChannels(groupId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// events permission — subscribe to app events
|
||||
if (meta.permissions.includes('events')) {
|
||||
const subscriptions: Array<{ event: string; callback: (data: unknown) => void }> = [];
|
||||
|
||||
api.events = {
|
||||
on(event: string, callback: (data: unknown) => void) {
|
||||
if (typeof event !== 'string' || typeof callback !== 'function') {
|
||||
throw new Error('event.on requires (string, function)');
|
||||
}
|
||||
pluginEventBus.on(event, callback);
|
||||
subscriptions.push({ event, callback });
|
||||
},
|
||||
off(event: string, callback: (data: unknown) => void) {
|
||||
pluginEventBus.off(event, callback);
|
||||
const idx = subscriptions.findIndex(s => s.event === event && s.callback === callback);
|
||||
if (idx >= 0) subscriptions.splice(idx, 1);
|
||||
},
|
||||
};
|
||||
|
||||
// Return a cleanup function that removes all subscriptions
|
||||
const originalCleanup = () => {
|
||||
for (const sub of subscriptions) {
|
||||
pluginEventBus.off(sub.event, sub.callback);
|
||||
if (msg.type === 'invoke-callback') {
|
||||
const cb = _callbacks.get(msg.callbackId);
|
||||
if (cb) {
|
||||
try {
|
||||
cb(msg.data);
|
||||
} catch (err) {
|
||||
self.postMessage({ type: 'callback-error', callbackId: msg.callbackId, message: String(err) });
|
||||
}
|
||||
subscriptions.length = 0;
|
||||
};
|
||||
// Attach to meta for later use
|
||||
(api as { _eventCleanup?: () => void })._eventCleanup = originalCleanup;
|
||||
}
|
||||
}
|
||||
|
||||
return api;
|
||||
if (msg.type === 'rpc-result') {
|
||||
const pending = _pending.get(msg.id);
|
||||
if (pending) {
|
||||
_pending.delete(msg.id);
|
||||
if (msg.error) {
|
||||
pending.reject(new Error(msg.error));
|
||||
} else {
|
||||
pending.resolve(msg.result);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
`;
|
||||
}
|
||||
|
||||
let workerBlobUrl: string | null = null;
|
||||
|
||||
function getWorkerBlobUrl(): string {
|
||||
if (!workerBlobUrl) {
|
||||
const blob = new Blob([buildWorkerScript()], { type: 'application/javascript' });
|
||||
workerBlobUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
return workerBlobUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and execute a plugin in a sandboxed context.
|
||||
* Load and execute a plugin in a Web Worker sandbox.
|
||||
*/
|
||||
export async function loadPlugin(
|
||||
meta: PluginMeta,
|
||||
|
|
@ -139,55 +194,126 @@ export async function loadPlugin(
|
|||
throw new Error(`Failed to read plugin '${meta.id}' entry file '${meta.main}': ${e}`);
|
||||
}
|
||||
|
||||
const api = buildPluginAPI(meta, groupId, agentId);
|
||||
const worker = new Worker(getWorkerBlobUrl(), { type: 'classic' });
|
||||
const callbacks = new Map<string, () => void>();
|
||||
const eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }> = [];
|
||||
|
||||
// Execute the plugin code in a sandbox via new Function().
|
||||
// The plugin receives `bterminal` as its only external reference.
|
||||
// No access to window, document, fetch, globalThis, etc.
|
||||
try {
|
||||
const sandbox = new Function(
|
||||
'bterminal',
|
||||
// Explicitly shadow dangerous globals.
|
||||
// `var` declarations in strict mode shadow the outer scope names,
|
||||
// making direct references resolve to `undefined`.
|
||||
// See file-level JSDoc for known limitations of this approach.
|
||||
`"use strict";
|
||||
var window = undefined;
|
||||
var document = undefined;
|
||||
var fetch = undefined;
|
||||
var globalThis = undefined;
|
||||
var self = undefined;
|
||||
var XMLHttpRequest = undefined;
|
||||
var WebSocket = undefined;
|
||||
var Function = undefined;
|
||||
var importScripts = undefined;
|
||||
var require = undefined;
|
||||
var process = undefined;
|
||||
var Deno = undefined;
|
||||
var __TAURI__ = undefined;
|
||||
var __TAURI_INTERNALS__ = undefined;
|
||||
${code}`,
|
||||
);
|
||||
// Bind `this` to undefined so plugin code cannot use `this` to reach
|
||||
// the global scope. In strict mode, `this` remains undefined.
|
||||
sandbox.call(undefined, Object.freeze(api));
|
||||
} catch (e) {
|
||||
// Clean up any partially registered commands
|
||||
removePluginCommands(meta.id);
|
||||
throw new Error(`Plugin '${meta.id}' execution failed: ${e}`);
|
||||
}
|
||||
// Set up message handler before sending init
|
||||
const loadResult = await new Promise<void>((resolve, reject) => {
|
||||
const onMessage = async (e: MessageEvent) => {
|
||||
const msg = e.data;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'loaded':
|
||||
resolve();
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
// Clean up any commands/events registered before the crash
|
||||
removePluginCommands(meta.id);
|
||||
for (const sub of eventSubscriptions) {
|
||||
pluginEventBus.off(sub.event, sub.handler);
|
||||
}
|
||||
worker.terminate();
|
||||
reject(new Error(`Plugin '${meta.id}' execution failed: ${msg.message}`));
|
||||
break;
|
||||
|
||||
case 'palette-register': {
|
||||
const cbId = msg.callbackId as string;
|
||||
const invokeCallback = () => {
|
||||
worker.postMessage({ type: 'invoke-callback', callbackId: cbId });
|
||||
};
|
||||
callbacks.set(cbId, invokeCallback);
|
||||
addPluginCommand(meta.id, msg.label, invokeCallback);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'event-on': {
|
||||
const cbId = msg.callbackId as string;
|
||||
const handler = (data: unknown) => {
|
||||
worker.postMessage({ type: 'invoke-callback', callbackId: cbId, data });
|
||||
};
|
||||
eventSubscriptions.push({ event: msg.event, handler });
|
||||
pluginEventBus.on(msg.event, handler);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'event-off': {
|
||||
const idx = eventSubscriptions.findIndex(s => s.event === msg.event);
|
||||
if (idx >= 0) {
|
||||
pluginEventBus.off(eventSubscriptions[idx].event, eventSubscriptions[idx].handler);
|
||||
eventSubscriptions.splice(idx, 1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'rpc': {
|
||||
const { id, method, args } = msg;
|
||||
try {
|
||||
let result: unknown;
|
||||
switch (method) {
|
||||
case 'tasks.list':
|
||||
result = await listTasks(groupId);
|
||||
break;
|
||||
case 'tasks.comments':
|
||||
result = await getTaskComments(args.taskId);
|
||||
break;
|
||||
case 'messages.inbox':
|
||||
result = await getUnreadMessages(agentId);
|
||||
break;
|
||||
case 'messages.channels':
|
||||
result = await getChannels(groupId);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown RPC method: ${method}`);
|
||||
}
|
||||
worker.postMessage({ type: 'rpc-result', id, result });
|
||||
} catch (err) {
|
||||
worker.postMessage({
|
||||
type: 'rpc-result',
|
||||
id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'callback-error':
|
||||
console.error(`Plugin '${meta.id}' callback error:`, msg.message);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
worker.onmessage = onMessage;
|
||||
worker.onerror = (err) => {
|
||||
reject(new Error(`Plugin '${meta.id}' worker error: ${err.message}`));
|
||||
};
|
||||
|
||||
// Send init message with plugin code, permissions, and meta
|
||||
worker.postMessage({
|
||||
type: 'init',
|
||||
code,
|
||||
permissions: meta.permissions,
|
||||
meta: { id: meta.id, name: meta.name, version: meta.version, description: meta.description },
|
||||
});
|
||||
});
|
||||
|
||||
// If we get here, the plugin loaded successfully
|
||||
const cleanup = () => {
|
||||
removePluginCommands(meta.id);
|
||||
const eventCleanup = (api as { _eventCleanup?: () => void })._eventCleanup;
|
||||
if (eventCleanup) eventCleanup();
|
||||
for (const sub of eventSubscriptions) {
|
||||
pluginEventBus.off(sub.event, sub.handler);
|
||||
}
|
||||
eventSubscriptions.length = 0;
|
||||
callbacks.clear();
|
||||
worker.terminate();
|
||||
};
|
||||
|
||||
loadedPlugins.set(meta.id, { meta, cleanup });
|
||||
loadedPlugins.set(meta.id, { meta, worker, callbacks, eventSubscriptions, cleanup });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload a plugin, removing all its registered commands and event subscriptions.
|
||||
* Unload a plugin, terminating its Worker.
|
||||
*/
|
||||
export function unloadPlugin(id: string): void {
|
||||
const plugin = loadedPlugins.get(id);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export interface ProjectConfig {
|
|||
useWorktrees?: boolean;
|
||||
/** When true, sidecar process is sandboxed via Landlock (Linux 5.13+, restricts filesystem access) */
|
||||
sandboxEnabled?: boolean;
|
||||
/** Shell execution mode for AI agents. 'restricted' (default) surfaces commands for approval; 'autonomous' auto-executes with audit logging */
|
||||
autonomousMode?: 'restricted' | 'autonomous';
|
||||
/** Anchor token budget scale (defaults to 'medium' = 6K tokens) */
|
||||
anchorBudgetScale?: AnchorBudgetScale;
|
||||
/** Stall detection threshold in minutes (defaults to 15) */
|
||||
|
|
@ -56,6 +58,7 @@ export function agentToProject(agent: GroupAgentConfig, groupCwd: string): Proje
|
|||
isAgent: true,
|
||||
agentRole: agent.role,
|
||||
systemPrompt: agent.systemPrompt,
|
||||
autonomousMode: agent.autonomousMode,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -83,6 +86,8 @@ export interface GroupAgentConfig {
|
|||
wakeStrategy?: WakeStrategy;
|
||||
/** Wake threshold 0..1 for smart strategy (default 0.5) */
|
||||
wakeThreshold?: number;
|
||||
/** Shell execution mode. 'restricted' (default) surfaces commands for approval; 'autonomous' auto-executes with audit logging */
|
||||
autonomousMode?: 'restricted' | 'autonomous';
|
||||
}
|
||||
|
||||
export interface GroupConfig {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,6 @@ export default defineConfig({
|
|||
},
|
||||
clearScreen: false,
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
include: ['src/**/*.test.ts', 'sidecar/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue