diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index af39358..4933885 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -3,10 +3,7 @@ ## 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 Phases 1-7 + multi-machine (A-D) + profiles/skills complete. Extras: SSH, ctx, themes, detached mode, auto-updater, shiki, copy/paste, session resume, drag-resize, session groups, Deno sidecar, Claude profiles, skill discovery. -- v3 Mission Control (All Phases 1-10 Complete + S-1 Phase 1/1.5/2/3 + S-2 Session Anchors + Provider Adapter Pattern + Provider Runners + Memora Adapter + SOLID Phase 3 + Multi-Agent Orchestration): project groups, workspace store, 15 Workspace components, session continuity, workspace teardown, file overlap conflict detection, inotify-based external write detection, multi-provider adapter pattern (3 phases + Codex/Ollama runners), worktree isolation, session anchors, Memora adapter (read-only SQLite), SOLID refactoring (agent-dispatcher split → 4 utils, session.rs split → 7 sub-modules, branded types), multi-agent orchestration (btmsg inter-agent messaging, bttask kanban task board, agent prompt generator, BTMSG_AGENT_ID env passthrough, periodic re-injection, role-specific tabs: Manager=Tasks, Architect=Arch, Tester=Selenium+Tests), dead v2 component cleanup. 286 vitest + 49 cargo tests. -- v3 docs: `docs/v3-task_plan.md`, `docs/v3-findings.md`, `docs/v3-progress.md`. +- v2 planning docs are in `docs/`. Architecture decisions are in `docs/task_plan.md`. - Consult Memora (tag: `bterminal`) before making architectural changes. ## Documentation References @@ -15,78 +12,21 @@ - Implementation phases: [docs/phases.md](../docs/phases.md) - Research findings: [docs/findings.md](../docs/findings.md) - Progress log: [docs/progress.md](../docs/progress.md) -- v3 architecture: [docs/v3-task_plan.md](../docs/v3-task_plan.md) -- v3 findings: [docs/v3-findings.md](../docs/v3-findings.md) -- v3 progress: [docs/v3-progress.md](../docs/v3-progress.md) ## Rules - Do not modify v1 code (`bterminal.py`) unless explicitly asked — it is production-stable. -- v2/v3 work goes on the `v2-mission-control` branch, not master. -- v2 architecture decisions must reference `docs/task_plan.md` Decisions Log. -- v3 architecture decisions must reference `docs/v3-task_plan.md` Decisions Log. +- v2 work goes on a feature branch (`v2-mission-control`), not master. +- All v2 architecture decisions must reference `docs/task_plan.md` Decisions Log. - When adding new decisions, append to the Decisions Log table with date. - Update `docs/progress.md` after each significant work session. ## Key Technical Constraints - WebKit2GTK has no WebGL — xterm.js must use Canvas addon explicitly. -- Agent sessions use `@anthropic-ai/claude-agent-sdk` query() function (migrated from raw CLI spawning due to piped stdio hang bug). SDK handles subprocess management internally. All output goes through the adapter layer (`src/lib/adapters/claude-messages.ts` via `message-adapters.ts` registry) — SDK message format matches CLI stream-json. Multi-provider support: message-adapters.ts routes by ProviderId to provider-specific parsers (claude-messages.ts, codex-messages.ts, ollama-messages.ts — all 3 registered). -- Sidecar uses per-provider runner bundles (`sidecar/dist/{provider}-runner.mjs`). Currently only `claude-runner.mjs` exists. SidecarManager.resolve_sidecar_for_provider(provider) finds the right runner file. Deno preferred (faster startup), Node.js fallback. Communicates with Rust via stdio NDJSON. Claude CLI auto-detected at startup via `findClaudeCli()` — checks ~/.local/bin/claude, ~/.claude/local/claude, /usr/local/bin/claude, /usr/bin/claude, then `which claude`. Path passed to SDK via `pathToClaudeCodeExecutable` option. Agents error immediately if CLI not found. Provider env var stripping: strip_provider_env_var() strips CLAUDE*/CODEX*/OLLAMA* vars (whitelists CLAUDE_CODE_EXPERIMENTAL_*). Dual-layer: (1) Rust env_clear() + clean_env, (2) JS runner SDK `env` option. Session stop uses AbortController.abort(). `agent-runner-deno.ts` exists as standalone alternative runner but is NOT used by SidecarManager. -- AgentPane does NOT stop agents in onDestroy — onDestroy fires on layout remounts, not just explicit close. Stop-on-close is handled externally (was TilingGrid in v2, now workspace teardown in v3). -- Agent dispatcher (`src/lib/agent-dispatcher.ts`) is a thin coordinator (260 lines) routing sidecar events to the agent store. Delegates to extracted modules: `utils/session-persistence.ts` (session-project maps, persistSessionForProject), `utils/subagent-router.ts` (spawn + route subagent panes), `utils/auto-anchoring.ts` (triggerAutoAnchor on compaction), `utils/worktree-detection.ts` (detectWorktreeFromCwd pure function). Provider-aware via message-adapters.ts. -- AgentQueryOptions supports `provider` field (defaults to 'claude', flows Rust -> sidecar), `provider_config` blob (Rust passes through as serde_json::Value), `permission_mode` (defaults to 'bypassPermissions'), `setting_sources` (defaults to ['user', 'project']), `system_prompt`, `model`, `claude_config_dir` (for multi-account), `additional_directories`, `worktree_name` (when set, passed as `extraArgs: { worktree: name }` to SDK → `--worktree ` CLI flag), `extra_env` (HashMap, injected into sidecar process env; used for BTMSG_AGENT_ID). -- Multi-agent orchestration: Tier 1 (management agents: Manager, Architect, Tester, Reviewer) defined in groups.json `agents[]`, converted to ProjectConfig via `agentToProject()`, rendered as full ProjectBoxes. Tier 2 (project agents) are regular ProjectConfig entries. Both tiers get system prompts. Tier 1 prompt built by `generateAgentPrompt()` (utils/agent-prompts.ts): 7 sections (Identity, Environment, Team, btmsg docs, bttask docs, Custom context, Workflow). Tier 2 gets optional `project.systemPrompt` as custom context. BTMSG_AGENT_ID env var injected for Tier 1 agents only (enables btmsg/bttask CLI usage). Periodic re-injection: AgentSession runs 1-hour timer, sends context refresh prompt when agent is idle (autoPrompt → AgentPane → startQuery with resume=true). -- bttask kanban: Rust bttask.rs module reads/writes tasks table in shared btmsg.db (~/. local/share/bterminal/btmsg.db). 6 operations: list_tasks, create_task, update_task_status, delete_task, add_comment, task_comments. Frontend: TaskBoardTab.svelte (kanban 5 columns, 5s poll). CLI `bttask` tool gives agents direct access; Manager has full CRUD, other roles have read-only + comments. -- ArchitectureTab: PlantUML diagram viewer/editor. Stores .puml files in `.architecture/` project dir. Renders via plantuml.com server using ~h hex encoding (no Java dependency). 4 templates: Class, Sequence, State, Component. Editor + SVG preview toggle. -- TestingTab: Dual-mode component (mode='selenium'|'tests'). Selenium: watches `.selenium/screenshots/` for PNG/JPG, displays in gallery with session log, 3s poll. Tests: discovers files in standard dirs (tests/, test/, spec/, __tests__/, e2e/), shows content. -- Worktree isolation (S-1 Phase 3): Per-project `useWorktrees` toggle in SettingsTab. When enabled, AgentPane passes `worktree_name=sessionId` in queryAgent(). Agent runs in `/.claude/worktrees//`. CWD-based detection: `utils/worktree-detection.ts` `detectWorktreeFromCwd()` matches `.claude/worktrees/`, `.codex/worktrees/`, `.cursor/worktrees/` patterns on init events → calls `setSessionWorktree()` for conflict suppression. Dual detection: CWD-based (primary, from init event) + tool_call-based `extractWorktreePath()` (subagent fallback). -- Claude profiles: claude_list_profiles() reads ~/.config/switcher/profiles/ with profile.toml metadata. Profile set per-project in Settings (project.profile field), passed through AgentSession -> AgentPane `profile` prop -> resolved to config_dir for SDK. Profile name shown as info-only in ProjectHeader. -- ProjectBox has project-level tab bar: Model | Docs | Context | Files | SSH | Memory + role-specific tabs. Three mount strategies: PERSISTED-EAGER (Model, Docs, Context — always mounted, display:flex/none), PERSISTED-LAZY (Files, SSH, Memory, Tasks, Architecture, Selenium, Tests — mount on first activation via {#if everActivated} + display:flex/none). Tab type: `'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories' | 'tasks' | 'architecture' | 'selenium' | 'tests'`. Role-specific tabs: Manager gets Tasks (kanban), Architect gets Arch (PlantUML), Tester gets Selenium+Tests. Conditional on `isAgent && agentRole`. Model tab = AgentSession+TeamAgentsPanel. Docs tab = ProjectFiles (markdown viewer). Context tab = ContextTab.svelte (LLM context window visualization: stats bar, segmented token meter, file references, turn breakdown; reads from agent store via sessionId prop; replaced old ContextPane ctx database viewer). Files tab = FilesTab.svelte (VSCode-style directory tree + CodeMirror 6 editor with 15 language modes, dirty tracking, Ctrl+S save, save-on-blur setting, image display via convertFileSrc, 10MB gate; CodeEditor.svelte wrapper; PdfViewer.svelte for PDF files via pdfjs-dist with canvas multi-page rendering + zoom 0.5x–3x; CsvTable.svelte for CSV with RFC 4180 parser, delimiter auto-detect, sortable columns). SSH tab = SshTab.svelte (CRUD for SSH connections, launch spawns terminal tab in Model tab). Memory tab = MemoriesTab.svelte (pluggable via MemoryAdapter interface in memory-adapter.ts; MemoraAdapter registered at startup, reads ~/.local/share/memora/memories.db via Rust memora.rs). Tasks tab = TaskBoardTab.svelte (kanban board, 5 columns, 5s poll, Manager only). Arch tab = ArchitectureTab.svelte (PlantUML viewer/editor, .architecture/ dir, plantuml.com ~h hex encoding, Architect only). Selenium tab = TestingTab.svelte mode=selenium (screenshot gallery, session log, 3s poll, Tester only). Tests tab = TestingTab.svelte mode=tests (test file discovery, content viewer, Tester only). Rust backend: list_directory_children + read_file_content + write_file_content (FileContent tagged union: Text/Binary/TooLarge). Frontend bridge: files-bridge.ts. -- ProjectHeader shows CWD (ellipsized from START via `direction: rtl`) + profile name as info-only text on right side. AgentPane no longer has DIR/ACC toolbar — CWD and profile are props from parent. -- Skill discovery: claude_list_skills() reads ~/.claude/skills/ (dirs with SKILL.md or .md files). claude_read_skill() reads content. AgentPane `/` prefix triggers autocomplete menu. Skill content injected as prompt via expandSkillPrompt(). -- claude-bridge.ts adapter wraps profile/skill Tauri commands (ClaudeProfile, ClaudeSkill interfaces). provider-bridge.ts wraps claude-bridge as generic provider bridge (delegates by ProviderId). -- Provider adapter pattern: ProviderId = 'claude' | 'codex' | 'ollama'. ProviderCapabilities flags gate UI (hasProfiles, hasSkills, hasModelSelection, hasSandbox, supportsSubagents, supportsCost, supportsResume). ProviderMeta registered via registerProvider() in App.svelte onMount. AgentPane receives provider + capabilities props. SettingsTab has Providers section with collapsible per-provider config panels. ProjectConfig.provider field for per-project selection. Settings persisted as `provider_settings` JSON blob. -- Sidecar build: `npm run build:sidecar` builds all 3 runners via esbuild (claude-runner.mjs, codex-runner.mjs, ollama-runner.mjs). Each is a standalone ESM bundle. Codex runner dynamically imports @openai/codex-sdk (graceful failure if not installed). Ollama runner uses native fetch (zero deps). -- Agent preview terminal: `AgentPreviewPane.svelte` is a read-only xterm.js terminal (disableStdin:true) that subscribes to an agent session's messages via `$derived(getAgentSession(sessionId))` and renders tool calls/results in real-time. Bash commands shown as cyan `❯ cmd`, file ops as yellow `[Read] path`, results as plain text (80-line truncation), errors in red. Spawned via 👁 button in TerminalTabs (appears when agentSessionId prop is set). TerminalTab type: `'agent-preview'` with `agentSessionId` field. Deduplicates — won't create two previews for the same session. ProjectBox passes mainSessionId to TerminalTabs. -- Maximum 4 active xterm.js instances to avoid WebKit2GTK memory issues. Agent preview uses disableStdin and no PTY so is lighter, but still counts. -- Store files using Svelte 5 runes (`$state`, `$derived`) MUST have `.svelte.ts` extension (not `.ts`). Import with `.svelte` suffix. Plain `.ts` compiles but fails at runtime with "rune_outside_svelte". -- Session persistence uses rusqlite (bundled) with WAL mode. Data dir: `dirs::data_dir()/bterminal/sessions.db`. -- Layout store persists to SQLite on every addPane/removePane/setPreset/setPaneGroup change (fire-and-forget). Restores on app startup via `restoreFromDb()`. -- Session groups: Pane.group? field in layout store, group_name column in sessions table, collapsible group headers in sidebar. Right-click pane to set group. -- File watcher uses notify crate v6, watches parent directory (NonRecursive), emits `file-changed` Tauri events. -- Settings use key-value `settings` table in SQLite (session/settings.rs). Frontend: `settings-bridge.ts` adapter. v3 uses SettingsTab.svelte rendered in sidebar drawer panel (v2 SettingsDialog.svelte deleted in P10). SettingsTab has two sections: Global (single-column layout, split into Appearance [theme dropdown, UI font dropdown with sans-serif options + size stepper, Terminal font dropdown with monospace options + size stepper] and Defaults [shell, CWD] — all custom themed dropdowns, no native ``), all persisted via settings-bridge -- Typography CSS custom properties (`--ui-font-family`, `--ui-font-size`, `--term-font-family`, `--term-font-size`) in catppuccin.css with defaults; consumed by app.css body rule -- `initTheme()` now restores 4 saved font settings (ui_font_family, ui_font_size, term_font_family, term_font_size) from SQLite on startup alongside theme restoration -- v3 Mission Control (All Phases 1-10 complete): multi-project dashboard with project groups, per-project Claude sessions, team agents panel, terminal tabs, 3 workspace tabs (Sessions/Docs/Context) + settings drawer -- v3 session continuity (P6): `persistSessionForProject()` saves agent state + messages to SQLite on session complete; `registerSessionProject()` maps session to project; `ClaudeSession.restoreMessagesFromRecords()` restores cached messages on mount -- v3 workspace teardown (P7): `clearAllAgentSessions()` clears agent sessions on group switch; terminal tabs reset via `switchGroup()` -- v3 data model: `groups.rs` (Rust structs + load/save `~/.config/bterminal/groups.json`), `groups.ts` (TypeScript interfaces), `groups-bridge.ts` (IPC adapter), `--group` CLI argument -- v3 workspace store (`workspace.svelte.ts`): replaces `layout.svelte.ts`, manages groups/activeGroupId/activeTab/focusedProjectId with Svelte 5 runes -- v3 SQLite migrations: `agent_messages` table (per-project message persistence), `project_agent_state` table (sdkSessionId/cost/status per project), `project_id` column on sessions -- 12 new Workspace components: GlobalTabBar, ProjectGrid, ProjectBox, ProjectHeader, ClaudeSession, TeamAgentsPanel, AgentCard, TerminalTabs, CommandPalette, DocsTab, ContextTab, SettingsTab -- v3 App.svelte full rewrite: GlobalTabBar + tab content area + StatusBar (no sidebar, no TilingGrid) -- 24 new vitest tests for workspace store, 7 new cargo tests for groups (total: 138 vitest + 36 cargo) -- v3 adversarial architecture review: 3 agents (Architect, Devil's Advocate, UX+Performance Specialist), 12 issues identified and resolved -- v3 Mission Control redesign planning: architecture docs (`docs/v3-task_plan.md`, `docs/v3-findings.md`, `docs/v3-progress.md`), codebase reuse analysis -- Claude profile/account switching: `claude_list_profiles()` reads `~/.config/switcher/profiles/` directories with `profile.toml` metadata (email, subscription_type, display_name); profile selector dropdown in AgentPane toolbar when multiple profiles available; selected profile's `config_dir` passed as `CLAUDE_CONFIG_DIR` env override to SDK -- Skill discovery and autocomplete: `claude_list_skills()` reads `~/.claude/skills/` (directories with `SKILL.md` or standalone `.md` files); type `/` in agent prompt textarea to trigger autocomplete menu with arrow key navigation, Tab/Enter selection, Escape dismiss; `expandSkillPrompt()` reads skill content and injects as prompt -- New frontend adapter `claude-bridge.ts`: `ClaudeProfile` and `ClaudeSkill` interfaces, `listProfiles()`, `listSkills()`, `readSkill()` IPC wrappers -- AgentPane session toolbar: editable working directory input, profile/account selector (shown when >1 profile), all rendered above prompt form -- Extended `AgentQueryOptions` with 5 new fields across full stack (Rust struct, sidecar JSON, SDK options): `setting_sources` (defaults to `['user', 'project']`), `system_prompt`, `model`, `claude_config_dir`, `additional_directories` -- 4 new Tauri commands: `claude_list_profiles`, `claude_list_skills`, `claude_read_skill`, `pick_directory` -- Claude CLI path auto-detection: `findClaudeCli()` in both sidecar runners checks common paths (~/.local/bin/claude, ~/.claude/local/claude, /usr/local/bin/claude, /usr/bin/claude) then falls back to `which`/`where`; resolved path passed to SDK via `pathToClaudeCodeExecutable` option -- Early error reporting when Claude CLI is not found — sidecar emits `agent_error` immediately instead of cryptic SDK failure - -### Changed -- SettingsTab global settings restructured to single-column layout with labels above controls, split into "Appearance" (theme, UI font, terminal font) and "Defaults" (shell, CWD) subsections; all native `` with custom themed dropdown in SettingsTab.svelte -- [x] Trigger: color swatch (base) + label + arrow; menu: grouped sections with styled headers -- [x] Options show color swatch + label + 4 accent dots (red/green/blue/yellow) via getPalette() -- [x] Click-outside and Escape to close; aria-haspopup/aria-expanded for a11y -- [x] Uses --ctp-* CSS vars — fully themed with active theme - -### Session: 2026-03-07 (continued) — Global Font Controls - -#### SettingsTab Font Controls + Layout Restructure -- [x] Added font family select (9 monospace fonts + Default) with live CSS var preview -- [x] Added font size +/- stepper (8-24px range) with live CSS var preview -- [x] Restructured global settings: 2-column grid layout with labels above controls (replaced inline rows) -- [x] Added --ui-font-family and --ui-font-size CSS custom properties to catppuccin.css -- [x] app.css body rule now uses CSS vars instead of hardcoded font values -- [x] initTheme() in theme.svelte.ts restores saved font settings on startup (try/catch, non-fatal) -- [x] Font settings persisted as 'font_family' and 'font_size' keys in SQLite settings table - -### Session: 2026-03-07 (continued) — SettingsTab Global Settings Redesign - -#### Font Settings Split (UI Font + Terminal Font) -- [x] Split single font into UI font (sans-serif: System Sans-Serif, Inter, Roboto, etc.) and Terminal font (monospace: JetBrains Mono, Fira Code, etc.) -- [x] Each font dropdown renders preview text in its own typeface -- [x] Independent size steppers (8-24px) for UI and Terminal font -- [x] Setting keys changed: font_family/font_size -> ui_font_family/ui_font_size + term_font_family/term_font_size - -#### SettingsTab Layout + CSS Updates -- [x] Rewrote global settings: single-column layout, "Appearance" + "Defaults" subsections -- [x] All dropdowns are custom themed (no native `` with custom themed dropdown in SettingsTab.svelte -- [x] Dropdown trigger shows color swatch (base color from getPalette()) + theme label + arrow indicator -- [x] Dropdown menu groups themes by category (Catppuccin/Editor/Deep Dark) with styled uppercase headers -- [x] Each option shows: color swatch + label + 4 accent color dots (red/green/blue/yellow) -- [x] Active theme highlighted with surface0 background + bold text -- [x] Click-outside handler and Escape key to close dropdown -- [x] Uses --ctp-* CSS vars throughout — fully themed with any active theme -- [x] Added `getPalette` import from themes.ts for live color rendering -- [x] Added aria-haspopup/aria-expanded attributes for accessibility - -#### Verification -- No test changes needed — UI-only change, no logic changes - -### Session: 2026-03-07 — Theme Dropdown CSS Polish - -#### SettingsTab Dropdown Sizing Fix -- [x] Set `min-width: 180px` on `.theme-dropdown` container (was `min-width: 0`) to prevent trigger from collapsing -- [x] Set `min-width: 280px` on `.theme-options` dropdown menu (was `right: 0`) to ensure full theme names visible -- [x] Increased `max-height` from 320px to 400px on dropdown menu for better scrolling experience -- [x] Added `white-space: nowrap` on `.theme-option-label` (was `min-width: 0`) to prevent label text wrapping - -#### Verification -- No test changes needed — CSS-only change - -### Session: 2026-03-07 — Global Font Controls - -#### SettingsTab Font Family + Font Size Controls -- [x] Added font family `` anywhere) - -#### CSS + Theme Store Updates -- [x] Added `--term-font-family` and `--term-font-size` CSS custom properties to catppuccin.css -- [x] Updated `initTheme()` in theme.svelte.ts: loads 4 font settings (ui_font_family, ui_font_size, term_font_family, term_font_size) instead of 2 -- [x] UI font fallback changed from monospace to sans-serif - -#### Verification -- No test changes needed — UI/CSS-only changes, no logic changes - -### Session: 2026-03-08 — CSS Relative Units Rule - -#### New Rule: 18-relative-units.md -- [x] Created `.claude/rules/18-relative-units.md` enforcing rem/em for layout CSS -- [x] Pixels allowed only for icon sizes, borders/outlines, box shadows -- [x] Exception: --ui-font-size/--term-font-size CSS vars store px (xterm.js API requirement) -- [x] Added rule #18 to `.claude/CLAUDE.md` rule index - -#### CSS Conversions -- [x] GlobalTabBar.svelte: rail width 36px -> 2.75rem, button 28px -> 2rem, gap 2px -> 0.25rem, padding 6px 4px -> 0.5rem 0.375rem, border-radius 4px -> 0.375rem -- [x] App.svelte: sidebar header padding 8px 12px -> 0.5rem 0.75rem, close button 22px -> 1.375rem, border-radius 4px -> 0.25rem -- [x] Also changed GlobalTabBar rail-btn color from --ctp-overlay1 to --ctp-subtext0 for better contrast - -### Session: 2026-03-08 — Content-Driven Sidebar Width - -#### Sidebar Panel Sizing -- [x] Changed `.sidebar-panel` from fixed `width: 28em` to `width: max-content` with `min-width: 16em` and `max-width: 50%` -- [x] Changed `.sidebar-panel` and `.panel-content` from `overflow: hidden` to `overflow-y: auto` — hidden was blocking content from driving parent width -- [x] Each tab component now defines its own `min-width: 22em` (SettingsTab, ContextTab, DocsTab) - -#### Additional px → rem Conversions -- [x] SettingsTab.svelte: padding 12px 16px → 0.75rem 1rem -- [x] DocsTab.svelte: file-picker 220px → 14em, picker-title padding → rem, file-btn padding → rem, empty/loading padding → rem -- [x] ContextPane.svelte: font-size, padding, margin, gap converted from px to rem; added `white-space: nowrap` on `.ctx-header`/`.ctx-error` for intrinsic width measurement - -#### Fix: Sidebar Drawer Content-Driven Width -- [x] Root cause found: `#app` in `app.css` had leftover v2 grid layout (`display: grid; grid-template-columns: var(--sidebar-width) 1fr`) constraining `.app-shell` to 260px first column -- [x] Removed v2 grid + both media queries from `#app` — v3 `.app-shell` manages its own flexbox layout -- [x] Added JS `$effect` in App.svelte: measures content width via `requestAnimationFrame` + `querySelectorAll` for nowrap elements, headings, inputs, tab-specific selectors; `panelWidth` state drives inline `style:width` -- [x] Verified all 4 tabs scale to content: Sessions ~473px, Settings ~322px, Context ~580px, Docs varies by content -- [x] Investigation path: CSS intrinsic sizing (max-content, fit-content) failed due to column-flex circular dependency → JS measurement approach → discovered inline style set but rendered width wrong → Playwright inspection revealed parent `.main-row` only 260px → traced to `#app` grid layout - -### Session: 2026-03-08 — Native Directory Picker - -#### tauri-plugin-dialog Integration -- [x] Added `tauri-plugin-dialog` Rust crate + `@tauri-apps/plugin-dialog` npm package -- [x] Registered plugin in lib.rs (`tauri_plugin_dialog::init()`) -- [x] Removed stub `pick_directory` Tauri command (always returned None) -- [x] Added `browseDirectory()` helper in SettingsTab.svelte using `open({ directory: true })` -- [x] Added folder browse button (folder SVG icon) to: Default CWD, existing project CWD, Add Project path -- [x] Styled `.input-with-browse` layout (flex row, themed browse button) -- [x] Fixed nested input theme: `.setting-field .input-with-browse input` selector for dark background -- [x] Fixed dialog not opening: added `"dialog:default"` permission to `v2/src-tauri/capabilities/default.json` — Tauri IPC security blocked invoke() without capability -- [x] Verified via Playwright: error was `Cannot read properties of undefined (reading 'invoke')` in browser context (expected — Tauri IPC only exists in WebView), confirming code is correct -- [x] Clean rebuild required after capability changes (cached binary doesn't pick up new permissions) - -#### Modal + Dark-Themed Dialog -- [x] Root cause: `tauri-plugin-dialog` skips `set_parent(&window)` on Linux via `cfg(any(windows, target_os = "macos"))` gate in commands.rs — dialog not modal -- [x] Root cause: native GTK file chooser uses system GTK theme, not app's CSS theme — dialog appears light -- [x] Fix: custom `pick_directory` Tauri command using `rfd::AsyncFileDialog` directly with `.set_parent(&window)` — modal on Linux -- [x] Fix: `std::env::set_var("GTK_THEME", "Adwaita:dark")` at start of `run()` in lib.rs — dark-themed dialog -- [x] Added `rfd = { version = "0.16", default-features = false, features = ["gtk3"] }` as direct dep — MUST disable defaults to avoid gtk3+xdg-portal feature conflict -- [x] Switched SettingsTab from `@tauri-apps/plugin-dialog` `open()` to `invoke('pick_directory')` - -### Session: 2026-03-08 — Project Workspace Layout Redesign + Icon Fix - -#### Icon Fix -- [x] Replaced Nerd Font codepoints (`\uf120`) with emoji (`📁` default) — Nerd Font not installed, showed "?" -- [x] Added emoji picker grid (24 project-relevant emoji, 8-column popup) in SettingsTab instead of plain text input -- [x] Removed `font-family: 'NerdFontsSymbols Nerd Font'` from ProjectHeader and TerminalTabs - -#### ProjectBox Layout Redesign -- [x] Switched ProjectBox from flex to CSS grid (`grid-template-rows: auto 1fr auto`) — header | session | terminal zones -- [x] Terminal area: explicit `height: 16rem` instead of collapsing to content -- [x] Session area: `min-height: 0` for proper flex child overflow - -#### AgentPane Prompt Layout -- [x] Prompt area anchored to bottom (`justify-content: flex-end`) instead of vertical center -- [x] Removed `max-width: 600px` constraint on form and toolbar — uses full panel width -- [x] Toolbar sits directly above textarea - -#### CSS px → rem Conversions -- [x] ProjectGrid.svelte: gap 4px → 0.25rem, padding 4px → 0.25rem, min-width 480px → 30rem -- [x] TerminalTabs.svelte: tab bar, tabs, close/add buttons all converted to rem -- [x] ProjectBox.svelte: min-width 480px → 30rem - -### Session: 2026-03-08 — Project-Level Tabs + Clean AgentPane - -#### ProjectHeader Info Bar -- [x] Added CWD path display (ellipsized from START via `direction: rtl` + `text-overflow: ellipsis`) -- [x] Added profile name as info-only text (right side of header) -- [x] Home dir shortening: `/home/user/foo` → `~/foo` - -#### Project-Level Tab Bar -- [x] Added tab bar in ProjectBox below header: Claude | Files | Context -- [x] Content area switches between ClaudeSession, ProjectFiles, ContextPane based on selected tab -- [x] CSS grid updated to 4 rows: `auto auto 1fr auto` (header | tabs | content | terminal) -- [x] TeamAgentsPanel still renders alongside ClaudeSession in Claude tab - -#### ProjectFiles Component (NEW) -- [x] Created `ProjectFiles.svelte` — project-scoped markdown file viewer -- [x] Accepts `cwd` + `projectName` props (not workspace store) -- [x] File picker sidebar (10rem) + MarkdownPane content area -- [x] Auto-selects priority file or first file - -#### AgentPane Cleanup -- [x] Removed entire session toolbar (DIR/ACC interactive inputs + all CSS) -- [x] Added `profile` prop — resolved via `listProfiles()` to get config_dir -- [x] CWD passed as prop from parent (project.cwd), no longer editable in pane -- [x] Clean chat interface: prompt (bottom-anchored) + messages + send button -- [x] ClaudeSession now passes `project.profile` to AgentPane - -#### Verification -- All 138 vitest tests pass -- Vite build succeeds - -### Session: 2026-03-08 — Security Audit Fixes + OTEL Telemetry - -#### Security Audit Fixes -- [x] Fixed all CRITICAL (5) + HIGH (4) findings — path traversal, race conditions, memory leaks, listener leaks, transaction safety -- [x] Fixed all MEDIUM (6) findings — runtime type guards, ANTHROPIC_* env stripping, timestamp mismatch, async lock, error propagation -- [x] Fixed all LOW (8) findings — input validation, mutex poisoning, log warnings, payload validation -- [x] 3 false positives dismissed with rationale -- [x] 172/172 tests pass (138 vitest + 34 cargo) - -#### OTEL Telemetry Implementation -- [x] Added 6 Rust deps: tracing, tracing-subscriber, opentelemetry 0.28, opentelemetry_sdk 0.28, opentelemetry-otlp 0.28, tracing-opentelemetry 0.29 -- [x] Created `v2/src-tauri/src/telemetry.rs` — TelemetryGuard, layer composition, OTLP export via BTERMINAL_OTLP_ENDPOINT env var -- [x] Integrated into lib.rs: TelemetryGuard in AppState, init before Tauri builder -- [x] Instrumented 10 Tauri commands with `#[tracing::instrument]`: pty_spawn, pty_kill, agent_query/stop/restart, remote_connect/disconnect/agent_query/agent_stop/pty_spawn -- [x] Added `frontend_log` Tauri command for frontend→Rust tracing bridge -- [x] Created `v2/src/lib/adapters/telemetry-bridge.ts` — `tel.info/warn/error/debug/trace()` convenience API -- [x] Wired agent dispatcher lifecycle events: agent_started, agent_stopped, agent_error, sidecar_crashed, cost metrics -- [x] Created Docker compose stack: `docker/tempo/` — Tempo (4317/4318/3200) + Grafana (port 9715) - -### Session: 2026-03-08 — Teardown Race Fix + px→rem Conversion - -#### Workspace Teardown Race Fix -- [x] Added `pendingPersistCount` counter + `waitForPendingPersistence()` export in agent-dispatcher.ts -- [x] `persistSessionForProject()` increments/decrements counter in try/finally -- [x] `switchGroup()` in workspace.svelte.ts now awaits `waitForPendingPersistence()` before clearing state -- [x] SettingsTab.svelte switchGroup onclick handler made async with await -- [x] Added test for `waitForPendingPersistence` in agent-dispatcher.test.ts -- [x] Added mock for `waitForPendingPersistence` in workspace.test.ts -- [x] Last open HIGH audit finding resolved (workspace teardown race) - -#### px→rem Conversion (Rule 18 Compliance) -- [x] Converted ~100 px layout violations to rem across 10 components -- [x] AgentPane.svelte (~35 violations: font-size, padding, gap, margin, max-height, border-radius) -- [x] ToastContainer.svelte, CommandPalette.svelte, TeamAgentsPanel.svelte, AgentCard.svelte -- [x] StatusBar.svelte, AgentTree.svelte, TerminalPane.svelte, AgentPreviewPane.svelte, SettingsTab.svelte -- [x] Icon/decorative dot dimensions kept as px per rule 18 -- [x] 139 vitest + 34 cargo tests pass, vite build succeeds - -### Session: 2026-03-08 — E2E Testing Infrastructure - -#### WebdriverIO + tauri-driver Setup -- [x] Installed @wdio/cli, @wdio/local-runner, @wdio/mocha-framework, @wdio/spec-reporter (v9.24.0) -- [x] Created wdio.conf.js with tauri-driver lifecycle hooks (onPrepare builds debug binary, beforeSession/afterSession spawns/kills tauri-driver) -- [x] Created tsconfig.json for e2e test TypeScript compilation -- [x] Created smoke.test.ts with 6 tests: app title, status bar, version text, sidebar rail, workspace area, sidebar toggle -- [x] Added `test:e2e` npm script (`wdio run tests/e2e/wdio.conf.js`) -- [x] Updated README.md with complete setup instructions and CI guide -- [x] Key decision: WebdriverIO over Playwright (Playwright cannot control Tauri/WebKit2GTK apps) -- [x] Prerequisites: tauri-driver (cargo install), webkit2gtk-driver (apt), display server or xvfb-run - -#### E2E Fixes (wdio v9 + tauri-driver compatibility) -- [x] Fixed wdio v9 BiDi: added `wdio:enforceWebDriverClassic: true` — wdio v9 injects webSocketUrl:true which tauri-driver rejects -- [x] Removed `browserName: 'wry'` from capabilities (not needed in wdio, only Selenium) -- [x] Fixed binary path: Cargo workspace target is v2/target/debug/, not v2/src-tauri/target/debug/ -- [x] Fixed tauri-plugin-log panic: telemetry::init() registers tracing-subscriber before plugin-log → removed tauri-plugin-log entirely (redundant with telemetry::init()) -- [x] Removed tauri-plugin-log from Cargo.toml dependency - -#### E2E Coverage Expansion (25 tests, single spec file) -- [x] Consolidated 4 spec files into single bterminal.test.ts — Tauri creates one app session per spec file; after first spec completes, app closes and subsequent specs get "invalid session id" -- [x] Added Workspace & Projects tests (8): project grid, project boxes, header with name, 3 project tabs, active highlight, tab switching, status bar counts -- [x] Added Settings Panel tests (6): settings tab, sections, theme dropdown, dropdown open+options, group list, close button -- [x] Added Keyboard Shortcuts tests (5): Ctrl+K command palette, Ctrl+, settings, Ctrl+B sidebar, Escape close, palette group list -- [x] Fixed WebDriver clicks on Svelte 5 components: `element.click()` doesn't reliably trigger onclick inside complex components via WebKit2GTK/tauri-driver — use `browser.execute()` for JS-level clicks -- [x] Fixed CSS text-transform: `.ptab` getText() returns uppercase — use `.toLowerCase()` for comparison -- [x] Fixed element scoping: `browser.$('.ptab')` returns ALL tabs across project boxes — scope via `box.$('.ptab')` -- [x] Fixed keyboard focus: `browser.execute(() => document.body.focus())` before sending shortcuts -- [x] Removed old individual spec files (smoke.test.ts, keyboard.test.ts, settings.test.ts, workspace.test.ts) -- [x] All 25 E2E tests pass (9s runtime after build) - -### Session: 2026-03-10 — Tab System Overhaul - -#### Tab Renames + New Tabs -- [x] Renamed Claude → Model, Files → Docs in ProjectBox -- [x] Added 3 new tabs: Files (directory browser), SSH (connection manager), Memory (knowledge explorer) -- [x] Implemented PERSISTED-EAGER (Model/Docs/Context — display:flex/none) vs PERSISTED-LAZY (Files/SSH/Memory — {#if everActivated} + display:flex/none) mount strategy -- [x] Tab type union: 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories' - -#### Files Tab (FilesTab.svelte) -- [x] VSCode-style tree sidebar (14rem) + content viewer -- [x] Rust list_directory_children command: lazy expansion, hidden files skipped, dirs-first sort -- [x] Rust read_file_content command: FileContent tagged union (Text/Binary/TooLarge), 10MB gate, 30+ language mappings -- [x] Frontend files-bridge.ts adapter (DirEntry, FileContent types) -- [x] Shiki syntax highlighting for code files, image display via convertFileSrc, emoji file icons - -#### SSH Tab (SshTab.svelte) -- [x] CRUD panel for SSH connections using existing ssh-bridge.ts/SshSession model -- [x] Launch button spawns terminal tab in Model tab's TerminalTabs section via addTerminalTab() - -#### Memory Tab (MemoriesTab.svelte) -- [x] Pluggable MemoryAdapter interface (memory-adapter.ts): name, available, list(), search(), get() -- [x] Adapter registry: registerMemoryAdapter(), getDefaultAdapter(), getAvailableAdapters() -- [x] UI: search bar, tag display, expandable cards, adapter switcher, placeholder when no adapter - -#### Context Tab Repurpose (ContextTab.svelte) -- [x] Replaced ContextPane (ctx database viewer) with LLM context window visualization -- [x] Tribunal debate for design (S-1-R4 winner at 82% confidence) -- [x] Stats bar: input/output tokens, cost, turns, duration -- [x] Segmented token meter: CSS flex bar with color-coded categories (assistant/thinking/tool calls/tool results) -- [x] File references: extracted from tool_call messages, colored op badges -- [x] Turn breakdown: collapsible message groups by user prompt -- [x] Token estimation via ~4 chars/token heuristic -- [x] Wired into ProjectBox (replaces ContextPane, passes sessionId) -- [x] Sub-tab navigation: Overview | AST | Graph -- [x] AST tab: per-turn SVG conversation trees (Thinking/Response/ToolCall/File nodes, bezier edges, token counts) -- [x] Graph tab: bipartite tool→file DAG (tools left, files right, curved edges, count badges) -- [x] Compaction detection: sdk-messages.ts adapts `compact_boundary` system messages → `CompactionContent` type -- [x] Stats bar compaction pill: yellow count badge with tooltip (last trigger, tokens removed) -- [x] AST compaction boundaries: red "Compacted" nodes inserted between turns at compaction points - -#### FilesTab Fixes & CodeMirror Editor -- [x] Fixed HTML nesting error: ` - -
- {#if activeTab === 'comms'} - - {:else} - - {/if} -
- - {/if} - -
- - -
- - - - - - paletteOpen = false} /> -{:else} -
Loading workspace...
-{/if} - - - diff --git a/v2/src/app.css b/v2/src/app.css deleted file mode 100644 index c0bc9aa..0000000 --- a/v2/src/app.css +++ /dev/null @@ -1,44 +0,0 @@ -@import './lib/styles/catppuccin.css'; - -*, -*::before, -*::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -html, body { - height: 100%; - width: 100%; - overflow: hidden; - background: var(--bg-primary); - color: var(--text-primary); - font-family: var(--ui-font-family); - font-size: var(--ui-font-size); - line-height: 1.4; - -webkit-font-smoothing: antialiased; -} - -#app { - height: 100%; - width: 100%; -} - -::-webkit-scrollbar { - width: 6px; - height: 6px; -} - -::-webkit-scrollbar-track { - background: var(--bg-secondary); -} - -::-webkit-scrollbar-thumb { - background: var(--ctp-surface2); - border-radius: 3px; -} - -::-webkit-scrollbar-thumb:hover { - background: var(--ctp-overlay0); -} diff --git a/v2/src/lib/adapters/agent-bridge.test.ts b/v2/src/lib/adapters/agent-bridge.test.ts deleted file mode 100644 index e9a2b8e..0000000 --- a/v2/src/lib/adapters/agent-bridge.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Use vi.hoisted to declare mocks that are accessible inside vi.mock factories -const { mockInvoke, mockListen } = vi.hoisted(() => ({ - mockInvoke: vi.fn(), - mockListen: vi.fn(), -})); - -vi.mock('@tauri-apps/api/core', () => ({ - invoke: mockInvoke, -})); - -vi.mock('@tauri-apps/api/event', () => ({ - listen: mockListen, -})); - -import { - queryAgent, - stopAgent, - isAgentReady, - restartAgent, - onSidecarMessage, - onSidecarExited, - type AgentQueryOptions, -} from './agent-bridge'; - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe('agent-bridge', () => { - describe('queryAgent', () => { - it('invokes agent_query with options', async () => { - mockInvoke.mockResolvedValue(undefined); - - const options: AgentQueryOptions = { - session_id: 'sess-1', - prompt: 'Hello Claude', - cwd: '/tmp', - max_turns: 10, - max_budget_usd: 1.0, - }; - - await queryAgent(options); - - expect(mockInvoke).toHaveBeenCalledWith('agent_query', { options }); - }); - - it('passes minimal options (only required fields)', async () => { - mockInvoke.mockResolvedValue(undefined); - - const options: AgentQueryOptions = { - session_id: 'sess-2', - prompt: 'Do something', - }; - - await queryAgent(options); - - expect(mockInvoke).toHaveBeenCalledWith('agent_query', { options }); - }); - - it('propagates invoke errors', async () => { - mockInvoke.mockRejectedValue(new Error('Sidecar not running')); - - await expect( - queryAgent({ session_id: 'sess-3', prompt: 'test' }), - ).rejects.toThrow('Sidecar not running'); - }); - }); - - describe('stopAgent', () => { - it('invokes agent_stop with session ID', async () => { - mockInvoke.mockResolvedValue(undefined); - - await stopAgent('sess-1'); - - expect(mockInvoke).toHaveBeenCalledWith('agent_stop', { sessionId: 'sess-1' }); - }); - }); - - describe('isAgentReady', () => { - it('returns true when sidecar is ready', async () => { - mockInvoke.mockResolvedValue(true); - - const result = await isAgentReady(); - - expect(result).toBe(true); - expect(mockInvoke).toHaveBeenCalledWith('agent_ready'); - }); - - it('returns false when sidecar is not ready', async () => { - mockInvoke.mockResolvedValue(false); - - const result = await isAgentReady(); - - expect(result).toBe(false); - }); - }); - - describe('restartAgent', () => { - it('invokes agent_restart', async () => { - mockInvoke.mockResolvedValue(undefined); - - await restartAgent(); - - expect(mockInvoke).toHaveBeenCalledWith('agent_restart'); - }); - }); - - describe('onSidecarMessage', () => { - it('registers listener on sidecar-message event', async () => { - const unlisten = vi.fn(); - mockListen.mockResolvedValue(unlisten); - - const callback = vi.fn(); - const result = await onSidecarMessage(callback); - - expect(mockListen).toHaveBeenCalledWith('sidecar-message', expect.any(Function)); - expect(result).toBe(unlisten); - }); - - it('extracts payload and passes to callback', async () => { - mockListen.mockImplementation(async (_event: string, handler: (e: unknown) => void) => { - // Simulate Tauri event delivery - handler({ - payload: { - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'system', subtype: 'init' }, - }, - }); - return vi.fn(); - }); - - const callback = vi.fn(); - await onSidecarMessage(callback); - - expect(callback).toHaveBeenCalledWith({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'system', subtype: 'init' }, - }); - }); - }); - - describe('onSidecarExited', () => { - it('registers listener on sidecar-exited event', async () => { - const unlisten = vi.fn(); - mockListen.mockResolvedValue(unlisten); - - const callback = vi.fn(); - const result = await onSidecarExited(callback); - - expect(mockListen).toHaveBeenCalledWith('sidecar-exited', expect.any(Function)); - expect(result).toBe(unlisten); - }); - - it('invokes callback without arguments on exit', async () => { - mockListen.mockImplementation(async (_event: string, handler: () => void) => { - handler(); - return vi.fn(); - }); - - const callback = vi.fn(); - await onSidecarExited(callback); - - expect(callback).toHaveBeenCalledWith(); - }); - }); -}); diff --git a/v2/src/lib/adapters/agent-bridge.ts b/v2/src/lib/adapters/agent-bridge.ts deleted file mode 100644 index 1277f9d..0000000 --- a/v2/src/lib/adapters/agent-bridge.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Agent Bridge — Tauri IPC adapter for sidecar communication -// Mirrors pty-bridge.ts pattern: invoke for commands, listen for events - -import { invoke } from '@tauri-apps/api/core'; -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; - -import type { ProviderId } from '../providers/types'; - -export interface AgentQueryOptions { - provider?: ProviderId; - session_id: string; - prompt: string; - cwd?: string; - max_turns?: number; - max_budget_usd?: number; - resume_session_id?: string; - permission_mode?: string; - setting_sources?: string[]; - system_prompt?: string; - model?: string; - claude_config_dir?: string; - additional_directories?: string[]; - /** When set, agent runs in a git worktree for isolation */ - worktree_name?: string; - provider_config?: Record; - /** Extra environment variables injected into the agent process (e.g. BTMSG_AGENT_ID) */ - extra_env?: Record; - remote_machine_id?: string; -} - -export async function queryAgent(options: AgentQueryOptions): Promise { - if (options.remote_machine_id) { - const { remote_machine_id: machineId, ...agentOptions } = options; - return invoke('remote_agent_query', { machineId, options: agentOptions }); - } - return invoke('agent_query', { options }); -} - -export async function stopAgent(sessionId: string, remoteMachineId?: string): Promise { - if (remoteMachineId) { - return invoke('remote_agent_stop', { machineId: remoteMachineId, sessionId }); - } - return invoke('agent_stop', { sessionId }); -} - -export async function isAgentReady(): Promise { - return invoke('agent_ready'); -} - -export async function restartAgent(): Promise { - return invoke('agent_restart'); -} - -export interface SidecarMessage { - type: string; - sessionId?: string; - event?: Record; - message?: string; - exitCode?: number | null; - signal?: string | null; -} - -export async function onSidecarMessage( - callback: (msg: SidecarMessage) => void, -): Promise { - return listen('sidecar-message', (event) => { - const payload = event.payload; - if (typeof payload !== 'object' || payload === null) return; - callback(payload as SidecarMessage); - }); -} - -export async function onSidecarExited(callback: () => void): Promise { - return listen('sidecar-exited', () => { - callback(); - }); -} diff --git a/v2/src/lib/adapters/anchors-bridge.ts b/v2/src/lib/adapters/anchors-bridge.ts deleted file mode 100644 index abc51ca..0000000 --- a/v2/src/lib/adapters/anchors-bridge.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Anchors Bridge — Tauri IPC adapter for session anchor CRUD -// Mirrors groups-bridge.ts pattern - -import { invoke } from '@tauri-apps/api/core'; -import type { SessionAnchorRecord } from '../types/anchors'; - -export async function saveSessionAnchors(anchors: SessionAnchorRecord[]): Promise { - return invoke('session_anchors_save', { anchors }); -} - -export async function loadSessionAnchors(projectId: string): Promise { - return invoke('session_anchors_load', { projectId }); -} - -export async function deleteSessionAnchor(id: string): Promise { - return invoke('session_anchor_delete', { id }); -} - -export async function clearProjectAnchors(projectId: string): Promise { - return invoke('session_anchors_clear', { projectId }); -} - -export async function updateAnchorType(id: string, anchorType: string): Promise { - return invoke('session_anchor_update_type', { id, anchorType }); -} diff --git a/v2/src/lib/adapters/btmsg-bridge.ts b/v2/src/lib/adapters/btmsg-bridge.ts deleted file mode 100644 index 2060a6b..0000000 --- a/v2/src/lib/adapters/btmsg-bridge.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * btmsg bridge — reads btmsg SQLite database for agent notifications. - * Used by GroupAgentsPanel to show unread counts and agent statuses. - * Polls the database periodically for new messages. - */ - -import { invoke } from '@tauri-apps/api/core'; - -export interface BtmsgAgent { - id: string; - name: string; - role: string; - group_id: string; - tier: number; - model: string | null; - status: string; - unread_count: number; -} - -export interface BtmsgMessage { - id: string; - from_agent: string; - to_agent: string; - content: string; - read: boolean; - reply_to: string | null; - created_at: string; - sender_name?: string; - sender_role?: string; -} - -export interface BtmsgFeedMessage { - id: string; - fromAgent: string; - toAgent: string; - content: string; - createdAt: string; - replyTo: string | null; - senderName: string; - senderRole: string; - recipientName: string; - recipientRole: string; -} - -export interface BtmsgChannel { - id: string; - name: string; - groupId: string; - createdBy: string; - memberCount: number; - createdAt: string; -} - -export interface BtmsgChannelMessage { - id: string; - channelId: string; - fromAgent: string; - content: string; - createdAt: string; - senderName: string; - senderRole: string; -} - -/** - * Get all agents in a group with their unread counts. - */ -export async function getGroupAgents(groupId: string): Promise { - return invoke('btmsg_get_agents', { groupId }); -} - -/** - * Get unread message count for an agent. - */ -export async function getUnreadCount(agentId: string): Promise { - return invoke('btmsg_unread_count', { agentId }); -} - -/** - * Get unread messages for an agent. - */ -export async function getUnreadMessages(agentId: string): Promise { - return invoke('btmsg_unread_messages', { agentId }); -} - -/** - * Get conversation history between two agents. - */ -export async function getHistory(agentId: string, otherId: string, limit: number = 20): Promise { - return invoke('btmsg_history', { agentId, otherId, limit }); -} - -/** - * Send a message from one agent to another. - */ -export async function sendMessage(fromAgent: string, toAgent: string, content: string): Promise { - return invoke('btmsg_send', { fromAgent, toAgent, content }); -} - -/** - * Update agent status (active/sleeping/stopped). - */ -export async function setAgentStatus(agentId: string, status: string): Promise { - return invoke('btmsg_set_status', { agentId, status }); -} - -/** - * Ensure admin agent exists with contacts to all agents. - */ -export async function ensureAdmin(groupId: string): Promise { - return invoke('btmsg_ensure_admin', { groupId }); -} - -/** - * Get all messages in group (admin global feed). - */ -export async function getAllFeed(groupId: string, limit: number = 100): Promise { - return invoke('btmsg_all_feed', { groupId, limit }); -} - -/** - * Mark all messages from sender to reader as read. - */ -export async function markRead(readerId: string, senderId: string): Promise { - return invoke('btmsg_mark_read', { readerId, senderId }); -} - -/** - * Get channels in a group. - */ -export async function getChannels(groupId: string): Promise { - return invoke('btmsg_get_channels', { groupId }); -} - -/** - * Get messages in a channel. - */ -export async function getChannelMessages(channelId: string, limit: number = 100): Promise { - return invoke('btmsg_channel_messages', { channelId, limit }); -} - -/** - * Send a message to a channel. - */ -export async function sendChannelMessage(channelId: string, fromAgent: string, content: string): Promise { - return invoke('btmsg_channel_send', { channelId, fromAgent, content }); -} - -/** - * Create a new channel. - */ -export async function createChannel(name: string, groupId: string, createdBy: string): Promise { - return invoke('btmsg_create_channel', { name, groupId, createdBy }); -} - -/** - * Add a member to a channel. - */ -export async function addChannelMember(channelId: string, agentId: string): Promise { - return invoke('btmsg_add_channel_member', { channelId, agentId }); -} diff --git a/v2/src/lib/adapters/bttask-bridge.ts b/v2/src/lib/adapters/bttask-bridge.ts deleted file mode 100644 index 7146b8f..0000000 --- a/v2/src/lib/adapters/bttask-bridge.ts +++ /dev/null @@ -1,57 +0,0 @@ -// bttask Bridge — Tauri IPC adapter for task board - -import { invoke } from '@tauri-apps/api/core'; - -export interface Task { - id: string; - title: string; - description: string; - status: 'todo' | 'progress' | 'review' | 'done' | 'blocked'; - priority: 'low' | 'medium' | 'high' | 'critical'; - assignedTo: string | null; - createdBy: string; - groupId: string; - parentTaskId: string | null; - sortOrder: number; - createdAt: string; - updatedAt: string; -} - -export interface TaskComment { - id: string; - taskId: string; - agentId: string; - content: string; - createdAt: string; -} - -export async function listTasks(groupId: string): Promise { - return invoke('bttask_list', { groupId }); -} - -export async function getTaskComments(taskId: string): Promise { - return invoke('bttask_comments', { taskId }); -} - -export async function updateTaskStatus(taskId: string, status: string): Promise { - return invoke('bttask_update_status', { taskId, status }); -} - -export async function addTaskComment(taskId: string, agentId: string, content: string): Promise { - return invoke('bttask_add_comment', { taskId, agentId, content }); -} - -export async function createTask( - title: string, - description: string, - priority: string, - groupId: string, - createdBy: string, - assignedTo?: string, -): Promise { - return invoke('bttask_create', { title, description, priority, groupId, createdBy, assignedTo }); -} - -export async function deleteTask(taskId: string): Promise { - return invoke('bttask_delete', { taskId }); -} diff --git a/v2/src/lib/adapters/claude-bridge.ts b/v2/src/lib/adapters/claude-bridge.ts deleted file mode 100644 index a03c318..0000000 --- a/v2/src/lib/adapters/claude-bridge.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Claude Bridge — Tauri IPC adapter for Claude profiles and skills -import { invoke } from '@tauri-apps/api/core'; - -export interface ClaudeProfile { - name: string; - email: string | null; - subscription_type: string | null; - display_name: string | null; - config_dir: string; -} - -export interface ClaudeSkill { - name: string; - description: string; - source_path: string; -} - -export async function listProfiles(): Promise { - return invoke('claude_list_profiles'); -} - -export async function listSkills(): Promise { - return invoke('claude_list_skills'); -} - -export async function readSkill(path: string): Promise { - return invoke('claude_read_skill', { path }); -} diff --git a/v2/src/lib/adapters/claude-messages.test.ts b/v2/src/lib/adapters/claude-messages.test.ts deleted file mode 100644 index 752f0ad..0000000 --- a/v2/src/lib/adapters/claude-messages.test.ts +++ /dev/null @@ -1,446 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { adaptSDKMessage } from './claude-messages'; -import type { InitContent, TextContent, ThinkingContent, ToolCallContent, ToolResultContent, StatusContent, CostContent } from './claude-messages'; - -// Mock crypto.randomUUID for deterministic IDs when uuid is missing -beforeEach(() => { - vi.stubGlobal('crypto', { - randomUUID: () => 'fallback-uuid', - }); -}); - -describe('adaptSDKMessage', () => { - describe('system/init messages', () => { - it('adapts a system init message', () => { - const raw = { - type: 'system', - subtype: 'init', - uuid: 'sys-001', - session_id: 'sess-abc', - model: 'claude-sonnet-4-20250514', - cwd: '/home/user/project', - tools: ['Read', 'Write', 'Bash'], - }; - - const result = adaptSDKMessage(raw); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe('sys-001'); - expect(result[0].type).toBe('init'); - const content = result[0].content as InitContent; - expect(content.sessionId).toBe('sess-abc'); - expect(content.model).toBe('claude-sonnet-4-20250514'); - expect(content.cwd).toBe('/home/user/project'); - expect(content.tools).toEqual(['Read', 'Write', 'Bash']); - }); - - it('defaults tools to empty array when missing', () => { - const raw = { - type: 'system', - subtype: 'init', - uuid: 'sys-002', - session_id: 'sess-abc', - model: 'claude-sonnet-4-20250514', - cwd: '/tmp', - }; - - const result = adaptSDKMessage(raw); - const content = result[0].content as InitContent; - expect(content.tools).toEqual([]); - }); - }); - - describe('system/status messages (non-init subtypes)', () => { - it('adapts a system status message', () => { - const raw = { - type: 'system', - subtype: 'api_key_check', - uuid: 'sys-003', - status: 'API key is valid', - }; - - const result = adaptSDKMessage(raw); - - expect(result).toHaveLength(1); - expect(result[0].type).toBe('status'); - const content = result[0].content as StatusContent; - expect(content.subtype).toBe('api_key_check'); - expect(content.message).toBe('API key is valid'); - }); - - it('handles missing status field', () => { - const raw = { - type: 'system', - subtype: 'some_event', - uuid: 'sys-004', - }; - - const result = adaptSDKMessage(raw); - const content = result[0].content as StatusContent; - expect(content.subtype).toBe('some_event'); - expect(content.message).toBeUndefined(); - }); - }); - - describe('assistant/text messages', () => { - it('adapts a single text block', () => { - const raw = { - type: 'assistant', - uuid: 'asst-001', - message: { - content: [{ type: 'text', text: 'Hello, world!' }], - }, - }; - - const result = adaptSDKMessage(raw); - - expect(result).toHaveLength(1); - expect(result[0].type).toBe('text'); - expect(result[0].id).toBe('asst-001-text-0'); - const content = result[0].content as TextContent; - expect(content.text).toBe('Hello, world!'); - }); - - it('preserves parentId on assistant messages', () => { - const raw = { - type: 'assistant', - uuid: 'asst-002', - parent_tool_use_id: 'tool-parent-123', - message: { - content: [{ type: 'text', text: 'subagent response' }], - }, - }; - - const result = adaptSDKMessage(raw); - expect(result[0].parentId).toBe('tool-parent-123'); - }); - }); - - describe('assistant/thinking messages', () => { - it('adapts a thinking block with thinking field', () => { - const raw = { - type: 'assistant', - uuid: 'asst-003', - message: { - content: [{ type: 'thinking', thinking: 'Let me consider...', text: 'fallback' }], - }, - }; - - const result = adaptSDKMessage(raw); - - expect(result).toHaveLength(1); - expect(result[0].type).toBe('thinking'); - expect(result[0].id).toBe('asst-003-think-0'); - const content = result[0].content as ThinkingContent; - expect(content.text).toBe('Let me consider...'); - }); - - it('falls back to text field when thinking is absent', () => { - const raw = { - type: 'assistant', - uuid: 'asst-004', - message: { - content: [{ type: 'thinking', text: 'Thinking via text field' }], - }, - }; - - const result = adaptSDKMessage(raw); - const content = result[0].content as ThinkingContent; - expect(content.text).toBe('Thinking via text field'); - }); - }); - - describe('assistant/tool_use messages', () => { - it('adapts a tool_use block', () => { - const raw = { - type: 'assistant', - uuid: 'asst-005', - message: { - content: [{ - type: 'tool_use', - id: 'toolu_abc123', - name: 'Read', - input: { file_path: '/src/main.ts' }, - }], - }, - }; - - const result = adaptSDKMessage(raw); - - expect(result).toHaveLength(1); - expect(result[0].type).toBe('tool_call'); - expect(result[0].id).toBe('asst-005-tool-0'); - const content = result[0].content as ToolCallContent; - expect(content.toolUseId).toBe('toolu_abc123'); - expect(content.name).toBe('Read'); - expect(content.input).toEqual({ file_path: '/src/main.ts' }); - }); - }); - - describe('assistant messages with multiple content blocks', () => { - it('produces one AgentMessage per content block', () => { - const raw = { - type: 'assistant', - uuid: 'asst-multi', - message: { - content: [ - { type: 'thinking', thinking: 'Hmm...' }, - { type: 'text', text: 'Here is the answer.' }, - { type: 'tool_use', id: 'toolu_xyz', name: 'Bash', input: { command: 'ls' } }, - ], - }, - }; - - const result = adaptSDKMessage(raw); - - expect(result).toHaveLength(3); - expect(result[0].type).toBe('thinking'); - expect(result[0].id).toBe('asst-multi-think-0'); - expect(result[1].type).toBe('text'); - expect(result[1].id).toBe('asst-multi-text-1'); - expect(result[2].type).toBe('tool_call'); - expect(result[2].id).toBe('asst-multi-tool-2'); - }); - - it('skips unknown content block types silently', () => { - const raw = { - type: 'assistant', - uuid: 'asst-unk-block', - message: { - content: [ - { type: 'text', text: 'Hello' }, - { type: 'image', data: 'base64...' }, - ], - }, - }; - - const result = adaptSDKMessage(raw); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('text'); - }); - }); - - describe('user/tool_result messages', () => { - it('adapts a tool_result block', () => { - const raw = { - type: 'user', - uuid: 'user-001', - message: { - content: [{ - type: 'tool_result', - tool_use_id: 'toolu_abc123', - content: 'file contents here', - }], - }, - }; - - const result = adaptSDKMessage(raw); - - expect(result).toHaveLength(1); - expect(result[0].type).toBe('tool_result'); - expect(result[0].id).toBe('user-001-result-0'); - const content = result[0].content as ToolResultContent; - expect(content.toolUseId).toBe('toolu_abc123'); - expect(content.output).toBe('file contents here'); - }); - - it('falls back to tool_use_result when block content is missing', () => { - const raw = { - type: 'user', - uuid: 'user-002', - tool_use_result: { status: 'success', output: 'done' }, - message: { - content: [{ - type: 'tool_result', - tool_use_id: 'toolu_def456', - // no content field - }], - }, - }; - - const result = adaptSDKMessage(raw); - const content = result[0].content as ToolResultContent; - expect(content.output).toEqual({ status: 'success', output: 'done' }); - }); - - it('preserves parentId on user messages', () => { - const raw = { - type: 'user', - uuid: 'user-003', - parent_tool_use_id: 'parent-tool-id', - message: { - content: [{ - type: 'tool_result', - tool_use_id: 'toolu_ghi', - content: 'ok', - }], - }, - }; - - const result = adaptSDKMessage(raw); - expect(result[0].parentId).toBe('parent-tool-id'); - }); - }); - - describe('result/cost messages', () => { - it('adapts a full result message', () => { - const raw = { - type: 'result', - uuid: 'res-001', - total_cost_usd: 0.0125, - duration_ms: 4500, - usage: { input_tokens: 1000, output_tokens: 500 }, - num_turns: 3, - is_error: false, - result: 'Task completed successfully.', - }; - - const result = adaptSDKMessage(raw); - - expect(result).toHaveLength(1); - expect(result[0].type).toBe('cost'); - expect(result[0].id).toBe('res-001'); - const content = result[0].content as CostContent; - expect(content.totalCostUsd).toBe(0.0125); - expect(content.durationMs).toBe(4500); - expect(content.inputTokens).toBe(1000); - expect(content.outputTokens).toBe(500); - expect(content.numTurns).toBe(3); - expect(content.isError).toBe(false); - expect(content.result).toBe('Task completed successfully.'); - expect(content.errors).toBeUndefined(); - }); - - it('defaults numeric fields to 0 when missing', () => { - const raw = { - type: 'result', - uuid: 'res-002', - }; - - const result = adaptSDKMessage(raw); - const content = result[0].content as CostContent; - expect(content.totalCostUsd).toBe(0); - expect(content.durationMs).toBe(0); - expect(content.inputTokens).toBe(0); - expect(content.outputTokens).toBe(0); - expect(content.numTurns).toBe(0); - expect(content.isError).toBe(false); - }); - - it('includes errors array when present', () => { - const raw = { - type: 'result', - uuid: 'res-003', - is_error: true, - errors: ['Rate limit exceeded', 'Retry failed'], - }; - - const result = adaptSDKMessage(raw); - const content = result[0].content as CostContent; - expect(content.isError).toBe(true); - expect(content.errors).toEqual(['Rate limit exceeded', 'Retry failed']); - }); - }); - - describe('edge cases', () => { - it('returns unknown type for unrecognized message types', () => { - const raw = { - type: 'something_new', - uuid: 'unk-001', - data: 'arbitrary', - }; - - const result = adaptSDKMessage(raw); - - expect(result).toHaveLength(1); - expect(result[0].type).toBe('unknown'); - expect(result[0].id).toBe('unk-001'); - expect(result[0].content).toBe(raw); - }); - - it('uses crypto.randomUUID when uuid is missing', () => { - const raw = { - type: 'result', - total_cost_usd: 0.001, - }; - - const result = adaptSDKMessage(raw); - expect(result[0].id).toBe('fallback-uuid'); - }); - - it('returns empty array when assistant message has no message field', () => { - const raw = { - type: 'assistant', - uuid: 'asst-empty', - }; - - const result = adaptSDKMessage(raw); - expect(result).toHaveLength(0); - }); - - it('returns empty array when assistant message.content is not an array', () => { - const raw = { - type: 'assistant', - uuid: 'asst-bad-content', - message: { content: 'not-an-array' }, - }; - - const result = adaptSDKMessage(raw); - expect(result).toHaveLength(0); - }); - - it('returns empty array when user message has no message field', () => { - const raw = { - type: 'user', - uuid: 'user-empty', - }; - - const result = adaptSDKMessage(raw); - expect(result).toHaveLength(0); - }); - - it('returns empty array when user message.content is not an array', () => { - const raw = { - type: 'user', - uuid: 'user-bad', - message: { content: 'string' }, - }; - - const result = adaptSDKMessage(raw); - expect(result).toHaveLength(0); - }); - - it('ignores non-tool_result blocks in user messages', () => { - const raw = { - type: 'user', - uuid: 'user-text', - message: { - content: [ - { type: 'text', text: 'User typed something' }, - { type: 'tool_result', tool_use_id: 'toolu_1', content: 'ok' }, - ], - }, - }; - - const result = adaptSDKMessage(raw); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('tool_result'); - }); - - it('sets timestamp on every message', () => { - const before = Date.now(); - const result = adaptSDKMessage({ - type: 'system', - subtype: 'init', - uuid: 'ts-test', - session_id: 's', - model: 'm', - cwd: '/', - }); - const after = Date.now(); - - expect(result[0].timestamp).toBeGreaterThanOrEqual(before); - expect(result[0].timestamp).toBeLessThanOrEqual(after); - }); - }); -}); diff --git a/v2/src/lib/adapters/claude-messages.ts b/v2/src/lib/adapters/claude-messages.ts deleted file mode 100644 index 2d75d07..0000000 --- a/v2/src/lib/adapters/claude-messages.ts +++ /dev/null @@ -1,257 +0,0 @@ -// Claude Message Adapter — transforms Claude Agent SDK wire format to internal AgentMessage format -// This is the ONLY place that knows Claude SDK internals. - -export type AgentMessageType = - | 'init' - | 'text' - | 'thinking' - | 'tool_call' - | 'tool_result' - | 'status' - | 'compaction' - | 'cost' - | 'error' - | 'unknown'; - -export interface AgentMessage { - id: string; - type: AgentMessageType; - parentId?: string; - content: unknown; - timestamp: number; -} - -export interface InitContent { - sessionId: string; - model: string; - cwd: string; - tools: string[]; -} - -export interface TextContent { - text: string; -} - -export interface ThinkingContent { - text: string; -} - -export interface ToolCallContent { - toolUseId: string; - name: string; - input: unknown; -} - -export interface ToolResultContent { - toolUseId: string; - output: unknown; -} - -export interface StatusContent { - subtype: string; - message?: string; -} - -export interface CostContent { - totalCostUsd: number; - durationMs: number; - inputTokens: number; - outputTokens: number; - numTurns: number; - isError: boolean; - result?: string; - errors?: string[]; -} - -export interface CompactionContent { - trigger: 'manual' | 'auto'; - preTokens: number; -} - -export interface ErrorContent { - message: string; -} - -import { str, num } from '../utils/type-guards'; - -/** - * Adapt a raw SDK stream-json message to our internal format. - * When SDK changes wire format, only this function needs updating. - */ -export function adaptSDKMessage(raw: Record): AgentMessage[] { - const uuid = str(raw.uuid) || crypto.randomUUID(); - const timestamp = Date.now(); - const parentId = typeof raw.parent_tool_use_id === 'string' ? raw.parent_tool_use_id : undefined; - - switch (raw.type) { - case 'system': - return adaptSystemMessage(raw, uuid, timestamp); - case 'assistant': - return adaptAssistantMessage(raw, uuid, timestamp, parentId); - case 'user': - return adaptUserMessage(raw, uuid, timestamp, parentId); - case 'result': - return adaptResultMessage(raw, uuid, timestamp); - default: - return [{ - id: uuid, - type: 'unknown', - content: raw, - timestamp, - }]; - } -} - -function adaptSystemMessage( - raw: Record, - uuid: string, - timestamp: number, -): AgentMessage[] { - const subtype = str(raw.subtype); - - if (subtype === 'init') { - return [{ - id: uuid, - type: 'init', - content: { - sessionId: str(raw.session_id), - model: str(raw.model), - cwd: str(raw.cwd), - tools: Array.isArray(raw.tools) ? raw.tools.filter((t): t is string => typeof t === 'string') : [], - } satisfies InitContent, - timestamp, - }]; - } - - if (subtype === 'compact_boundary') { - const meta = typeof raw.compact_metadata === 'object' && raw.compact_metadata !== null - ? raw.compact_metadata as Record - : {}; - return [{ - id: uuid, - type: 'compaction', - content: { - trigger: str(meta.trigger, 'auto') as 'manual' | 'auto', - preTokens: num(meta.pre_tokens), - } satisfies CompactionContent, - timestamp, - }]; - } - - return [{ - id: uuid, - type: 'status', - content: { - subtype, - message: typeof raw.status === 'string' ? raw.status : undefined, - } satisfies StatusContent, - timestamp, - }]; -} - -function adaptAssistantMessage( - raw: Record, - uuid: string, - timestamp: number, - parentId?: string, -): AgentMessage[] { - const messages: AgentMessage[] = []; - const msg = typeof raw.message === 'object' && raw.message !== null ? raw.message as Record : undefined; - if (!msg) return messages; - - const content = Array.isArray(msg.content) ? msg.content as Array> : undefined; - if (!content) return messages; - - for (const block of content) { - switch (block.type) { - case 'text': - messages.push({ - id: `${uuid}-text-${messages.length}`, - type: 'text', - parentId, - content: { text: str(block.text) } satisfies TextContent, - timestamp, - }); - break; - case 'thinking': - messages.push({ - id: `${uuid}-think-${messages.length}`, - type: 'thinking', - parentId, - content: { text: str(block.thinking ?? block.text) } satisfies ThinkingContent, - timestamp, - }); - break; - case 'tool_use': - messages.push({ - id: `${uuid}-tool-${messages.length}`, - type: 'tool_call', - parentId, - content: { - toolUseId: str(block.id), - name: str(block.name), - input: block.input, - } satisfies ToolCallContent, - timestamp, - }); - break; - } - } - - return messages; -} - -function adaptUserMessage( - raw: Record, - uuid: string, - timestamp: number, - parentId?: string, -): AgentMessage[] { - const messages: AgentMessage[] = []; - const msg = typeof raw.message === 'object' && raw.message !== null ? raw.message as Record : undefined; - if (!msg) return messages; - - const content = Array.isArray(msg.content) ? msg.content as Array> : undefined; - if (!content) return messages; - - for (const block of content) { - if (block.type === 'tool_result') { - messages.push({ - id: `${uuid}-result-${messages.length}`, - type: 'tool_result', - parentId, - content: { - toolUseId: str(block.tool_use_id), - output: block.content ?? raw.tool_use_result, - } satisfies ToolResultContent, - timestamp, - }); - } - } - - return messages; -} - -function adaptResultMessage( - raw: Record, - uuid: string, - timestamp: number, -): AgentMessage[] { - const usage = typeof raw.usage === 'object' && raw.usage !== null ? raw.usage as Record : undefined; - - return [{ - id: uuid, - type: 'cost', - content: { - totalCostUsd: num(raw.total_cost_usd), - durationMs: num(raw.duration_ms), - inputTokens: num(usage?.input_tokens), - outputTokens: num(usage?.output_tokens), - numTurns: num(raw.num_turns), - isError: raw.is_error === true, - result: typeof raw.result === 'string' ? raw.result : undefined, - errors: Array.isArray(raw.errors) ? raw.errors.filter((e): e is string => typeof e === 'string') : undefined, - } satisfies CostContent, - timestamp, - }]; -} diff --git a/v2/src/lib/adapters/codex-messages.test.ts b/v2/src/lib/adapters/codex-messages.test.ts deleted file mode 100644 index e3ac559..0000000 --- a/v2/src/lib/adapters/codex-messages.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { adaptCodexMessage } from './codex-messages'; - -describe('adaptCodexMessage', () => { - describe('thread.started', () => { - it('maps to init message with thread_id as sessionId', () => { - const result = adaptCodexMessage({ - type: 'thread.started', - thread_id: 'thread-abc-123', - }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('init'); - expect((result[0].content as any).sessionId).toBe('thread-abc-123'); - }); - }); - - describe('turn.started', () => { - it('maps to status message', () => { - const result = adaptCodexMessage({ type: 'turn.started' }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('status'); - expect((result[0].content as any).subtype).toBe('turn_started'); - }); - }); - - describe('turn.completed', () => { - it('maps to cost message with token usage', () => { - const result = adaptCodexMessage({ - type: 'turn.completed', - usage: { input_tokens: 1000, output_tokens: 200, cached_input_tokens: 800 }, - }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('cost'); - const content = result[0].content as any; - expect(content.inputTokens).toBe(1000); - expect(content.outputTokens).toBe(200); - expect(content.totalCostUsd).toBe(0); - }); - }); - - describe('turn.failed', () => { - it('maps to error message', () => { - const result = adaptCodexMessage({ - type: 'turn.failed', - error: { message: 'Rate limit exceeded' }, - }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('error'); - expect((result[0].content as any).message).toBe('Rate limit exceeded'); - }); - }); - - describe('item.completed — agent_message', () => { - it('maps to text message', () => { - const result = adaptCodexMessage({ - type: 'item.completed', - item: { id: 'item_3', type: 'agent_message', text: 'Done. I updated foo.ts.' }, - }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('text'); - expect((result[0].content as any).text).toBe('Done. I updated foo.ts.'); - }); - - it('ignores item.started for agent_message', () => { - const result = adaptCodexMessage({ - type: 'item.started', - item: { type: 'agent_message', text: '' }, - }); - expect(result).toHaveLength(0); - }); - }); - - describe('item.completed — reasoning', () => { - it('maps to thinking message', () => { - const result = adaptCodexMessage({ - type: 'item.completed', - item: { type: 'reasoning', text: 'Let me think about this...' }, - }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('thinking'); - expect((result[0].content as any).text).toBe('Let me think about this...'); - }); - }); - - describe('item — command_execution', () => { - it('maps item.started to tool_call', () => { - const result = adaptCodexMessage({ - type: 'item.started', - item: { id: 'item_1', type: 'command_execution', command: 'ls -la', status: 'in_progress' }, - }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('tool_call'); - expect((result[0].content as any).name).toBe('Bash'); - expect((result[0].content as any).input.command).toBe('ls -la'); - }); - - it('maps item.completed to tool_call + tool_result pair', () => { - const result = adaptCodexMessage({ - type: 'item.completed', - item: { - id: 'item_1', - type: 'command_execution', - command: 'ls -la', - aggregated_output: 'total 48\ndrwxr-xr-x', - exit_code: 0, - status: 'completed', - }, - }); - expect(result).toHaveLength(2); - expect(result[0].type).toBe('tool_call'); - expect(result[1].type).toBe('tool_result'); - expect((result[1].content as any).output).toBe('total 48\ndrwxr-xr-x'); - }); - - it('ignores item.updated for command_execution', () => { - const result = adaptCodexMessage({ - type: 'item.updated', - item: { type: 'command_execution', command: 'ls', status: 'in_progress' }, - }); - expect(result).toHaveLength(0); - }); - }); - - describe('item.completed — file_change', () => { - it('maps file changes to tool_call + tool_result pairs', () => { - const result = adaptCodexMessage({ - type: 'item.completed', - item: { - type: 'file_change', - changes: [ - { path: 'src/foo.ts', kind: 'update' }, - { path: 'src/bar.ts', kind: 'add' }, - ], - status: 'completed', - }, - }); - expect(result).toHaveLength(4); - expect(result[0].type).toBe('tool_call'); - expect((result[0].content as any).name).toBe('Edit'); - expect(result[1].type).toBe('tool_result'); - expect(result[2].type).toBe('tool_call'); - expect((result[2].content as any).name).toBe('Write'); - }); - - it('maps delete to Bash tool name', () => { - const result = adaptCodexMessage({ - type: 'item.completed', - item: { - type: 'file_change', - changes: [{ path: 'old.ts', kind: 'delete' }], - status: 'completed', - }, - }); - expect(result).toHaveLength(2); - expect((result[0].content as any).name).toBe('Bash'); - }); - - it('returns empty for no changes', () => { - const result = adaptCodexMessage({ - type: 'item.completed', - item: { type: 'file_change', changes: [], status: 'completed' }, - }); - expect(result).toHaveLength(0); - }); - }); - - describe('item.completed — mcp_tool_call', () => { - it('maps to tool_call + tool_result with server:tool name', () => { - const result = adaptCodexMessage({ - type: 'item.completed', - item: { - id: 'mcp_1', - type: 'mcp_tool_call', - server: 'filesystem', - tool: 'read_file', - arguments: { path: '/tmp/test.txt' }, - result: { content: 'file contents' }, - status: 'completed', - }, - }); - expect(result).toHaveLength(2); - expect((result[0].content as any).name).toBe('filesystem:read_file'); - expect((result[0].content as any).input.path).toBe('/tmp/test.txt'); - }); - - it('maps error result to error message in tool_result', () => { - const result = adaptCodexMessage({ - type: 'item.completed', - item: { - id: 'mcp_2', - type: 'mcp_tool_call', - server: 'fs', - tool: 'write', - arguments: {}, - error: { message: 'Permission denied' }, - status: 'completed', - }, - }); - expect(result).toHaveLength(2); - expect((result[1].content as any).output).toBe('Permission denied'); - }); - }); - - describe('item.completed — web_search', () => { - it('maps to WebSearch tool_call', () => { - const result = adaptCodexMessage({ - type: 'item.completed', - item: { id: 'ws_1', type: 'web_search', query: 'ollama api docs' }, - }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('tool_call'); - expect((result[0].content as any).name).toBe('WebSearch'); - expect((result[0].content as any).input.query).toBe('ollama api docs'); - }); - }); - - describe('item — error', () => { - it('maps to error message', () => { - const result = adaptCodexMessage({ - type: 'item.completed', - item: { type: 'error', message: 'Sandbox violation' }, - }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('error'); - expect((result[0].content as any).message).toBe('Sandbox violation'); - }); - }); - - describe('top-level error', () => { - it('maps to error message', () => { - const result = adaptCodexMessage({ - type: 'error', - message: 'Connection lost', - }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('error'); - expect((result[0].content as any).message).toBe('Connection lost'); - }); - }); - - describe('unknown event type', () => { - it('maps to unknown message preserving raw data', () => { - const result = adaptCodexMessage({ type: 'custom.event', data: 42 }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('unknown'); - expect((result[0].content as any).data).toBe(42); - }); - }); -}); diff --git a/v2/src/lib/adapters/codex-messages.ts b/v2/src/lib/adapters/codex-messages.ts deleted file mode 100644 index 9290335..0000000 --- a/v2/src/lib/adapters/codex-messages.ts +++ /dev/null @@ -1,291 +0,0 @@ -// Codex Message Adapter — transforms Codex CLI NDJSON events to internal AgentMessage format -// Codex events: thread.started, turn.started, item.started/updated/completed, turn.completed/failed - -import type { - AgentMessage, - InitContent, - TextContent, - ThinkingContent, - ToolCallContent, - ToolResultContent, - StatusContent, - CostContent, - ErrorContent, -} from './claude-messages'; - -import { str, num } from '../utils/type-guards'; - -export function adaptCodexMessage(raw: Record): AgentMessage[] { - const timestamp = Date.now(); - const uuid = crypto.randomUUID(); - - switch (raw.type) { - case 'thread.started': - return [{ - id: uuid, - type: 'init', - content: { - sessionId: str(raw.thread_id), - model: '', - cwd: '', - tools: [], - } satisfies InitContent, - timestamp, - }]; - - case 'turn.started': - return [{ - id: uuid, - type: 'status', - content: { subtype: 'turn_started' } satisfies StatusContent, - timestamp, - }]; - - case 'turn.completed': - return adaptTurnCompleted(raw, uuid, timestamp); - - case 'turn.failed': - return [{ - id: uuid, - type: 'error', - content: { - message: str((raw.error as Record)?.message, 'Turn failed'), - } satisfies ErrorContent, - timestamp, - }]; - - case 'item.started': - case 'item.updated': - case 'item.completed': - return adaptItem(raw, uuid, timestamp); - - case 'error': - return [{ - id: uuid, - type: 'error', - content: { message: str(raw.message, 'Unknown error') } satisfies ErrorContent, - timestamp, - }]; - - default: - return [{ - id: uuid, - type: 'unknown', - content: raw, - timestamp, - }]; - } -} - -function adaptTurnCompleted( - raw: Record, - uuid: string, - timestamp: number, -): AgentMessage[] { - const usage = typeof raw.usage === 'object' && raw.usage !== null - ? raw.usage as Record - : {}; - - return [{ - id: uuid, - type: 'cost', - content: { - totalCostUsd: 0, - durationMs: 0, - inputTokens: num(usage.input_tokens), - outputTokens: num(usage.output_tokens), - numTurns: 1, - isError: false, - } satisfies CostContent, - timestamp, - }]; -} - -function adaptItem( - raw: Record, - uuid: string, - timestamp: number, -): AgentMessage[] { - const item = typeof raw.item === 'object' && raw.item !== null - ? raw.item as Record - : {}; - const itemType = str(item.type); - const eventType = str(raw.type); - - switch (itemType) { - case 'agent_message': - if (eventType !== 'item.completed') return []; - return [{ - id: uuid, - type: 'text', - content: { text: str(item.text) } satisfies TextContent, - timestamp, - }]; - - case 'reasoning': - if (eventType !== 'item.completed') return []; - return [{ - id: uuid, - type: 'thinking', - content: { text: str(item.text) } satisfies ThinkingContent, - timestamp, - }]; - - case 'command_execution': - return adaptCommandExecution(item, uuid, timestamp, eventType); - - case 'file_change': - return adaptFileChange(item, uuid, timestamp, eventType); - - case 'mcp_tool_call': - return adaptMcpToolCall(item, uuid, timestamp, eventType); - - case 'web_search': - if (eventType !== 'item.completed') return []; - return [{ - id: uuid, - type: 'tool_call', - content: { - toolUseId: str(item.id, uuid), - name: 'WebSearch', - input: { query: str(item.query) }, - } satisfies ToolCallContent, - timestamp, - }]; - - case 'error': - return [{ - id: uuid, - type: 'error', - content: { message: str(item.message, 'Item error') } satisfies ErrorContent, - timestamp, - }]; - - default: - return []; - } -} - -function adaptCommandExecution( - item: Record, - uuid: string, - timestamp: number, - eventType: string, -): AgentMessage[] { - const messages: AgentMessage[] = []; - const toolUseId = str(item.id, uuid); - - if (eventType === 'item.started' || eventType === 'item.completed') { - messages.push({ - id: `${uuid}-call`, - type: 'tool_call', - content: { - toolUseId, - name: 'Bash', - input: { command: str(item.command) }, - } satisfies ToolCallContent, - timestamp, - }); - } - - if (eventType === 'item.completed') { - messages.push({ - id: `${uuid}-result`, - type: 'tool_result', - content: { - toolUseId, - output: str(item.aggregated_output), - } satisfies ToolResultContent, - timestamp, - }); - } - - return messages; -} - -function adaptFileChange( - item: Record, - uuid: string, - timestamp: number, - eventType: string, -): AgentMessage[] { - if (eventType !== 'item.completed') return []; - - const changes = Array.isArray(item.changes) ? item.changes as Array> : []; - if (changes.length === 0) return []; - - const messages: AgentMessage[] = []; - for (const change of changes) { - const kind = str(change.kind); - const toolName = kind === 'delete' ? 'Bash' : kind === 'add' ? 'Write' : 'Edit'; - const toolUseId = `${uuid}-${str(change.path)}`; - - messages.push({ - id: `${toolUseId}-call`, - type: 'tool_call', - content: { - toolUseId, - name: toolName, - input: { file_path: str(change.path) }, - } satisfies ToolCallContent, - timestamp, - }); - - messages.push({ - id: `${toolUseId}-result`, - type: 'tool_result', - content: { - toolUseId, - output: `File ${kind}: ${str(change.path)}`, - } satisfies ToolResultContent, - timestamp, - }); - } - - return messages; -} - -function adaptMcpToolCall( - item: Record, - uuid: string, - timestamp: number, - eventType: string, -): AgentMessage[] { - const messages: AgentMessage[] = []; - const toolUseId = str(item.id, uuid); - const toolName = `${str(item.server)}:${str(item.tool)}`; - - if (eventType === 'item.started' || eventType === 'item.completed') { - messages.push({ - id: `${uuid}-call`, - type: 'tool_call', - content: { - toolUseId, - name: toolName, - input: item.arguments, - } satisfies ToolCallContent, - timestamp, - }); - } - - if (eventType === 'item.completed') { - const result = typeof item.result === 'object' && item.result !== null - ? item.result as Record - : undefined; - const error = typeof item.error === 'object' && item.error !== null - ? item.error as Record - : undefined; - - messages.push({ - id: `${uuid}-result`, - type: 'tool_result', - content: { - toolUseId, - output: error ? str(error.message, 'MCP tool error') : (result?.content ?? result?.structured_content ?? 'OK'), - } satisfies ToolResultContent, - timestamp, - }); - } - - return messages; -} diff --git a/v2/src/lib/adapters/ctx-bridge.ts b/v2/src/lib/adapters/ctx-bridge.ts deleted file mode 100644 index 4956282..0000000 --- a/v2/src/lib/adapters/ctx-bridge.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { invoke } from '@tauri-apps/api/core'; - -export interface CtxEntry { - project: string; - key: string; - value: string; - updated_at: string; -} - -export interface CtxSummary { - project: string; - summary: string; - created_at: string; -} - -export async function ctxInitDb(): Promise { - return invoke('ctx_init_db'); -} - -export async function ctxRegisterProject(name: string, description: string, workDir?: string): Promise { - return invoke('ctx_register_project', { name, description, workDir: workDir ?? null }); -} - -export async function ctxGetContext(project: string): Promise { - return invoke('ctx_get_context', { project }); -} - -export async function ctxGetShared(): Promise { - return invoke('ctx_get_shared'); -} - -export async function ctxGetSummaries(project: string, limit: number = 5): Promise { - return invoke('ctx_get_summaries', { project, limit }); -} - -export async function ctxSearch(query: string): Promise { - return invoke('ctx_search', { query }); -} diff --git a/v2/src/lib/adapters/file-bridge.ts b/v2/src/lib/adapters/file-bridge.ts deleted file mode 100644 index 7937488..0000000 --- a/v2/src/lib/adapters/file-bridge.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { invoke } from '@tauri-apps/api/core'; -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; - -export interface FileChangedPayload { - pane_id: string; - path: string; - content: string; -} - -/** Start watching a file; returns initial content */ -export async function watchFile(paneId: string, path: string): Promise { - return invoke('file_watch', { paneId, path }); -} - -export async function unwatchFile(paneId: string): Promise { - return invoke('file_unwatch', { paneId }); -} - -export async function readFile(path: string): Promise { - return invoke('file_read', { path }); -} - -export async function onFileChanged( - callback: (payload: FileChangedPayload) => void -): Promise { - return listen('file-changed', (event) => { - callback(event.payload); - }); -} diff --git a/v2/src/lib/adapters/files-bridge.ts b/v2/src/lib/adapters/files-bridge.ts deleted file mode 100644 index a46f4f2..0000000 --- a/v2/src/lib/adapters/files-bridge.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { invoke } from '@tauri-apps/api/core'; - -export interface DirEntry { - name: string; - path: string; - is_dir: boolean; - size: number; - ext: string; -} - -export type FileContent = - | { type: 'Text'; content: string; lang: string } - | { type: 'Binary'; message: string } - | { type: 'TooLarge'; size: number }; - -export function listDirectoryChildren(path: string): Promise { - return invoke('list_directory_children', { path }); -} - -export function readFileContent(path: string): Promise { - return invoke('read_file_content', { path }); -} - -export function writeFileContent(path: string, content: string): Promise { - return invoke('write_file_content', { path, content }); -} diff --git a/v2/src/lib/adapters/fs-watcher-bridge.ts b/v2/src/lib/adapters/fs-watcher-bridge.ts deleted file mode 100644 index 17d4e6b..0000000 --- a/v2/src/lib/adapters/fs-watcher-bridge.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Filesystem watcher bridge — listens for inotify-based write events from Rust -// Part of S-1 Phase 2: real-time filesystem write detection - -import { invoke } from '@tauri-apps/api/core'; -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; - -export interface FsWriteEvent { - project_id: string; - file_path: string; - timestamp_ms: number; -} - -/** Start watching a project's CWD for filesystem writes */ -export function fsWatchProject(projectId: string, cwd: string): Promise { - return invoke('fs_watch_project', { projectId, cwd }); -} - -/** Stop watching a project's CWD */ -export function fsUnwatchProject(projectId: string): Promise { - return invoke('fs_unwatch_project', { projectId }); -} - -/** Listen for filesystem write events from all watched projects */ -export function onFsWriteDetected( - callback: (event: FsWriteEvent) => void, -): Promise { - return listen('fs-write-detected', (e) => callback(e.payload)); -} - -export interface FsWatcherStatus { - max_watches: number; - estimated_watches: number; - usage_ratio: number; - active_projects: number; - warning: string | null; -} - -/** Get inotify watcher status including kernel limit check */ -export function fsWatcherStatus(): Promise { - return invoke('fs_watcher_status'); -} diff --git a/v2/src/lib/adapters/groups-bridge.ts b/v2/src/lib/adapters/groups-bridge.ts deleted file mode 100644 index 1782563..0000000 --- a/v2/src/lib/adapters/groups-bridge.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { invoke } from '@tauri-apps/api/core'; -import type { GroupsFile, ProjectConfig, GroupConfig } from '../types/groups'; - -export type { GroupsFile, ProjectConfig, GroupConfig }; - -export interface MdFileEntry { - name: string; - path: string; - priority: boolean; -} - -export interface AgentMessageRecord { - id: number; - session_id: string; - project_id: string; - sdk_session_id: string | null; - message_type: string; - content: string; - parent_id: string | null; - created_at: number; -} - -export interface ProjectAgentState { - project_id: string; - last_session_id: string; - sdk_session_id: string | null; - status: string; - cost_usd: number; - input_tokens: number; - output_tokens: number; - last_prompt: string | null; - updated_at: number; -} - -// --- Group config --- - -export async function loadGroups(): Promise { - return invoke('groups_load'); -} - -export async function saveGroups(config: GroupsFile): Promise { - return invoke('groups_save', { config }); -} - -// --- Markdown discovery --- - -export async function discoverMarkdownFiles(cwd: string): Promise { - return invoke('discover_markdown_files', { cwd }); -} - -// --- Agent message persistence --- - -export async function saveAgentMessages( - sessionId: string, - projectId: string, - sdkSessionId: string | undefined, - messages: AgentMessageRecord[], -): Promise { - return invoke('agent_messages_save', { - sessionId, - projectId, - sdkSessionId: sdkSessionId ?? null, - messages, - }); -} - -export async function loadAgentMessages(projectId: string): Promise { - return invoke('agent_messages_load', { projectId }); -} - -// --- Project agent state --- - -export async function saveProjectAgentState(state: ProjectAgentState): Promise { - return invoke('project_agent_state_save', { state }); -} - -export async function loadProjectAgentState(projectId: string): Promise { - return invoke('project_agent_state_load', { projectId }); -} - -// --- Session metrics --- - -export interface SessionMetric { - id: number; - project_id: string; - session_id: string; - start_time: number; - end_time: number; - peak_tokens: number; - turn_count: number; - tool_call_count: number; - cost_usd: number; - model: string | null; - status: string; - error_message: string | null; -} - -export async function saveSessionMetric(metric: Omit): Promise { - return invoke('session_metric_save', { metric: { id: 0, ...metric } }); -} - -export async function loadSessionMetrics(projectId: string, limit = 20): Promise { - return invoke('session_metrics_load', { projectId, limit }); -} - -// --- CLI arguments --- - -export async function getCliGroup(): Promise { - return invoke('cli_get_group'); -} diff --git a/v2/src/lib/adapters/memora-bridge.test.ts b/v2/src/lib/adapters/memora-bridge.test.ts deleted file mode 100644 index 206bcaf..0000000 --- a/v2/src/lib/adapters/memora-bridge.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -const { mockInvoke } = vi.hoisted(() => ({ - mockInvoke: vi.fn(), -})); - -vi.mock('@tauri-apps/api/core', () => ({ - invoke: mockInvoke, -})); - -import { - memoraAvailable, - memoraList, - memoraSearch, - memoraGet, - MemoraAdapter, -} from './memora-bridge'; - -beforeEach(() => { - vi.clearAllMocks(); -}); - -describe('memora IPC wrappers', () => { - it('memoraAvailable invokes memora_available', async () => { - mockInvoke.mockResolvedValue(true); - const result = await memoraAvailable(); - expect(result).toBe(true); - expect(mockInvoke).toHaveBeenCalledWith('memora_available'); - }); - - it('memoraList invokes memora_list with defaults', async () => { - mockInvoke.mockResolvedValue({ nodes: [], total: 0 }); - await memoraList(); - expect(mockInvoke).toHaveBeenCalledWith('memora_list', { - tags: null, - limit: 50, - offset: 0, - }); - }); - - it('memoraList passes tags and pagination', async () => { - mockInvoke.mockResolvedValue({ nodes: [], total: 0 }); - await memoraList({ tags: ['bterminal'], limit: 10, offset: 5 }); - expect(mockInvoke).toHaveBeenCalledWith('memora_list', { - tags: ['bterminal'], - limit: 10, - offset: 5, - }); - }); - - it('memoraSearch invokes memora_search', async () => { - mockInvoke.mockResolvedValue({ nodes: [], total: 0 }); - await memoraSearch('test query', { tags: ['foo'], limit: 20 }); - expect(mockInvoke).toHaveBeenCalledWith('memora_search', { - query: 'test query', - tags: ['foo'], - limit: 20, - }); - }); - - it('memoraSearch uses defaults when no options', async () => { - mockInvoke.mockResolvedValue({ nodes: [], total: 0 }); - await memoraSearch('hello'); - expect(mockInvoke).toHaveBeenCalledWith('memora_search', { - query: 'hello', - tags: null, - limit: 50, - }); - }); - - it('memoraGet invokes memora_get', async () => { - const node = { id: 42, content: 'test', tags: ['a'], metadata: null, created_at: null, updated_at: null }; - mockInvoke.mockResolvedValue(node); - const result = await memoraGet(42); - expect(result).toEqual(node); - expect(mockInvoke).toHaveBeenCalledWith('memora_get', { id: 42 }); - }); - - it('memoraGet returns null for missing', async () => { - mockInvoke.mockResolvedValue(null); - const result = await memoraGet(999); - expect(result).toBeNull(); - }); -}); - -describe('MemoraAdapter', () => { - it('has name "memora"', () => { - const adapter = new MemoraAdapter(); - expect(adapter.name).toBe('memora'); - }); - - it('available is true by default (optimistic)', () => { - const adapter = new MemoraAdapter(); - expect(adapter.available).toBe(true); - }); - - it('checkAvailability updates available state', async () => { - mockInvoke.mockResolvedValue(false); - const adapter = new MemoraAdapter(); - const result = await adapter.checkAvailability(); - expect(result).toBe(false); - expect(adapter.available).toBe(false); - }); - - it('list returns mapped MemorySearchResult', async () => { - mockInvoke.mockResolvedValue({ - nodes: [ - { id: 1, content: 'hello', tags: ['a', 'b'], metadata: { key: 'val' }, created_at: '2026-01-01', updated_at: null }, - ], - total: 1, - }); - - const adapter = new MemoraAdapter(); - const result = await adapter.list({ limit: 10 }); - expect(result.total).toBe(1); - expect(result.nodes).toHaveLength(1); - expect(result.nodes[0].id).toBe(1); - expect(result.nodes[0].content).toBe('hello'); - expect(result.nodes[0].tags).toEqual(['a', 'b']); - expect(result.nodes[0].metadata).toEqual({ key: 'val' }); - }); - - it('search returns mapped results', async () => { - mockInvoke.mockResolvedValue({ - nodes: [{ id: 5, content: 'found', tags: ['x'], metadata: null, created_at: null, updated_at: null }], - total: 1, - }); - - const adapter = new MemoraAdapter(); - const result = await adapter.search('found', { limit: 5 }); - expect(result.nodes[0].content).toBe('found'); - expect(adapter.available).toBe(true); - }); - - it('get returns mapped node', async () => { - mockInvoke.mockResolvedValue({ - id: 10, content: 'node', tags: ['t'], metadata: null, created_at: '2026-01-01', updated_at: '2026-01-02', - }); - - const adapter = new MemoraAdapter(); - const node = await adapter.get(10); - expect(node).not.toBeNull(); - expect(node!.id).toBe(10); - expect(node!.updated_at).toBe('2026-01-02'); - }); - - it('get returns null for missing node', async () => { - mockInvoke.mockResolvedValue(null); - const adapter = new MemoraAdapter(); - const node = await adapter.get(999); - expect(node).toBeNull(); - }); - - it('get handles string id', async () => { - mockInvoke.mockResolvedValue({ - id: 7, content: 'x', tags: [], metadata: null, created_at: null, updated_at: null, - }); - - const adapter = new MemoraAdapter(); - const node = await adapter.get('7'); - expect(node).not.toBeNull(); - expect(mockInvoke).toHaveBeenCalledWith('memora_get', { id: 7 }); - }); - - it('get returns null for non-numeric string id', async () => { - const adapter = new MemoraAdapter(); - const node = await adapter.get('abc'); - expect(node).toBeNull(); - expect(mockInvoke).not.toHaveBeenCalled(); - }); -}); diff --git a/v2/src/lib/adapters/memora-bridge.ts b/v2/src/lib/adapters/memora-bridge.ts deleted file mode 100644 index c206c73..0000000 --- a/v2/src/lib/adapters/memora-bridge.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Memora IPC bridge — read-only access to the Memora memory database. - * Wraps Tauri commands and provides a MemoryAdapter implementation. - */ - -import { invoke } from '@tauri-apps/api/core'; -import type { MemoryAdapter, MemoryNode, MemorySearchResult } from './memory-adapter'; - -// --- Raw IPC types (match Rust structs) --- - -interface MemoraNode { - id: number; - content: string; - tags: string[]; - metadata?: Record; - created_at?: string; - updated_at?: string; -} - -interface MemoraSearchResult { - nodes: MemoraNode[]; - total: number; -} - -// --- IPC wrappers --- - -export async function memoraAvailable(): Promise { - return invoke('memora_available'); -} - -export async function memoraList(options?: { - tags?: string[]; - limit?: number; - offset?: number; -}): Promise { - return invoke('memora_list', { - tags: options?.tags ?? null, - limit: options?.limit ?? 50, - offset: options?.offset ?? 0, - }); -} - -export async function memoraSearch( - query: string, - options?: { tags?: string[]; limit?: number }, -): Promise { - return invoke('memora_search', { - query, - tags: options?.tags ?? null, - limit: options?.limit ?? 50, - }); -} - -export async function memoraGet(id: number): Promise { - return invoke('memora_get', { id }); -} - -// --- MemoryAdapter implementation --- - -function toMemoryNode(n: MemoraNode): MemoryNode { - return { - id: n.id, - content: n.content, - tags: n.tags, - metadata: n.metadata, - created_at: n.created_at, - updated_at: n.updated_at, - }; -} - -function toSearchResult(r: MemoraSearchResult): MemorySearchResult { - return { - nodes: r.nodes.map(toMemoryNode), - total: r.total, - }; -} - -export class MemoraAdapter implements MemoryAdapter { - readonly name = 'memora'; - private _available: boolean | null = null; - - get available(): boolean { - // Optimistic: assume available until first check proves otherwise. - // Actual availability is checked lazily on first operation. - return this._available ?? true; - } - - async checkAvailability(): Promise { - this._available = await memoraAvailable(); - return this._available; - } - - async list(options?: { - tags?: string[]; - limit?: number; - offset?: number; - }): Promise { - const result = await memoraList(options); - this._available = true; - return toSearchResult(result); - } - - async search( - query: string, - options?: { tags?: string[]; limit?: number }, - ): Promise { - const result = await memoraSearch(query, options); - this._available = true; - return toSearchResult(result); - } - - async get(id: string | number): Promise { - const numId = typeof id === 'string' ? parseInt(id, 10) : id; - if (isNaN(numId)) return null; - const node = await memoraGet(numId); - if (node) { - this._available = true; - return toMemoryNode(node); - } - return null; - } -} diff --git a/v2/src/lib/adapters/memory-adapter.ts b/v2/src/lib/adapters/memory-adapter.ts deleted file mode 100644 index 69d0e14..0000000 --- a/v2/src/lib/adapters/memory-adapter.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Pluggable memory adapter interface. - * Memora is the default implementation, but others can be swapped in. - */ - -export interface MemoryNode { - id: string | number; - content: string; - tags: string[]; - metadata?: Record; - created_at?: string; - updated_at?: string; -} - -export interface MemorySearchResult { - nodes: MemoryNode[]; - total: number; -} - -export interface MemoryAdapter { - readonly name: string; - readonly available: boolean; - - /** List memories, optionally filtered by tags */ - list(options?: { tags?: string[]; limit?: number; offset?: number }): Promise; - - /** Semantic search across memories */ - search(query: string, options?: { tags?: string[]; limit?: number }): Promise; - - /** Get a single memory by ID */ - get(id: string | number): Promise; -} - -/** Registry of available memory adapters */ -const adapters = new Map(); - -export function registerMemoryAdapter(adapter: MemoryAdapter): void { - adapters.set(adapter.name, adapter); -} - -export function getMemoryAdapter(name: string): MemoryAdapter | undefined { - return adapters.get(name); -} - -export function getAvailableAdapters(): MemoryAdapter[] { - return Array.from(adapters.values()).filter(a => a.available); -} - -export function getDefaultAdapter(): MemoryAdapter | undefined { - // Prefer Memora if available, otherwise first available - return adapters.get('memora') ?? getAvailableAdapters()[0]; -} diff --git a/v2/src/lib/adapters/message-adapters.ts b/v2/src/lib/adapters/message-adapters.ts deleted file mode 100644 index b816b91..0000000 --- a/v2/src/lib/adapters/message-adapters.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Message Adapter Registry — routes raw provider messages to the correct parser -// Each provider registers its own adapter; the dispatcher calls adaptMessage() - -import type { AgentMessage } from './claude-messages'; -import type { ProviderId } from '../providers/types'; -import { adaptSDKMessage } from './claude-messages'; -import { adaptCodexMessage } from './codex-messages'; -import { adaptOllamaMessage } from './ollama-messages'; - -/** Function signature for a provider message adapter */ -export type MessageAdapter = (raw: Record) => AgentMessage[]; - -const adapters = new Map(); - -/** Register a message adapter for a provider */ -export function registerMessageAdapter(providerId: ProviderId, adapter: MessageAdapter): void { - adapters.set(providerId, adapter); -} - -/** Adapt a raw message using the appropriate provider adapter */ -export function adaptMessage(providerId: ProviderId, raw: Record): AgentMessage[] { - const adapter = adapters.get(providerId); - if (!adapter) { - console.warn(`No message adapter for provider: ${providerId}, falling back to claude`); - return adaptSDKMessage(raw); - } - return adapter(raw); -} - -// Register all provider adapters -registerMessageAdapter('claude', adaptSDKMessage); -registerMessageAdapter('codex', adaptCodexMessage); -registerMessageAdapter('ollama', adaptOllamaMessage); diff --git a/v2/src/lib/adapters/ollama-messages.test.ts b/v2/src/lib/adapters/ollama-messages.test.ts deleted file mode 100644 index 65d4719..0000000 --- a/v2/src/lib/adapters/ollama-messages.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { adaptOllamaMessage } from './ollama-messages'; - -describe('adaptOllamaMessage', () => { - describe('system init', () => { - it('maps to init message', () => { - const result = adaptOllamaMessage({ - type: 'system', - subtype: 'init', - session_id: 'sess-123', - model: 'qwen3:8b', - cwd: '/home/user/project', - }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('init'); - const content = result[0].content as any; - expect(content.sessionId).toBe('sess-123'); - expect(content.model).toBe('qwen3:8b'); - expect(content.cwd).toBe('/home/user/project'); - }); - }); - - describe('system status', () => { - it('maps non-init subtypes to status message', () => { - const result = adaptOllamaMessage({ - type: 'system', - subtype: 'model_loaded', - status: 'Model loaded successfully', - }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('status'); - expect((result[0].content as any).subtype).toBe('model_loaded'); - expect((result[0].content as any).message).toBe('Model loaded successfully'); - }); - }); - - describe('chunk — text content', () => { - it('maps streaming text to text message', () => { - const result = adaptOllamaMessage({ - type: 'chunk', - message: { role: 'assistant', content: 'Hello world' }, - done: false, - }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('text'); - expect((result[0].content as any).text).toBe('Hello world'); - }); - - it('ignores empty content', () => { - const result = adaptOllamaMessage({ - type: 'chunk', - message: { role: 'assistant', content: '' }, - done: false, - }); - expect(result).toHaveLength(0); - }); - }); - - describe('chunk — thinking content', () => { - it('maps thinking field to thinking message', () => { - const result = adaptOllamaMessage({ - type: 'chunk', - message: { role: 'assistant', content: '', thinking: 'Let me reason about this...' }, - done: false, - }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('thinking'); - expect((result[0].content as any).text).toBe('Let me reason about this...'); - }); - - it('emits both thinking and text when both present', () => { - const result = adaptOllamaMessage({ - type: 'chunk', - message: { role: 'assistant', content: 'Answer', thinking: 'Reasoning' }, - done: false, - }); - expect(result).toHaveLength(2); - expect(result[0].type).toBe('thinking'); - expect(result[1].type).toBe('text'); - }); - }); - - describe('chunk — done with token counts', () => { - it('maps final chunk to cost message', () => { - const result = adaptOllamaMessage({ - type: 'chunk', - message: { role: 'assistant', content: '' }, - done: true, - done_reason: 'stop', - prompt_eval_count: 500, - eval_count: 120, - eval_duration: 2_000_000_000, - total_duration: 3_000_000_000, - }); - // Should have cost message (no text since content is empty) - const costMsg = result.find(m => m.type === 'cost'); - expect(costMsg).toBeDefined(); - const content = costMsg!.content as any; - expect(content.inputTokens).toBe(500); - expect(content.outputTokens).toBe(120); - expect(content.durationMs).toBe(2000); - expect(content.totalCostUsd).toBe(0); - expect(content.isError).toBe(false); - }); - - it('marks error done_reason as isError', () => { - const result = adaptOllamaMessage({ - type: 'chunk', - message: { role: 'assistant', content: '' }, - done: true, - done_reason: 'error', - prompt_eval_count: 0, - eval_count: 0, - }); - const costMsg = result.find(m => m.type === 'cost'); - expect(costMsg).toBeDefined(); - expect((costMsg!.content as any).isError).toBe(true); - }); - - it('includes text + cost when final chunk has content', () => { - const result = adaptOllamaMessage({ - type: 'chunk', - message: { role: 'assistant', content: '.' }, - done: true, - done_reason: 'stop', - prompt_eval_count: 10, - eval_count: 5, - }); - expect(result.some(m => m.type === 'text')).toBe(true); - expect(result.some(m => m.type === 'cost')).toBe(true); - }); - }); - - describe('error event', () => { - it('maps to error message', () => { - const result = adaptOllamaMessage({ - type: 'error', - message: 'model not found', - }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('error'); - expect((result[0].content as any).message).toBe('model not found'); - }); - }); - - describe('unknown event type', () => { - it('maps to unknown message preserving raw data', () => { - const result = adaptOllamaMessage({ type: 'something_else', data: 'test' }); - expect(result).toHaveLength(1); - expect(result[0].type).toBe('unknown'); - }); - }); -}); diff --git a/v2/src/lib/adapters/ollama-messages.ts b/v2/src/lib/adapters/ollama-messages.ts deleted file mode 100644 index f5e6e48..0000000 --- a/v2/src/lib/adapters/ollama-messages.ts +++ /dev/null @@ -1,141 +0,0 @@ -// Ollama Message Adapter — transforms Ollama chat streaming events to internal AgentMessage format -// Ollama runner emits synthesized events wrapping /api/chat NDJSON chunks - -import type { - AgentMessage, - InitContent, - TextContent, - ThinkingContent, - StatusContent, - CostContent, - ErrorContent, -} from './claude-messages'; - -import { str, num } from '../utils/type-guards'; - -/** - * Adapt a raw Ollama runner event to AgentMessage[]. - * - * The Ollama runner emits events in this format: - * - {type:'system', subtype:'init', model, ...} - * - {type:'chunk', message:{role,content,thinking}, done:false} - * - {type:'chunk', message:{role,content}, done:true, done_reason, prompt_eval_count, eval_count, ...} - * - {type:'error', message:'...'} - */ -export function adaptOllamaMessage(raw: Record): AgentMessage[] { - const timestamp = Date.now(); - const uuid = crypto.randomUUID(); - - switch (raw.type) { - case 'system': - return adaptSystemEvent(raw, uuid, timestamp); - - case 'chunk': - return adaptChunk(raw, uuid, timestamp); - - case 'error': - return [{ - id: uuid, - type: 'error', - content: { message: str(raw.message, 'Ollama error') } satisfies ErrorContent, - timestamp, - }]; - - default: - return [{ - id: uuid, - type: 'unknown', - content: raw, - timestamp, - }]; - } -} - -function adaptSystemEvent( - raw: Record, - uuid: string, - timestamp: number, -): AgentMessage[] { - const subtype = str(raw.subtype); - - if (subtype === 'init') { - return [{ - id: uuid, - type: 'init', - content: { - sessionId: str(raw.session_id), - model: str(raw.model), - cwd: str(raw.cwd), - tools: [], - } satisfies InitContent, - timestamp, - }]; - } - - return [{ - id: uuid, - type: 'status', - content: { - subtype, - message: typeof raw.status === 'string' ? raw.status : undefined, - } satisfies StatusContent, - timestamp, - }]; -} - -function adaptChunk( - raw: Record, - uuid: string, - timestamp: number, -): AgentMessage[] { - const messages: AgentMessage[] = []; - const msg = typeof raw.message === 'object' && raw.message !== null - ? raw.message as Record - : {}; - const done = raw.done === true; - - // Thinking content (extended thinking from Qwen3 etc.) - const thinking = str(msg.thinking); - if (thinking) { - messages.push({ - id: `${uuid}-think`, - type: 'thinking', - content: { text: thinking } satisfies ThinkingContent, - timestamp, - }); - } - - // Text content - const text = str(msg.content); - if (text) { - messages.push({ - id: `${uuid}-text`, - type: 'text', - content: { text } satisfies TextContent, - timestamp, - }); - } - - // Final chunk with token counts - if (done) { - const doneReason = str(raw.done_reason); - const evalDuration = num(raw.eval_duration); - const durationMs = evalDuration > 0 ? Math.round(evalDuration / 1_000_000) : 0; - - messages.push({ - id: `${uuid}-cost`, - type: 'cost', - content: { - totalCostUsd: 0, - durationMs, - inputTokens: num(raw.prompt_eval_count), - outputTokens: num(raw.eval_count), - numTurns: 1, - isError: doneReason === 'error', - } satisfies CostContent, - timestamp, - }); - } - - return messages; -} diff --git a/v2/src/lib/adapters/provider-bridge.ts b/v2/src/lib/adapters/provider-bridge.ts deleted file mode 100644 index e5fb5db..0000000 --- a/v2/src/lib/adapters/provider-bridge.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Provider Bridge — generic adapter that delegates to provider-specific bridges -// Currently only Claude is implemented; future providers add their own bridge files - -import type { ProviderId } from '../providers/types'; -import { listProfiles as claudeListProfiles, listSkills as claudeListSkills, readSkill as claudeReadSkill, type ClaudeProfile, type ClaudeSkill } from './claude-bridge'; - -// Re-export types for consumers -export type { ClaudeProfile, ClaudeSkill }; - -/** List profiles for a given provider (only Claude supports this) */ -export async function listProviderProfiles(provider: ProviderId): Promise { - if (provider === 'claude') return claudeListProfiles(); - return []; -} - -/** List skills for a given provider (only Claude supports this) */ -export async function listProviderSkills(provider: ProviderId): Promise { - if (provider === 'claude') return claudeListSkills(); - return []; -} - -/** Read a skill file (only Claude supports this) */ -export async function readProviderSkill(provider: ProviderId, path: string): Promise { - if (provider === 'claude') return claudeReadSkill(path); - return ''; -} diff --git a/v2/src/lib/adapters/pty-bridge.ts b/v2/src/lib/adapters/pty-bridge.ts deleted file mode 100644 index 1018c37..0000000 --- a/v2/src/lib/adapters/pty-bridge.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { invoke } from '@tauri-apps/api/core'; -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; - -export interface PtyOptions { - shell?: string; - cwd?: string; - args?: string[]; - cols?: number; - rows?: number; - remote_machine_id?: string; -} - -export async function spawnPty(options: PtyOptions): Promise { - if (options.remote_machine_id) { - const { remote_machine_id: machineId, ...ptyOptions } = options; - return invoke('remote_pty_spawn', { machineId, options: ptyOptions }); - } - return invoke('pty_spawn', { options }); -} - -export async function writePty(id: string, data: string, remoteMachineId?: string): Promise { - if (remoteMachineId) { - return invoke('remote_pty_write', { machineId: remoteMachineId, id, data }); - } - return invoke('pty_write', { id, data }); -} - -export async function resizePty(id: string, cols: number, rows: number, remoteMachineId?: string): Promise { - if (remoteMachineId) { - return invoke('remote_pty_resize', { machineId: remoteMachineId, id, cols, rows }); - } - return invoke('pty_resize', { id, cols, rows }); -} - -export async function killPty(id: string, remoteMachineId?: string): Promise { - if (remoteMachineId) { - return invoke('remote_pty_kill', { machineId: remoteMachineId, id }); - } - return invoke('pty_kill', { id }); -} - -export async function onPtyData(id: string, callback: (data: string) => void): Promise { - return listen(`pty-data-${id}`, (event) => { - callback(event.payload); - }); -} - -export async function onPtyExit(id: string, callback: () => void): Promise { - return listen(`pty-exit-${id}`, () => { - callback(); - }); -} diff --git a/v2/src/lib/adapters/remote-bridge.ts b/v2/src/lib/adapters/remote-bridge.ts deleted file mode 100644 index 51a66a9..0000000 --- a/v2/src/lib/adapters/remote-bridge.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Remote Machine Bridge — Tauri IPC adapter for multi-machine management - -import { invoke } from '@tauri-apps/api/core'; -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; - -export interface RemoteMachineConfig { - label: string; - url: string; - token: string; - auto_connect: boolean; -} - -export interface RemoteMachineInfo { - id: string; - label: string; - url: string; - status: string; - auto_connect: boolean; -} - -// --- Machine management --- - -export async function listRemoteMachines(): Promise { - return invoke('remote_list'); -} - -export async function addRemoteMachine(config: RemoteMachineConfig): Promise { - return invoke('remote_add', { config }); -} - -export async function removeRemoteMachine(machineId: string): Promise { - return invoke('remote_remove', { machineId }); -} - -export async function connectRemoteMachine(machineId: string): Promise { - return invoke('remote_connect', { machineId }); -} - -export async function disconnectRemoteMachine(machineId: string): Promise { - return invoke('remote_disconnect', { machineId }); -} - -// --- Remote event listeners --- - -export interface RemoteSidecarMessage { - machineId: string; - sessionId?: string; - event?: Record; -} - -export interface RemotePtyData { - machineId: string; - sessionId?: string; - data?: string; -} - -export interface RemotePtyExit { - machineId: string; - sessionId?: string; -} - -export interface RemoteMachineEvent { - machineId: string; - payload?: unknown; - error?: unknown; -} - -export async function onRemoteSidecarMessage( - callback: (msg: RemoteSidecarMessage) => void, -): Promise { - return listen('remote-sidecar-message', (event) => { - callback(event.payload); - }); -} - -export async function onRemotePtyData( - callback: (msg: RemotePtyData) => void, -): Promise { - return listen('remote-pty-data', (event) => { - callback(event.payload); - }); -} - -export async function onRemotePtyExit( - callback: (msg: RemotePtyExit) => void, -): Promise { - return listen('remote-pty-exit', (event) => { - callback(event.payload); - }); -} - -export async function onRemoteMachineReady( - callback: (msg: RemoteMachineEvent) => void, -): Promise { - return listen('remote-machine-ready', (event) => { - callback(event.payload); - }); -} - -export async function onRemoteMachineDisconnected( - callback: (msg: RemoteMachineEvent) => void, -): Promise { - return listen('remote-machine-disconnected', (event) => { - callback(event.payload); - }); -} - -export async function onRemoteStateSync( - callback: (msg: RemoteMachineEvent) => void, -): Promise { - return listen('remote-state-sync', (event) => { - callback(event.payload); - }); -} - -export async function onRemoteError( - callback: (msg: RemoteMachineEvent) => void, -): Promise { - return listen('remote-error', (event) => { - callback(event.payload); - }); -} - -export interface RemoteReconnectingEvent { - machineId: string; - backoffSecs: number; -} - -export async function onRemoteMachineReconnecting( - callback: (msg: RemoteReconnectingEvent) => void, -): Promise { - return listen('remote-machine-reconnecting', (event) => { - callback(event.payload); - }); -} - -export async function onRemoteMachineReconnectReady( - callback: (msg: RemoteMachineEvent) => void, -): Promise { - return listen('remote-machine-reconnect-ready', (event) => { - callback(event.payload); - }); -} diff --git a/v2/src/lib/adapters/session-bridge.ts b/v2/src/lib/adapters/session-bridge.ts deleted file mode 100644 index 4815ca7..0000000 --- a/v2/src/lib/adapters/session-bridge.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { invoke } from '@tauri-apps/api/core'; - -export interface PersistedSession { - id: string; - type: string; - title: string; - shell?: string; - cwd?: string; - args?: string[]; - group_name?: string; - created_at: number; - last_used_at: number; -} - -export interface PersistedLayout { - preset: string; - pane_ids: string[]; -} - -export async function listSessions(): Promise { - return invoke('session_list'); -} - -export async function saveSession(session: PersistedSession): Promise { - return invoke('session_save', { session }); -} - -export async function deleteSession(id: string): Promise { - return invoke('session_delete', { id }); -} - -export async function updateSessionTitle(id: string, title: string): Promise { - return invoke('session_update_title', { id, title }); -} - -export async function touchSession(id: string): Promise { - return invoke('session_touch', { id }); -} - -export async function updateSessionGroup(id: string, groupName: string): Promise { - return invoke('session_update_group', { id, group_name: groupName }); -} - -export async function saveLayout(layout: PersistedLayout): Promise { - return invoke('layout_save', { layout }); -} - -export async function loadLayout(): Promise { - return invoke('layout_load'); -} diff --git a/v2/src/lib/adapters/settings-bridge.ts b/v2/src/lib/adapters/settings-bridge.ts deleted file mode 100644 index 0dfb346..0000000 --- a/v2/src/lib/adapters/settings-bridge.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { invoke } from '@tauri-apps/api/core'; - -export async function getSetting(key: string): Promise { - return invoke('settings_get', { key }); -} - -export async function setSetting(key: string, value: string): Promise { - return invoke('settings_set', { key, value }); -} - -export async function listSettings(): Promise<[string, string][]> { - return invoke('settings_list'); -} diff --git a/v2/src/lib/adapters/ssh-bridge.ts b/v2/src/lib/adapters/ssh-bridge.ts deleted file mode 100644 index a01c40c..0000000 --- a/v2/src/lib/adapters/ssh-bridge.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { invoke } from '@tauri-apps/api/core'; - -export interface SshSession { - id: string; - name: string; - host: string; - port: number; - username: string; - key_file: string; - folder: string; - color: string; - created_at: number; - last_used_at: number; -} - -export async function listSshSessions(): Promise { - return invoke('ssh_session_list'); -} - -export async function saveSshSession(session: SshSession): Promise { - return invoke('ssh_session_save', { session }); -} - -export async function deleteSshSession(id: string): Promise { - return invoke('ssh_session_delete', { id }); -} diff --git a/v2/src/lib/adapters/telemetry-bridge.ts b/v2/src/lib/adapters/telemetry-bridge.ts deleted file mode 100644 index 394c596..0000000 --- a/v2/src/lib/adapters/telemetry-bridge.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Telemetry bridge — routes frontend events to Rust tracing via IPC -// No browser OTEL SDK needed (WebKit2GTK incompatible) - -import { invoke } from '@tauri-apps/api/core'; - -type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'; - -/** Emit a structured log event to the Rust tracing layer */ -export function telemetryLog( - level: LogLevel, - message: string, - context?: Record, -): void { - invoke('frontend_log', { level, message, context: context ?? null }).catch(() => { - // Swallow IPC errors — telemetry must never break the app - }); -} - -/** Convenience wrappers */ -export const tel = { - error: (msg: string, ctx?: Record) => telemetryLog('error', msg, ctx), - warn: (msg: string, ctx?: Record) => telemetryLog('warn', msg, ctx), - info: (msg: string, ctx?: Record) => telemetryLog('info', msg, ctx), - debug: (msg: string, ctx?: Record) => telemetryLog('debug', msg, ctx), - trace: (msg: string, ctx?: Record) => telemetryLog('trace', msg, ctx), -}; diff --git a/v2/src/lib/agent-dispatcher.test.ts b/v2/src/lib/agent-dispatcher.test.ts deleted file mode 100644 index c1e4ce5..0000000 --- a/v2/src/lib/agent-dispatcher.test.ts +++ /dev/null @@ -1,666 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// --- Hoisted mocks --- - -const { - capturedCallbacks, - mockUnlistenMsg, - mockUnlistenExit, - mockRestartAgent, - mockUpdateAgentStatus, - mockSetAgentSdkSessionId, - mockSetAgentModel, - mockAppendAgentMessages, - mockUpdateAgentCost, - mockGetAgentSessions, - mockCreateAgentSession, - mockFindChildByToolUseId, - mockAddPane, - mockGetPanes, - mockNotify, -} = vi.hoisted(() => ({ - capturedCallbacks: { - msg: null as ((msg: any) => void) | null, - exit: null as (() => void) | null, - }, - mockUnlistenMsg: vi.fn(), - mockUnlistenExit: vi.fn(), - mockRestartAgent: vi.fn(), - mockUpdateAgentStatus: vi.fn(), - mockSetAgentSdkSessionId: vi.fn(), - mockSetAgentModel: vi.fn(), - mockAppendAgentMessages: vi.fn(), - mockUpdateAgentCost: vi.fn(), - mockGetAgentSessions: vi.fn().mockReturnValue([]), - mockCreateAgentSession: vi.fn(), - mockFindChildByToolUseId: vi.fn().mockReturnValue(undefined), - mockAddPane: vi.fn(), - mockGetPanes: vi.fn().mockReturnValue([]), - mockNotify: vi.fn(), -})); - -vi.mock('./adapters/agent-bridge', () => ({ - onSidecarMessage: vi.fn(async (cb: (msg: any) => void) => { - capturedCallbacks.msg = cb; - return mockUnlistenMsg; - }), - onSidecarExited: vi.fn(async (cb: () => void) => { - capturedCallbacks.exit = cb; - return mockUnlistenExit; - }), - restartAgent: (...args: unknown[]) => mockRestartAgent(...args), -})); - -vi.mock('./providers/types', () => ({})); - -vi.mock('./adapters/message-adapters', () => ({ - adaptMessage: vi.fn((_provider: string, raw: Record) => { - if (raw.type === 'system' && raw.subtype === 'init') { - return [{ - id: 'msg-1', - type: 'init', - content: { sessionId: 'sdk-sess', model: 'claude-sonnet-4-20250514', cwd: '/tmp', tools: [] }, - timestamp: Date.now(), - }]; - } - if (raw.type === 'result') { - return [{ - id: 'msg-2', - type: 'cost', - content: { - totalCostUsd: 0.05, - durationMs: 5000, - inputTokens: 500, - outputTokens: 200, - numTurns: 2, - isError: false, - }, - timestamp: Date.now(), - }]; - } - if (raw.type === 'assistant') { - return [{ - id: 'msg-3', - type: 'text', - content: { text: 'Hello' }, - timestamp: Date.now(), - }]; - } - // Subagent tool_call (Agent/Task) - if (raw.type === 'tool_call_agent') { - return [{ - id: 'msg-tc-agent', - type: 'tool_call', - content: { - toolUseId: raw.toolUseId ?? 'tu-123', - name: raw.toolName ?? 'Agent', - input: raw.toolInput ?? { prompt: 'Do something', name: 'researcher' }, - }, - timestamp: Date.now(), - }]; - } - // Non-subagent tool_call - if (raw.type === 'tool_call_normal') { - return [{ - id: 'msg-tc-normal', - type: 'tool_call', - content: { - toolUseId: 'tu-normal', - name: 'Read', - input: { file: 'test.ts' }, - }, - timestamp: Date.now(), - }]; - } - // Message with parentId (routed to child) - if (raw.type === 'child_message') { - return [{ - id: 'msg-child', - type: 'text', - parentId: raw.parentId as string, - content: { text: 'Child output' }, - timestamp: Date.now(), - }]; - } - // Child init message - if (raw.type === 'child_init') { - return [{ - id: 'msg-child-init', - type: 'init', - parentId: raw.parentId as string, - content: { sessionId: 'child-sdk-sess', model: 'claude-sonnet-4-20250514', cwd: '/tmp', tools: [] }, - timestamp: Date.now(), - }]; - } - // Child cost message - if (raw.type === 'child_cost') { - return [{ - id: 'msg-child-cost', - type: 'cost', - parentId: raw.parentId as string, - content: { - totalCostUsd: 0.02, - durationMs: 2000, - inputTokens: 200, - outputTokens: 100, - numTurns: 1, - isError: false, - }, - timestamp: Date.now(), - }]; - } - return []; - }), -})); - -vi.mock('./stores/agents.svelte', () => ({ - updateAgentStatus: (...args: unknown[]) => mockUpdateAgentStatus(...args), - setAgentSdkSessionId: (...args: unknown[]) => mockSetAgentSdkSessionId(...args), - setAgentModel: (...args: unknown[]) => mockSetAgentModel(...args), - appendAgentMessages: (...args: unknown[]) => mockAppendAgentMessages(...args), - updateAgentCost: (...args: unknown[]) => mockUpdateAgentCost(...args), - getAgentSessions: () => mockGetAgentSessions(), - createAgentSession: (...args: unknown[]) => mockCreateAgentSession(...args), - findChildByToolUseId: (...args: unknown[]) => mockFindChildByToolUseId(...args), -})); - -vi.mock('./stores/layout.svelte', () => ({ - addPane: (...args: unknown[]) => mockAddPane(...args), - getPanes: () => mockGetPanes(), -})); - -vi.mock('./stores/notifications.svelte', () => ({ - notify: (...args: unknown[]) => mockNotify(...args), -})); - -vi.mock('./stores/conflicts.svelte', () => ({ - recordFileWrite: vi.fn().mockReturnValue(false), - clearSessionWrites: vi.fn(), - setSessionWorktree: vi.fn(), -})); - -vi.mock('./utils/tool-files', () => ({ - extractWritePaths: vi.fn().mockReturnValue([]), - extractWorktreePath: vi.fn().mockReturnValue(null), -})); - -// Use fake timers to control setTimeout in sidecar crash recovery -beforeEach(() => { - vi.useFakeTimers(); - vi.clearAllMocks(); - capturedCallbacks.msg = null; - capturedCallbacks.exit = null; - mockRestartAgent.mockResolvedValue(undefined); - mockGetAgentSessions.mockReturnValue([]); -}); - -// We need to dynamically import the dispatcher in each test to get fresh module state. -// However, vi.mock is module-scoped so the mocks persist. The module-level restartAttempts -// and sidecarAlive variables persist across tests since they share the same module instance. -// We work around this by resetting via the exported setSidecarAlive and stopAgentDispatcher. - -import { - startAgentDispatcher, - stopAgentDispatcher, - isSidecarAlive, - setSidecarAlive, - waitForPendingPersistence, -} from './agent-dispatcher'; - -// Stop any previous dispatcher between tests so `unlistenMsg` is null and start works -beforeEach(() => { - stopAgentDispatcher(); -}); - -afterEach(async () => { - vi.useRealTimers(); -}); - -// Need afterEach import -import { afterEach } from 'vitest'; - -describe('agent-dispatcher', () => { - describe('startAgentDispatcher', () => { - it('registers sidecar message and exit listeners', async () => { - await startAgentDispatcher(); - - expect(capturedCallbacks.msg).toBeTypeOf('function'); - expect(capturedCallbacks.exit).toBeTypeOf('function'); - }); - - it('does not register duplicate listeners on repeated calls', async () => { - await startAgentDispatcher(); - await startAgentDispatcher(); // second call should be no-op - - const { onSidecarMessage } = await import('./adapters/agent-bridge'); - expect(onSidecarMessage).toHaveBeenCalledTimes(1); - }); - - it('sets sidecarAlive to true on start', async () => { - setSidecarAlive(false); - await startAgentDispatcher(); - expect(isSidecarAlive()).toBe(true); - }); - }); - - describe('message routing', () => { - beforeEach(async () => { - await startAgentDispatcher(); - }); - - it('routes agent_started to updateAgentStatus(running)', () => { - capturedCallbacks.msg!({ - type: 'agent_started', - sessionId: 'sess-1', - }); - - expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-1', 'running'); - }); - - it('routes agent_stopped to updateAgentStatus(done) and notifies', () => { - capturedCallbacks.msg!({ - type: 'agent_stopped', - sessionId: 'sess-1', - }); - - expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-1', 'done'); - expect(mockNotify).toHaveBeenCalledWith('success', expect.stringContaining('completed')); - }); - - it('routes agent_error to updateAgentStatus(error) with message', () => { - capturedCallbacks.msg!({ - type: 'agent_error', - sessionId: 'sess-1', - message: 'Process crashed', - }); - - expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-1', 'error', 'Process crashed'); - expect(mockNotify).toHaveBeenCalledWith('error', expect.stringContaining('Process crashed')); - }); - - it('ignores messages without sessionId', () => { - capturedCallbacks.msg!({ - type: 'agent_started', - }); - - expect(mockUpdateAgentStatus).not.toHaveBeenCalled(); - }); - - it('handles agent_log silently (no-op)', () => { - capturedCallbacks.msg!({ - type: 'agent_log', - sessionId: 'sess-1', - message: 'Debug info', - }); - - expect(mockUpdateAgentStatus).not.toHaveBeenCalled(); - expect(mockNotify).not.toHaveBeenCalled(); - }); - }); - - describe('agent_event routing via SDK adapter', () => { - beforeEach(async () => { - await startAgentDispatcher(); - }); - - it('routes init event to setAgentSdkSessionId and setAgentModel', () => { - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'system', subtype: 'init' }, - }); - - expect(mockSetAgentSdkSessionId).toHaveBeenCalledWith('sess-1', 'sdk-sess'); - expect(mockSetAgentModel).toHaveBeenCalledWith('sess-1', 'claude-sonnet-4-20250514'); - expect(mockAppendAgentMessages).toHaveBeenCalled(); - }); - - it('routes cost event to updateAgentCost and updateAgentStatus', () => { - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'result' }, - }); - - expect(mockUpdateAgentCost).toHaveBeenCalledWith('sess-1', { - costUsd: 0.05, - inputTokens: 500, - outputTokens: 200, - numTurns: 2, - durationMs: 5000, - }); - expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-1', 'done'); - }); - - it('appends messages to agent session', () => { - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'assistant' }, - }); - - expect(mockAppendAgentMessages).toHaveBeenCalledWith('sess-1', [ - expect.objectContaining({ type: 'text', content: { text: 'Hello' } }), - ]); - }); - - it('does not append when adapter returns empty array', () => { - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'unknown_event' }, - }); - - expect(mockAppendAgentMessages).not.toHaveBeenCalled(); - }); - }); - - describe('sidecar exit handling', () => { - beforeEach(async () => { - await startAgentDispatcher(); - }); - - it('marks running sessions as errored on exit', async () => { - mockGetAgentSessions.mockReturnValue([ - { id: 'sess-1', status: 'running' }, - { id: 'sess-2', status: 'done' }, - { id: 'sess-3', status: 'starting' }, - ]); - - // Trigger exit -- don't await, since it has internal setTimeout - const exitPromise = capturedCallbacks.exit!(); - // Advance past the backoff delay (up to 4s) - await vi.advanceTimersByTimeAsync(5000); - await exitPromise; - - expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-1', 'error', 'Sidecar crashed'); - expect(mockUpdateAgentStatus).toHaveBeenCalledWith('sess-3', 'error', 'Sidecar crashed'); - // sess-2 (done) should not be updated with 'error'/'Sidecar crashed' - const calls = mockUpdateAgentStatus.mock.calls; - const sess2Calls = calls.filter((c: unknown[]) => c[0] === 'sess-2'); - expect(sess2Calls).toHaveLength(0); - }); - - it('attempts auto-restart and notifies with warning', async () => { - const exitPromise = capturedCallbacks.exit!(); - await vi.advanceTimersByTimeAsync(5000); - await exitPromise; - - expect(mockRestartAgent).toHaveBeenCalled(); - expect(mockNotify).toHaveBeenCalledWith('warning', expect.stringContaining('restarting')); - }); - }); - - describe('stopAgentDispatcher', () => { - it('calls unlisten functions', async () => { - await startAgentDispatcher(); - stopAgentDispatcher(); - - expect(mockUnlistenMsg).toHaveBeenCalled(); - expect(mockUnlistenExit).toHaveBeenCalled(); - }); - - it('allows re-registering after stop', async () => { - await startAgentDispatcher(); - stopAgentDispatcher(); - await startAgentDispatcher(); - - const { onSidecarMessage } = await import('./adapters/agent-bridge'); - expect(onSidecarMessage).toHaveBeenCalledTimes(2); - }); - }); - - describe('isSidecarAlive / setSidecarAlive', () => { - it('defaults to true after start', async () => { - await startAgentDispatcher(); - expect(isSidecarAlive()).toBe(true); - }); - - it('can be set manually', () => { - setSidecarAlive(false); - expect(isSidecarAlive()).toBe(false); - setSidecarAlive(true); - expect(isSidecarAlive()).toBe(true); - }); - }); - - describe('subagent routing', () => { - beforeEach(async () => { - await startAgentDispatcher(); - mockGetPanes.mockReturnValue([ - { id: 'sess-1', type: 'agent', title: 'Agent 1', focused: false }, - ]); - mockFindChildByToolUseId.mockReturnValue(undefined); - }); - - it('spawns a subagent pane when Agent tool_call is detected', () => { - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'tool_call_agent', toolUseId: 'tu-agent-1', toolName: 'Agent', toolInput: { prompt: 'Research X', name: 'researcher' } }, - }); - - expect(mockCreateAgentSession).toHaveBeenCalledWith( - expect.any(String), - 'Research X', - { sessionId: 'sess-1', toolUseId: 'tu-agent-1' }, - ); - expect(mockUpdateAgentStatus).toHaveBeenCalledWith(expect.any(String), 'running'); - expect(mockAddPane).toHaveBeenCalledWith(expect.objectContaining({ - type: 'agent', - title: 'Sub: researcher', - group: 'Agent 1', - })); - }); - - it('spawns a subagent pane for Task tool_call', () => { - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'tool_call_agent', toolUseId: 'tu-task-1', toolName: 'Task', toolInput: { prompt: 'Build it', name: 'builder' } }, - }); - - expect(mockCreateAgentSession).toHaveBeenCalled(); - expect(mockAddPane).toHaveBeenCalledWith(expect.objectContaining({ - title: 'Sub: builder', - })); - }); - - it('does not spawn pane for non-subagent tool_calls', () => { - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'tool_call_normal' }, - }); - - expect(mockCreateAgentSession).not.toHaveBeenCalled(); - expect(mockAddPane).not.toHaveBeenCalled(); - }); - - it('does not spawn duplicate pane for same toolUseId', () => { - // First call — spawns pane - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'tool_call_agent', toolUseId: 'tu-dup', toolName: 'Agent', toolInput: { prompt: 'test', name: 'dup' } }, - }); - - // Second call with same toolUseId — should not create another - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'tool_call_agent', toolUseId: 'tu-dup', toolName: 'Agent', toolInput: { prompt: 'test', name: 'dup' } }, - }); - - expect(mockCreateAgentSession).toHaveBeenCalledTimes(1); - expect(mockAddPane).toHaveBeenCalledTimes(1); - }); - - it('reuses existing child session from findChildByToolUseId', () => { - mockFindChildByToolUseId.mockReturnValue({ id: 'existing-child' }); - - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'tool_call_agent', toolUseId: 'tu-existing', toolName: 'Agent', toolInput: { prompt: 'test' } }, - }); - - // Should not create a new session or pane - expect(mockCreateAgentSession).not.toHaveBeenCalled(); - expect(mockAddPane).not.toHaveBeenCalled(); - }); - - it('routes messages with parentId to the child pane', () => { - // First spawn a subagent - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'tool_call_agent', toolUseId: 'tu-route', toolName: 'Agent', toolInput: { prompt: 'test', name: 'worker' } }, - }); - - const childId = mockCreateAgentSession.mock.calls[0][0]; - - // Now send a message with parentId matching the toolUseId - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'child_message', parentId: 'tu-route' }, - }); - - // The child message should go to child pane, not main session - expect(mockAppendAgentMessages).toHaveBeenCalledWith( - childId, - [expect.objectContaining({ type: 'text', content: { text: 'Child output' } })], - ); - }); - - it('routes child init message and updates child session', () => { - // Spawn subagent - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'tool_call_agent', toolUseId: 'tu-cinit', toolName: 'Agent', toolInput: { prompt: 'test', name: 'init-test' } }, - }); - - const childId = mockCreateAgentSession.mock.calls[0][0]; - mockUpdateAgentStatus.mockClear(); - - // Send child init - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'child_init', parentId: 'tu-cinit' }, - }); - - expect(mockSetAgentSdkSessionId).toHaveBeenCalledWith(childId, 'child-sdk-sess'); - expect(mockSetAgentModel).toHaveBeenCalledWith(childId, 'claude-sonnet-4-20250514'); - expect(mockUpdateAgentStatus).toHaveBeenCalledWith(childId, 'running'); - }); - - it('routes child cost message and marks child done', () => { - // Spawn subagent - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'tool_call_agent', toolUseId: 'tu-ccost', toolName: 'Agent', toolInput: { prompt: 'test', name: 'cost-test' } }, - }); - - const childId = mockCreateAgentSession.mock.calls[0][0]; - - // Send child cost - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'child_cost', parentId: 'tu-ccost' }, - }); - - expect(mockUpdateAgentCost).toHaveBeenCalledWith(childId, { - costUsd: 0.02, - inputTokens: 200, - outputTokens: 100, - numTurns: 1, - durationMs: 2000, - }); - expect(mockUpdateAgentStatus).toHaveBeenCalledWith(childId, 'done'); - }); - - it('uses tool name as fallback when input has no prompt/name', () => { - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'tool_call_agent', toolUseId: 'tu-fallback', toolName: 'dispatch_agent', toolInput: 'raw string input' }, - }); - - expect(mockCreateAgentSession).toHaveBeenCalledWith( - expect.any(String), - 'dispatch_agent', - expect.any(Object), - ); - expect(mockAddPane).toHaveBeenCalledWith(expect.objectContaining({ - title: 'Sub: dispatch_agent', - })); - }); - - it('uses parent fallback title when parent pane not found', () => { - mockGetPanes.mockReturnValue([]); // no panes found - - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-1', - event: { type: 'tool_call_agent', toolUseId: 'tu-noparent', toolName: 'Agent', toolInput: { prompt: 'test', name: 'orphan' } }, - }); - - expect(mockAddPane).toHaveBeenCalledWith(expect.objectContaining({ - group: 'Agent sess-1', - })); - }); - }); - - describe('waitForPendingPersistence', () => { - it('resolves immediately when no persistence is in-flight', async () => { - vi.useRealTimers(); - await expect(waitForPendingPersistence()).resolves.toBeUndefined(); - }); - }); - - describe('init event CWD worktree detection', () => { - beforeEach(async () => { - await startAgentDispatcher(); - }); - - it('calls setSessionWorktree when init CWD contains worktree path', async () => { - const { setSessionWorktree } = await import('./stores/conflicts.svelte'); - - // Override the mock adapter to return init with worktree CWD - const { adaptMessage } = await import('./adapters/message-adapters'); - (adaptMessage as ReturnType).mockReturnValueOnce([{ - id: 'msg-wt', - type: 'init', - content: { sessionId: 'sdk-wt', model: 'claude-sonnet-4-20250514', cwd: '/home/user/repo/.claude/worktrees/my-session', tools: [] }, - timestamp: Date.now(), - }]); - - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-wt', - event: { type: 'system', subtype: 'init' }, - }); - - expect(setSessionWorktree).toHaveBeenCalledWith('sess-wt', '/.claude/worktrees/my-session'); - }); - - it('does not call setSessionWorktree for non-worktree CWD', async () => { - const { setSessionWorktree } = await import('./stores/conflicts.svelte'); - (setSessionWorktree as ReturnType).mockClear(); - - capturedCallbacks.msg!({ - type: 'agent_event', - sessionId: 'sess-normal', - event: { type: 'system', subtype: 'init' }, - }); - - // The default mock returns cwd: '/tmp' which is not a worktree - expect(setSessionWorktree).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/v2/src/lib/agent-dispatcher.ts b/v2/src/lib/agent-dispatcher.ts deleted file mode 100644 index fcd9beb..0000000 --- a/v2/src/lib/agent-dispatcher.ts +++ /dev/null @@ -1,305 +0,0 @@ -// Agent Dispatcher — connects sidecar bridge events to agent store -// Thin coordinator that routes sidecar messages to specialized modules - -import { SessionId, type SessionId as SessionIdType } from './types/ids'; -import { onSidecarMessage, onSidecarExited, restartAgent, type SidecarMessage } from './adapters/agent-bridge'; -import { adaptMessage } from './adapters/message-adapters'; -import type { InitContent, CostContent, ToolCallContent } from './adapters/claude-messages'; -import { - updateAgentStatus, - setAgentSdkSessionId, - setAgentModel, - appendAgentMessages, - updateAgentCost, - getAgentSessions, - getAgentSession, -} from './stores/agents.svelte'; -import { notify } from './stores/notifications.svelte'; -import { tel } from './adapters/telemetry-bridge'; -import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte'; -import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte'; -import { extractWritePaths, extractWorktreePath } from './utils/tool-files'; -import { hasAutoAnchored, markAutoAnchored } from './stores/anchors.svelte'; -import { detectWorktreeFromCwd } from './utils/worktree-detection'; -import { - getSessionProjectId, - getSessionProvider, - recordSessionStart, - persistSessionForProject, - clearSessionMaps, -} from './utils/session-persistence'; -import { triggerAutoAnchor } from './utils/auto-anchoring'; -import { - isSubagentToolCall, - getChildPaneId, - spawnSubagentPane, - clearSubagentRoutes, -} from './utils/subagent-router'; - -// Re-export public API consumed by other modules -export { registerSessionProject, waitForPendingPersistence } from './utils/session-persistence'; - -let unlistenMsg: (() => void) | null = null; -let unlistenExit: (() => void) | null = null; - -// Sidecar liveness — checked by UI components -let sidecarAlive = true; - -// Sidecar crash recovery state -const MAX_RESTART_ATTEMPTS = 3; -let restartAttempts = 0; -let restarting = false; -export function isSidecarAlive(): boolean { - return sidecarAlive; -} -export function setSidecarAlive(alive: boolean): void { - sidecarAlive = alive; -} - -export async function startAgentDispatcher(): Promise { - if (unlistenMsg) return; - - sidecarAlive = true; - - unlistenMsg = await onSidecarMessage((msg: SidecarMessage) => { - sidecarAlive = true; - // Reset restart counter on any successful message — sidecar recovered - if (restartAttempts > 0) { - notify('success', 'Sidecar recovered'); - restartAttempts = 0; - } - - if (!msg.sessionId) return; - const sessionId = SessionId(msg.sessionId); - - switch (msg.type) { - case 'agent_started': - updateAgentStatus(sessionId, 'running'); - recordSessionStart(sessionId); - tel.info('agent_started', { sessionId }); - break; - - case 'agent_event': - if (msg.event) handleAgentEvent(sessionId, msg.event); - break; - - case 'agent_stopped': - updateAgentStatus(sessionId, 'done'); - tel.info('agent_stopped', { sessionId }); - notify('success', `Agent ${sessionId.slice(0, 8)} completed`); - break; - - case 'agent_error': - updateAgentStatus(sessionId, 'error', msg.message); - tel.error('agent_error', { sessionId, error: msg.message }); - notify('error', `Agent error: ${msg.message ?? 'Unknown'}`); - break; - - case 'agent_log': - break; - } - }); - - unlistenExit = await onSidecarExited(async () => { - sidecarAlive = false; - tel.error('sidecar_crashed', { restartAttempts }); - - // Guard against re-entrant exit handler (double-restart race) - if (restarting) return; - restarting = true; - - // Mark all running sessions as errored - for (const session of getAgentSessions()) { - if (session.status === 'running' || session.status === 'starting') { - updateAgentStatus(session.id, 'error', 'Sidecar crashed'); - } - } - - // Attempt auto-restart with exponential backoff - try { - if (restartAttempts < MAX_RESTART_ATTEMPTS) { - restartAttempts++; - const delayMs = 1000 * Math.pow(2, restartAttempts - 1); // 1s, 2s, 4s - notify('warning', `Sidecar crashed, restarting (attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS})...`); - await new Promise((resolve) => setTimeout(resolve, delayMs)); - try { - await restartAgent(); - sidecarAlive = true; - // Note: restartAttempts is reset when next sidecar message arrives - } catch { - if (restartAttempts >= MAX_RESTART_ATTEMPTS) { - notify('error', `Sidecar restart failed after ${MAX_RESTART_ATTEMPTS} attempts`); - } - } - } else { - notify('error', `Sidecar restart failed after ${MAX_RESTART_ATTEMPTS} attempts`); - } - } finally { - restarting = false; - } - }); -} - -function handleAgentEvent(sessionId: SessionIdType, event: Record): void { - const provider = getSessionProvider(sessionId); - const messages = adaptMessage(provider, event); - - // Route messages with parentId to the appropriate child pane - const mainMessages: typeof messages = []; - const childBuckets = new Map(); - - for (const msg of messages) { - const childPaneId = msg.parentId ? getChildPaneId(msg.parentId) : undefined; - if (childPaneId) { - if (!childBuckets.has(childPaneId)) childBuckets.set(childPaneId, []); - childBuckets.get(childPaneId)!.push(msg); - } else { - mainMessages.push(msg); - } - } - - // Process main session messages - for (const msg of mainMessages) { - switch (msg.type) { - case 'init': { - const init = msg.content as InitContent; - setAgentSdkSessionId(sessionId, init.sessionId); - setAgentModel(sessionId, init.model); - // CWD-based worktree detection for conflict suppression - if (init.cwd) { - const wtPath = detectWorktreeFromCwd(init.cwd); - if (wtPath) { - setSessionWorktree(sessionId, wtPath); - } - } - break; - } - - case 'tool_call': { - const tc = msg.content as ToolCallContent; - if (isSubagentToolCall(tc.name)) { - spawnSubagentPane(sessionId, tc); - } - // Health: record tool start - const projId = getSessionProjectId(sessionId); - if (projId) { - recordActivity(projId, tc.name); - // Worktree tracking - const wtPath = extractWorktreePath(tc); - if (wtPath) { - setSessionWorktree(sessionId, wtPath); - } - // Conflict detection: track file writes - const writePaths = extractWritePaths(tc); - for (const filePath of writePaths) { - const isNewConflict = recordFileWrite(projId, sessionId, filePath); - if (isNewConflict) { - const shortName = filePath.split('/').pop() ?? filePath; - notify('warning', `File conflict: ${shortName} — multiple agents writing`); - } - } - } - break; - } - - case 'compaction': { - // Auto-anchor on first compaction for this project - const compactProjId = getSessionProjectId(sessionId); - if (compactProjId && !hasAutoAnchored(compactProjId)) { - markAutoAnchored(compactProjId); - const session = getAgentSession(sessionId); - if (session) { - triggerAutoAnchor(compactProjId, session.messages, session.prompt); - } - } - break; - } - - case 'cost': { - const cost = msg.content as CostContent; - updateAgentCost(sessionId, { - costUsd: cost.totalCostUsd, - inputTokens: cost.inputTokens, - outputTokens: cost.outputTokens, - numTurns: cost.numTurns, - durationMs: cost.durationMs, - }); - tel.info('agent_cost', { - sessionId, - costUsd: cost.totalCostUsd, - inputTokens: cost.inputTokens, - outputTokens: cost.outputTokens, - numTurns: cost.numTurns, - durationMs: cost.durationMs, - isError: cost.isError, - }); - if (cost.isError) { - updateAgentStatus(sessionId, 'error', cost.errors?.join('; ')); - notify('error', `Agent failed: ${cost.errors?.[0] ?? 'Unknown error'}`); - } else { - updateAgentStatus(sessionId, 'done'); - notify('success', `Agent done — $${cost.totalCostUsd.toFixed(4)}, ${cost.numTurns} turns`); - } - // Health: record token snapshot + tool done - const costProjId = getSessionProjectId(sessionId); - if (costProjId) { - recordTokenSnapshot(costProjId, cost.inputTokens + cost.outputTokens, cost.totalCostUsd); - recordToolDone(costProjId); - // Conflict tracking: clear session writes on completion - clearSessionWrites(costProjId, sessionId); - } - // Persist session state for project-scoped sessions - persistSessionForProject(sessionId); - break; - } - } - } - - // Health: record general activity for non-tool messages (text, thinking) - if (mainMessages.length > 0) { - const actProjId = getSessionProjectId(sessionId); - if (actProjId) { - const hasToolResult = mainMessages.some(m => m.type === 'tool_result'); - if (hasToolResult) recordToolDone(actProjId); - else recordActivity(actProjId); - } - appendAgentMessages(sessionId, mainMessages); - } - - // Append messages to child panes and update their status - for (const [childPaneId, childMsgs] of childBuckets) { - for (const msg of childMsgs) { - if (msg.type === 'init') { - const init = msg.content as InitContent; - setAgentSdkSessionId(childPaneId, init.sessionId); - setAgentModel(childPaneId, init.model); - updateAgentStatus(childPaneId, 'running'); - } else if (msg.type === 'cost') { - const cost = msg.content as CostContent; - updateAgentCost(childPaneId, { - costUsd: cost.totalCostUsd, - inputTokens: cost.inputTokens, - outputTokens: cost.outputTokens, - numTurns: cost.numTurns, - durationMs: cost.durationMs, - }); - updateAgentStatus(childPaneId, cost.isError ? 'error' : 'done'); - } - } - appendAgentMessages(childPaneId, childMsgs); - } -} - -export function stopAgentDispatcher(): void { - if (unlistenMsg) { - unlistenMsg(); - unlistenMsg = null; - } - if (unlistenExit) { - unlistenExit(); - unlistenExit = null; - } - // Clear routing maps to prevent unbounded memory growth - clearSubagentRoutes(); - clearSessionMaps(); -} diff --git a/v2/src/lib/components/Agent/AgentPane.svelte b/v2/src/lib/components/Agent/AgentPane.svelte deleted file mode 100644 index c470411..0000000 --- a/v2/src/lib/components/Agent/AgentPane.svelte +++ /dev/null @@ -1,1500 +0,0 @@ - - -
- {#if parentSession} - - {/if} - {#if childSessions.length > 0} -
- {childSessions.length} subagent{childSessions.length > 1 ? 's' : ''} - {#each childSessions as child (child.id)} - - {/each} -
- {/if} - {#if hasToolCalls} -
- -
- {#if showTree && session} - - {/if} - {/if} - -
- {#if !session || session.messages.length === 0} -
-
- - - -
- Ask Claude anything - Type / for skills • Shift+Enter for newline -
- {:else} - {#each session.messages as msg (msg.id)} -
- {#if msg.type === 'init'} -
- Session started - {(msg.content as import('../../adapters/claude-messages').InitContent).model} -
- {:else if msg.type === 'text'} - {@const textContent = (msg.content as TextContent).text} - {@const firstLine = textContent.split('\n')[0].slice(0, 120)} -
- - - {firstLine}{firstLine.length >= 120 ? '...' : ''} - {#if projectId} - - {/if} - -
{@html renderMarkdown(textContent)}
-
- {:else if msg.type === 'thinking'} -
- Thinking... -
{(msg.content as ThinkingContent).text}
-
- {:else if msg.type === 'tool_call'} - {@const tc = msg.content as ToolCallContent} - {@const pairedResult = toolResultMap[tc.toolUseId]} -
- - - {tc.name} - {#if pairedResult} - - {:else if isRunning} - - {/if} - -
-
- -
{formatToolInput(tc.input)}
-
- {#if pairedResult} - {@const outputStr = formatToolInput(pairedResult.output)} - {@const limit = getTruncationLimit(tc.name)} - {@const truncated = truncateByLines(outputStr, limit)} -
- - {#if truncated.truncated && !expandedTools.has(tc.toolUseId)} -
{truncated.text}
- - {:else} -
{outputStr}
- {/if} -
- {:else if isRunning} -
- - Awaiting tool result -
- {/if} -
-
- {:else if msg.type === 'tool_result'} - - {:else if msg.type === 'cost'} - {@const cost = msg.content as CostContent} -
- ${cost.totalCostUsd.toFixed(4)} - {cost.inputTokens + cost.outputTokens} tokens - {cost.numTurns} turns - {(cost.durationMs / 1000).toFixed(1)}s -
- {#if cost.result} -
- - - {cost.result.split('\n')[0].slice(0, 80)}{cost.result.length > 80 ? '...' : ''} - -
{cost.result}
-
- {/if} - {:else if msg.type === 'error'} -
{(msg.content as ErrorContent).message}
- {:else if msg.type === 'status'} - {@const statusContent = msg.content as StatusContent} - {#if isHookMessage(statusContent)} -
- {hookDisplayName(statusContent.subtype)} -
{statusContent.message || JSON.stringify(msg.content, null, 2)}
-
- {:else} -
{statusContent.message || statusContent.subtype}
- {/if} - {/if} -
- {/each} -
- {/if} -
- - - {#if session} -
- {#if session.status === 'running' || session.status === 'starting'} -
- - Running... - {#if contextPercent > 0} - - - {contextPercent}% - - {/if} - {#if !autoScroll} - - {/if} - -
- {:else if session.status === 'done'} -
- ${session.costUsd.toFixed(4)} - {#if totalCost && totalCost.costUsd > session.costUsd} - (total: ${totalCost.costUsd.toFixed(4)}) - {/if} - {session.inputTokens + session.outputTokens} tok - {(session.durationMs / 1000).toFixed(1)}s - {#if contextPercent > 0} - - - {contextPercent}% - - {/if} - {#if !autoScroll} - - {/if} -
- {:else if session.status === 'error'} -
- Error: {session.error ?? 'Unknown'} - {#if session.error?.includes('Sidecar') || session.error?.includes('crashed')} - - {/if} -
- {/if} -
- {/if} - - - {#if session && (session.status === 'done' || session.status === 'error') && session.sdkSessionId} -
- - {#if capabilities.supportsResume} - - {/if} -
- {/if} - - -
-
- {#if capabilities.hasSkills && showSkillMenu && filteredSkills.length > 0} -
- {#each filteredSkills as skill, i (skill.name)} - - {/each} -
- {/if} - - -
-
-
- - diff --git a/v2/src/lib/components/Agent/AgentTree.svelte b/v2/src/lib/components/Agent/AgentTree.svelte deleted file mode 100644 index 8b3e8bb..0000000 --- a/v2/src/lib/components/Agent/AgentTree.svelte +++ /dev/null @@ -1,173 +0,0 @@ - - -
- - {#snippet renderNode(layout: LayoutNode)} - - {#each layout.children as child} - - {/each} - - - - - onNodeClick?.(layout.node.id)} - style="cursor: {onNodeClick ? 'pointer' : 'default'}" - > - - - - - {truncateLabel(layout.node.label, 10)} - - {#if subtreeCost(layout.node) > 0} - ${subtreeCost(layout.node).toFixed(4)} - {/if} - - - - {#each layout.children as child} - {@render renderNode(child)} - {/each} - {/snippet} - - {@render renderNode(layoutResult.layout)} - -
- - diff --git a/v2/src/lib/components/Context/ContextPane.svelte b/v2/src/lib/components/Context/ContextPane.svelte deleted file mode 100644 index d7d2834..0000000 --- a/v2/src/lib/components/Context/ContextPane.svelte +++ /dev/null @@ -1,396 +0,0 @@ - - -
-
-

{projectName}

- { if (e.key === 'Enter') handleSearch(); }} - /> -
- - {#if error} -
-
- - - - - -
- {#if dbMissing} -
Context database not found
-
- Create the database at ~/.claude-context/context.db to get started. -
- - {:else} -
{error}
- {/if} -
- {/if} - - {#if !error} -
- {#if loading} -
Loading...
- {:else if searchResults.length > 0} -
-

Search Results

- {#each searchResults as result} -
-
- {result.project} - {result.key} -
-
{result.value}
-
- {/each} - -
- {:else} - {#if entries.length > 0} -
-

Project Context

- {#each entries as entry} -
-
- {entry.key} - -
-
{entry.value}
-
- {/each} -
- {/if} - - {#if sharedEntries.length > 0} -
-

Shared Context

- {#each sharedEntries as entry} -
-
- {entry.key} -
-
{entry.value}
-
- {/each} -
- {/if} - - {#if summaries.length > 0} -
-

Recent Sessions

- {#each summaries as summary} -
-
- -
-
{summary.summary}
-
- {/each} -
- {/if} - - {#if entries.length === 0 && sharedEntries.length === 0 && summaries.length === 0} -
-

No context stored yet.

-

Use ctx set {projectName} <key> <value> to add context entries.

-
- {/if} - {/if} -
- {/if} -
- - diff --git a/v2/src/lib/components/Markdown/MarkdownPane.svelte b/v2/src/lib/components/Markdown/MarkdownPane.svelte deleted file mode 100644 index 0d03a03..0000000 --- a/v2/src/lib/components/Markdown/MarkdownPane.svelte +++ /dev/null @@ -1,428 +0,0 @@ - - -
- {#if error} -
{error}
- {:else} - - -
-
- {@html renderedHtml} -
-
- {/if} -
{filePath}
-
- - diff --git a/v2/src/lib/components/Notifications/ToastContainer.svelte b/v2/src/lib/components/Notifications/ToastContainer.svelte deleted file mode 100644 index 2602b16..0000000 --- a/v2/src/lib/components/Notifications/ToastContainer.svelte +++ /dev/null @@ -1,94 +0,0 @@ - - -{#if toasts.length > 0} -
- {#each toasts as toast (toast.id)} - - {/each} -
-{/if} - - diff --git a/v2/src/lib/components/StatusBar/StatusBar.svelte b/v2/src/lib/components/StatusBar/StatusBar.svelte deleted file mode 100644 index 4cd036e..0000000 --- a/v2/src/lib/components/StatusBar/StatusBar.svelte +++ /dev/null @@ -1,299 +0,0 @@ - - -
-
- {#if activeGroup} - {activeGroup.name} - - {/if} - {projectCount} projects - - - - {#if health.running > 0} - - - {health.running} running - - - {/if} - {#if health.idle > 0} - {health.idle} idle - - {/if} - {#if health.stalled > 0} - - {health.stalled} stalled - - - {/if} - {#if totalConflicts > 0} - - ⚠ {totalConflicts} conflict{totalConflicts > 1 ? 's' : ''} - - - {/if} - - - {#if attentionQueue.length > 0} - - {/if} -
- -
- {#if health.totalBurnRatePerHour > 0} - - {formatRate(health.totalBurnRatePerHour)} - - - {/if} - {#if totalTokens > 0} - {totalTokens.toLocaleString()} tok - - {/if} - {#if totalCost > 0} - ${totalCost.toFixed(4)} - - {/if} - BTerminal v3 -
-
- - -{#if showAttention && attentionQueue.length > 0} -
- {#each attentionQueue as item (item.projectId)} - - {/each} -
-{/if} - - diff --git a/v2/src/lib/components/Terminal/AgentPreviewPane.svelte b/v2/src/lib/components/Terminal/AgentPreviewPane.svelte deleted file mode 100644 index f65bb90..0000000 --- a/v2/src/lib/components/Terminal/AgentPreviewPane.svelte +++ /dev/null @@ -1,197 +0,0 @@ - - -
- - diff --git a/v2/src/lib/components/Terminal/TerminalPane.svelte b/v2/src/lib/components/Terminal/TerminalPane.svelte deleted file mode 100644 index 258c352..0000000 --- a/v2/src/lib/components/Terminal/TerminalPane.svelte +++ /dev/null @@ -1,134 +0,0 @@ - - -
- - diff --git a/v2/src/lib/components/Workspace/AgentCard.svelte b/v2/src/lib/components/Workspace/AgentCard.svelte deleted file mode 100644 index e511f5e..0000000 --- a/v2/src/lib/components/Workspace/AgentCard.svelte +++ /dev/null @@ -1,100 +0,0 @@ - - -
e.key === 'Enter' && onclick?.()}> -
- - {session.status} - {#if session.costUsd > 0} - ${session.costUsd.toFixed(4)} - {/if} -
-
{truncatedPrompt}
- {#if session.status === 'running'} -
- {session.numTurns} turns -
- {/if} -
- - diff --git a/v2/src/lib/components/Workspace/AgentSession.svelte b/v2/src/lib/components/Workspace/AgentSession.svelte deleted file mode 100644 index 9c26677..0000000 --- a/v2/src/lib/components/Workspace/AgentSession.svelte +++ /dev/null @@ -1,220 +0,0 @@ - - -
- {#if loading} -
Loading session...
- {:else} - - {/if} -
- - diff --git a/v2/src/lib/components/Workspace/ArchitectureTab.svelte b/v2/src/lib/components/Workspace/ArchitectureTab.svelte deleted file mode 100644 index 1e04907..0000000 --- a/v2/src/lib/components/Workspace/ArchitectureTab.svelte +++ /dev/null @@ -1,493 +0,0 @@ - - -
-
- - - {#if showNewForm} -
- -
- {#each Object.entries(DIAGRAM_TEMPLATES) as [name, template]} - - {/each} -
-
- {/if} - -
- {#each diagrams as file (file.path)} - - {/each} - {#if diagrams.length === 0 && !showNewForm} -
- No diagrams yet. The Architect agent creates .puml files in {ARCH_DIR}/ -
- {/if} -
-
- -
- {#if !selectedFile} -
- Select a diagram or create a new one -
- {:else if loading} -
Loading...
- {:else if error} -
{error}
- {:else} -
- {selectedFile?.split('/').pop()} - - {#if editing} - - {/if} -
- - {#if editing} - - {:else if svgUrl} -
- PlantUML diagram -
- {/if} - {/if} -
-
- - diff --git a/v2/src/lib/components/Workspace/CodeEditor.svelte b/v2/src/lib/components/Workspace/CodeEditor.svelte deleted file mode 100644 index 5a124ee..0000000 --- a/v2/src/lib/components/Workspace/CodeEditor.svelte +++ /dev/null @@ -1,332 +0,0 @@ - - -
- - diff --git a/v2/src/lib/components/Workspace/CommandPalette.svelte b/v2/src/lib/components/Workspace/CommandPalette.svelte deleted file mode 100644 index 2b25d4e..0000000 --- a/v2/src/lib/components/Workspace/CommandPalette.svelte +++ /dev/null @@ -1,159 +0,0 @@ - - -{#if open} - -
- -
e.stopPropagation()} onkeydown={handleKeydown}> - -
    - {#each filtered as group} -
  • - -
  • - {/each} - {#if filtered.length === 0} -
  • No groups match "{query}"
  • - {/if} -
-
-
-{/if} - - diff --git a/v2/src/lib/components/Workspace/CommsTab.svelte b/v2/src/lib/components/Workspace/CommsTab.svelte deleted file mode 100644 index b007d05..0000000 --- a/v2/src/lib/components/Workspace/CommsTab.svelte +++ /dev/null @@ -1,676 +0,0 @@ - - -
- -
-
- Messages -
- - - - - - {#if channels.length > 0 || showNewChannel} -
- Channels - -
- {#each channels as channel (channel.id)} - - {/each} - {#if showNewChannel} -
- { if (e.key === 'Enter') handleCreateChannel(); }} - /> - -
- {/if} - {:else} -
- Channels - -
- {#if showNewChannel} -
- { if (e.key === 'Enter') handleCreateChannel(); }} - /> - -
- {/if} - {/if} - - -
- Direct Messages -
- {#each agents.filter(a => a.id !== ADMIN_ID) as agent (agent.id)} - {@const statusClass = agent.status === 'active' ? 'active' : agent.status === 'sleeping' ? 'sleeping' : 'stopped'} - - {/each} -
- - -
-
- {#if currentView.type === 'feed'} - 📡 Activity Feed - All agent communication - {:else if currentView.type === 'dm'} - DM with {currentView.agentName} - {:else if currentView.type === 'channel'} - # {currentView.channelName} - {/if} -
- -
- {#if currentView.type === 'feed'} - {#if feedMessages.length === 0} -
No messages yet. Agents haven't started communicating.
- {:else} - {#each [...feedMessages].reverse() as msg (msg.id)} -
-
- {getAgentIcon(msg.senderRole)} - {msg.senderName} - - {msg.recipientName} - {formatTime(msg.createdAt)} -
-
{msg.content}
-
- {/each} - {/if} - - {:else if currentView.type === 'dm'} - {#if dmMessages.length === 0} -
No messages yet. Start the conversation!
- {:else} - {#each dmMessages as msg (msg.id)} - {@const isMe = msg.from_agent === ADMIN_ID} -
-
- {isMe ? 'You' : (msg.sender_name ?? msg.from_agent)} - {formatTime(msg.created_at)} -
-
{msg.content}
-
- {/each} - {/if} - - {:else if currentView.type === 'channel'} - {#if channelMessages.length === 0} -
No messages in this channel yet.
- {:else} - {#each channelMessages as msg (msg.id)} - {@const isMe = msg.fromAgent === ADMIN_ID} -
-
- {getAgentIcon(msg.senderRole)} - {isMe ? 'You' : msg.senderName} - {formatTime(msg.createdAt)} -
-
{msg.content}
-
- {/each} - {/if} - {/if} -
- - {#if currentView.type !== 'feed'} -
- - -
- {/if} -
-
- - diff --git a/v2/src/lib/components/Workspace/ContextTab.svelte b/v2/src/lib/components/Workspace/ContextTab.svelte deleted file mode 100644 index 3f48de6..0000000 --- a/v2/src/lib/components/Workspace/ContextTab.svelte +++ /dev/null @@ -1,1703 +0,0 @@ - - -
- {#if !session} -
-
- - - - -
-

No active session

-

Start an agent session to see context window analysis

-
- {:else} - -
- - - -
- - -
- -
-
- {formatTokens(totalCost.inputTokens)} - input -
-
- {formatTokens(totalCost.outputTokens)} - output -
-
- {session.numTurns} - turns -
-
- {formatCost(totalCost.costUsd)} - cost -
-
- {formatDuration(session.durationMs)} - time -
-
- {session.status} -
- {#if compactions.length > 0} -
- {compactions.length}× - compacted -
- {/if} -
- - -
-
- Context Window - {formatTokens(totalCost.inputTokens)} / {formatTokens(CONTEXT_WINDOW)} -
-
- {#each categories as cat} -
- {/each} -
-
-
- {#each categories as cat} -
- - {cat.label} - {formatTokens(cat.tokens)} -
- {/each} -
-
- - - {#if anchors.length > 0} -
-
- Session Anchors - {anchors.length} - {#if injectableAnchors.length > 0} - - {injectableAnchors.length} injectable - - {/if} -
- - -
-
- Anchor Budget - {formatTokens(anchorTokens)} / {formatTokens(anchorBudget)} -
-
-
75} - class:full={anchorBudgetPct >= 100} - style="width: {anchorBudgetPct}%" - >
-
-
- - -
- {#each anchors as anchor (anchor.id)} -
- - {anchorTypeLabel(anchor.anchorType)} - {anchor.content.split('\n')[0].slice(0, 60)}{anchor.content.length > 60 ? '...' : ''} - {formatTokens(anchor.estimatedTokens)} -
- {#if anchor.anchorType === 'pinned'} - - {:else if anchor.anchorType === 'promoted'} - - {/if} - -
-
- {/each} -
-
- {/if} - - - {#if fileRefs.length > 0} -
-
- Files Touched - {fileRefs.length} -
-
- {#each fileRefs.slice(0, 30) as ref (ref.path)} -
-
- {#each Array.from(ref.ops) as op} - {op[0].toUpperCase()} - {/each} -
- {ref.shortName} - {ref.count}× -
- {/each} - {#if fileRefs.length > 30} -
+{fileRefs.length - 30} more
- {/if} -
-
- {/if} - - -
-
- Turns - {turns.length} -
-
- {#each turns as turn (turn.index)} -
- - - {#if expandedTurns.has(turn.index)} -
- {#each turn.messages as msg} - {#if msg.type !== 'cost' && msg.type !== 'status' && msg.type !== 'init'} -
- {msgTypeLabel(msg.type)} - ~{formatTokens(estimateTokens(msg))} - {#if msg.type === 'tool_call'} - {@const tc = msg.content as ToolCallContent} - {tc.name} - {/if} -
- {/if} - {/each} -
- {/if} -
- {/each} - {#if turns.length === 0} -
No turns yet
- {/if} -
-
-
- - -
- {#if astTree.length === 0} -
No conversation data yet
- {:else} -
- {#each astTree as turnNode (turnNode.id)} - {@const result = layoutAst(turnNode, 8, 8)} - {@const svgW = astSvgWidth(result.layout)} - {@const svgH = Math.max(50, result.height + 20)} -
-
- {turnNode.label} - {formatTokens(turnNode.tokens)} -
-
- - {#snippet renderAstNode(layout: AstLayout)} - - {#each layout.children as child} - - {/each} - - - - - - - {truncateText(layout.node.label, 12)} - - {#if layout.node.tokens > 0} - {formatTokens(layout.node.tokens)} - {/if} - - - {#if layout.node.detail} - {layout.node.detail} - {/if} - - {#each layout.children as child} - {@render renderAstNode(child)} - {/each} - {/snippet} - - {@render renderAstNode(result.layout)} - -
-
- {/each} -
- {/if} -
- - -
- {#if toolGraph.nodes.length === 0} -
No tool calls yet
- {:else} - {@const maxY = Math.max(...toolGraph.nodes.map(n => n.y)) + 40} -
- - - {#each toolGraph.edges as edge} - {@const fromNode = toolGraph.nodes.find(n => n.id === edge.from)} - {@const toNode = toolGraph.nodes.find(n => n.id === edge.to)} - {#if fromNode && toNode} - - {/if} - {/each} - - - {#each toolGraph.nodes as node (node.id)} - {#if node.type === 'tool'} - - - {node.label} - {node.count}× - {:else} - - - {truncateText(node.label, 16)} - {node.count}× - {/if} - {/each} - - - Tools - Files - -
- {/if} -
- {/if} -
- - diff --git a/v2/src/lib/components/Workspace/CsvTable.svelte b/v2/src/lib/components/Workspace/CsvTable.svelte deleted file mode 100644 index 5b0a12d..0000000 --- a/v2/src/lib/components/Workspace/CsvTable.svelte +++ /dev/null @@ -1,253 +0,0 @@ - - -
-
- - {totalRows} row{totalRows !== 1 ? 's' : ''} × {colCount} col{colCount !== 1 ? 's' : ''} - - {filename} -
- -
- - - - - {#each headers as header, i} - - {/each} - - - - {#each sortedRows as row, rowIdx (rowIdx)} - - - {#each { length: colCount } as _, colIdx} - - {/each} - - {/each} - -
# toggleSort(i)} class="sortable"> - {header}{sortIndicator(i)} -
{rowIdx + 1}{row[colIdx] ?? ''}
-
-
- - diff --git a/v2/src/lib/components/Workspace/DocsTab.svelte b/v2/src/lib/components/Workspace/DocsTab.svelte deleted file mode 100644 index 299adf9..0000000 --- a/v2/src/lib/components/Workspace/DocsTab.svelte +++ /dev/null @@ -1,160 +0,0 @@ - - -
- - -
- {#if selectedPath} - - {:else} -
Select a document from the sidebar
- {/if} -
-
- - diff --git a/v2/src/lib/components/Workspace/FilesTab.svelte b/v2/src/lib/components/Workspace/FilesTab.svelte deleted file mode 100644 index 0cc4f37..0000000 --- a/v2/src/lib/components/Workspace/FilesTab.svelte +++ /dev/null @@ -1,700 +0,0 @@ - - -
- {#if !sidebarCollapsed} - - -
- {:else} - - {/if} - -
- - {#if fileTabs.length > 0} -
- {#each fileTabs as tab (tab.path)} -
activeTabPath = tab.path} - ondblclick={() => { tab.pinned = true; }} - onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); activeTabPath = tab.path; } }} - role="tab" - tabindex="0" - > - - {tab.name}{#if tab.dirty}{/if} - - -
- {/each} -
- {/if} - - - {#if fileLoading && activeTabPath && !activeTab?.content} -
Loading…
- {:else if !activeTab} -
Select a file to view
- {:else if activeTab.content?.type === 'TooLarge'} -
- File too large - {formatSize(activeTab.content.size)} -
- {:else if activeTab.content?.type === 'Binary'} - {#if isPdfExt(activeTab.path)} - {#key activeTabPath} - - {/key} - {:else if isImageExt(activeTab.path)} -
- {activeTab.name} -
- {:else} -
{activeTab.content.message}
- {/if} - {:else if activeTab.content?.type === 'Text'} - {#if isCsvLang(activeTab.content.lang)} - {#key activeTabPath} - - {/key} - {:else} - {#key activeTabPath} - handleEditorChange(activeTab!.path, c)} - onsave={saveActiveTab} - onblur={() => handleEditorBlur(activeTab!.path)} - /> - {/key} - {/if} - {/if} - - {#if activeTab} -
- {activeTab.path} - {#if activeTab.dirty} - (unsaved) - {/if} -
- {/if} -
-
- - diff --git a/v2/src/lib/components/Workspace/GlobalTabBar.svelte b/v2/src/lib/components/Workspace/GlobalTabBar.svelte deleted file mode 100644 index 36c555b..0000000 --- a/v2/src/lib/components/Workspace/GlobalTabBar.svelte +++ /dev/null @@ -1,102 +0,0 @@ - - - - - diff --git a/v2/src/lib/components/Workspace/GroupAgentsPanel.svelte b/v2/src/lib/components/Workspace/GroupAgentsPanel.svelte deleted file mode 100644 index 67a2ffb..0000000 --- a/v2/src/lib/components/Workspace/GroupAgentsPanel.svelte +++ /dev/null @@ -1,427 +0,0 @@ - - -{#if hasAgents} -
- - - {#if !collapsed} - {#if agents.length > 0} -
- Tier 1 — Management -
-
- {#each agents as agent (agent.id)} - {@const status = getStatus(agent.id)} -
setActiveProject(agent.id)} - role="button" - tabindex="0" - > -
- {ROLE_ICONS[agent.role] ?? '🤖'} - {agent.name} - -
-
- {ROLE_LABELS[agent.role] ?? agent.role} - {#if agent.model} - {agent.model} - {/if} - {#if getUnread(agent.id) > 0} - {getUnread(agent.id)} - {/if} -
-
- -
-
- {/each} -
- {/if} - - {#if projects.length > 0} -
-
- Tier 2 — Execution -
-
- {#each projects as project (project.id)} - {@const status = getStatus(project.id)} -
setActiveProject(project.id)} - role="button" - tabindex="0" - > -
- {project.icon} - {project.name} - -
-
- Project - {#if getUnread(project.id) > 0} - {getUnread(project.id)} - {/if} -
-
- {/each} -
- {/if} - {/if} -
-{/if} - - diff --git a/v2/src/lib/components/Workspace/MemoriesTab.svelte b/v2/src/lib/components/Workspace/MemoriesTab.svelte deleted file mode 100644 index 1b7bab2..0000000 --- a/v2/src/lib/components/Workspace/MemoriesTab.svelte +++ /dev/null @@ -1,375 +0,0 @@ - - -
- {#if !adapter} -
-
- - - - -
-

No memory adapter configured

-

Register a memory adapter (e.g. Memora) to browse knowledge here.

-
- {:else} -
-

{adapterName}

- {total} memories -
- {#each getAvailableAdapters() as a (a.name)} - - {/each} -
-
- - - - {#if error} -
{error}
- {/if} - -
- {#if loading} -
Loading…
- {:else if nodes.length === 0} -
No memories found
- {:else} - {#each nodes as node (node.id)} - - {/each} - {/if} -
- {/if} -
- - diff --git a/v2/src/lib/components/Workspace/PdfViewer.svelte b/v2/src/lib/components/Workspace/PdfViewer.svelte deleted file mode 100644 index b63159d..0000000 --- a/v2/src/lib/components/Workspace/PdfViewer.svelte +++ /dev/null @@ -1,292 +0,0 @@ - - -
-
- - {#if loading} - Loading… - {:else if error} - Error - {:else} - {pageCount} page{pageCount !== 1 ? 's' : ''} - {/if} - -
- - - -
-
- - {#if error} -
{error}
- {:else} -
- {/if} -
- - diff --git a/v2/src/lib/components/Workspace/ProjectBox.svelte b/v2/src/lib/components/Workspace/ProjectBox.svelte deleted file mode 100644 index c3a0813..0000000 --- a/v2/src/lib/components/Workspace/ProjectBox.svelte +++ /dev/null @@ -1,384 +0,0 @@ - - -
- - -
- - - - - - - {#if isAgent && agentRole === 'manager'} - - {/if} - {#if isAgent && agentRole === 'architect'} - - {/if} - {#if isAgent && agentRole === 'tester'} - - - {/if} -
- -
- -
- mainSessionId = id} /> - {#if mainSessionId} - - {/if} -
-
- -
-
- -
- - - {#if everActivated['files']} -
- -
- {/if} - {#if everActivated['ssh']} -
- -
- {/if} - {#if everActivated['memories']} -
- -
- {/if} - {#if everActivated['tasks'] && activeGroup} -
- -
- {/if} - {#if everActivated['architecture']} -
- -
- {/if} - {#if everActivated['selenium']} -
- -
- {/if} - {#if everActivated['tests']} -
- -
- {/if} -
- -
- - - {#if terminalExpanded} -
- -
- {/if} -
-
- - diff --git a/v2/src/lib/components/Workspace/ProjectFiles.svelte b/v2/src/lib/components/Workspace/ProjectFiles.svelte deleted file mode 100644 index 1d3d806..0000000 --- a/v2/src/lib/components/Workspace/ProjectFiles.svelte +++ /dev/null @@ -1,152 +0,0 @@ - - -
- - -
- {#if selectedPath} - - {:else} -
Select a file
- {/if} -
-
- - diff --git a/v2/src/lib/components/Workspace/ProjectGrid.svelte b/v2/src/lib/components/Workspace/ProjectGrid.svelte deleted file mode 100644 index e8b6da2..0000000 --- a/v2/src/lib/components/Workspace/ProjectGrid.svelte +++ /dev/null @@ -1,86 +0,0 @@ - - -
- {#each projects as project, i (project.id)} -
- setActiveProject(project.id)} - /> -
- {/each} - - {#if projects.length === 0} -
- No enabled projects in this group. Go to Settings to add projects. -
- {/if} -
- - diff --git a/v2/src/lib/components/Workspace/ProjectHeader.svelte b/v2/src/lib/components/Workspace/ProjectHeader.svelte deleted file mode 100644 index 481c5fd..0000000 --- a/v2/src/lib/components/Workspace/ProjectHeader.svelte +++ /dev/null @@ -1,288 +0,0 @@ - - - - · - {/if} - {#if health && health.fileConflictCount - (health.externalConflictCount ?? 0) > 0} - - · - {/if} - {#if contextPct !== null && contextPct > 0} - ctx {contextPct}% - · - {/if} - {#if health && health.burnRatePerHour > 0.01} - - ${health.burnRatePerHour < 1 ? health.burnRatePerHour.toFixed(2) : health.burnRatePerHour.toFixed(1)}/hr - - · - {/if} - {displayCwd()} - {#if project.profile} - · - {project.profile} - {/if} - - - - diff --git a/v2/src/lib/components/Workspace/SettingsTab.svelte b/v2/src/lib/components/Workspace/SettingsTab.svelte deleted file mode 100644 index 4fc67a5..0000000 --- a/v2/src/lib/components/Workspace/SettingsTab.svelte +++ /dev/null @@ -1,1971 +0,0 @@ - - - - -
-
-

Appearance

-
-
- Theme -
- - {#if themeDropdownOpen} - - {/if} -
-
- -
- UI Font -
- -
- - handleUiFontSizeChange((e.target as HTMLInputElement).value)} - /> - px - -
-
-
- -
- Terminal Font -
- -
- - handleTermFontSizeChange((e.target as HTMLInputElement).value)} - /> - px - -
-
-
-
- Project max aspect ratio -
- - handleAspectChange((e.target as HTMLInputElement).value)} - /> - w:h - -
-
-
-
- -
-

Defaults

-
-
- - { defaultShell = (e.target as HTMLInputElement).value; saveGlobalSetting('default_shell', defaultShell); }} - /> -
-
- -
- { defaultCwd = (e.target as HTMLInputElement).value; saveGlobalSetting('default_cwd', defaultCwd); }} - /> - -
-
-
- -

Editor

-
-
- - Auto-save files when the editor loses focus -
-
-
- -
-

Providers

-
- {#each registeredProviders as provider} -
- - {#if expandedProvider === provider.id} -
-
- -
- {#if provider.capabilities.hasModelSelection} -
- Default model - setProviderModel(provider.id, (e.target as HTMLInputElement).value)} - /> -
- {/if} -
- Capabilities -
- {#if provider.capabilities.hasProfiles}Profiles{/if} - {#if provider.capabilities.hasSkills}Skills{/if} - {#if provider.capabilities.supportsSubagents}Subagents{/if} - {#if provider.capabilities.supportsCost}Cost tracking{/if} - {#if provider.capabilities.supportsResume}Resume{/if} - {#if provider.capabilities.hasSandbox}Sandbox{/if} -
-
-
- {/if} -
- {/each} -
-
- -
-

Groups

-
- {#each groups as group} -
- - {group.projects.length} projects - {#if groups.length > 1} - - {/if} -
- {/each} -
- -
- - -
-
- - {#if activeGroup && (activeGroup.agents?.length ?? 0) > 0} -
-

Agents in "{activeGroup.name}"

- -
- {#each activeGroup.agents ?? [] as agent (agent.id)} -
-
- {AGENT_ROLE_ICONS[agent.role] ?? '🤖'} - updateAgent(activeGroupId, agent.id, { name: (e.target as HTMLInputElement).value })} - /> - {agent.role} - -
- -
- - - Working Directory - -
- updateAgent(activeGroupId, agent.id, { cwd: (e.target as HTMLInputElement).value || undefined })} - /> - -
-
- -
- - - Model - - updateAgent(activeGroupId, agent.id, { model: (e.target as HTMLInputElement).value || undefined })} - /> -
- - {#if agent.role === 'manager'} -
- - - Wake Interval - -
- updateAgent(activeGroupId, agent.id, { wakeIntervalMin: parseInt((e.target as HTMLInputElement).value) })} - /> - {agent.wakeIntervalMin ?? 3} min -
-
- {/if} - -
- - - Custom Context - - -
- -
- - - Preview full introductory prompt - -
{generateAgentPrompt({
-                role: agent.role as GroupAgentRole,
-                agentId: agent.id,
-                agentName: agent.name,
-                group: activeGroup,
-                customPrompt: agent.systemPrompt,
-              })}
-
-
- {/each} -
-
- {/if} - - {#if activeGroup} -
-

Projects in "{activeGroup.name}"

- -
- {#each activeGroup.projects as project} -
-
-
- - {#if iconPickerOpenFor === project.id} -
- {#each PROJECT_ICONS as emoji} - - {/each} -
- {/if} -
-
- updateProject(activeGroupId, project.id, { name: (e.target as HTMLInputElement).value })} - /> -
- -
- -
- - - Path - -
- updateProject(activeGroupId, project.id, { cwd: (e.target as HTMLInputElement).value })} - /> - -
-
- -
- - - Account - - {#if profiles.length > 1} -
- - {#if profileDropdownOpenFor === project.id} - - {/if} -
- {:else} - {getProfileLabel(project.profile)} - {/if} -
- - {#if registeredProviders.length > 1} -
- - - Provider - -
- - {#if providerDropdownOpenFor === project.id} - - {/if} -
-
- {/if} - -
- - - Anchor Budget - -
- { - const idx = parseInt((e.target as HTMLInputElement).value); - updateProject(activeGroupId, project.id, { anchorBudgetScale: ANCHOR_BUDGET_SCALES[idx] }); - }} - /> - {ANCHOR_BUDGET_SCALE_LABELS[project.anchorBudgetScale ?? 'medium']} -
-
- -
- - - Worktree Isolation - - -
- -
- - - Stall Threshold - -
- { - updateProject(activeGroupId, project.id, { stallThresholdMin: parseInt((e.target as HTMLInputElement).value) }); - }} - /> - {project.stallThresholdMin ?? 15} min -
-
- -
- - - Custom Context - - -
- - -
- {/each} -
- - {#if activeGroup.projects.length < 5} -
-
- -
- - -
- -
-
- {:else} -

Maximum 5 projects per group reached.

- {/if} -
- {/if} -
- - diff --git a/v2/src/lib/components/Workspace/SshTab.svelte b/v2/src/lib/components/Workspace/SshTab.svelte deleted file mode 100644 index 91c698a..0000000 --- a/v2/src/lib/components/Workspace/SshTab.svelte +++ /dev/null @@ -1,425 +0,0 @@ - - -
-
-

SSH Connections

- -
- - {#if showForm} -
-
{editing ? 'Edit Connection' : 'New Connection'}
-
- - - - - - -
-
- - -
-
- {/if} - -
- {#if loading} -
Loading…
- {:else if sessions.length === 0 && !showForm} -
-

No SSH connections configured.

-

Add a connection to launch it as a terminal in the Model tab.

-
- {:else} - {#each sessions as session (session.id)} -
-
- {session.name} - {session.username}@{session.host}:{session.port} - {#if session.folder} - {session.folder} - {/if} -
-
- - - -
-
- {/each} - {/if} -
-
- - diff --git a/v2/src/lib/components/Workspace/TaskBoardTab.svelte b/v2/src/lib/components/Workspace/TaskBoardTab.svelte deleted file mode 100644 index 9ca1299..0000000 --- a/v2/src/lib/components/Workspace/TaskBoardTab.svelte +++ /dev/null @@ -1,572 +0,0 @@ - - -
-
- Task Board - - {pendingCount === 0 ? 'All done' : `${pendingCount} pending`} - - -
- - {#if showAddForm} -
- { if (e.key === 'Enter') handleAddTask(); }} - /> - -
- - -
-
- {/if} - - {#if loading} -
Loading tasks...
- {:else if error} -
{error}
- {:else} -
- {#each STATUSES as status} -
-
- {STATUS_ICONS[status]} - {STATUS_LABELS[status]} - {tasksByStatus[status].length} -
-
- {#each tasksByStatus[status] as task (task.id)} -
- - - {#if expandedTaskId === task.id} -
- {#if task.description} -

{task.description}

- {/if} - -
- {#each STATUSES as s} - - {/each} -
- - {#if taskComments.length > 0} -
- {#each taskComments as comment} -
- {comment.agentId} - {comment.content} -
- {/each} -
- {/if} - -
- { if (e.key === 'Enter') handleAddComment(); }} - /> -
- - -
- {/if} -
- {/each} -
-
- {/each} -
- {/if} -
- - diff --git a/v2/src/lib/components/Workspace/TeamAgentsPanel.svelte b/v2/src/lib/components/Workspace/TeamAgentsPanel.svelte deleted file mode 100644 index 5707693..0000000 --- a/v2/src/lib/components/Workspace/TeamAgentsPanel.svelte +++ /dev/null @@ -1,91 +0,0 @@ - - -{#if hasAgents} -
- - - {#if expanded} -
- {#each childSessions as child (child.id)} - - {/each} -
- {/if} -
-{/if} - - diff --git a/v2/src/lib/components/Workspace/TerminalTabs.svelte b/v2/src/lib/components/Workspace/TerminalTabs.svelte deleted file mode 100644 index d372359..0000000 --- a/v2/src/lib/components/Workspace/TerminalTabs.svelte +++ /dev/null @@ -1,275 +0,0 @@ - - -
-
- {#each tabs as tab (tab.id)} - - {/each} - - {#if agentSessionId} - - {/if} -
- -
- {#each tabs as tab (tab.id)} -
- {#if tab.type === 'agent-preview' && tab.agentSessionId} - {#if activeTabId === tab.id} - - {/if} - {:else if tab.type === 'ssh' && sshArgsCache[tab.id]} - handleTabExit(tab.id)} - /> - {:else if tab.type === 'shell'} - handleTabExit(tab.id)} - /> - {/if} -
- {/each} - - {#if tabs.length === 0} -
- -
- {/if} -
-
- - diff --git a/v2/src/lib/components/Workspace/TestingTab.svelte b/v2/src/lib/components/Workspace/TestingTab.svelte deleted file mode 100644 index 52ea06b..0000000 --- a/v2/src/lib/components/Workspace/TestingTab.svelte +++ /dev/null @@ -1,427 +0,0 @@ - - -
- {#if mode === 'selenium'} - -
-
- -
- {#each screenshots as path} - - {/each} - {#if screenshots.length === 0} -
- No screenshots yet. The Tester agent saves screenshots to {SCREENSHOTS_DIR}/ -
- {/if} -
- -
- -
- {#each seleniumLog as line} -
{line}
- {/each} - {#if seleniumLog.length === 0} -
No log entries
- {/if} -
-
-
- -
- {#if selectedScreenshot} -
- Selenium screenshot -
- {:else} -
- Selenium screenshots will appear here during testing. -
- The Tester agent uses Selenium WebDriver for UI testing. -
- {/if} -
-
- - {:else} - -
-
- -
- {#each testFiles as file (file.path)} - - {/each} - {#if testFiles.length === 0} -
- No test files found. The Tester agent creates tests in standard directories (tests/, test/, spec/). -
- {/if} -
-
- -
- {#if selectedTestFile} -
- {selectedTestFile.split('/').pop()} -
-
{testOutput}
- {:else} -
- Select a test file to view its contents. -
- The Tester agent runs tests via the terminal. -
- {/if} -
-
- {/if} -
- - diff --git a/v2/src/lib/providers/claude.ts b/v2/src/lib/providers/claude.ts deleted file mode 100644 index c458651..0000000 --- a/v2/src/lib/providers/claude.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Claude Provider — metadata and capabilities for Claude Code - -import type { ProviderMeta } from './types'; - -export const CLAUDE_PROVIDER: ProviderMeta = { - id: 'claude', - name: 'Claude Code', - description: 'Anthropic Claude Code agent via SDK', - capabilities: { - hasProfiles: true, - hasSkills: true, - hasModelSelection: true, - hasSandbox: false, - supportsSubagents: true, - supportsCost: true, - supportsResume: true, - }, - sidecarRunner: 'claude-runner.mjs', - defaultModel: 'claude-opus-4-6', -}; diff --git a/v2/src/lib/providers/codex.ts b/v2/src/lib/providers/codex.ts deleted file mode 100644 index f5b6d8b..0000000 --- a/v2/src/lib/providers/codex.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Codex Provider — metadata and capabilities for OpenAI Codex CLI - -import type { ProviderMeta } from './types'; - -export const CODEX_PROVIDER: ProviderMeta = { - id: 'codex', - name: 'Codex CLI', - description: 'OpenAI Codex CLI agent via SDK', - capabilities: { - hasProfiles: false, - hasSkills: false, - hasModelSelection: true, - hasSandbox: true, - supportsSubagents: false, - supportsCost: false, - supportsResume: true, - }, - sidecarRunner: 'codex-runner.mjs', - defaultModel: 'gpt-5.4', -}; diff --git a/v2/src/lib/providers/ollama.ts b/v2/src/lib/providers/ollama.ts deleted file mode 100644 index 9d58419..0000000 --- a/v2/src/lib/providers/ollama.ts +++ /dev/null @@ -1,20 +0,0 @@ -// Ollama Provider — metadata and capabilities for local Ollama models - -import type { ProviderMeta } from './types'; - -export const OLLAMA_PROVIDER: ProviderMeta = { - id: 'ollama', - name: 'Ollama', - description: 'Local Ollama models via REST API', - capabilities: { - hasProfiles: false, - hasSkills: false, - hasModelSelection: true, - hasSandbox: false, - supportsSubagents: false, - supportsCost: false, - supportsResume: false, - }, - sidecarRunner: 'ollama-runner.mjs', - defaultModel: 'qwen3:8b', -}; diff --git a/v2/src/lib/providers/registry.svelte.ts b/v2/src/lib/providers/registry.svelte.ts deleted file mode 100644 index 90e80ac..0000000 --- a/v2/src/lib/providers/registry.svelte.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Provider Registry — singleton registry of available providers (Svelte 5 runes) - -import type { ProviderId, ProviderMeta } from './types'; - -const providers = $state(new Map()); - -export function registerProvider(meta: ProviderMeta): void { - providers.set(meta.id, meta); -} - -export function getProvider(id: ProviderId): ProviderMeta | undefined { - return providers.get(id); -} - -export function getProviders(): ProviderMeta[] { - return Array.from(providers.values()); -} - -export function getDefaultProviderId(): ProviderId { - return 'claude'; -} - -/** Check if a specific provider is registered */ -export function hasProvider(id: ProviderId): boolean { - return providers.has(id); -} diff --git a/v2/src/lib/providers/types.ts b/v2/src/lib/providers/types.ts deleted file mode 100644 index 63401eb..0000000 --- a/v2/src/lib/providers/types.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Provider abstraction types — defines the interface for multi-provider agent support - -export type ProviderId = 'claude' | 'codex' | 'ollama'; - -/** What a provider can do — UI gates features on these flags */ -export interface ProviderCapabilities { - hasProfiles: boolean; - hasSkills: boolean; - hasModelSelection: boolean; - hasSandbox: boolean; - supportsSubagents: boolean; - supportsCost: boolean; - supportsResume: boolean; -} - -/** Static metadata about a provider */ -export interface ProviderMeta { - id: ProviderId; - name: string; - description: string; - capabilities: ProviderCapabilities; - /** Name of the sidecar runner file (e.g. 'claude-runner.mjs') */ - sidecarRunner: string; - /** Default model identifier, if applicable */ - defaultModel?: string; -} - -/** Per-provider configuration (stored in settings) */ -export interface ProviderSettings { - enabled: boolean; - defaultModel?: string; - /** Provider-specific config blob */ - config: Record; -} diff --git a/v2/src/lib/stores/agents.svelte.ts b/v2/src/lib/stores/agents.svelte.ts deleted file mode 100644 index b301f6a..0000000 --- a/v2/src/lib/stores/agents.svelte.ts +++ /dev/null @@ -1,148 +0,0 @@ -// Agent tracking state — Svelte 5 runes -// Manages agent session lifecycle and message history - -import type { AgentMessage } from '../adapters/claude-messages'; - -export type AgentStatus = 'idle' | 'starting' | 'running' | 'done' | 'error'; - -export interface AgentSession { - id: string; - sdkSessionId?: string; - status: AgentStatus; - model?: string; - prompt: string; - messages: AgentMessage[]; - costUsd: number; - inputTokens: number; - outputTokens: number; - numTurns: number; - durationMs: number; - error?: string; - // Agent Teams: parent/child hierarchy - parentSessionId?: string; - parentToolUseId?: string; - childSessionIds: string[]; -} - -let sessions = $state([]); - -export function getAgentSessions(): AgentSession[] { - return sessions; -} - -export function getAgentSession(id: string): AgentSession | undefined { - return sessions.find(s => s.id === id); -} - -export function createAgentSession(id: string, prompt: string, parent?: { sessionId: string; toolUseId: string }): void { - sessions.push({ - id, - status: 'starting', - prompt, - messages: [], - costUsd: 0, - inputTokens: 0, - outputTokens: 0, - numTurns: 0, - durationMs: 0, - parentSessionId: parent?.sessionId, - parentToolUseId: parent?.toolUseId, - childSessionIds: [], - }); - - // Register as child of parent - if (parent) { - const parentSession = sessions.find(s => s.id === parent.sessionId); - if (parentSession) { - parentSession.childSessionIds.push(id); - } - } -} - -export function updateAgentStatus(id: string, status: AgentStatus, error?: string): void { - const session = sessions.find(s => s.id === id); - if (!session) return; - session.status = status; - if (error) session.error = error; -} - -export function setAgentSdkSessionId(id: string, sdkSessionId: string): void { - const session = sessions.find(s => s.id === id); - if (session) session.sdkSessionId = sdkSessionId; -} - -export function setAgentModel(id: string, model: string): void { - const session = sessions.find(s => s.id === id); - if (session) session.model = model; -} - -export function appendAgentMessage(id: string, message: AgentMessage): void { - const session = sessions.find(s => s.id === id); - if (!session) return; - session.messages.push(message); -} - -export function appendAgentMessages(id: string, messages: AgentMessage[]): void { - const session = sessions.find(s => s.id === id); - if (!session) return; - session.messages.push(...messages); -} - -export function updateAgentCost( - id: string, - cost: { costUsd: number; inputTokens: number; outputTokens: number; numTurns: number; durationMs: number }, -): void { - const session = sessions.find(s => s.id === id); - if (!session) return; - // Accumulate across query invocations (each resume produces its own cost event) - session.costUsd += cost.costUsd; - session.inputTokens += cost.inputTokens; - session.outputTokens += cost.outputTokens; - session.numTurns += cost.numTurns; - session.durationMs += cost.durationMs; -} - -/** Find a child session that was spawned by a specific tool_use */ -export function findChildByToolUseId(parentId: string, toolUseId: string): AgentSession | undefined { - return sessions.find(s => s.parentSessionId === parentId && s.parentToolUseId === toolUseId); -} - -/** Get all child sessions for a given parent */ -export function getChildSessions(parentId: string): AgentSession[] { - return sessions.filter(s => s.parentSessionId === parentId); -} - -/** Aggregate cost of a session plus all its children (recursive) */ -export function getTotalCost(id: string): { costUsd: number; inputTokens: number; outputTokens: number } { - const session = sessions.find(s => s.id === id); - if (!session) return { costUsd: 0, inputTokens: 0, outputTokens: 0 }; - - let costUsd = session.costUsd; - let inputTokens = session.inputTokens; - let outputTokens = session.outputTokens; - - for (const childId of session.childSessionIds) { - const childCost = getTotalCost(childId); - costUsd += childCost.costUsd; - inputTokens += childCost.inputTokens; - outputTokens += childCost.outputTokens; - } - - return { costUsd, inputTokens, outputTokens }; -} - -export function clearAllAgentSessions(): void { - sessions = []; -} - -export function removeAgentSession(id: string): void { - // Also remove from parent's childSessionIds - const session = sessions.find(s => s.id === id); - if (session?.parentSessionId) { - const parent = sessions.find(s => s.id === session.parentSessionId); - if (parent) { - parent.childSessionIds = parent.childSessionIds.filter(cid => cid !== id); - } - } - sessions = sessions.filter(s => s.id !== id); -} diff --git a/v2/src/lib/stores/anchors.svelte.ts b/v2/src/lib/stores/anchors.svelte.ts deleted file mode 100644 index 4b4144a..0000000 --- a/v2/src/lib/stores/anchors.svelte.ts +++ /dev/null @@ -1,129 +0,0 @@ -// Session Anchors store — Svelte 5 runes -// Per-project anchor management with re-injection support - -import type { SessionAnchor, AnchorType, AnchorBudgetScale, SessionAnchorRecord } from '../types/anchors'; -import { DEFAULT_ANCHOR_SETTINGS, ANCHOR_BUDGET_SCALE_MAP } from '../types/anchors'; -import { - saveSessionAnchors, - loadSessionAnchors, - deleteSessionAnchor, - updateAnchorType as updateAnchorTypeBridge, -} from '../adapters/anchors-bridge'; - -// Per-project anchor state -const projectAnchors = $state>(new Map()); - -// Track which projects have had auto-anchoring triggered (prevents re-anchoring on subsequent compactions) -const autoAnchoredProjects = $state>(new Set()); - -export function getProjectAnchors(projectId: string): SessionAnchor[] { - return projectAnchors.get(projectId) ?? []; -} - -/** Get only re-injectable anchors (auto + promoted, not pinned-only) */ -export function getInjectableAnchors(projectId: string): SessionAnchor[] { - const anchors = projectAnchors.get(projectId) ?? []; - return anchors.filter(a => a.anchorType === 'auto' || a.anchorType === 'promoted'); -} - -/** Total estimated tokens for re-injectable anchors */ -export function getInjectableTokenCount(projectId: string): number { - return getInjectableAnchors(projectId).reduce((sum, a) => sum + a.estimatedTokens, 0); -} - -/** Check if auto-anchoring has already run for this project */ -export function hasAutoAnchored(projectId: string): boolean { - return autoAnchoredProjects.has(projectId); -} - -/** Mark project as having been auto-anchored */ -export function markAutoAnchored(projectId: string): void { - autoAnchoredProjects.add(projectId); -} - -/** Add anchors to a project (in-memory + persist) */ -export async function addAnchors(projectId: string, anchors: SessionAnchor[]): Promise { - const existing = projectAnchors.get(projectId) ?? []; - const updated = [...existing, ...anchors]; - projectAnchors.set(projectId, updated); - - // Persist to SQLite - const records: SessionAnchorRecord[] = anchors.map(a => ({ - id: a.id, - project_id: a.projectId, - message_id: a.messageId, - anchor_type: a.anchorType, - content: a.content, - estimated_tokens: a.estimatedTokens, - turn_index: a.turnIndex, - created_at: a.createdAt, - })); - - try { - await saveSessionAnchors(records); - } catch (e) { - console.warn('Failed to persist anchors:', e); - } -} - -/** Remove a single anchor */ -export async function removeAnchor(projectId: string, anchorId: string): Promise { - const existing = projectAnchors.get(projectId) ?? []; - projectAnchors.set(projectId, existing.filter(a => a.id !== anchorId)); - - try { - await deleteSessionAnchor(anchorId); - } catch (e) { - console.warn('Failed to delete anchor:', e); - } -} - -/** Change anchor type (pinned <-> promoted) */ -export async function changeAnchorType(projectId: string, anchorId: string, newType: AnchorType): Promise { - const existing = projectAnchors.get(projectId) ?? []; - const anchor = existing.find(a => a.id === anchorId); - if (!anchor) return; - - anchor.anchorType = newType; - // Trigger reactivity - projectAnchors.set(projectId, [...existing]); - - try { - await updateAnchorTypeBridge(anchorId, newType); - } catch (e) { - console.warn('Failed to update anchor type:', e); - } -} - -/** Load anchors from SQLite for a project */ -export async function loadAnchorsForProject(projectId: string): Promise { - try { - const records = await loadSessionAnchors(projectId); - const anchors: SessionAnchor[] = records.map(r => ({ - id: r.id, - projectId: r.project_id, - messageId: r.message_id, - anchorType: r.anchor_type as AnchorType, - content: r.content, - estimatedTokens: r.estimated_tokens, - turnIndex: r.turn_index, - createdAt: r.created_at, - })); - projectAnchors.set(projectId, anchors); - // If anchors exist, mark as already auto-anchored - if (anchors.some(a => a.anchorType === 'auto')) { - autoAnchoredProjects.add(projectId); - } - } catch (e) { - console.warn('Failed to load anchors for project:', e); - } -} - -/** Get anchor settings, resolving budget from per-project scale if provided */ -export function getAnchorSettings(budgetScale?: AnchorBudgetScale) { - if (!budgetScale) return DEFAULT_ANCHOR_SETTINGS; - return { - ...DEFAULT_ANCHOR_SETTINGS, - anchorTokenBudget: ANCHOR_BUDGET_SCALE_MAP[budgetScale], - }; -} diff --git a/v2/src/lib/stores/conflicts.svelte.ts b/v2/src/lib/stores/conflicts.svelte.ts deleted file mode 100644 index e5427f7..0000000 --- a/v2/src/lib/stores/conflicts.svelte.ts +++ /dev/null @@ -1,284 +0,0 @@ -// File overlap conflict detection — Svelte 5 runes -// Tracks which files each agent session writes to per project. -// Detects when two or more sessions write to the same file (file overlap conflict). -// Also detects external filesystem writes (S-1 Phase 2) via inotify events. - -import { SessionId, ProjectId, type SessionId as SessionIdType, type ProjectId as ProjectIdType } from '../types/ids'; - -/** Sentinel session ID for external (non-agent) writes */ -export const EXTERNAL_SESSION_ID = SessionId('__external__'); - -export interface FileConflict { - /** Absolute file path */ - filePath: string; - /** Short display name (last path segment) */ - shortName: string; - /** Session IDs that have written to this file */ - sessionIds: SessionIdType[]; - /** Timestamp of most recent write */ - lastWriteTs: number; - /** True if this conflict involves an external (non-agent) writer */ - isExternal: boolean; -} - -export interface ProjectConflicts { - projectId: ProjectIdType; - /** Active file conflicts (2+ sessions writing same file) */ - conflicts: FileConflict[]; - /** Total conflicting files */ - conflictCount: number; - /** Number of files with external write conflicts */ - externalConflictCount: number; -} - -// --- State --- - -interface FileWriteEntry { - sessionIds: Set; - lastWriteTs: number; -} - -// projectId -> filePath -> FileWriteEntry -let projectFileWrites = $state>>(new Map()); - -// projectId -> set of acknowledged file paths (suppresses badge until new conflict on that file) -let acknowledgedFiles = $state>>(new Map()); - -// sessionId -> worktree path (null = main working tree) -let sessionWorktrees = $state>(new Map()); - -// projectId -> filePath -> timestamp of most recent agent write (for external write heuristic) -let agentWriteTimestamps = $state>>(new Map()); - -// Time window: if an fs event arrives within this window after an agent tool_call write, -// it's attributed to the agent (suppressed). Otherwise it's external. -const AGENT_WRITE_GRACE_MS = 2000; - -// --- Public API --- - -/** Register the worktree path for a session (null = main working tree) */ -export function setSessionWorktree(sessionId: SessionIdType, worktreePath: string | null): void { - sessionWorktrees.set(sessionId, worktreePath ?? null); -} - -/** Check if two sessions are in different worktrees (conflict suppression) */ -function areInDifferentWorktrees(sessionIdA: SessionIdType, sessionIdB: SessionIdType): boolean { - const wtA = sessionWorktrees.get(sessionIdA) ?? null; - const wtB = sessionWorktrees.get(sessionIdB) ?? null; - // Both null = same main tree, both same string = same worktree → not different - if (wtA === wtB) return false; - // One or both non-null and different → different worktrees - return true; -} - -/** Record that a session wrote to a file. Returns true if this creates a new conflict. */ -export function recordFileWrite(projectId: ProjectIdType, sessionId: SessionIdType, filePath: string): boolean { - let projectMap = projectFileWrites.get(projectId); - if (!projectMap) { - projectMap = new Map(); - projectFileWrites.set(projectId, projectMap); - } - - // Track agent write timestamp for external write heuristic - if (sessionId !== EXTERNAL_SESSION_ID) { - let tsMap = agentWriteTimestamps.get(projectId); - if (!tsMap) { - tsMap = new Map(); - agentWriteTimestamps.set(projectId, tsMap); - } - tsMap.set(filePath, Date.now()); - } - - let entry = projectMap.get(filePath); - const hadConflict = entry ? countRealConflictSessions(entry, sessionId) >= 2 : false; - - if (!entry) { - entry = { sessionIds: new Set([sessionId]), lastWriteTs: Date.now() }; - projectMap.set(filePath, entry); - return false; - } - - const isNewSession = !entry.sessionIds.has(sessionId); - entry.sessionIds.add(sessionId); - entry.lastWriteTs = Date.now(); - - // Check if this is a real conflict (not suppressed by worktrees) - const realConflictCount = countRealConflictSessions(entry, sessionId); - const isNewConflict = !hadConflict && realConflictCount >= 2; - - // Clear acknowledgement when a new session writes to a previously-acknowledged file - if (isNewSession && realConflictCount >= 2) { - const ackSet = acknowledgedFiles.get(projectId); - if (ackSet) ackSet.delete(filePath); - } - - return isNewConflict; -} - -/** - * Record an external filesystem write detected via inotify. - * Uses timing heuristic: if an agent wrote this file within AGENT_WRITE_GRACE_MS, - * the write is attributed to the agent and suppressed. - * Returns true if this creates a new external write conflict. - */ -export function recordExternalWrite(projectId: ProjectIdType, filePath: string, timestampMs: number): boolean { - // Timing heuristic: check if any agent recently wrote this file - const tsMap = agentWriteTimestamps.get(projectId); - if (tsMap) { - const lastAgentWrite = tsMap.get(filePath); - if (lastAgentWrite && (timestampMs - lastAgentWrite) < AGENT_WRITE_GRACE_MS) { - // This is likely our agent's write — suppress - return false; - } - } - - // Check if any agent session has written this file (for conflict to be meaningful) - const projectMap = projectFileWrites.get(projectId); - if (!projectMap) return false; // No agent writes at all — not a conflict - const entry = projectMap.get(filePath); - if (!entry || entry.sessionIds.size === 0) return false; // No agent wrote this file - - // Record external write as a conflict - return recordFileWrite(projectId, EXTERNAL_SESSION_ID, filePath); -} - -/** Get the count of external write conflicts for a project */ -export function getExternalConflictCount(projectId: ProjectIdType): number { - const projectMap = projectFileWrites.get(projectId); - if (!projectMap) return 0; - const ackSet = acknowledgedFiles.get(projectId); - let count = 0; - for (const [filePath, entry] of projectMap) { - if (entry.sessionIds.has(EXTERNAL_SESSION_ID) && !(ackSet?.has(filePath))) { - count++; - } - } - return count; -} - -/** - * Count sessions that are in a real conflict with the given session - * (same worktree or both in main tree). Returns total including the session itself. - */ -function countRealConflictSessions(entry: FileWriteEntry, forSessionId: SessionIdType): number { - let count = 0; - for (const sid of entry.sessionIds) { - if (sid === forSessionId || !areInDifferentWorktrees(sid, forSessionId)) { - count++; - } - } - return count; -} - -/** Get all conflicts for a project (excludes acknowledged and worktree-suppressed) */ -export function getProjectConflicts(projectId: ProjectIdType): ProjectConflicts { - const projectMap = projectFileWrites.get(projectId); - if (!projectMap) return { projectId, conflicts: [], conflictCount: 0, externalConflictCount: 0 }; - - const ackSet = acknowledgedFiles.get(projectId); - const conflicts: FileConflict[] = []; - let externalConflictCount = 0; - for (const [filePath, entry] of projectMap) { - if (hasRealConflict(entry) && !(ackSet?.has(filePath))) { - const isExternal = entry.sessionIds.has(EXTERNAL_SESSION_ID); - if (isExternal) externalConflictCount++; - conflicts.push({ - filePath, - shortName: filePath.split('/').pop() ?? filePath, - sessionIds: Array.from(entry.sessionIds), - lastWriteTs: entry.lastWriteTs, - isExternal, - }); - } - } - - // Most recent conflicts first - conflicts.sort((a, b) => b.lastWriteTs - a.lastWriteTs); - return { projectId, conflicts, conflictCount: conflicts.length, externalConflictCount }; -} - -/** Check if a project has any unacknowledged real conflicts */ -export function hasConflicts(projectId: ProjectIdType): boolean { - const projectMap = projectFileWrites.get(projectId); - if (!projectMap) return false; - const ackSet = acknowledgedFiles.get(projectId); - for (const [filePath, entry] of projectMap) { - if (hasRealConflict(entry) && !(ackSet?.has(filePath))) return true; - } - return false; -} - -/** Get total unacknowledged conflict count across all projects */ -export function getTotalConflictCount(): number { - let total = 0; - for (const [projectId, projectMap] of projectFileWrites) { - const ackSet = acknowledgedFiles.get(projectId); - for (const [filePath, entry] of projectMap) { - if (hasRealConflict(entry) && !(ackSet?.has(filePath))) total++; - } - } - return total; -} - -/** Check if a file write entry has a real conflict (2+ sessions in same worktree) */ -function hasRealConflict(entry: FileWriteEntry): boolean { - if (entry.sessionIds.size < 2) return false; - // Check all pairs for same-worktree conflict - const sids = Array.from(entry.sessionIds); - for (let i = 0; i < sids.length; i++) { - for (let j = i + 1; j < sids.length; j++) { - if (!areInDifferentWorktrees(sids[i], sids[j])) return true; - } - } - return false; -} - -/** Acknowledge all current conflicts for a project (suppresses badge until new conflict) */ -export function acknowledgeConflicts(projectId: ProjectIdType): void { - const projectMap = projectFileWrites.get(projectId); - if (!projectMap) return; - - const ackSet = acknowledgedFiles.get(projectId) ?? new Set(); - for (const [filePath, entry] of projectMap) { - if (hasRealConflict(entry)) { - ackSet.add(filePath); - } - } - acknowledgedFiles.set(projectId, ackSet); -} - -/** Remove a session from all file write tracking (call on session end) */ -export function clearSessionWrites(projectId: ProjectIdType, sessionId: SessionIdType): void { - const projectMap = projectFileWrites.get(projectId); - if (!projectMap) return; - - for (const [filePath, entry] of projectMap) { - entry.sessionIds.delete(sessionId); - if (entry.sessionIds.size === 0) { - projectMap.delete(filePath); - } - } - - if (projectMap.size === 0) { - projectFileWrites.delete(projectId); - acknowledgedFiles.delete(projectId); - } - - // Clean up worktree tracking - sessionWorktrees.delete(sessionId); -} - -/** Clear all conflict tracking for a project */ -export function clearProjectConflicts(projectId: ProjectIdType): void { - projectFileWrites.delete(projectId); - acknowledgedFiles.delete(projectId); - agentWriteTimestamps.delete(projectId); -} - -/** Clear all conflict state */ -export function clearAllConflicts(): void { - projectFileWrites = new Map(); - acknowledgedFiles = new Map(); - sessionWorktrees = new Map(); - agentWriteTimestamps = new Map(); -} diff --git a/v2/src/lib/stores/conflicts.test.ts b/v2/src/lib/stores/conflicts.test.ts deleted file mode 100644 index 86a1991..0000000 --- a/v2/src/lib/stores/conflicts.test.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; -import { SessionId, ProjectId } from '../types/ids'; -import { - recordFileWrite, - recordExternalWrite, - getProjectConflicts, - getExternalConflictCount, - hasConflicts, - getTotalConflictCount, - clearSessionWrites, - clearProjectConflicts, - clearAllConflicts, - acknowledgeConflicts, - setSessionWorktree, - EXTERNAL_SESSION_ID, -} from './conflicts.svelte'; - -// Test helpers — branded IDs -const P1 = ProjectId('proj-1'); -const P2 = ProjectId('proj-2'); -const SA = SessionId('sess-a'); -const SB = SessionId('sess-b'); -const SC = SessionId('sess-c'); -const SD = SessionId('sess-d'); - -beforeEach(() => { - clearAllConflicts(); -}); - -describe('conflicts store', () => { - describe('recordFileWrite', () => { - it('returns false for first write to a file', () => { - expect(recordFileWrite(P1, SA, '/src/main.ts')).toBe(false); - }); - - it('returns false for same session writing same file again', () => { - recordFileWrite(P1, SA, '/src/main.ts'); - expect(recordFileWrite(P1, SA, '/src/main.ts')).toBe(false); - }); - - it('returns true when a second session writes same file (new conflict)', () => { - recordFileWrite(P1, SA, '/src/main.ts'); - expect(recordFileWrite(P1, SB, '/src/main.ts')).toBe(true); - }); - - it('returns false when third session writes already-conflicted file', () => { - recordFileWrite(P1, SA, '/src/main.ts'); - recordFileWrite(P1, SB, '/src/main.ts'); - expect(recordFileWrite(P1, SC, '/src/main.ts')).toBe(false); - }); - - it('tracks writes per project independently', () => { - recordFileWrite(P1, SA, '/src/main.ts'); - expect(recordFileWrite(P2, SB, '/src/main.ts')).toBe(false); - }); - }); - - describe('getProjectConflicts', () => { - it('returns empty for unknown project', () => { - const result = getProjectConflicts(ProjectId('nonexistent')); - expect(result.conflicts).toEqual([]); - expect(result.conflictCount).toBe(0); - }); - - it('returns empty when no overlapping writes', () => { - recordFileWrite(P1, SA, '/src/a.ts'); - recordFileWrite(P1, SB, '/src/b.ts'); - const result = getProjectConflicts(P1); - expect(result.conflicts).toEqual([]); - expect(result.conflictCount).toBe(0); - }); - - it('returns conflict when two sessions write same file', () => { - recordFileWrite(P1, SA, '/src/main.ts'); - recordFileWrite(P1, SB, '/src/main.ts'); - const result = getProjectConflicts(P1); - expect(result.conflictCount).toBe(1); - expect(result.conflicts[0].filePath).toBe('/src/main.ts'); - expect(result.conflicts[0].shortName).toBe('main.ts'); - expect(result.conflicts[0].sessionIds).toContain(SA); - expect(result.conflicts[0].sessionIds).toContain(SB); - }); - - it('returns multiple conflicts sorted by recency', () => { - vi.useFakeTimers(); - vi.setSystemTime(1000); - recordFileWrite(P1, SA, '/src/old.ts'); - recordFileWrite(P1, SB, '/src/old.ts'); - vi.setSystemTime(2000); - recordFileWrite(P1, SA, '/src/new.ts'); - recordFileWrite(P1, SB, '/src/new.ts'); - const result = getProjectConflicts(P1); - expect(result.conflictCount).toBe(2); - // Most recent first - expect(result.conflicts[0].filePath).toBe('/src/new.ts'); - vi.useRealTimers(); - }); - }); - - describe('hasConflicts', () => { - it('returns false for unknown project', () => { - expect(hasConflicts(ProjectId('nonexistent'))).toBe(false); - }); - - it('returns false with no overlapping writes', () => { - recordFileWrite(P1, SA, '/src/a.ts'); - expect(hasConflicts(P1)).toBe(false); - }); - - it('returns true with overlapping writes', () => { - recordFileWrite(P1, SA, '/src/a.ts'); - recordFileWrite(P1, SB, '/src/a.ts'); - expect(hasConflicts(P1)).toBe(true); - }); - }); - - describe('getTotalConflictCount', () => { - it('returns 0 with no conflicts', () => { - expect(getTotalConflictCount()).toBe(0); - }); - - it('counts conflicts across projects', () => { - recordFileWrite(P1, SA, '/a.ts'); - recordFileWrite(P1, SB, '/a.ts'); - recordFileWrite(P2, SC, '/b.ts'); - recordFileWrite(P2, SD, '/b.ts'); - expect(getTotalConflictCount()).toBe(2); - }); - }); - - describe('clearSessionWrites', () => { - it('removes session from file write tracking', () => { - recordFileWrite(P1, SA, '/a.ts'); - recordFileWrite(P1, SB, '/a.ts'); - expect(hasConflicts(P1)).toBe(true); - clearSessionWrites(P1, SB); - expect(hasConflicts(P1)).toBe(false); - }); - - it('cleans up empty entries', () => { - recordFileWrite(P1, SA, '/a.ts'); - clearSessionWrites(P1, SA); - expect(getProjectConflicts(P1).conflictCount).toBe(0); - }); - - it('no-ops for unknown project', () => { - clearSessionWrites(ProjectId('nonexistent'), SA); // Should not throw - }); - }); - - describe('clearProjectConflicts', () => { - it('clears all tracking for a project', () => { - recordFileWrite(P1, SA, '/a.ts'); - recordFileWrite(P1, SB, '/a.ts'); - clearProjectConflicts(P1); - expect(hasConflicts(P1)).toBe(false); - expect(getTotalConflictCount()).toBe(0); - }); - }); - - describe('clearAllConflicts', () => { - it('clears everything', () => { - recordFileWrite(P1, SA, '/a.ts'); - recordFileWrite(P1, SB, '/a.ts'); - recordFileWrite(P2, SC, '/b.ts'); - recordFileWrite(P2, SD, '/b.ts'); - clearAllConflicts(); - expect(getTotalConflictCount()).toBe(0); - }); - }); - - describe('acknowledgeConflicts', () => { - it('suppresses conflict from counts after acknowledge', () => { - recordFileWrite(P1, SA, '/a.ts'); - recordFileWrite(P1, SB, '/a.ts'); - expect(hasConflicts(P1)).toBe(true); - acknowledgeConflicts(P1); - expect(hasConflicts(P1)).toBe(false); - expect(getTotalConflictCount()).toBe(0); - expect(getProjectConflicts(P1).conflictCount).toBe(0); - }); - - it('resurfaces conflict when new write arrives on acknowledged file', () => { - recordFileWrite(P1, SA, '/a.ts'); - recordFileWrite(P1, SB, '/a.ts'); - acknowledgeConflicts(P1); - expect(hasConflicts(P1)).toBe(false); - // Third session writes same file — should resurface - recordFileWrite(P1, SC, '/a.ts'); - // recordFileWrite returns false for already-conflicted file, but the ack should be cleared - expect(hasConflicts(P1)).toBe(true); - }); - - it('no-ops for unknown project', () => { - acknowledgeConflicts(ProjectId('nonexistent')); // Should not throw - }); - }); - - describe('worktree suppression', () => { - it('suppresses conflict between sessions in different worktrees', () => { - setSessionWorktree(SA, null); // main tree - setSessionWorktree(SB, '/tmp/wt-1'); // worktree - recordFileWrite(P1, SA, '/a.ts'); - recordFileWrite(P1, SB, '/a.ts'); - expect(hasConflicts(P1)).toBe(false); - expect(getTotalConflictCount()).toBe(0); - }); - - it('detects conflict between sessions in same worktree', () => { - setSessionWorktree(SA, '/tmp/wt-1'); - setSessionWorktree(SB, '/tmp/wt-1'); - recordFileWrite(P1, SA, '/a.ts'); - recordFileWrite(P1, SB, '/a.ts'); - expect(hasConflicts(P1)).toBe(true); - }); - - it('detects conflict between sessions both in main tree', () => { - setSessionWorktree(SA, null); - setSessionWorktree(SB, null); - recordFileWrite(P1, SA, '/a.ts'); - recordFileWrite(P1, SB, '/a.ts'); - expect(hasConflicts(P1)).toBe(true); - }); - - it('suppresses conflict when two worktrees differ', () => { - setSessionWorktree(SA, '/tmp/wt-1'); - setSessionWorktree(SB, '/tmp/wt-2'); - recordFileWrite(P1, SA, '/a.ts'); - recordFileWrite(P1, SB, '/a.ts'); - expect(hasConflicts(P1)).toBe(false); - }); - - it('sessions without worktree info conflict normally (backward compat)', () => { - // No setSessionWorktree calls — both default to null (main tree) - recordFileWrite(P1, SA, '/a.ts'); - recordFileWrite(P1, SB, '/a.ts'); - expect(hasConflicts(P1)).toBe(true); - }); - - it('clearSessionWrites cleans up worktree tracking', () => { - setSessionWorktree(SA, '/tmp/wt-1'); - recordFileWrite(P1, SA, '/a.ts'); - clearSessionWrites(P1, SA); - // Subsequent session in main tree should not be compared against stale wt data - recordFileWrite(P1, SB, '/a.ts'); - recordFileWrite(P1, SC, '/a.ts'); - expect(hasConflicts(P1)).toBe(true); - }); - }); - - describe('external write detection (S-1 Phase 2)', () => { - it('suppresses external write within grace period after agent write', () => { - vi.useFakeTimers(); - vi.setSystemTime(1000); - recordFileWrite(P1, SA, '/src/main.ts'); - // External write arrives 500ms later — within 2s grace period - vi.setSystemTime(1500); - const result = recordExternalWrite(P1, '/src/main.ts', 1500); - expect(result).toBe(false); - expect(getExternalConflictCount(P1)).toBe(0); - vi.useRealTimers(); - }); - - it('detects external write outside grace period', () => { - vi.useFakeTimers(); - vi.setSystemTime(1000); - recordFileWrite(P1, SA, '/src/main.ts'); - // External write arrives 3s later — outside 2s grace period - vi.setSystemTime(4000); - const result = recordExternalWrite(P1, '/src/main.ts', 4000); - expect(result).toBe(true); - expect(getExternalConflictCount(P1)).toBe(1); - vi.useRealTimers(); - }); - - it('ignores external write to file no agent has written', () => { - recordFileWrite(P1, SA, '/src/other.ts'); - const result = recordExternalWrite(P1, '/src/unrelated.ts', Date.now()); - expect(result).toBe(false); - }); - - it('ignores external write for project with no agent writes', () => { - const result = recordExternalWrite(P1, '/src/main.ts', Date.now()); - expect(result).toBe(false); - }); - - it('marks conflict as external in getProjectConflicts', () => { - vi.useFakeTimers(); - vi.setSystemTime(1000); - recordFileWrite(P1, SA, '/src/main.ts'); - vi.setSystemTime(4000); - recordExternalWrite(P1, '/src/main.ts', 4000); - const result = getProjectConflicts(P1); - expect(result.conflictCount).toBe(1); - expect(result.externalConflictCount).toBe(1); - expect(result.conflicts[0].isExternal).toBe(true); - expect(result.conflicts[0].sessionIds).toContain(EXTERNAL_SESSION_ID); - vi.useRealTimers(); - }); - - it('external conflicts can be acknowledged', () => { - vi.useFakeTimers(); - vi.setSystemTime(1000); - recordFileWrite(P1, SA, '/src/main.ts'); - vi.setSystemTime(4000); - recordExternalWrite(P1, '/src/main.ts', 4000); - expect(hasConflicts(P1)).toBe(true); - acknowledgeConflicts(P1); - expect(hasConflicts(P1)).toBe(false); - expect(getExternalConflictCount(P1)).toBe(0); - vi.useRealTimers(); - }); - - it('clearAllConflicts clears external write timestamps', () => { - vi.useFakeTimers(); - vi.setSystemTime(1000); - recordFileWrite(P1, SA, '/src/main.ts'); - clearAllConflicts(); - // After clearing, external writes should not create conflicts (no agent writes tracked) - vi.setSystemTime(4000); - const result = recordExternalWrite(P1, '/src/main.ts', 4000); - expect(result).toBe(false); - vi.useRealTimers(); - }); - - it('external conflict coexists with agent-agent conflict', () => { - vi.useFakeTimers(); - vi.setSystemTime(1000); - recordFileWrite(P1, SA, '/src/agent.ts'); - recordFileWrite(P1, SB, '/src/agent.ts'); - recordFileWrite(P1, SA, '/src/ext.ts'); - vi.setSystemTime(4000); - recordExternalWrite(P1, '/src/ext.ts', 4000); - const result = getProjectConflicts(P1); - expect(result.conflictCount).toBe(2); - expect(result.externalConflictCount).toBe(1); - const extConflict = result.conflicts.find(c => c.isExternal); - const agentConflict = result.conflicts.find(c => !c.isExternal); - expect(extConflict?.filePath).toBe('/src/ext.ts'); - expect(agentConflict?.filePath).toBe('/src/agent.ts'); - vi.useRealTimers(); - }); - }); -}); diff --git a/v2/src/lib/stores/health.svelte.ts b/v2/src/lib/stores/health.svelte.ts deleted file mode 100644 index 43ba9da..0000000 --- a/v2/src/lib/stores/health.svelte.ts +++ /dev/null @@ -1,319 +0,0 @@ -// Project health tracking — Svelte 5 runes -// Tracks per-project activity state, burn rate, context pressure, and attention scoring - -import type { SessionId as SessionIdType, ProjectId as ProjectIdType } from '../types/ids'; -import { getAgentSession, type AgentSession } from './agents.svelte'; -import { getProjectConflicts } from './conflicts.svelte'; -import { scoreAttention } from '../utils/attention-scorer'; - -// --- Types --- - -export type ActivityState = 'inactive' | 'running' | 'idle' | 'stalled'; - -export interface ProjectHealth { - projectId: ProjectIdType; - sessionId: SessionIdType | null; - /** Current activity state */ - activityState: ActivityState; - /** Name of currently running tool (if any) */ - activeTool: string | null; - /** Duration in ms since last activity (0 if running a tool) */ - idleDurationMs: number; - /** Burn rate in USD per hour (0 if no data) */ - burnRatePerHour: number; - /** Context pressure as fraction 0..1 (null if unknown) */ - contextPressure: number | null; - /** Number of file conflicts (2+ agents writing same file) */ - fileConflictCount: number; - /** Number of external write conflicts (filesystem writes by non-agent processes) */ - externalConflictCount: number; - /** Attention urgency score (higher = more urgent, 0 = no attention needed) */ - attentionScore: number; - /** Human-readable attention reason */ - attentionReason: string | null; -} - -export type AttentionItem = ProjectHealth & { projectName: string; projectIcon: string }; - -// --- Configuration --- - -const DEFAULT_STALL_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes -const TICK_INTERVAL_MS = 5_000; // Update derived state every 5s -const BURN_RATE_WINDOW_MS = 5 * 60 * 1000; // 5-minute window for burn rate calc - -// Context limits by model (tokens) -const MODEL_CONTEXT_LIMITS: Record = { - 'claude-sonnet-4-20250514': 200_000, - 'claude-opus-4-20250514': 200_000, - 'claude-haiku-4-20250506': 200_000, - 'claude-3-5-sonnet-20241022': 200_000, - 'claude-3-5-haiku-20241022': 200_000, - 'claude-sonnet-4-6': 200_000, - 'claude-opus-4-6': 200_000, -}; -const DEFAULT_CONTEXT_LIMIT = 200_000; - - -// --- State --- - -interface ProjectTracker { - projectId: ProjectIdType; - sessionId: SessionIdType | null; - lastActivityTs: number; // epoch ms - lastToolName: string | null; - toolInFlight: boolean; - /** Token snapshots for burn rate calculation: [timestamp, totalTokens] */ - tokenSnapshots: Array<[number, number]>; - /** Cost snapshots for $/hr: [timestamp, costUsd] */ - costSnapshots: Array<[number, number]>; -} - -let trackers = $state>(new Map()); -let stallThresholds = $state>(new Map()); // projectId → ms -let tickTs = $state(Date.now()); -let tickInterval: ReturnType | null = null; - -// --- Public API --- - -/** Register a project for health tracking */ -export function trackProject(projectId: ProjectIdType, sessionId: SessionIdType | null): void { - const existing = trackers.get(projectId); - if (existing) { - existing.sessionId = sessionId; - return; - } - trackers.set(projectId, { - projectId, - sessionId, - lastActivityTs: Date.now(), - lastToolName: null, - toolInFlight: false, - tokenSnapshots: [], - costSnapshots: [], - }); -} - -/** Remove a project from health tracking */ -export function untrackProject(projectId: ProjectIdType): void { - trackers.delete(projectId); -} - -/** Set per-project stall threshold in minutes (null to use default) */ -export function setStallThreshold(projectId: ProjectIdType, minutes: number | null): void { - if (minutes === null) { - stallThresholds.delete(projectId); - } else { - stallThresholds.set(projectId, minutes * 60 * 1000); - } -} - -/** Update session ID for a tracked project */ -export function updateProjectSession(projectId: ProjectIdType, sessionId: SessionIdType): void { - const t = trackers.get(projectId); - if (t) { - t.sessionId = sessionId; - } -} - -/** Record activity — call on every agent message. Auto-starts tick if stopped. */ -export function recordActivity(projectId: ProjectIdType, toolName?: string): void { - const t = trackers.get(projectId); - if (!t) return; - t.lastActivityTs = Date.now(); - if (toolName !== undefined) { - t.lastToolName = toolName; - t.toolInFlight = true; - } - // Auto-start tick when activity resumes - if (!tickInterval) startHealthTick(); -} - -/** Record tool completion */ -export function recordToolDone(projectId: ProjectIdType): void { - const t = trackers.get(projectId); - if (!t) return; - t.lastActivityTs = Date.now(); - t.toolInFlight = false; -} - -/** Record a token/cost snapshot for burn rate calculation */ -export function recordTokenSnapshot(projectId: ProjectIdType, totalTokens: number, costUsd: number): void { - const t = trackers.get(projectId); - if (!t) return; - const now = Date.now(); - t.tokenSnapshots.push([now, totalTokens]); - t.costSnapshots.push([now, costUsd]); - // Prune old snapshots beyond window - const cutoff = now - BURN_RATE_WINDOW_MS * 2; - t.tokenSnapshots = t.tokenSnapshots.filter(([ts]) => ts > cutoff); - t.costSnapshots = t.costSnapshots.filter(([ts]) => ts > cutoff); -} - -/** Check if any tracked project has an active (running/starting) session */ -function hasActiveSession(): boolean { - for (const t of trackers.values()) { - if (!t.sessionId) continue; - const session = getAgentSession(t.sessionId); - if (session && (session.status === 'running' || session.status === 'starting')) return true; - } - return false; -} - -/** Start the health tick timer (auto-stops when no active sessions) */ -export function startHealthTick(): void { - if (tickInterval) return; - tickInterval = setInterval(() => { - if (!hasActiveSession()) { - stopHealthTick(); - return; - } - tickTs = Date.now(); - }, TICK_INTERVAL_MS); -} - -/** Stop the health tick timer */ -export function stopHealthTick(): void { - if (tickInterval) { - clearInterval(tickInterval); - tickInterval = null; - } -} - -/** Clear all tracked projects */ -export function clearHealthTracking(): void { - trackers = new Map(); - stallThresholds = new Map(); -} - -// --- Derived health per project --- - -function getContextLimit(model?: string): number { - if (!model) return DEFAULT_CONTEXT_LIMIT; - return MODEL_CONTEXT_LIMITS[model] ?? DEFAULT_CONTEXT_LIMIT; -} - -function computeBurnRate(snapshots: Array<[number, number]>): number { - if (snapshots.length < 2) return 0; - const windowStart = Date.now() - BURN_RATE_WINDOW_MS; - const recent = snapshots.filter(([ts]) => ts >= windowStart); - if (recent.length < 2) return 0; - const first = recent[0]; - const last = recent[recent.length - 1]; - const elapsedHours = (last[0] - first[0]) / 3_600_000; - if (elapsedHours < 0.001) return 0; // Less than ~4 seconds - const costDelta = last[1] - first[1]; - return Math.max(0, costDelta / elapsedHours); -} - -function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth { - const session: AgentSession | undefined = tracker.sessionId - ? getAgentSession(tracker.sessionId) - : undefined; - - // Activity state - let activityState: ActivityState; - let idleDurationMs = 0; - let activeTool: string | null = null; - - if (!session || session.status === 'idle' || session.status === 'done' || session.status === 'error') { - activityState = session?.status === 'error' ? 'inactive' : 'inactive'; - } else if (tracker.toolInFlight) { - activityState = 'running'; - activeTool = tracker.lastToolName; - idleDurationMs = 0; - } else { - idleDurationMs = now - tracker.lastActivityTs; - const stallMs = stallThresholds.get(tracker.projectId) ?? DEFAULT_STALL_THRESHOLD_MS; - if (idleDurationMs >= stallMs) { - activityState = 'stalled'; - } else { - activityState = 'idle'; - } - } - - // Context pressure - let contextPressure: number | null = null; - if (session && (session.inputTokens + session.outputTokens) > 0) { - const limit = getContextLimit(session.model); - contextPressure = Math.min(1, (session.inputTokens + session.outputTokens) / limit); - } - - // Burn rate - const burnRatePerHour = computeBurnRate(tracker.costSnapshots); - - // File conflicts - const conflicts = getProjectConflicts(tracker.projectId); - const fileConflictCount = conflicts.conflictCount; - const externalConflictCount = conflicts.externalConflictCount; - - // Attention scoring — delegated to pure function - const attention = scoreAttention({ - sessionStatus: session?.status, - sessionError: session?.error, - activityState, - idleDurationMs, - contextPressure, - fileConflictCount, - externalConflictCount, - }); - - return { - projectId: tracker.projectId, - sessionId: tracker.sessionId, - activityState, - activeTool, - idleDurationMs, - burnRatePerHour, - contextPressure, - fileConflictCount, - externalConflictCount, - attentionScore: attention.score, - attentionReason: attention.reason, - }; -} - -/** Get health for a single project (reactive via tickTs) */ -export function getProjectHealth(projectId: ProjectIdType): ProjectHealth | null { - // Touch tickTs to make this reactive to the timer - const now = tickTs; - const t = trackers.get(projectId); - if (!t) return null; - return computeHealth(t, now); -} - -/** Get all project health sorted by attention score descending */ -export function getAllProjectHealth(): ProjectHealth[] { - const now = tickTs; - const results: ProjectHealth[] = []; - for (const t of trackers.values()) { - results.push(computeHealth(t, now)); - } - results.sort((a, b) => b.attentionScore - a.attentionScore); - return results; -} - -/** Get top N items needing attention */ -export function getAttentionQueue(limit = 5): ProjectHealth[] { - return getAllProjectHealth().filter(h => h.attentionScore > 0).slice(0, limit); -} - -/** Get aggregate stats across all tracked projects */ -export function getHealthAggregates(): { - running: number; - idle: number; - stalled: number; - totalBurnRatePerHour: number; -} { - const all = getAllProjectHealth(); - let running = 0; - let idle = 0; - let stalled = 0; - let totalBurnRatePerHour = 0; - for (const h of all) { - if (h.activityState === 'running') running++; - else if (h.activityState === 'idle') idle++; - else if (h.activityState === 'stalled') stalled++; - totalBurnRatePerHour += h.burnRatePerHour; - } - return { running, idle, stalled, totalBurnRatePerHour }; -} diff --git a/v2/src/lib/stores/layout.svelte.ts b/v2/src/lib/stores/layout.svelte.ts deleted file mode 100644 index acfe905..0000000 --- a/v2/src/lib/stores/layout.svelte.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { - listSessions, - saveSession, - deleteSession, - updateSessionTitle, - touchSession, - saveLayout, - loadLayout, - updateSessionGroup, - type PersistedSession, -} from '../adapters/session-bridge'; - -export type LayoutPreset = '1-col' | '2-col' | '3-col' | '2x2' | 'master-stack'; - -export type PaneType = 'terminal' | 'agent' | 'markdown' | 'ssh' | 'context' | 'empty'; - -export interface Pane { - id: string; - type: PaneType; - title: string; - shell?: string; - cwd?: string; - args?: string[]; - group?: string; - focused: boolean; - remoteMachineId?: string; -} - -let panes = $state([]); -let activePreset = $state('1-col'); -let focusedPaneId = $state(null); -let initialized = false; - -// --- Persistence helpers (fire-and-forget with error logging) --- - -function persistSession(pane: Pane): void { - const now = Math.floor(Date.now() / 1000); - const session: PersistedSession = { - id: pane.id, - type: pane.type, - title: pane.title, - shell: pane.shell, - cwd: pane.cwd, - args: pane.args, - group_name: pane.group ?? '', - created_at: now, - last_used_at: now, - }; - saveSession(session).catch(e => console.warn('Failed to persist session:', e)); -} - -function persistLayout(): void { - saveLayout({ - preset: activePreset, - pane_ids: panes.map(p => p.id), - }).catch(e => console.warn('Failed to persist layout:', e)); -} - -// --- Public API --- - -export function getPanes(): Pane[] { - return panes; -} - -export function getActivePreset(): LayoutPreset { - return activePreset; -} - -export function getFocusedPaneId(): string | null { - return focusedPaneId; -} - -export function addPane(pane: Omit): void { - panes.push({ ...pane, focused: false }); - focusPane(pane.id); - autoPreset(); - persistSession({ ...pane, focused: false }); - persistLayout(); -} - -export function removePane(id: string): void { - panes = panes.filter(p => p.id !== id); - if (focusedPaneId === id) { - focusedPaneId = panes.length > 0 ? panes[0].id : null; - } - autoPreset(); - deleteSession(id).catch(e => console.warn('Failed to delete session:', e)); - persistLayout(); -} - -export function focusPane(id: string): void { - focusedPaneId = id; - panes = panes.map(p => ({ ...p, focused: p.id === id })); - touchSession(id).catch(e => console.warn('Failed to touch session:', e)); -} - -export function focusPaneByIndex(index: number): void { - if (index >= 0 && index < panes.length) { - focusPane(panes[index].id); - } -} - -export function setPreset(preset: LayoutPreset): void { - activePreset = preset; - persistLayout(); -} - -export function renamePaneTitle(id: string, title: string): void { - const pane = panes.find(p => p.id === id); - if (pane) { - pane.title = title; - updateSessionTitle(id, title).catch(e => console.warn('Failed to update title:', e)); - } -} - -export function setPaneGroup(id: string, group: string): void { - const pane = panes.find(p => p.id === id); - if (pane) { - pane.group = group || undefined; - updateSessionGroup(id, group).catch(e => console.warn('Failed to update group:', e)); - } -} - -/** Restore panes and layout from SQLite on app startup */ -export async function restoreFromDb(): Promise { - if (initialized) return; - initialized = true; - - try { - const [sessions, layout] = await Promise.all([listSessions(), loadLayout()]); - - if (layout.preset) { - activePreset = layout.preset as LayoutPreset; - } - - // Restore panes in layout order, falling back to DB order - const sessionMap = new Map(sessions.map(s => [s.id, s])); - const orderedIds = layout.pane_ids.length > 0 ? layout.pane_ids : sessions.map(s => s.id); - - for (const id of orderedIds) { - const s = sessionMap.get(id); - if (!s) continue; - panes.push({ - id: s.id, - type: s.type as PaneType, - title: s.title, - shell: s.shell ?? undefined, - cwd: s.cwd ?? undefined, - args: s.args ?? undefined, - group: s.group_name || undefined, - focused: false, - }); - } - - if (panes.length > 0) { - focusPane(panes[0].id); - } - } catch (e) { - console.warn('Failed to restore sessions from DB:', e); - } -} - -function autoPreset(): void { - const count = panes.length; - if (count <= 1) activePreset = '1-col'; - else if (count === 2) activePreset = '2-col'; - else if (count === 3) activePreset = 'master-stack'; - else activePreset = '2x2'; -} - -/** CSS grid-template for current preset */ -export function getGridTemplate(): { columns: string; rows: string } { - switch (activePreset) { - case '1-col': - return { columns: '1fr', rows: '1fr' }; - case '2-col': - return { columns: '1fr 1fr', rows: '1fr' }; - case '3-col': - return { columns: '1fr 1fr 1fr', rows: '1fr' }; - case '2x2': - return { columns: '1fr 1fr', rows: '1fr 1fr' }; - case 'master-stack': - return { columns: '2fr 1fr', rows: '1fr 1fr' }; - } -} - -/** For master-stack: first pane spans full height */ -export function getPaneGridArea(index: number): string | undefined { - if (activePreset === 'master-stack' && index === 0) { - return '1 / 1 / 3 / 2'; - } - return undefined; -} diff --git a/v2/src/lib/stores/layout.test.ts b/v2/src/lib/stores/layout.test.ts deleted file mode 100644 index ffd4b1b..0000000 --- a/v2/src/lib/stores/layout.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Mock session-bridge before importing the layout store -vi.mock('../adapters/session-bridge', () => ({ - listSessions: vi.fn().mockResolvedValue([]), - saveSession: vi.fn().mockResolvedValue(undefined), - deleteSession: vi.fn().mockResolvedValue(undefined), - updateSessionTitle: vi.fn().mockResolvedValue(undefined), - touchSession: vi.fn().mockResolvedValue(undefined), - saveLayout: vi.fn().mockResolvedValue(undefined), - loadLayout: vi.fn().mockResolvedValue({ preset: '1-col', pane_ids: [] }), -})); - -import { - getPanes, - getActivePreset, - getFocusedPaneId, - addPane, - removePane, - focusPane, - focusPaneByIndex, - setPreset, - renamePaneTitle, - getGridTemplate, - getPaneGridArea, - type LayoutPreset, - type Pane, -} from './layout.svelte'; - -// Helper to reset module state between tests -// The layout store uses module-level $state, so we need to clean up -function clearAllPanes(): void { - const panes = getPanes(); - const ids = panes.map(p => p.id); - for (const id of ids) { - removePane(id); - } -} - -beforeEach(() => { - clearAllPanes(); - setPreset('1-col'); - vi.clearAllMocks(); -}); - -describe('layout store', () => { - describe('addPane', () => { - it('adds a pane to the list', () => { - addPane({ id: 'p1', type: 'terminal', title: 'Terminal 1' }); - - const panes = getPanes(); - expect(panes).toHaveLength(1); - expect(panes[0].id).toBe('p1'); - expect(panes[0].type).toBe('terminal'); - expect(panes[0].title).toBe('Terminal 1'); - }); - - it('sets focused to false initially then focuses via focusPane', () => { - addPane({ id: 'p1', type: 'terminal', title: 'T1' }); - - // addPane calls focusPane internally, so the pane should be focused - expect(getFocusedPaneId()).toBe('p1'); - const panes = getPanes(); - expect(panes[0].focused).toBe(true); - }); - - it('focuses the newly added pane', () => { - addPane({ id: 'p1', type: 'terminal', title: 'T1' }); - addPane({ id: 'p2', type: 'agent', title: 'Agent 1' }); - - expect(getFocusedPaneId()).toBe('p2'); - }); - - it('calls autoPreset when adding panes', () => { - // 1 pane -> 1-col - addPane({ id: 'p1', type: 'terminal', title: 'T1' }); - expect(getActivePreset()).toBe('1-col'); - - // 2 panes -> 2-col - addPane({ id: 'p2', type: 'terminal', title: 'T2' }); - expect(getActivePreset()).toBe('2-col'); - - // 3 panes -> master-stack - addPane({ id: 'p3', type: 'terminal', title: 'T3' }); - expect(getActivePreset()).toBe('master-stack'); - - // 4+ panes -> 2x2 - addPane({ id: 'p4', type: 'terminal', title: 'T4' }); - expect(getActivePreset()).toBe('2x2'); - }); - }); - - describe('removePane', () => { - it('removes a pane by id', () => { - addPane({ id: 'p1', type: 'terminal', title: 'T1' }); - addPane({ id: 'p2', type: 'terminal', title: 'T2' }); - - removePane('p1'); - - const panes = getPanes(); - expect(panes).toHaveLength(1); - expect(panes[0].id).toBe('p2'); - }); - - it('focuses the first remaining pane when focused pane is removed', () => { - addPane({ id: 'p1', type: 'terminal', title: 'T1' }); - addPane({ id: 'p2', type: 'terminal', title: 'T2' }); - addPane({ id: 'p3', type: 'terminal', title: 'T3' }); - - // p3 is focused (last added) - expect(getFocusedPaneId()).toBe('p3'); - - removePane('p3'); - - // Should focus p1 (first remaining) - expect(getFocusedPaneId()).toBe('p1'); - }); - - it('sets focusedPaneId to null when last pane is removed', () => { - addPane({ id: 'p1', type: 'terminal', title: 'T1' }); - removePane('p1'); - - expect(getFocusedPaneId()).toBeNull(); - }); - - it('adjusts preset via autoPreset after removal', () => { - addPane({ id: 'p1', type: 'terminal', title: 'T1' }); - addPane({ id: 'p2', type: 'terminal', title: 'T2' }); - expect(getActivePreset()).toBe('2-col'); - - removePane('p2'); - expect(getActivePreset()).toBe('1-col'); - }); - - it('does not change focus if removed pane was not focused', () => { - addPane({ id: 'p1', type: 'terminal', title: 'T1' }); - addPane({ id: 'p2', type: 'terminal', title: 'T2' }); - - // p2 is focused (last added). Remove p1 - focusPane('p2'); - removePane('p1'); - - expect(getFocusedPaneId()).toBe('p2'); - }); - }); - - describe('focusPane', () => { - it('sets focused flag on the target pane', () => { - addPane({ id: 'p1', type: 'terminal', title: 'T1' }); - addPane({ id: 'p2', type: 'terminal', title: 'T2' }); - - focusPane('p1'); - - const panes = getPanes(); - expect(panes.find(p => p.id === 'p1')?.focused).toBe(true); - expect(panes.find(p => p.id === 'p2')?.focused).toBe(false); - expect(getFocusedPaneId()).toBe('p1'); - }); - }); - - describe('focusPaneByIndex', () => { - it('focuses pane at the given index', () => { - addPane({ id: 'p1', type: 'terminal', title: 'T1' }); - addPane({ id: 'p2', type: 'terminal', title: 'T2' }); - - focusPaneByIndex(0); - - expect(getFocusedPaneId()).toBe('p1'); - }); - - it('ignores out-of-bounds indices', () => { - addPane({ id: 'p1', type: 'terminal', title: 'T1' }); - - focusPaneByIndex(5); - - // Should remain on p1 - expect(getFocusedPaneId()).toBe('p1'); - }); - - it('ignores negative indices', () => { - addPane({ id: 'p1', type: 'terminal', title: 'T1' }); - - focusPaneByIndex(-1); - - expect(getFocusedPaneId()).toBe('p1'); - }); - }); - - describe('setPreset', () => { - it('overrides the active preset', () => { - setPreset('3-col'); - expect(getActivePreset()).toBe('3-col'); - }); - - it('allows setting any valid preset', () => { - const presets: LayoutPreset[] = ['1-col', '2-col', '3-col', '2x2', 'master-stack']; - for (const preset of presets) { - setPreset(preset); - expect(getActivePreset()).toBe(preset); - } - }); - }); - - describe('renamePaneTitle', () => { - it('updates the title of a pane', () => { - addPane({ id: 'p1', type: 'terminal', title: 'Old Title' }); - - renamePaneTitle('p1', 'New Title'); - - const panes = getPanes(); - expect(panes[0].title).toBe('New Title'); - }); - - it('does nothing for non-existent pane', () => { - addPane({ id: 'p1', type: 'terminal', title: 'Title' }); - - renamePaneTitle('p-nonexistent', 'New Title'); - - expect(getPanes()[0].title).toBe('Title'); - }); - }); - - describe('getGridTemplate', () => { - it('returns 1fr / 1fr for 1-col', () => { - setPreset('1-col'); - expect(getGridTemplate()).toEqual({ columns: '1fr', rows: '1fr' }); - }); - - it('returns 1fr 1fr / 1fr for 2-col', () => { - setPreset('2-col'); - expect(getGridTemplate()).toEqual({ columns: '1fr 1fr', rows: '1fr' }); - }); - - it('returns 1fr 1fr 1fr / 1fr for 3-col', () => { - setPreset('3-col'); - expect(getGridTemplate()).toEqual({ columns: '1fr 1fr 1fr', rows: '1fr' }); - }); - - it('returns 1fr 1fr / 1fr 1fr for 2x2', () => { - setPreset('2x2'); - expect(getGridTemplate()).toEqual({ columns: '1fr 1fr', rows: '1fr 1fr' }); - }); - - it('returns 2fr 1fr / 1fr 1fr for master-stack', () => { - setPreset('master-stack'); - expect(getGridTemplate()).toEqual({ columns: '2fr 1fr', rows: '1fr 1fr' }); - }); - }); - - describe('getPaneGridArea', () => { - it('returns grid area for first pane in master-stack', () => { - setPreset('master-stack'); - expect(getPaneGridArea(0)).toBe('1 / 1 / 3 / 2'); - }); - - it('returns undefined for non-first panes in master-stack', () => { - setPreset('master-stack'); - expect(getPaneGridArea(1)).toBeUndefined(); - expect(getPaneGridArea(2)).toBeUndefined(); - }); - - it('returns undefined for all panes in non-master-stack presets', () => { - setPreset('2-col'); - expect(getPaneGridArea(0)).toBeUndefined(); - expect(getPaneGridArea(1)).toBeUndefined(); - }); - }); - - describe('autoPreset behavior', () => { - it('0 panes -> 1-col', () => { - expect(getActivePreset()).toBe('1-col'); - }); - - it('1 pane -> 1-col', () => { - addPane({ id: 'p1', type: 'terminal', title: 'T1' }); - expect(getActivePreset()).toBe('1-col'); - }); - - it('2 panes -> 2-col', () => { - addPane({ id: 'p1', type: 'terminal', title: 'T1' }); - addPane({ id: 'p2', type: 'terminal', title: 'T2' }); - expect(getActivePreset()).toBe('2-col'); - }); - - it('3 panes -> master-stack', () => { - addPane({ id: 'p1', type: 'terminal', title: 'T1' }); - addPane({ id: 'p2', type: 'terminal', title: 'T2' }); - addPane({ id: 'p3', type: 'terminal', title: 'T3' }); - expect(getActivePreset()).toBe('master-stack'); - }); - - it('4+ panes -> 2x2', () => { - for (let i = 1; i <= 5; i++) { - addPane({ id: `p${i}`, type: 'terminal', title: `T${i}` }); - } - expect(getActivePreset()).toBe('2x2'); - }); - }); -}); diff --git a/v2/src/lib/stores/machines.svelte.ts b/v2/src/lib/stores/machines.svelte.ts deleted file mode 100644 index c035ce9..0000000 --- a/v2/src/lib/stores/machines.svelte.ts +++ /dev/null @@ -1,131 +0,0 @@ -// Remote machines store — tracks connection state for multi-machine support - -import { - listRemoteMachines, - addRemoteMachine, - removeRemoteMachine, - connectRemoteMachine, - disconnectRemoteMachine, - onRemoteMachineReady, - onRemoteMachineDisconnected, - onRemoteError, - onRemoteMachineReconnecting, - onRemoteMachineReconnectReady, - type RemoteMachineConfig, - type RemoteMachineInfo, -} from '../adapters/remote-bridge'; -import { notify } from './notifications.svelte'; - -export interface Machine extends RemoteMachineInfo {} - -let machines = $state([]); - -export function getMachines(): Machine[] { - return machines; -} - -export function getMachine(id: string): Machine | undefined { - return machines.find(m => m.id === id); -} - -export async function loadMachines(): Promise { - try { - machines = await listRemoteMachines(); - } catch (e) { - console.warn('Failed to load remote machines:', e); - } -} - -export async function addMachine(config: RemoteMachineConfig): Promise { - const id = await addRemoteMachine(config); - machines.push({ - id, - label: config.label, - url: config.url, - status: 'disconnected', - auto_connect: config.auto_connect, - }); - return id; -} - -export async function removeMachine(id: string): Promise { - await removeRemoteMachine(id); - machines = machines.filter(m => m.id !== id); -} - -export async function connectMachine(id: string): Promise { - const machine = machines.find(m => m.id === id); - if (machine) machine.status = 'connecting'; - try { - await connectRemoteMachine(id); - if (machine) machine.status = 'connected'; - } catch (e) { - if (machine) machine.status = 'error'; - throw e; - } -} - -export async function disconnectMachine(id: string): Promise { - await disconnectRemoteMachine(id); - const machine = machines.find(m => m.id === id); - if (machine) machine.status = 'disconnected'; -} - -// Stored unlisten functions for cleanup -let unlistenFns: (() => void)[] = []; - -// Initialize event listeners for machine status updates -export async function initMachineListeners(): Promise { - // Clean up any existing listeners first - destroyMachineListeners(); - - unlistenFns.push(await onRemoteMachineReady((msg) => { - const machine = machines.find(m => m.id === msg.machineId); - if (machine) { - machine.status = 'connected'; - notify('success', `Connected to ${machine.label}`); - } - })); - - unlistenFns.push(await onRemoteMachineDisconnected((msg) => { - const machine = machines.find(m => m.id === msg.machineId); - if (machine) { - machine.status = 'disconnected'; - notify('warning', `Disconnected from ${machine.label}`); - } - })); - - unlistenFns.push(await onRemoteError((msg) => { - const machine = machines.find(m => m.id === msg.machineId); - if (machine) { - machine.status = 'error'; - notify('error', `Error from ${machine.label}: ${msg.error}`); - } - })); - - unlistenFns.push(await onRemoteMachineReconnecting((msg) => { - const machine = machines.find(m => m.id === msg.machineId); - if (machine) { - machine.status = 'reconnecting'; - notify('info', `Reconnecting to ${machine.label} in ${msg.backoffSecs}s…`); - } - })); - - unlistenFns.push(await onRemoteMachineReconnectReady((msg) => { - const machine = machines.find(m => m.id === msg.machineId); - if (machine) { - notify('info', `${machine.label} reachable — reconnecting…`); - connectMachine(msg.machineId).catch((e) => { - notify('error', `Auto-reconnect failed for ${machine.label}: ${e}`); - }); - } - })); -} - -/** Remove all event listeners to prevent leaks */ -export function destroyMachineListeners(): void { - for (const unlisten of unlistenFns) { - unlisten(); - } - unlistenFns = []; -} diff --git a/v2/src/lib/stores/notifications.svelte.ts b/v2/src/lib/stores/notifications.svelte.ts deleted file mode 100644 index e8c9364..0000000 --- a/v2/src/lib/stores/notifications.svelte.ts +++ /dev/null @@ -1,38 +0,0 @@ -// Notification store — ephemeral toast messages - -export type NotificationType = 'info' | 'success' | 'warning' | 'error'; - -export interface Notification { - id: string; - type: NotificationType; - message: string; - timestamp: number; -} - -let notifications = $state([]); - -const MAX_TOASTS = 5; -const TOAST_DURATION_MS = 4000; - -export function getNotifications(): Notification[] { - return notifications; -} - -export function notify(type: NotificationType, message: string): string { - const id = crypto.randomUUID(); - notifications.push({ id, type, message, timestamp: Date.now() }); - - // Cap visible toasts - if (notifications.length > MAX_TOASTS) { - notifications = notifications.slice(-MAX_TOASTS); - } - - // Auto-dismiss - setTimeout(() => dismissNotification(id), TOAST_DURATION_MS); - - return id; -} - -export function dismissNotification(id: string): void { - notifications = notifications.filter(n => n.id !== id); -} diff --git a/v2/src/lib/stores/sessions.svelte.ts b/v2/src/lib/stores/sessions.svelte.ts deleted file mode 100644 index ab1ecef..0000000 --- a/v2/src/lib/stores/sessions.svelte.ts +++ /dev/null @@ -1,26 +0,0 @@ -// Session state management — Svelte 5 runes -// Phase 4: full session CRUD, persistence - -export type SessionType = 'terminal' | 'agent' | 'markdown'; - -export interface Session { - id: string; - type: SessionType; - title: string; - createdAt: number; -} - -// Reactive session list -let sessions = $state([]); - -export function getSessions() { - return sessions; -} - -export function addSession(session: Session) { - sessions.push(session); -} - -export function removeSession(id: string) { - sessions = sessions.filter(s => s.id !== id); -} diff --git a/v2/src/lib/stores/theme.svelte.ts b/v2/src/lib/stores/theme.svelte.ts deleted file mode 100644 index 8c32966..0000000 --- a/v2/src/lib/stores/theme.svelte.ts +++ /dev/null @@ -1,98 +0,0 @@ -// Theme store — persists theme selection via settings bridge - -import { getSetting, setSetting } from '../adapters/settings-bridge'; -import { - type ThemeId, - type CatppuccinFlavor, - ALL_THEME_IDS, - buildXtermTheme, - applyCssVariables, - type XtermTheme, -} from '../styles/themes'; - -let currentTheme = $state('mocha'); - -/** Registered theme-change listeners */ -const themeChangeCallbacks = new Set<() => void>(); - -/** Register a callback invoked after every theme change. Returns an unsubscribe function. */ -export function onThemeChange(callback: () => void): () => void { - themeChangeCallbacks.add(callback); - return () => { - themeChangeCallbacks.delete(callback); - }; -} - -export function getCurrentTheme(): ThemeId { - return currentTheme; -} - -/** @deprecated Use getCurrentTheme() */ -export function getCurrentFlavor(): CatppuccinFlavor { - // Return valid CatppuccinFlavor or default to 'mocha' - const catFlavors: string[] = ['latte', 'frappe', 'macchiato', 'mocha']; - return catFlavors.includes(currentTheme) ? currentTheme as CatppuccinFlavor : 'mocha'; -} - -export function getXtermTheme(): XtermTheme { - return buildXtermTheme(currentTheme); -} - -/** Change theme, apply CSS variables, and persist to settings DB */ -export async function setTheme(theme: ThemeId): Promise { - currentTheme = theme; - applyCssVariables(theme); - // Notify all listeners (e.g. open xterm.js terminals) - for (const cb of themeChangeCallbacks) { - try { - cb(); - } catch (e) { - console.error('Theme change callback error:', e); - } - } - - try { - await setSetting('theme', theme); - } catch (e) { - console.error('Failed to persist theme setting:', e); - } -} - -/** @deprecated Use setTheme() */ -export async function setFlavor(flavor: CatppuccinFlavor): Promise { - return setTheme(flavor); -} - -/** Load saved theme from settings DB and apply. Call once on app startup. */ -export async function initTheme(): Promise { - try { - const saved = await getSetting('theme'); - if (saved && ALL_THEME_IDS.includes(saved as ThemeId)) { - currentTheme = saved as ThemeId; - } - } catch { - // Fall back to default (mocha) — catppuccin.css provides Mocha defaults - } - // Always apply to sync CSS vars with current theme - // (skip if mocha — catppuccin.css already has Mocha values) - if (currentTheme !== 'mocha') { - applyCssVariables(currentTheme); - } - - // Apply saved font settings - try { - const [uiFont, uiSize, termFont, termSize] = await Promise.all([ - getSetting('ui_font_family'), - getSetting('ui_font_size'), - getSetting('term_font_family'), - getSetting('term_font_size'), - ]); - const root = document.documentElement.style; - if (uiFont) root.setProperty('--ui-font-family', `'${uiFont}', sans-serif`); - if (uiSize) root.setProperty('--ui-font-size', `${uiSize}px`); - if (termFont) root.setProperty('--term-font-family', `'${termFont}', monospace`); - if (termSize) root.setProperty('--term-font-size', `${termSize}px`); - } catch { - // Font settings are optional — defaults from catppuccin.css apply - } -} diff --git a/v2/src/lib/stores/workspace.svelte.ts b/v2/src/lib/stores/workspace.svelte.ts deleted file mode 100644 index 6cb10a6..0000000 --- a/v2/src/lib/stores/workspace.svelte.ts +++ /dev/null @@ -1,260 +0,0 @@ -import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge'; -import type { GroupsFile, GroupConfig, ProjectConfig, GroupAgentConfig } from '../types/groups'; -import { agentToProject } from '../types/groups'; -import { clearAllAgentSessions } from '../stores/agents.svelte'; -import { clearHealthTracking } from '../stores/health.svelte'; -import { clearAllConflicts } from '../stores/conflicts.svelte'; -import { waitForPendingPersistence } from '../agent-dispatcher'; - -export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings' | 'comms'; - -export interface TerminalTab { - id: string; - title: string; - type: 'shell' | 'ssh' | 'agent-terminal' | 'agent-preview'; - /** SSH session ID if type === 'ssh' */ - sshSessionId?: string; - /** Agent session ID if type === 'agent-preview' */ - agentSessionId?: string; -} - -// --- Core state --- - -let groupsConfig = $state(null); -let activeGroupId = $state(''); -let activeTab = $state('sessions'); -let activeProjectId = $state(null); - -/** Terminal tabs per project (keyed by project ID) */ -let projectTerminals = $state>({}); - -// --- Getters --- - -export function getGroupsConfig(): GroupsFile | null { - return groupsConfig; -} - -export function getActiveGroupId(): string { - return activeGroupId; -} - -export function getActiveTab(): WorkspaceTab { - return activeTab; -} - -export function getActiveProjectId(): string | null { - return activeProjectId; -} - -export function getActiveGroup(): GroupConfig | undefined { - return groupsConfig?.groups.find(g => g.id === activeGroupId); -} - -export function getEnabledProjects(): ProjectConfig[] { - const group = getActiveGroup(); - if (!group) return []; - return group.projects.filter(p => p.enabled); -} - -/** Get all work items: enabled projects + agents as virtual project entries */ -export function getAllWorkItems(): ProjectConfig[] { - const group = getActiveGroup(); - if (!group) return []; - const projects = group.projects.filter(p => p.enabled); - const agentProjects = (group.agents ?? []) - .filter(a => a.enabled) - .map(a => { - // Use first project's parent dir as default CWD for agents - const groupCwd = projects[0]?.cwd?.replace(/\/[^/]+\/?$/, '/') ?? '/tmp'; - return agentToProject(a, groupCwd); - }); - return [...agentProjects, ...projects]; -} - -export function getAllGroups(): GroupConfig[] { - return groupsConfig?.groups ?? []; -} - -// --- Setters --- - -export function setActiveTab(tab: WorkspaceTab): void { - activeTab = tab; -} - -export function setActiveProject(projectId: string | null): void { - activeProjectId = projectId; -} - -export async function switchGroup(groupId: string): Promise { - if (groupId === activeGroupId) return; - - // Wait for any in-flight persistence before clearing state - await waitForPendingPersistence(); - - // Teardown: clear terminal tabs, agent sessions, and health tracking for the old group - projectTerminals = {}; - clearAllAgentSessions(); - clearHealthTracking(); - clearAllConflicts(); - - activeGroupId = groupId; - activeProjectId = null; - - // Auto-focus first enabled project - const projects = getEnabledProjects(); - if (projects.length > 0) { - activeProjectId = projects[0].id; - } - - // Persist active group - if (groupsConfig) { - groupsConfig.activeGroupId = groupId; - saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); - } -} - -// --- Terminal tab management per project --- - -export function getTerminalTabs(projectId: string): TerminalTab[] { - return projectTerminals[projectId] ?? []; -} - -export function addTerminalTab(projectId: string, tab: TerminalTab): void { - const tabs = projectTerminals[projectId] ?? []; - projectTerminals[projectId] = [...tabs, tab]; -} - -export function removeTerminalTab(projectId: string, tabId: string): void { - const tabs = projectTerminals[projectId] ?? []; - projectTerminals[projectId] = tabs.filter(t => t.id !== tabId); -} - -// --- Persistence --- - -export async function loadWorkspace(initialGroupId?: string): Promise { - try { - const config = await loadGroups(); - groupsConfig = config; - projectTerminals = {}; - - // CLI --group flag takes priority, then explicit param, then persisted - let cliGroup: string | null = null; - if (!initialGroupId) { - cliGroup = await getCliGroup(); - } - const targetId = initialGroupId || cliGroup || config.activeGroupId; - // Match by ID or by name (CLI users may pass name) - const targetGroup = config.groups.find( - g => g.id === targetId || g.name === targetId, - ); - - if (targetGroup) { - activeGroupId = targetGroup.id; - } else if (config.groups.length > 0) { - activeGroupId = config.groups[0].id; - } - - // Auto-focus first enabled project - const projects = getEnabledProjects(); - if (projects.length > 0) { - activeProjectId = projects[0].id; - } - } catch (e) { - console.warn('Failed to load groups config:', e); - groupsConfig = { version: 1, groups: [], activeGroupId: '' }; - } -} - -export async function saveWorkspace(): Promise { - if (!groupsConfig) return; - await saveGroups(groupsConfig); -} - -// --- Group/project mutation --- - -export function addGroup(group: GroupConfig): void { - if (!groupsConfig) return; - groupsConfig = { - ...groupsConfig, - groups: [...groupsConfig.groups, group], - }; - saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); -} - -export function removeGroup(groupId: string): void { - if (!groupsConfig) return; - groupsConfig = { - ...groupsConfig, - groups: groupsConfig.groups.filter(g => g.id !== groupId), - }; - if (activeGroupId === groupId) { - activeGroupId = groupsConfig.groups[0]?.id ?? ''; - activeProjectId = null; - } - saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); -} - -export function updateProject(groupId: string, projectId: string, updates: Partial): void { - if (!groupsConfig) return; - groupsConfig = { - ...groupsConfig, - groups: groupsConfig.groups.map(g => { - if (g.id !== groupId) return g; - return { - ...g, - projects: g.projects.map(p => { - if (p.id !== projectId) return p; - return { ...p, ...updates }; - }), - }; - }), - }; - saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); -} - -export function addProject(groupId: string, project: ProjectConfig): void { - if (!groupsConfig) return; - const group = groupsConfig.groups.find(g => g.id === groupId); - if (!group || group.projects.length >= 5) return; - groupsConfig = { - ...groupsConfig, - groups: groupsConfig.groups.map(g => { - if (g.id !== groupId) return g; - return { ...g, projects: [...g.projects, project] }; - }), - }; - saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); -} - -export function removeProject(groupId: string, projectId: string): void { - if (!groupsConfig) return; - groupsConfig = { - ...groupsConfig, - groups: groupsConfig.groups.map(g => { - if (g.id !== groupId) return g; - return { ...g, projects: g.projects.filter(p => p.id !== projectId) }; - }), - }; - if (activeProjectId === projectId) { - activeProjectId = null; - } - saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); -} - -export function updateAgent(groupId: string, agentId: string, updates: Partial): void { - if (!groupsConfig) return; - groupsConfig = { - ...groupsConfig, - groups: groupsConfig.groups.map(g => { - if (g.id !== groupId) return g; - return { - ...g, - agents: (g.agents ?? []).map(a => { - if (a.id !== agentId) return a; - return { ...a, ...updates }; - }), - }; - }), - }; - saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); -} diff --git a/v2/src/lib/stores/workspace.test.ts b/v2/src/lib/stores/workspace.test.ts deleted file mode 100644 index 4365d32..0000000 --- a/v2/src/lib/stores/workspace.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; - -// Mock groups-bridge before importing the workspace store -function mockGroupsData() { - return { - version: 1, - groups: [ - { - id: 'g1', - name: 'Group One', - projects: [ - { id: 'p1', name: 'Project 1', identifier: 'project-1', description: '', icon: '', cwd: '/tmp/p1', profile: 'default', enabled: true }, - { id: 'p2', name: 'Project 2', identifier: 'project-2', description: '', icon: '', cwd: '/tmp/p2', profile: 'default', enabled: true }, - { id: 'p3', name: 'Disabled', identifier: 'disabled', description: '', icon: '', cwd: '/tmp/p3', profile: 'default', enabled: false }, - ], - }, - { - id: 'g2', - name: 'Group Two', - projects: [ - { id: 'p4', name: 'Project 4', identifier: 'project-4', description: '', icon: '', cwd: '/tmp/p4', profile: 'default', enabled: true }, - ], - }, - ], - activeGroupId: 'g1', - }; -} - -vi.mock('../stores/agents.svelte', () => ({ - clearAllAgentSessions: vi.fn(), -})); - -vi.mock('../stores/conflicts.svelte', () => ({ - clearAllConflicts: vi.fn(), -})); - -vi.mock('../agent-dispatcher', () => ({ - waitForPendingPersistence: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock('../adapters/groups-bridge', () => ({ - loadGroups: vi.fn().mockImplementation(() => Promise.resolve(mockGroupsData())), - saveGroups: vi.fn().mockResolvedValue(undefined), - getCliGroup: vi.fn().mockResolvedValue(null), -})); - -import { - getGroupsConfig, - getActiveGroupId, - getActiveTab, - getActiveProjectId, - getActiveGroup, - getEnabledProjects, - getAllGroups, - setActiveTab, - setActiveProject, - switchGroup, - getTerminalTabs, - addTerminalTab, - removeTerminalTab, - loadWorkspace, - addGroup, - removeGroup, - updateProject, - addProject, - removeProject, -} from './workspace.svelte'; - -import { saveGroups, getCliGroup } from '../adapters/groups-bridge'; - -beforeEach(async () => { - vi.clearAllMocks(); - // Reset state by reloading - await loadWorkspace(); -}); - -describe('workspace store', () => { - describe('loadWorkspace', () => { - it('loads groups config and sets active group', async () => { - expect(getGroupsConfig()).not.toBeNull(); - expect(getActiveGroupId()).toBe('g1'); - }); - - it('auto-focuses first enabled project', async () => { - expect(getActiveProjectId()).toBe('p1'); - }); - - it('accepts initialGroupId override', async () => { - await loadWorkspace('g2'); - expect(getActiveGroupId()).toBe('g2'); - expect(getActiveProjectId()).toBe('p4'); - }); - - it('falls back to first group if target not found', async () => { - await loadWorkspace('nonexistent'); - expect(getActiveGroupId()).toBe('g1'); - }); - - it('uses CLI --group flag when no initialGroupId given', async () => { - vi.mocked(getCliGroup).mockResolvedValueOnce('Group Two'); - await loadWorkspace(); - expect(getActiveGroupId()).toBe('g2'); - }); - }); - - describe('getters', () => { - it('getActiveGroup returns the active group config', () => { - const group = getActiveGroup(); - expect(group).toBeDefined(); - expect(group!.id).toBe('g1'); - expect(group!.name).toBe('Group One'); - }); - - it('getEnabledProjects filters disabled projects', () => { - const projects = getEnabledProjects(); - expect(projects).toHaveLength(2); - expect(projects.map(p => p.id)).toEqual(['p1', 'p2']); - }); - - it('getAllGroups returns all groups', () => { - const groups = getAllGroups(); - expect(groups).toHaveLength(2); - }); - }); - - describe('setters', () => { - it('setActiveTab changes the active tab', () => { - setActiveTab('docs'); - expect(getActiveTab()).toBe('docs'); - setActiveTab('sessions'); - expect(getActiveTab()).toBe('sessions'); - }); - - it('setActiveProject changes the active project', () => { - setActiveProject('p2'); - expect(getActiveProjectId()).toBe('p2'); - }); - }); - - describe('switchGroup', () => { - it('switches to a different group and auto-focuses first project', async () => { - await switchGroup('g2'); - expect(getActiveGroupId()).toBe('g2'); - expect(getActiveProjectId()).toBe('p4'); - }); - - it('clears terminal tabs on group switch', async () => { - addTerminalTab('p1', { id: 't1', title: 'Shell', type: 'shell' }); - expect(getTerminalTabs('p1')).toHaveLength(1); - - await switchGroup('g2'); - expect(getTerminalTabs('p1')).toHaveLength(0); - }); - - it('no-ops when switching to current group', async () => { - const projectBefore = getActiveProjectId(); - vi.mocked(saveGroups).mockClear(); - await switchGroup('g1'); - // State should remain unchanged - expect(getActiveGroupId()).toBe('g1'); - expect(getActiveProjectId()).toBe(projectBefore); - expect(saveGroups).not.toHaveBeenCalled(); - }); - - it('persists active group', async () => { - await switchGroup('g2'); - expect(saveGroups).toHaveBeenCalled(); - }); - }); - - describe('terminal tabs', () => { - it('adds and retrieves terminal tabs per project', () => { - addTerminalTab('p1', { id: 't1', title: 'Shell 1', type: 'shell' }); - addTerminalTab('p1', { id: 't2', title: 'Agent', type: 'agent-terminal' }); - addTerminalTab('p2', { id: 't3', title: 'SSH', type: 'ssh', sshSessionId: 'ssh1' }); - - expect(getTerminalTabs('p1')).toHaveLength(2); - expect(getTerminalTabs('p2')).toHaveLength(1); - expect(getTerminalTabs('p2')[0].sshSessionId).toBe('ssh1'); - }); - - it('removes terminal tabs by id', () => { - addTerminalTab('p1', { id: 't1', title: 'Shell', type: 'shell' }); - addTerminalTab('p1', { id: 't2', title: 'Agent', type: 'agent-terminal' }); - - removeTerminalTab('p1', 't1'); - expect(getTerminalTabs('p1')).toHaveLength(1); - expect(getTerminalTabs('p1')[0].id).toBe('t2'); - }); - - it('returns empty array for unknown project', () => { - expect(getTerminalTabs('unknown')).toEqual([]); - }); - }); - - describe('group mutation', () => { - it('addGroup adds a new group', () => { - addGroup({ id: 'g3', name: 'New Group', projects: [] }); - expect(getAllGroups()).toHaveLength(3); - expect(saveGroups).toHaveBeenCalled(); - }); - - it('removeGroup removes the group and resets active if needed', () => { - removeGroup('g1'); - expect(getAllGroups()).toHaveLength(1); - expect(getActiveGroupId()).toBe('g2'); - }); - - it('removeGroup with non-active group keeps active unchanged', () => { - removeGroup('g2'); - expect(getAllGroups()).toHaveLength(1); - expect(getActiveGroupId()).toBe('g1'); - }); - }); - - describe('project mutation', () => { - it('updateProject updates project fields', () => { - updateProject('g1', 'p1', { name: 'Renamed' }); - const group = getActiveGroup()!; - expect(group.projects.find(p => p.id === 'p1')!.name).toBe('Renamed'); - expect(saveGroups).toHaveBeenCalled(); - }); - - it('addProject adds a project to a group', () => { - addProject('g1', { - id: 'p5', name: 'New', identifier: 'new', description: '', - icon: '', cwd: '/tmp', profile: 'default', enabled: true, - }); - const group = getActiveGroup()!; - expect(group.projects).toHaveLength(4); - }); - - it('addProject respects 5-project limit', () => { - // g1 already has 3 projects, add 2 more to reach 5 - addProject('g1', { id: 'x1', name: 'X1', identifier: 'x1', description: '', icon: '', cwd: '/tmp', profile: 'default', enabled: true }); - addProject('g1', { id: 'x2', name: 'X2', identifier: 'x2', description: '', icon: '', cwd: '/tmp', profile: 'default', enabled: true }); - // This 6th should be rejected - addProject('g1', { id: 'x3', name: 'X3', identifier: 'x3', description: '', icon: '', cwd: '/tmp', profile: 'default', enabled: true }); - const group = getActiveGroup()!; - expect(group.projects).toHaveLength(5); - }); - - it('removeProject removes and clears activeProjectId if needed', () => { - setActiveProject('p1'); - removeProject('g1', 'p1'); - expect(getActiveProjectId()).toBeNull(); - const group = getActiveGroup()!; - expect(group.projects.find(p => p.id === 'p1')).toBeUndefined(); - }); - }); -}); diff --git a/v2/src/lib/styles/catppuccin.css b/v2/src/lib/styles/catppuccin.css deleted file mode 100644 index 73a3ccd..0000000 --- a/v2/src/lib/styles/catppuccin.css +++ /dev/null @@ -1,61 +0,0 @@ -/* Catppuccin Mocha — https://catppuccin.com/palette */ -:root { - --ctp-rosewater: #f5e0dc; - --ctp-flamingo: #f2cdcd; - --ctp-pink: #f5c2e7; - --ctp-mauve: #cba6f7; - --ctp-red: #f38ba8; - --ctp-maroon: #eba0ac; - --ctp-peach: #fab387; - --ctp-yellow: #f9e2af; - --ctp-green: #a6e3a1; - --ctp-teal: #94e2d5; - --ctp-sky: #89dceb; - --ctp-sapphire: #74c7ec; - --ctp-blue: #89b4fa; - --ctp-lavender: #b4befe; - --ctp-text: #cdd6f4; - --ctp-subtext1: #bac2de; - --ctp-subtext0: #a6adc8; - --ctp-overlay2: #9399b2; - --ctp-overlay1: #7f849c; - --ctp-overlay0: #6c7086; - --ctp-surface2: #585b70; - --ctp-surface1: #45475a; - --ctp-surface0: #313244; - --ctp-base: #1e1e2e; - --ctp-mantle: #181825; - --ctp-crust: #11111b; - - /* Semantic aliases */ - --bg-primary: var(--ctp-base); - --bg-secondary: var(--ctp-mantle); - --bg-tertiary: var(--ctp-crust); - --bg-surface: var(--ctp-surface0); - --bg-surface-hover: var(--ctp-surface1); - --text-primary: var(--ctp-text); - --text-secondary: var(--ctp-subtext1); - --text-muted: var(--ctp-overlay1); - --border: var(--ctp-surface1); - --accent: var(--ctp-blue); - --accent-hover: var(--ctp-sapphire); - --success: var(--ctp-green); - --warning: var(--ctp-yellow); - --error: var(--ctp-red); - - /* Typography */ - --ui-font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; - --ui-font-size: 13px; - --term-font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace; - --term-font-size: 13px; - - /* Layout */ - --sidebar-width: 260px; - --right-panel-width: 380px; - --pane-header-height: 32px; - --pane-gap: 2px; - --border-radius: 4px; - - /* Pane content padding — shared between AgentPane and MarkdownPane */ - --bterminal-pane-padding-inline: clamp(0.75rem, 3.5cqi, 2rem); -} diff --git a/v2/src/lib/styles/themes.ts b/v2/src/lib/styles/themes.ts deleted file mode 100644 index cdeaa17..0000000 --- a/v2/src/lib/styles/themes.ts +++ /dev/null @@ -1,383 +0,0 @@ -// Theme system — Catppuccin flavors + popular editor themes -// All themes map to the same --ctp-* CSS custom property slots. - -/** All available theme identifiers */ -export type ThemeId = - | 'mocha' | 'macchiato' | 'frappe' | 'latte' - | 'vscode-dark' | 'atom-one-dark' | 'monokai' | 'dracula' - | 'nord' | 'solarized-dark' | 'github-dark' - | 'tokyo-night' | 'gruvbox-dark' | 'ayu-dark' | 'poimandres' - | 'vesper' | 'midnight'; - -/** Keep for backwards compat — subset of ThemeId */ -export type CatppuccinFlavor = 'latte' | 'frappe' | 'macchiato' | 'mocha'; - -export interface ThemePalette { - rosewater: string; - flamingo: string; - pink: string; - mauve: string; - red: string; - maroon: string; - peach: string; - yellow: string; - green: string; - teal: string; - sky: string; - sapphire: string; - blue: string; - lavender: string; - text: string; - subtext1: string; - subtext0: string; - overlay2: string; - overlay1: string; - overlay0: string; - surface2: string; - surface1: string; - surface0: string; - base: string; - mantle: string; - crust: string; -} - -/** Keep old name as alias */ -export type CatppuccinPalette = ThemePalette; - -export interface XtermTheme { - background: string; - foreground: string; - cursor: string; - cursorAccent: string; - selectionBackground: string; - selectionForeground: string; - black: string; - red: string; - green: string; - yellow: string; - blue: string; - magenta: string; - cyan: string; - white: string; - brightBlack: string; - brightRed: string; - brightGreen: string; - brightYellow: string; - brightBlue: string; - brightMagenta: string; - brightCyan: string; - brightWhite: string; -} - -export interface ThemeMeta { - id: ThemeId; - label: string; - group: string; // For grouping in - isDark: boolean; -} - -export const THEME_LIST: ThemeMeta[] = [ - { id: 'mocha', label: 'Catppuccin Mocha', group: 'Catppuccin', isDark: true }, - { id: 'macchiato', label: 'Catppuccin Macchiato',group: 'Catppuccin', isDark: true }, - { id: 'frappe', label: 'Catppuccin Frappé', group: 'Catppuccin', isDark: true }, - { id: 'latte', label: 'Catppuccin Latte', group: 'Catppuccin', isDark: false }, - { id: 'vscode-dark', label: 'VSCode Dark+', group: 'Editor', isDark: true }, - { id: 'atom-one-dark', label: 'Atom One Dark', group: 'Editor', isDark: true }, - { id: 'monokai', label: 'Monokai', group: 'Editor', isDark: true }, - { id: 'dracula', label: 'Dracula', group: 'Editor', isDark: true }, - { id: 'nord', label: 'Nord', group: 'Editor', isDark: true }, - { id: 'solarized-dark', label: 'Solarized Dark', group: 'Editor', isDark: true }, - { id: 'github-dark', label: 'GitHub Dark', group: 'Editor', isDark: true }, - { id: 'tokyo-night', label: 'Tokyo Night', group: 'Deep Dark', isDark: true }, - { id: 'gruvbox-dark', label: 'Gruvbox Dark', group: 'Deep Dark', isDark: true }, - { id: 'ayu-dark', label: 'Ayu Dark', group: 'Deep Dark', isDark: true }, - { id: 'poimandres', label: 'Poimandres', group: 'Deep Dark', isDark: true }, - { id: 'vesper', label: 'Vesper', group: 'Deep Dark', isDark: true }, - { id: 'midnight', label: 'Midnight', group: 'Deep Dark', isDark: true }, -]; - -const palettes: Record = { - // --- Catppuccin --- - latte: { - rosewater: '#dc8a78', flamingo: '#dd7878', pink: '#ea76cb', mauve: '#8839ef', - red: '#d20f39', maroon: '#e64553', peach: '#fe640b', yellow: '#df8e1d', - green: '#40a02b', teal: '#179299', sky: '#04a5e5', sapphire: '#209fb5', - blue: '#1e66f5', lavender: '#7287fd', - text: '#4c4f69', subtext1: '#5c5f77', subtext0: '#6c6f85', - overlay2: '#7c7f93', overlay1: '#8c8fa1', overlay0: '#9ca0b0', - surface2: '#acb0be', surface1: '#bcc0cc', surface0: '#ccd0da', - base: '#eff1f5', mantle: '#e6e9ef', crust: '#dce0e8', - }, - frappe: { - rosewater: '#f2d5cf', flamingo: '#eebebe', pink: '#f4b8e4', mauve: '#ca9ee6', - red: '#e78284', maroon: '#ea999c', peach: '#ef9f76', yellow: '#e5c890', - green: '#a6d189', teal: '#81c8be', sky: '#99d1db', sapphire: '#85c1dc', - blue: '#8caaee', lavender: '#babbf1', - text: '#c6d0f5', subtext1: '#b5bfe2', subtext0: '#a5adce', - overlay2: '#949cbb', overlay1: '#838ba7', overlay0: '#737994', - surface2: '#626880', surface1: '#51576d', surface0: '#414559', - base: '#303446', mantle: '#292c3c', crust: '#232634', - }, - macchiato: { - rosewater: '#f4dbd6', flamingo: '#f0c6c6', pink: '#f5bde6', mauve: '#c6a0f6', - red: '#ed8796', maroon: '#ee99a0', peach: '#f5a97f', yellow: '#eed49f', - green: '#a6da95', teal: '#8bd5ca', sky: '#91d7e3', sapphire: '#7dc4e4', - blue: '#8aadf4', lavender: '#b7bdf8', - text: '#cad3f5', subtext1: '#b8c0e0', subtext0: '#a5adcb', - overlay2: '#939ab7', overlay1: '#8087a2', overlay0: '#6e738d', - surface2: '#5b6078', surface1: '#494d64', surface0: '#363a4f', - base: '#24273a', mantle: '#1e2030', crust: '#181926', - }, - mocha: { - rosewater: '#f5e0dc', flamingo: '#f2cdcd', pink: '#f5c2e7', mauve: '#cba6f7', - red: '#f38ba8', maroon: '#eba0ac', peach: '#fab387', yellow: '#f9e2af', - green: '#a6e3a1', teal: '#94e2d5', sky: '#89dceb', sapphire: '#74c7ec', - blue: '#89b4fa', lavender: '#b4befe', - text: '#cdd6f4', subtext1: '#bac2de', subtext0: '#a6adc8', - overlay2: '#9399b2', overlay1: '#7f849c', overlay0: '#6c7086', - surface2: '#585b70', surface1: '#45475a', surface0: '#313244', - base: '#1e1e2e', mantle: '#181825', crust: '#11111b', - }, - - // --- VSCode Dark+ --- - 'vscode-dark': { - rosewater: '#d4a0a0', flamingo: '#cf8686', pink: '#c586c0', mauve: '#c586c0', - red: '#f44747', maroon: '#d16969', peach: '#ce9178', yellow: '#dcdcaa', - green: '#6a9955', teal: '#4ec9b0', sky: '#9cdcfe', sapphire: '#4fc1ff', - blue: '#569cd6', lavender: '#b4b4f7', - text: '#d4d4d4', subtext1: '#cccccc', subtext0: '#b0b0b0', - overlay2: '#858585', overlay1: '#6e6e6e', overlay0: '#5a5a5a', - surface2: '#3e3e42', surface1: '#333338', surface0: '#2d2d30', - base: '#1e1e1e', mantle: '#181818', crust: '#111111', - }, - - // --- Atom One Dark --- - 'atom-one-dark': { - rosewater: '#e5c07b', flamingo: '#e06c75', pink: '#c678dd', mauve: '#c678dd', - red: '#e06c75', maroon: '#be5046', peach: '#d19a66', yellow: '#e5c07b', - green: '#98c379', teal: '#56b6c2', sky: '#56b6c2', sapphire: '#61afef', - blue: '#61afef', lavender: '#c8ccd4', - text: '#abb2bf', subtext1: '#9da5b4', subtext0: '#8b92a0', - overlay2: '#7f848e', overlay1: '#636d83', overlay0: '#545862', - surface2: '#474b56', surface1: '#3b3f4c', surface0: '#333842', - base: '#282c34', mantle: '#21252b', crust: '#181a1f', - }, - - // --- Monokai --- - monokai: { - rosewater: '#f8f8f2', flamingo: '#f92672', pink: '#f92672', mauve: '#ae81ff', - red: '#f92672', maroon: '#f92672', peach: '#fd971f', yellow: '#e6db74', - green: '#a6e22e', teal: '#66d9ef', sky: '#66d9ef', sapphire: '#66d9ef', - blue: '#66d9ef', lavender: '#ae81ff', - text: '#f8f8f2', subtext1: '#e8e8e2', subtext0: '#cfcfc2', - overlay2: '#a8a8a2', overlay1: '#90908a', overlay0: '#75715e', - surface2: '#595950', surface1: '#49483e', surface0: '#3e3d32', - base: '#272822', mantle: '#1e1f1c', crust: '#141411', - }, - - // --- Dracula --- - dracula: { - rosewater: '#f1c4e0', flamingo: '#ff79c6', pink: '#ff79c6', mauve: '#bd93f9', - red: '#ff5555', maroon: '#ff6e6e', peach: '#ffb86c', yellow: '#f1fa8c', - green: '#50fa7b', teal: '#8be9fd', sky: '#8be9fd', sapphire: '#8be9fd', - blue: '#6272a4', lavender: '#bd93f9', - text: '#f8f8f2', subtext1: '#e8e8e2', subtext0: '#c0c0ba', - overlay2: '#a0a0a0', overlay1: '#7f7f7f', overlay0: '#6272a4', - surface2: '#555969', surface1: '#44475a', surface0: '#383a4a', - base: '#282a36', mantle: '#21222c', crust: '#191a21', - }, - - // --- Nord --- - nord: { - rosewater: '#d08770', flamingo: '#bf616a', pink: '#b48ead', mauve: '#b48ead', - red: '#bf616a', maroon: '#bf616a', peach: '#d08770', yellow: '#ebcb8b', - green: '#a3be8c', teal: '#8fbcbb', sky: '#88c0d0', sapphire: '#81a1c1', - blue: '#5e81ac', lavender: '#b48ead', - text: '#eceff4', subtext1: '#e5e9f0', subtext0: '#d8dee9', - overlay2: '#a5adba', overlay1: '#8891a0', overlay0: '#6c7588', - surface2: '#4c566a', surface1: '#434c5e', surface0: '#3b4252', - base: '#2e3440', mantle: '#272c36', crust: '#20242c', - }, - - // --- Solarized Dark --- - 'solarized-dark': { - rosewater: '#d33682', flamingo: '#dc322f', pink: '#d33682', mauve: '#6c71c4', - red: '#dc322f', maroon: '#cb4b16', peach: '#cb4b16', yellow: '#b58900', - green: '#859900', teal: '#2aa198', sky: '#2aa198', sapphire: '#268bd2', - blue: '#268bd2', lavender: '#6c71c4', - text: '#839496', subtext1: '#93a1a1', subtext0: '#778a8b', - overlay2: '#657b83', overlay1: '#586e75', overlay0: '#4a6068', - surface2: '#1c4753', surface1: '#143845', surface0: '#073642', - base: '#002b36', mantle: '#00222b', crust: '#001a21', - }, - - // --- GitHub Dark --- - 'github-dark': { - rosewater: '#ffa198', flamingo: '#ff7b72', pink: '#f778ba', mauve: '#d2a8ff', - red: '#ff7b72', maroon: '#ffa198', peach: '#ffa657', yellow: '#e3b341', - green: '#7ee787', teal: '#56d4dd', sky: '#79c0ff', sapphire: '#79c0ff', - blue: '#58a6ff', lavender: '#d2a8ff', - text: '#c9d1d9', subtext1: '#b1bac4', subtext0: '#8b949e', - overlay2: '#6e7681', overlay1: '#565c64', overlay0: '#484f58', - surface2: '#373e47', surface1: '#30363d', surface0: '#21262d', - base: '#0d1117', mantle: '#090c10', crust: '#050608', - }, - - // --- Tokyo Night --- - 'tokyo-night': { - rosewater: '#f7768e', flamingo: '#ff9e64', pink: '#bb9af7', mauve: '#bb9af7', - red: '#f7768e', maroon: '#db4b4b', peach: '#ff9e64', yellow: '#e0af68', - green: '#9ece6a', teal: '#73daca', sky: '#7dcfff', sapphire: '#7aa2f7', - blue: '#7aa2f7', lavender: '#bb9af7', - text: '#c0caf5', subtext1: '#a9b1d6', subtext0: '#9aa5ce', - overlay2: '#787c99', overlay1: '#565f89', overlay0: '#414868', - surface2: '#3b4261', surface1: '#292e42', surface0: '#232433', - base: '#1a1b26', mantle: '#16161e', crust: '#101014', - }, - - // --- Gruvbox Dark --- - 'gruvbox-dark': { - rosewater: '#d65d0e', flamingo: '#cc241d', pink: '#d3869b', mauve: '#b16286', - red: '#fb4934', maroon: '#cc241d', peach: '#fe8019', yellow: '#fabd2f', - green: '#b8bb26', teal: '#8ec07c', sky: '#83a598', sapphire: '#83a598', - blue: '#458588', lavender: '#d3869b', - text: '#ebdbb2', subtext1: '#d5c4a1', subtext0: '#bdae93', - overlay2: '#a89984', overlay1: '#928374', overlay0: '#7c6f64', - surface2: '#504945', surface1: '#3c3836', surface0: '#32302f', - base: '#1d2021', mantle: '#191b1c', crust: '#141617', - }, - - // --- Ayu Dark --- - 'ayu-dark': { - rosewater: '#f07178', flamingo: '#f07178', pink: '#d2a6ff', mauve: '#d2a6ff', - red: '#f07178', maroon: '#f07178', peach: '#ff8f40', yellow: '#ffb454', - green: '#aad94c', teal: '#95e6cb', sky: '#73b8ff', sapphire: '#59c2ff', - blue: '#59c2ff', lavender: '#d2a6ff', - text: '#bfbdb6', subtext1: '#acaaa4', subtext0: '#9b9892', - overlay2: '#73726e', overlay1: '#5c5b57', overlay0: '#464542', - surface2: '#383838', surface1: '#2c2c2c', surface0: '#242424', - base: '#0b0e14', mantle: '#080a0f', crust: '#05070a', - }, - - // --- Poimandres --- - 'poimandres': { - rosewater: '#d0679d', flamingo: '#d0679d', pink: '#fcc5e9', mauve: '#a6accd', - red: '#d0679d', maroon: '#d0679d', peach: '#e4f0fb', yellow: '#fffac2', - green: '#5de4c7', teal: '#5de4c7', sky: '#89ddff', sapphire: '#add7ff', - blue: '#91b4d5', lavender: '#a6accd', - text: '#e4f0fb', subtext1: '#d0d6e0', subtext0: '#a6accd', - overlay2: '#767c9d', overlay1: '#506477', overlay0: '#3e4f5e', - surface2: '#303340', surface1: '#252b37', surface0: '#1e2433', - base: '#1b1e28', mantle: '#171922', crust: '#12141c', - }, - - // --- Vesper --- - 'vesper': { - rosewater: '#de6e6e', flamingo: '#de6e6e', pink: '#c79bf0', mauve: '#c79bf0', - red: '#de6e6e', maroon: '#de6e6e', peach: '#ffcfa8', yellow: '#ffc799', - green: '#7cb37c', teal: '#6bccb0', sky: '#8abeb7', sapphire: '#6eb4bf', - blue: '#6eb4bf', lavender: '#c79bf0', - text: '#b8b5ad', subtext1: '#a09d95', subtext0: '#878480', - overlay2: '#6e6b66', overlay1: '#55524d', overlay0: '#3d3a36', - surface2: '#302e2a', surface1: '#252320', surface0: '#1c1a17', - base: '#101010', mantle: '#0a0a0a', crust: '#050505', - }, - - // --- Midnight (pure black OLED) --- - midnight: { - rosewater: '#e8a0bf', flamingo: '#ea6f91', pink: '#e8a0bf', mauve: '#c4a7e7', - red: '#eb6f92', maroon: '#ea6f91', peach: '#f6c177', yellow: '#ebbcba', - green: '#9ccfd8', teal: '#9ccfd8', sky: '#a4d4e4', sapphire: '#8bbee8', - blue: '#7ba4cc', lavender: '#c4a7e7', - text: '#c4c4c4', subtext1: '#a8a8a8', subtext0: '#8c8c8c', - overlay2: '#6e6e6e', overlay1: '#525252', overlay0: '#383838', - surface2: '#262626', surface1: '#1a1a1a', surface0: '#111111', - base: '#000000', mantle: '#000000', crust: '#000000', - }, -}; - -export function getPalette(theme: ThemeId): ThemePalette { - return palettes[theme]; -} - -/** Build xterm.js ITheme from a palette */ -export function buildXtermTheme(theme: ThemeId): XtermTheme { - const p = palettes[theme]; - return { - background: p.base, - foreground: p.text, - cursor: p.rosewater, - cursorAccent: p.base, - selectionBackground: p.surface1, - selectionForeground: p.text, - black: p.surface1, - red: p.red, - green: p.green, - yellow: p.yellow, - blue: p.blue, - magenta: p.pink, - cyan: p.teal, - white: p.subtext1, - brightBlack: p.surface2, - brightRed: p.red, - brightGreen: p.green, - brightYellow: p.yellow, - brightBlue: p.blue, - brightMagenta: p.pink, - brightCyan: p.teal, - brightWhite: p.subtext0, - }; -} - -/** CSS custom property names mapped to palette keys */ -const CSS_VAR_MAP: [string, keyof ThemePalette][] = [ - ['--ctp-rosewater', 'rosewater'], - ['--ctp-flamingo', 'flamingo'], - ['--ctp-pink', 'pink'], - ['--ctp-mauve', 'mauve'], - ['--ctp-red', 'red'], - ['--ctp-maroon', 'maroon'], - ['--ctp-peach', 'peach'], - ['--ctp-yellow', 'yellow'], - ['--ctp-green', 'green'], - ['--ctp-teal', 'teal'], - ['--ctp-sky', 'sky'], - ['--ctp-sapphire', 'sapphire'], - ['--ctp-blue', 'blue'], - ['--ctp-lavender', 'lavender'], - ['--ctp-text', 'text'], - ['--ctp-subtext1', 'subtext1'], - ['--ctp-subtext0', 'subtext0'], - ['--ctp-overlay2', 'overlay2'], - ['--ctp-overlay1', 'overlay1'], - ['--ctp-overlay0', 'overlay0'], - ['--ctp-surface2', 'surface2'], - ['--ctp-surface1', 'surface1'], - ['--ctp-surface0', 'surface0'], - ['--ctp-base', 'base'], - ['--ctp-mantle', 'mantle'], - ['--ctp-crust', 'crust'], -]; - -/** Apply a theme's CSS custom properties to document root */ -export function applyCssVariables(theme: ThemeId): void { - const p = palettes[theme]; - const style = document.documentElement.style; - for (const [varName, key] of CSS_VAR_MAP) { - style.setProperty(varName, p[key]); - } -} - -/** @deprecated Use THEME_LIST instead */ -export const FLAVOR_LABELS: Record = { - latte: 'Latte (Light)', - frappe: 'Frappe', - macchiato: 'Macchiato', - mocha: 'Mocha (Default)', -}; - -/** @deprecated Use THEME_LIST instead */ -export const ALL_FLAVORS: CatppuccinFlavor[] = ['latte', 'frappe', 'macchiato', 'mocha']; - -/** All valid theme IDs for validation */ -export const ALL_THEME_IDS: ThemeId[] = THEME_LIST.map(t => t.id); diff --git a/v2/src/lib/types/anchors.ts b/v2/src/lib/types/anchors.ts deleted file mode 100644 index cc61270..0000000 --- a/v2/src/lib/types/anchors.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Session Anchor types — preserves important conversation turns through compaction chains -// Anchored turns are re-injected into system prompt on subsequent queries - -/** Anchor classification */ -export type AnchorType = 'auto' | 'pinned' | 'promoted'; - -/** A single anchored turn, stored per-project */ -export interface SessionAnchor { - id: string; - projectId: string; - messageId: string; - anchorType: AnchorType; - /** Serialized turn text for re-injection (observation-masked) */ - content: string; - /** Estimated token count (~chars/4) */ - estimatedTokens: number; - /** Turn index in original session */ - turnIndex: number; - createdAt: number; -} - -/** Settings for anchor behavior, stored per-project */ -export interface AnchorSettings { - /** Number of turns to auto-anchor on first compaction (default: 3) */ - anchorTurns: number; - /** Hard cap on re-injectable anchor tokens (default: 6144) */ - anchorTokenBudget: number; -} - -export const DEFAULT_ANCHOR_SETTINGS: AnchorSettings = { - anchorTurns: 3, - anchorTokenBudget: 6144, -}; - -/** Maximum token budget for re-injected anchors */ -export const MAX_ANCHOR_TOKEN_BUDGET = 20_000; -/** Minimum token budget */ -export const MIN_ANCHOR_TOKEN_BUDGET = 2_000; - -/** Budget scale presets — maps to provider context window sizes */ -export type AnchorBudgetScale = 'small' | 'medium' | 'large' | 'full'; - -/** Token budget for each scale preset */ -export const ANCHOR_BUDGET_SCALE_MAP: Record = { - small: 2_000, - medium: 6_144, - large: 12_000, - full: 20_000, -}; - -/** Human-readable labels for budget scale presets */ -export const ANCHOR_BUDGET_SCALE_LABELS: Record = { - small: 'Small (2K)', - medium: 'Medium (6K)', - large: 'Large (12K)', - full: 'Full (20K)', -}; - -/** Ordered list of scales for slider indexing */ -export const ANCHOR_BUDGET_SCALES: AnchorBudgetScale[] = ['small', 'medium', 'large', 'full']; - -/** Rust-side record shape (matches SessionAnchorRecord in session.rs) */ -export interface SessionAnchorRecord { - id: string; - project_id: string; - message_id: string; - anchor_type: string; - content: string; - estimated_tokens: number; - turn_index: number; - created_at: number; -} diff --git a/v2/src/lib/types/groups.ts b/v2/src/lib/types/groups.ts deleted file mode 100644 index ff6e73d..0000000 --- a/v2/src/lib/types/groups.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { ProviderId } from '../providers/types'; -import type { AnchorBudgetScale } from './anchors'; - -export interface ProjectConfig { - id: string; - name: string; - identifier: string; - description: string; - icon: string; - cwd: string; - profile: string; - enabled: boolean; - /** Agent provider for this project (defaults to 'claude') */ - provider?: ProviderId; - /** When true, agents for this project use git worktrees for isolation */ - useWorktrees?: boolean; - /** Anchor token budget scale (defaults to 'medium' = 6K tokens) */ - anchorBudgetScale?: AnchorBudgetScale; - /** Stall detection threshold in minutes (defaults to 15) */ - stallThresholdMin?: number; - /** True for Tier 1 management agents rendered as project boxes */ - isAgent?: boolean; - /** Agent role (manager/architect/tester/reviewer) — only when isAgent */ - agentRole?: GroupAgentRole; - /** System prompt injected at session start — only when isAgent */ - systemPrompt?: string; -} - -export const AGENT_ROLE_ICONS: Record = { - manager: '🎯', - architect: '🏗', - tester: '🧪', - reviewer: '🔍', -}; - -/** Convert a GroupAgentConfig to a ProjectConfig for unified rendering */ -export function agentToProject(agent: GroupAgentConfig, groupCwd: string): ProjectConfig { - return { - id: agent.id, - name: agent.name, - identifier: agent.role, - description: `${agent.role.charAt(0).toUpperCase() + agent.role.slice(1)} agent`, - icon: AGENT_ROLE_ICONS[agent.role] ?? '🤖', - cwd: agent.cwd ?? groupCwd, - profile: 'default', - enabled: agent.enabled, - isAgent: true, - agentRole: agent.role, - systemPrompt: agent.systemPrompt, - }; -} - -/** Group-level agent role (Tier 1 management agents) */ -export type GroupAgentRole = 'manager' | 'architect' | 'tester' | 'reviewer'; - -/** Group-level agent status */ -export type GroupAgentStatus = 'active' | 'sleeping' | 'stopped'; - -/** Group-level agent configuration */ -export interface GroupAgentConfig { - id: string; - name: string; - role: GroupAgentRole; - model?: string; - cwd?: string; - systemPrompt?: string; - enabled: boolean; - /** Auto-wake interval in minutes (Manager only, default 3) */ - wakeIntervalMin?: number; -} - -export interface GroupConfig { - id: string; - name: string; - projects: ProjectConfig[]; - /** Group-level orchestration agents (Tier 1) */ - agents?: GroupAgentConfig[]; -} - -export interface GroupsFile { - version: number; - groups: GroupConfig[]; - activeGroupId: string; -} - -/** Derive a project identifier from a name: lowercase, spaces to dashes */ -export function deriveIdentifier(name: string): string { - return name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''); -} - -/** Project accent colors by slot index (0-4), Catppuccin Mocha */ -export const PROJECT_ACCENTS = [ - '--ctp-blue', - '--ctp-green', - '--ctp-mauve', - '--ctp-peach', - '--ctp-pink', -] as const; diff --git a/v2/src/lib/types/ids.test.ts b/v2/src/lib/types/ids.test.ts deleted file mode 100644 index 43798b3..0000000 --- a/v2/src/lib/types/ids.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { SessionId, ProjectId, type SessionId as SessionIdType, type ProjectId as ProjectIdType } from './ids'; - -describe('branded types', () => { - describe('SessionId', () => { - it('creates a SessionId from a string', () => { - const id = SessionId('sess-abc-123'); - expect(id).toBe('sess-abc-123'); - }); - - it('is usable as a string (template literal)', () => { - const id = SessionId('sess-1'); - expect(`session: ${id}`).toBe('session: sess-1'); - }); - - it('is usable as a Map key', () => { - const map = new Map(); - const id = SessionId('sess-1'); - map.set(id, 42); - expect(map.get(id)).toBe(42); - }); - - it('equality works between two SessionIds with same value', () => { - const a = SessionId('sess-1'); - const b = SessionId('sess-1'); - expect(a === b).toBe(true); - }); - }); - - describe('ProjectId', () => { - it('creates a ProjectId from a string', () => { - const id = ProjectId('proj-xyz'); - expect(id).toBe('proj-xyz'); - }); - - it('is usable as a Map key', () => { - const map = new Map(); - const id = ProjectId('proj-1'); - map.set(id, 'test-project'); - expect(map.get(id)).toBe('test-project'); - }); - }); - - describe('type safety (compile-time)', () => { - it('both types are strings at runtime', () => { - const sid = SessionId('s1'); - const pid = ProjectId('p1'); - expect(typeof sid).toBe('string'); - expect(typeof pid).toBe('string'); - }); - }); -}); diff --git a/v2/src/lib/types/ids.ts b/v2/src/lib/types/ids.ts deleted file mode 100644 index 1a51aca..0000000 --- a/v2/src/lib/types/ids.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Branded types for domain identifiers — prevents accidental swapping of sessionId/projectId -// These are compile-time only; at runtime they are plain strings. - -/** Unique identifier for an agent session */ -export type SessionId = string & { readonly __brand: 'SessionId' }; - -/** Unique identifier for a project */ -export type ProjectId = string & { readonly __brand: 'ProjectId' }; - -/** Create a SessionId from a raw string */ -export function SessionId(value: string): SessionId { - return value as SessionId; -} - -/** Create a ProjectId from a raw string */ -export function ProjectId(value: string): ProjectId { - return value as ProjectId; -} diff --git a/v2/src/lib/utils/agent-prompts.ts b/v2/src/lib/utils/agent-prompts.ts deleted file mode 100644 index 36c752e..0000000 --- a/v2/src/lib/utils/agent-prompts.ts +++ /dev/null @@ -1,360 +0,0 @@ -/** - * System prompt generator for management agents. - * Builds comprehensive introductory context including: - * - Environment description (group, projects, team) - * - Role-specific instructions - * - Full btmsg/bttask tool documentation - * - Communication hierarchy - * - Custom editable context (from groups.json or Memora) - * - * This prompt is injected at every session start and should be - * re-injected periodically (e.g., hourly) for long-running agents. - */ - -import type { GroupAgentRole, GroupConfig, GroupAgentConfig, ProjectConfig } from '../types/groups'; - -// ─── Role descriptions ────────────────────────────────────── - -const ROLE_DESCRIPTIONS: Record = { - manager: `You are the **Manager** — the central coordinator of this project group. - -**Your authority:** -- You have FULL visibility across all projects and agents -- You create and assign tasks to team members -- You can edit context/instructions for your subordinates -- You escalate blockers and decisions to the Operator (human admin) -- You are the ONLY agent who communicates directly with the Operator - -**Your responsibilities:** -- Break down high-level goals into actionable tasks -- Assign work to the right agents based on their capabilities -- Monitor progress, deadlines, and blockers across all projects -- Coordinate between Architect, Tester, and project agents -- Ensure team alignment and resolve conflicts -- Provide status summaries to the Operator when asked`, - - architect: `You are the **Architect** — responsible for technical design and code quality. - -**Your authority:** -- You review architecture decisions across ALL projects -- You can request changes or block merges on architectural grounds -- You propose technical solutions and document them - -**Your responsibilities:** -- Ensure API consistency between all components (backend, display, etc.) -- Review code for architectural correctness, patterns, and anti-patterns -- Design system interfaces and data flows -- Document architectural decisions and trade-offs -- Report architectural concerns to the Manager -- Mentor project agents on best practices`, - - tester: `You are the **Tester** — responsible for quality assurance across all projects. - -**Your authority:** -- You validate all features before they're considered "done" -- You can mark tasks as "blocked" if they fail tests -- You define testing standards for the team - -**Your responsibilities:** -- Write and run unit, integration, and E2E tests -- Validate features work end-to-end across projects -- Report bugs with clear reproduction steps (to Manager) -- Track test coverage and suggest improvements -- Use Selenium/browser automation for UI testing when needed -- Verify deployments on target hardware (Raspberry Pi)`, - - reviewer: `You are the **Reviewer** — responsible for code review and standards. - -**Your authority:** -- You review all code changes for quality and security -- You can request changes before approval - -**Your responsibilities:** -- Review code quality, security, and adherence to best practices -- Provide constructive, actionable feedback -- Ensure consistent coding standards across projects -- Flag security vulnerabilities and performance issues -- Verify error handling and edge cases`, -}; - -// ─── Tool documentation ───────────────────────────────────── - -const BTMSG_DOCS = ` -## Tool: btmsg — Agent Messenger - -btmsg is your primary communication channel with other agents and the Operator. -Your identity is set automatically (BTMSG_AGENT_ID env var). You don't need to configure it. - -### Reading messages -\`\`\`bash -btmsg inbox # Show unread messages (CHECK THIS FIRST!) -btmsg inbox --all # Show all messages (including read) -btmsg read # Read a specific message (marks as read) -\`\`\` - -### Sending messages -\`\`\`bash -btmsg send "Your message here" # Send direct message -btmsg reply "Your reply here" # Reply to a message -\`\`\` -You can only message agents in your contacts list. Use \`btmsg contacts\` to see who. - -### Information -\`\`\`bash -btmsg contacts # List agents you can message -btmsg history # Conversation history with an agent -btmsg status # All agents and their current status -btmsg whoami # Your identity and unread count -btmsg graph # Visual hierarchy of the team -\`\`\` - -### Channels (group chat) -\`\`\`bash -btmsg channel list # List channels -btmsg channel send "message" # Post to a channel -btmsg channel history # Channel message history -btmsg channel create # Create a new channel -\`\`\` - -### Communication rules -- **Always check \`btmsg inbox\` first** when you start or wake up -- Respond to messages promptly — other agents may be waiting on you -- Keep messages concise and actionable -- Use reply threading (\`btmsg reply\`) to maintain conversation context -- If you need someone not in your contacts, ask the Manager to relay`; - -const BTTASK_DOCS = ` -## Tool: bttask — Task Board - -bttask is a Kanban-style task tracker shared across the team. -Tasks flow through: todo → progress → review → done (or blocked). - -### Viewing tasks -\`\`\`bash -bttask list # List all tasks -bttask board # Kanban board view (5 columns) -bttask show # Full task details + comments -\`\`\` - -### Managing tasks (Manager only) -\`\`\`bash -bttask add "Title" --desc "Description" --priority high # Create task -bttask assign # Assign to agent -bttask delete # Delete task -\`\`\` - -### Working on tasks (all agents) -\`\`\`bash -bttask status progress # Mark as in progress -bttask status review # Ready for review -bttask status done # Completed -bttask status blocked # Blocked (explain in comment!) -bttask comment "Comment" # Add a comment/update -\`\`\` - -### Task priorities: low, medium, high, critical -### Task statuses: todo, progress, review, done, blocked`; - -// ─── Prompt generator ─────────────────────────────────────── - -export interface AgentPromptContext { - role: GroupAgentRole; - agentId: string; - agentName: string; - group: GroupConfig; - /** Custom context editable by Manager/admin */ - customPrompt?: string; -} - -/** - * Generate the full introductory context for an agent. - * This should be injected at session start AND periodically re-injected. - */ -export function generateAgentPrompt(ctx: AgentPromptContext): string { - const { role, agentId, agentName, group, customPrompt } = ctx; - const roleDesc = ROLE_DESCRIPTIONS[role] ?? `You are a ${role} agent.`; - - const parts: string[] = []; - - // ── Section 1: Identity ── - parts.push(`# You are: ${agentName} - -${roleDesc} - -**Agent ID:** \`${agentId}\` -**Group:** ${group.name}`); - - // ── Section 2: Environment ── - parts.push(buildEnvironmentSection(group)); - - // ── Section 3: Team ── - parts.push(buildTeamSection(group, agentId)); - - // ── Section 4: Tools ── - parts.push(BTMSG_DOCS); - if (role === 'manager' || role === 'architect') { - parts.push(BTTASK_DOCS); - } else { - // Other agents get read-only bttask info - parts.push(` -## Tool: bttask — Task Board (read + update) - -You can view and update tasks, but cannot create or assign them. - -\`\`\`bash -bttask board # Kanban board view -bttask show # Task details -bttask status # Update: progress/review/done/blocked -bttask comment "update" # Add a comment -\`\`\``); - } - - // ── Section 5: Custom context (editable by Manager/admin) ── - if (customPrompt) { - parts.push(`## Project-Specific Context - -${customPrompt}`); - } - - // ── Section 6: Workflow ── - parts.push(buildWorkflowSection(role)); - - return parts.join('\n\n---\n\n'); -} - -function buildEnvironmentSection(group: GroupConfig): string { - const projects = group.projects.filter(p => p.enabled); - - const projectLines = projects.map(p => { - const parts = [`- **${p.name}** (\`${p.identifier}\`)`]; - if (p.description) parts.push(`— ${p.description}`); - parts.push(`\n CWD: \`${p.cwd}\``); - return parts.join(' '); - }).join('\n'); - - return `## Environment - -**Platform:** BTerminal Mission Control — multi-agent orchestration system -**Group:** ${group.name} -**Your working directory:** Same as the monorepo root (shared across Tier 1 agents) - -### Projects in this group -${projectLines} - -### How it works -- Each project has its own Claude session, terminal, file browser, and context -- Tier 1 agents (you and your peers) coordinate across ALL projects -- Tier 2 agents (project-level) execute code within their specific project CWD -- All communication goes through \`btmsg\`. There is no other way to talk to other agents. -- Task tracking goes through \`bttask\`. This is the shared task board.`; -} - -function buildTeamSection(group: GroupConfig, myId: string): string { - const agents = group.agents ?? []; - const projects = group.projects.filter(p => p.enabled); - - const lines: string[] = ['## Team']; - - // Tier 1 - const tier1 = agents.filter(a => a.id !== myId); - if (tier1.length > 0) { - lines.push('\n### Tier 1 — Management (your peers)'); - for (const a of tier1) { - const status = a.enabled ? '' : ' *(disabled)*'; - lines.push(`- **${a.name}** (\`${a.id}\`, ${a.role})${status}`); - } - } - - // Tier 2 - if (projects.length > 0) { - lines.push('\n### Tier 2 — Execution (project agents)'); - for (const p of projects) { - lines.push(`- **${p.name}** (\`${p.id}\`, project) — works in \`${p.cwd}\``); - } - } - - // Operator - lines.push('\n### Operator (human admin)'); - lines.push('- **Operator** (`admin`) — the human who controls this system. Has full visibility and authority.'); - if (agents.find(a => a.id === myId)?.role === 'manager') { - lines.push(' You report directly to the Operator. Escalate decisions and blockers to them.'); - } else { - lines.push(' Communicate with the Operator only through the Manager, unless directly addressed.'); - } - - // Communication hierarchy - lines.push(`\n### Communication hierarchy -- **Operator** ↔ Manager (direct line) -- **Manager** ↔ all Tier 1 agents ↔ Tier 2 agents they manage -- **Tier 2 agents** report to Manager (and can talk to assigned Tier 1 reviewers) -- Use \`btmsg contacts\` to see exactly who you can reach`); - - return lines.join('\n'); -} - -function buildWorkflowSection(role: GroupAgentRole): string { - if (role === 'manager') { - return `## Your Workflow - -1. **Check inbox:** \`btmsg inbox\` — read and respond to all messages -2. **Review task board:** \`bttask board\` — check status of all tasks -3. **Coordinate:** Assign new tasks, unblock agents, resolve conflicts -4. **Monitor:** Check agent status (\`btmsg status\`), follow up on stalled work -5. **Report:** Summarize progress to the Operator when asked -6. **Repeat:** Check inbox again — new messages may have arrived - -**Important:** You are the hub of all communication. If an agent is blocked, YOU unblock them. -If the Operator sends a message, it's your TOP PRIORITY.`; - } - - if (role === 'architect') { - return `## Your Workflow - -1. **Check inbox:** \`btmsg inbox\` — the Manager may have requests -2. **Review tasks:** \`bttask board\` — look for tasks assigned to you -3. **Analyze:** Review code, architecture, and design across projects -4. **Document:** Write down decisions and rationale -5. **Communicate:** Send findings to Manager, guide project agents -6. **Update tasks:** Mark completed reviews, comment on progress`; - } - - if (role === 'tester') { - return `## Your Workflow - -1. **Check inbox:** \`btmsg inbox\` — the Manager assigns testing tasks -2. **Review assignments:** Check \`bttask board\` for testing tasks -3. **Write tests:** Create test cases, scripts, or Selenium scenarios -4. **Run tests:** Execute and collect results -5. **Report:** Send bug reports to Manager via btmsg, update task status -6. **Verify fixes:** Re-test when developers say a bug is fixed`; - } - - return `## Your Workflow - -1. **Check inbox:** \`btmsg inbox\` — read all unread messages -2. **Check tasks:** \`bttask board\` — see what's assigned to you -3. **Work:** Execute your assigned tasks -4. **Update:** \`bttask status progress\` and \`bttask comment "update"\` -5. **Report:** Message the Manager when done or blocked -6. **Repeat:** Check inbox for new messages`; -} - -// ─── Legacy signature (backward compat) ───────────────────── - -/** - * @deprecated Use generateAgentPrompt(ctx) with full context instead - */ -export function generateAgentPromptSimple( - role: GroupAgentRole, - agentId: string, - customPrompt?: string, -): string { - // Minimal fallback without group context - const roleDesc = ROLE_DESCRIPTIONS[role] ?? `You are a ${role} agent.`; - return [ - `# Agent Role\n\n${roleDesc}`, - `\nYour agent ID: \`${agentId}\``, - BTMSG_DOCS, - customPrompt ? `\n## Additional Context\n\n${customPrompt}` : '', - ].filter(Boolean).join('\n'); -} diff --git a/v2/src/lib/utils/agent-tree.test.ts b/v2/src/lib/utils/agent-tree.test.ts deleted file mode 100644 index 2268244..0000000 --- a/v2/src/lib/utils/agent-tree.test.ts +++ /dev/null @@ -1,297 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { buildAgentTree, countTreeNodes, subtreeCost } from './agent-tree'; -import type { AgentMessage, ToolCallContent, ToolResultContent } from '../adapters/claude-messages'; -import type { AgentTreeNode } from './agent-tree'; - -// Helper to create typed AgentMessages -function makeToolCall( - uuid: string, - toolUseId: string, - name: string, - parentId?: string, -): AgentMessage { - return { - id: uuid, - type: 'tool_call', - parentId, - content: { toolUseId, name, input: {} } satisfies ToolCallContent, - timestamp: Date.now(), - }; -} - -function makeToolResult(uuid: string, toolUseId: string, parentId?: string): AgentMessage { - return { - id: uuid, - type: 'tool_result', - parentId, - content: { toolUseId, output: 'ok' } satisfies ToolResultContent, - timestamp: Date.now(), - }; -} - -function makeTextMessage(uuid: string, text: string, parentId?: string): AgentMessage { - return { - id: uuid, - type: 'text', - parentId, - content: { text }, - timestamp: Date.now(), - }; -} - -describe('buildAgentTree', () => { - it('creates a root node with no children from empty messages', () => { - const tree = buildAgentTree('session-1', [], 'done', 0.05, 1500); - - expect(tree.id).toBe('session-1'); - expect(tree.label).toBe('session-'); - expect(tree.status).toBe('done'); - expect(tree.costUsd).toBe(0.05); - expect(tree.tokens).toBe(1500); - expect(tree.children).toEqual([]); - }); - - it('maps running/starting status to running', () => { - const tree1 = buildAgentTree('s1', [], 'running', 0, 0); - expect(tree1.status).toBe('running'); - - const tree2 = buildAgentTree('s2', [], 'starting', 0, 0); - expect(tree2.status).toBe('running'); - }); - - it('maps error status to error', () => { - const tree = buildAgentTree('s3', [], 'error', 0, 0); - expect(tree.status).toBe('error'); - }); - - it('maps other statuses to done', () => { - const tree = buildAgentTree('s4', [], 'completed', 0, 0); - expect(tree.status).toBe('done'); - }); - - it('adds tool_call messages as children of root', () => { - const messages: AgentMessage[] = [ - makeToolCall('m1', 'tool-1', 'Read'), - makeToolCall('m2', 'tool-2', 'Write'), - ]; - - const tree = buildAgentTree('sess', messages, 'done', 0, 0); - - expect(tree.children).toHaveLength(2); - expect(tree.children[0].id).toBe('tool-1'); - expect(tree.children[0].label).toBe('Read'); - expect(tree.children[0].toolName).toBe('Read'); - expect(tree.children[1].id).toBe('tool-2'); - expect(tree.children[1].label).toBe('Write'); - }); - - it('marks tool nodes as running until a result arrives', () => { - const messages: AgentMessage[] = [ - makeToolCall('m1', 'tool-1', 'Bash'), - ]; - - const tree = buildAgentTree('sess', messages, 'running', 0, 0); - expect(tree.children[0].status).toBe('running'); - }); - - it('marks tool nodes as done when result arrives', () => { - const messages: AgentMessage[] = [ - makeToolCall('m1', 'tool-1', 'Bash'), - makeToolResult('m2', 'tool-1'), - ]; - - const tree = buildAgentTree('sess', messages, 'done', 0, 0); - expect(tree.children[0].status).toBe('done'); - }); - - it('nests subagent tool calls under their parent tool node', () => { - const messages: AgentMessage[] = [ - makeToolCall('m1', 'tool-parent', 'Agent'), - makeToolCall('m2', 'tool-child', 'Read', 'tool-parent'), - ]; - - const tree = buildAgentTree('sess', messages, 'done', 0, 0); - - expect(tree.children).toHaveLength(1); - const parentNode = tree.children[0]; - expect(parentNode.id).toBe('tool-parent'); - expect(parentNode.children).toHaveLength(1); - expect(parentNode.children[0].id).toBe('tool-child'); - expect(parentNode.children[0].label).toBe('Read'); - }); - - it('handles deeply nested subagents (3 levels)', () => { - const messages: AgentMessage[] = [ - makeToolCall('m1', 'level-1', 'Agent'), - makeToolCall('m2', 'level-2', 'SubAgent', 'level-1'), - makeToolCall('m3', 'level-3', 'Read', 'level-2'), - ]; - - const tree = buildAgentTree('sess', messages, 'done', 0, 0); - - expect(tree.children).toHaveLength(1); - expect(tree.children[0].children).toHaveLength(1); - expect(tree.children[0].children[0].children).toHaveLength(1); - expect(tree.children[0].children[0].children[0].id).toBe('level-3'); - }); - - it('attaches to root when parentId references a non-existent tool node', () => { - const messages: AgentMessage[] = [ - makeToolCall('m1', 'orphan-tool', 'Bash', 'nonexistent-parent'), - ]; - - const tree = buildAgentTree('sess', messages, 'done', 0, 0); - - expect(tree.children).toHaveLength(1); - expect(tree.children[0].id).toBe('orphan-tool'); - }); - - it('ignores non-tool messages (text, thinking, etc.)', () => { - const messages: AgentMessage[] = [ - makeTextMessage('m1', 'Hello'), - makeToolCall('m2', 'tool-1', 'Read'), - makeTextMessage('m3', 'Done'), - ]; - - const tree = buildAgentTree('sess', messages, 'done', 0, 0); - - expect(tree.children).toHaveLength(1); - expect(tree.children[0].id).toBe('tool-1'); - }); - - it('handles tool_result for a non-existent tool gracefully', () => { - const messages: AgentMessage[] = [ - makeToolResult('m1', 'nonexistent-tool'), - ]; - - // Should not throw - const tree = buildAgentTree('sess', messages, 'done', 0, 0); - expect(tree.children).toHaveLength(0); - }); - - it('truncates session ID to 8 chars for label', () => { - const tree = buildAgentTree('abcdefghijklmnop', [], 'done', 0, 0); - expect(tree.label).toBe('abcdefgh'); - }); -}); - -describe('countTreeNodes', () => { - it('returns 1 for a leaf node', () => { - const leaf: AgentTreeNode = { - id: 'leaf', - label: 'leaf', - status: 'done', - costUsd: 0, - tokens: 0, - children: [], - }; - expect(countTreeNodes(leaf)).toBe(1); - }); - - it('counts all nodes in a flat tree', () => { - const root: AgentTreeNode = { - id: 'root', - label: 'root', - status: 'done', - costUsd: 0, - tokens: 0, - children: [ - { id: 'a', label: 'a', status: 'done', costUsd: 0, tokens: 0, children: [] }, - { id: 'b', label: 'b', status: 'done', costUsd: 0, tokens: 0, children: [] }, - { id: 'c', label: 'c', status: 'done', costUsd: 0, tokens: 0, children: [] }, - ], - }; - expect(countTreeNodes(root)).toBe(4); - }); - - it('counts all nodes in a nested tree', () => { - const root: AgentTreeNode = { - id: 'root', - label: 'root', - status: 'done', - costUsd: 0, - tokens: 0, - children: [ - { - id: 'a', - label: 'a', - status: 'done', - costUsd: 0, - tokens: 0, - children: [ - { id: 'a1', label: 'a1', status: 'done', costUsd: 0, tokens: 0, children: [] }, - { id: 'a2', label: 'a2', status: 'done', costUsd: 0, tokens: 0, children: [] }, - ], - }, - { id: 'b', label: 'b', status: 'done', costUsd: 0, tokens: 0, children: [] }, - ], - }; - expect(countTreeNodes(root)).toBe(5); - }); -}); - -describe('subtreeCost', () => { - it('returns own cost for a leaf node', () => { - const leaf: AgentTreeNode = { - id: 'leaf', - label: 'leaf', - status: 'done', - costUsd: 0.05, - tokens: 0, - children: [], - }; - expect(subtreeCost(leaf)).toBe(0.05); - }); - - it('aggregates cost across children', () => { - const root: AgentTreeNode = { - id: 'root', - label: 'root', - status: 'done', - costUsd: 0.10, - tokens: 0, - children: [ - { id: 'a', label: 'a', status: 'done', costUsd: 0.03, tokens: 0, children: [] }, - { id: 'b', label: 'b', status: 'done', costUsd: 0.02, tokens: 0, children: [] }, - ], - }; - expect(subtreeCost(root)).toBeCloseTo(0.15); - }); - - it('aggregates cost recursively across nested children', () => { - const root: AgentTreeNode = { - id: 'root', - label: 'root', - status: 'done', - costUsd: 1.0, - tokens: 0, - children: [ - { - id: 'a', - label: 'a', - status: 'done', - costUsd: 0.5, - tokens: 0, - children: [ - { id: 'a1', label: 'a1', status: 'done', costUsd: 0.25, tokens: 0, children: [] }, - ], - }, - ], - }; - expect(subtreeCost(root)).toBeCloseTo(1.75); - }); - - it('returns 0 for a tree with all zero costs', () => { - const root: AgentTreeNode = { - id: 'root', - label: 'root', - status: 'done', - costUsd: 0, - tokens: 0, - children: [ - { id: 'a', label: 'a', status: 'done', costUsd: 0, tokens: 0, children: [] }, - ], - }; - expect(subtreeCost(root)).toBe(0); - }); -}); diff --git a/v2/src/lib/utils/agent-tree.ts b/v2/src/lib/utils/agent-tree.ts deleted file mode 100644 index 0f11bb2..0000000 --- a/v2/src/lib/utils/agent-tree.ts +++ /dev/null @@ -1,88 +0,0 @@ -// Agent tree builder — constructs hierarchical tree from agent messages -// Subagents are identified by parent_tool_use_id on their messages - -import type { AgentMessage, ToolCallContent, CostContent } from '../adapters/claude-messages'; - -export interface AgentTreeNode { - id: string; - label: string; - toolName?: string; - status: 'running' | 'done' | 'error'; - costUsd: number; - tokens: number; - children: AgentTreeNode[]; -} - -/** - * Build a tree from a flat list of agent messages. - * Root node represents the main agent session. - * Child nodes represent tool_use calls (potential subagents). - */ -export function buildAgentTree( - sessionId: string, - messages: AgentMessage[], - sessionStatus: string, - sessionCost: number, - sessionTokens: number, -): AgentTreeNode { - const root: AgentTreeNode = { - id: sessionId, - label: sessionId.slice(0, 8), - status: sessionStatus === 'running' || sessionStatus === 'starting' ? 'running' : - sessionStatus === 'error' ? 'error' : 'done', - costUsd: sessionCost, - tokens: sessionTokens, - children: [], - }; - - // Map tool_use_id -> node for nesting - const toolNodes = new Map(); - - for (const msg of messages) { - if (msg.type === 'tool_call') { - const tc = msg.content as ToolCallContent; - const node: AgentTreeNode = { - id: tc.toolUseId, - label: tc.name, - toolName: tc.name, - status: 'running', // will be updated by result - costUsd: 0, - tokens: 0, - children: [], - }; - toolNodes.set(tc.toolUseId, node); - - if (msg.parentId) { - // This is a subagent tool call — attach to parent tool node - const parent = toolNodes.get(msg.parentId); - if (parent) { - parent.children.push(node); - } else { - root.children.push(node); - } - } else { - root.children.push(node); - } - } - - if (msg.type === 'tool_result') { - const tr = msg.content as { toolUseId: string }; - const node = toolNodes.get(tr.toolUseId); - if (node) { - node.status = 'done'; - } - } - } - - return root; -} - -/** Flatten tree to get total count of nodes */ -export function countTreeNodes(node: AgentTreeNode): number { - return 1 + node.children.reduce((sum, c) => sum + countTreeNodes(c), 0); -} - -/** Aggregate cost across a subtree */ -export function subtreeCost(node: AgentTreeNode): number { - return node.costUsd + node.children.reduce((sum, c) => sum + subtreeCost(c), 0); -} diff --git a/v2/src/lib/utils/anchor-serializer.test.ts b/v2/src/lib/utils/anchor-serializer.test.ts deleted file mode 100644 index f664c18..0000000 --- a/v2/src/lib/utils/anchor-serializer.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -// Tests for anchor-serializer.ts — turn grouping, observation masking, token budgets - -import { describe, it, expect } from 'vitest'; -import { - estimateTokens, - groupMessagesIntoTurns, - selectAutoAnchors, - serializeAnchorsForInjection, -} from './anchor-serializer'; -import type { AgentMessage } from '../adapters/claude-messages'; - -function msg(type: AgentMessage['type'], content: unknown, id?: string): AgentMessage { - return { - id: id ?? crypto.randomUUID(), - type, - content, - timestamp: Date.now(), - }; -} - -describe('estimateTokens', () => { - it('estimates ~4 chars per token', () => { - expect(estimateTokens('abcd')).toBe(1); - expect(estimateTokens('abcdefgh')).toBe(2); - expect(estimateTokens('')).toBe(0); - }); - - it('rounds up', () => { - expect(estimateTokens('ab')).toBe(1); // ceil(2/4) = 1 - expect(estimateTokens('abcde')).toBe(2); // ceil(5/4) = 2 - }); -}); - -describe('groupMessagesIntoTurns', () => { - it('returns empty for no messages', () => { - expect(groupMessagesIntoTurns([])).toEqual([]); - }); - - it('groups text + tool_call + tool_result + cost into one turn', () => { - const messages: AgentMessage[] = [ - msg('text', { text: 'I will help you.' }), - msg('tool_call', { toolUseId: 'tc1', name: 'Read', input: { file_path: '/foo.ts' } }), - msg('tool_result', { toolUseId: 'tc1', output: 'file content here' }), - msg('cost', { totalCostUsd: 0.01, durationMs: 100, inputTokens: 100, outputTokens: 50, numTurns: 1, isError: false }), - ]; - - const turns = groupMessagesIntoTurns(messages); - expect(turns).toHaveLength(1); - expect(turns[0].index).toBe(0); - expect(turns[0].assistantText).toBe('I will help you.'); - expect(turns[0].toolSummaries).toHaveLength(1); - expect(turns[0].toolSummaries[0]).toContain('[Read'); - expect(turns[0].toolSummaries[0]).toContain('/foo.ts'); - }); - - it('splits turns on cost events', () => { - const messages: AgentMessage[] = [ - msg('text', { text: 'First response' }), - msg('cost', { totalCostUsd: 0.01, durationMs: 100, inputTokens: 100, outputTokens: 50, numTurns: 1, isError: false }), - msg('text', { text: 'Second response' }), - msg('cost', { totalCostUsd: 0.02, durationMs: 200, inputTokens: 200, outputTokens: 100, numTurns: 1, isError: false }), - ]; - - const turns = groupMessagesIntoTurns(messages); - expect(turns).toHaveLength(2); - expect(turns[0].assistantText).toBe('First response'); - expect(turns[1].assistantText).toBe('Second response'); - expect(turns[0].index).toBe(0); - expect(turns[1].index).toBe(1); - }); - - it('handles session without final cost event', () => { - const messages: AgentMessage[] = [ - msg('text', { text: 'Working on it...' }), - msg('tool_call', { toolUseId: 'tc1', name: 'Bash', input: { command: 'npm test' } }), - ]; - - const turns = groupMessagesIntoTurns(messages); - expect(turns).toHaveLength(1); - expect(turns[0].assistantText).toBe('Working on it...'); - expect(turns[0].toolSummaries[0]).toContain('[Bash'); - }); - - it('skips init, thinking, compaction, status messages', () => { - const messages: AgentMessage[] = [ - msg('init', { sessionId: 's1', model: 'claude', cwd: '/', tools: [] }), - msg('thinking', { text: 'Hmm...' }), - msg('text', { text: 'Here is the plan.' }), - msg('status', { subtype: 'progress' }), - msg('compaction', { trigger: 'auto', preTokens: 50000 }), - msg('cost', { totalCostUsd: 0.01, durationMs: 100, inputTokens: 100, outputTokens: 50, numTurns: 1, isError: false }), - ]; - - const turns = groupMessagesIntoTurns(messages); - expect(turns).toHaveLength(1); - expect(turns[0].assistantText).toBe('Here is the plan.'); - }); - - it('compacts tool summaries for Write with line count', () => { - const messages: AgentMessage[] = [ - msg('text', { text: 'Creating file.' }), - msg('tool_call', { toolUseId: 'tc1', name: 'Write', input: { file_path: '/app.ts', content: 'line1\nline2\nline3' } }), - msg('tool_result', { toolUseId: 'tc1', output: 'ok' }), - msg('cost', { totalCostUsd: 0.01, durationMs: 100, inputTokens: 100, outputTokens: 50, numTurns: 1, isError: false }), - ]; - - const turns = groupMessagesIntoTurns(messages); - expect(turns[0].toolSummaries[0]).toContain('[Write'); - expect(turns[0].toolSummaries[0]).toContain('/app.ts'); - expect(turns[0].toolSummaries[0]).toContain('3 lines'); - }); - - it('compacts Bash tool with truncated command', () => { - const longCmd = 'a'.repeat(100); - const messages: AgentMessage[] = [ - msg('text', { text: 'Running command.' }), - msg('tool_call', { toolUseId: 'tc1', name: 'Bash', input: { command: longCmd } }), - msg('cost', { totalCostUsd: 0.01, durationMs: 100, inputTokens: 100, outputTokens: 50, numTurns: 1, isError: false }), - ]; - - const turns = groupMessagesIntoTurns(messages); - // Command should be truncated to 80 chars - expect(turns[0].toolSummaries[0].length).toBeLessThan(longCmd.length); - }); - - it('concatenates multiple text messages in same turn', () => { - const messages: AgentMessage[] = [ - msg('text', { text: 'Part 1.' }), - msg('text', { text: 'Part 2.' }), - msg('cost', { totalCostUsd: 0.01, durationMs: 100, inputTokens: 100, outputTokens: 50, numTurns: 1, isError: false }), - ]; - - const turns = groupMessagesIntoTurns(messages); - expect(turns[0].assistantText).toBe('Part 1.\nPart 2.'); - }); -}); - -describe('selectAutoAnchors', () => { - const makeSessionMessages = (turnCount: number): AgentMessage[] => { - const messages: AgentMessage[] = []; - for (let i = 0; i < turnCount; i++) { - messages.push(msg('text', { text: `Response for turn ${i + 1}` })); - messages.push(msg('cost', { - totalCostUsd: 0.01, - durationMs: 100, - inputTokens: 100, - outputTokens: 50, - numTurns: 1, - isError: false, - })); - } - return messages; - }; - - it('selects first N turns up to maxTurns', () => { - const messages = makeSessionMessages(10); - const { turns } = selectAutoAnchors(messages, 'Build auth module', 3, 50000); - expect(turns).toHaveLength(3); - }); - - it('injects session prompt as turn 0 user prompt', () => { - const messages = makeSessionMessages(3); - const { turns } = selectAutoAnchors(messages, 'Build auth module', 3, 50000); - expect(turns[0].userPrompt).toBe('Build auth module'); - }); - - it('respects token budget', () => { - const messages = makeSessionMessages(10); - // Very small budget — should only fit 1-2 turns - const { turns } = selectAutoAnchors(messages, 'task', 10, 30); - expect(turns.length).toBeLessThan(10); - expect(turns.length).toBeGreaterThan(0); - }); - - it('returns empty for no messages', () => { - const { turns, totalTokens } = selectAutoAnchors([], 'task', 3, 6000); - expect(turns).toHaveLength(0); - expect(totalTokens).toBe(0); - }); -}); - -describe('serializeAnchorsForInjection', () => { - it('produces session-anchors XML wrapper', () => { - const turns = [{ - index: 0, - userPrompt: 'Build auth', - assistantText: 'I will create auth.ts', - toolSummaries: ['[Write /auth.ts → 50 lines]'], - estimatedTokens: 30, - }]; - - const result = serializeAnchorsForInjection(turns, 6000, 'my-project'); - expect(result).toContain(''); - expect(result).toContain('Build auth'); - expect(result).toContain('auth.ts'); - }); - - it('respects token budget by truncating turns', () => { - const turns = Array.from({ length: 10 }, (_, i) => ({ - index: i, - userPrompt: `Prompt ${i}`, - assistantText: 'A'.repeat(200), // ~50 tokens each - toolSummaries: [], - estimatedTokens: 80, - })); - - // Budget for ~3 turns - const result = serializeAnchorsForInjection(turns, 300); - // Should not contain all 10 turns - expect(result).toContain('Prompt 0'); - expect(result).not.toContain('Prompt 9'); - }); - - it('works without project name', () => { - const turns = [{ - index: 0, - userPrompt: 'Hello', - assistantText: 'Hi', - toolSummaries: [], - estimatedTokens: 5, - }]; - - const result = serializeAnchorsForInjection(turns, 6000); - expect(result).toContain(''); - expect(result).not.toContain('project='); - }); -}); diff --git a/v2/src/lib/utils/anchor-serializer.ts b/v2/src/lib/utils/anchor-serializer.ts deleted file mode 100644 index e2f48f2..0000000 --- a/v2/src/lib/utils/anchor-serializer.ts +++ /dev/null @@ -1,211 +0,0 @@ -// Anchor Serializer — converts agent messages into observation-masked anchor text -// Observation masking: preserve user prompts + assistant reasoning, compact tool results - -import type { AgentMessage, TextContent, ToolCallContent, ToolResultContent } from '../adapters/claude-messages'; - -/** Estimate token count from text (~4 chars per token) */ -export function estimateTokens(text: string): number { - return Math.ceil(text.length / 4); -} - -/** A turn group: one user prompt + assistant response + tool interactions */ -export interface TurnGroup { - index: number; - userPrompt: string; - assistantText: string; - toolSummaries: string[]; - estimatedTokens: number; -} - -/** - * Group messages into turns. A new turn starts at each 'cost' event boundary - * or at session start. The first turn includes messages from init to the first cost event. - */ -export function groupMessagesIntoTurns(messages: AgentMessage[]): TurnGroup[] { - const turns: TurnGroup[] = []; - let currentTurn: { userPrompt: string; assistantText: string; toolSummaries: string[]; messages: AgentMessage[] } = { - userPrompt: '', - assistantText: '', - toolSummaries: [], - messages: [], - }; - let turnIndex = 0; - - // Build a map of toolUseId -> tool_result for compact summaries - const toolResults = new Map(); - for (const msg of messages) { - if (msg.type === 'tool_result') { - const tr = msg.content as ToolResultContent; - toolResults.set(tr.toolUseId, msg); - } - } - - for (const msg of messages) { - switch (msg.type) { - case 'text': { - const text = (msg.content as TextContent).text; - currentTurn.assistantText += (currentTurn.assistantText ? '\n' : '') + text; - break; - } - case 'tool_call': { - const tc = msg.content as ToolCallContent; - const result = toolResults.get(tc.toolUseId); - const summary = compactToolSummary(tc, result); - currentTurn.toolSummaries.push(summary); - break; - } - case 'cost': { - // End of turn — finalize and start new one - if (currentTurn.assistantText || currentTurn.toolSummaries.length > 0) { - const serialized = serializeTurn(turnIndex, currentTurn); - turns.push({ - index: turnIndex, - userPrompt: currentTurn.userPrompt, - assistantText: currentTurn.assistantText, - toolSummaries: currentTurn.toolSummaries, - estimatedTokens: estimateTokens(serialized), - }); - turnIndex++; - } - currentTurn = { userPrompt: '', assistantText: '', toolSummaries: [], messages: [] }; - break; - } - // Skip init, thinking, compaction, status, etc. - } - } - - // Finalize last turn if it has content (session may not have ended with cost) - if (currentTurn.assistantText || currentTurn.toolSummaries.length > 0) { - const serialized = serializeTurn(turnIndex, currentTurn); - turns.push({ - index: turnIndex, - userPrompt: currentTurn.userPrompt, - assistantText: currentTurn.assistantText, - toolSummaries: currentTurn.toolSummaries, - estimatedTokens: estimateTokens(serialized), - }); - } - - return turns; -} - -/** Compact a tool_call + optional tool_result into a short summary */ -function compactToolSummary(tc: ToolCallContent, result?: AgentMessage): string { - const name = tc.name; - const input = tc.input as Record | undefined; - - // Extract key info based on tool type - let detail = ''; - if (input) { - if (name === 'Read' || name === 'read_file') { - detail = ` ${input.file_path ?? input.path ?? ''}`; - } else if (name === 'Write' || name === 'write_file') { - const path = input.file_path ?? input.path ?? ''; - const content = typeof input.content === 'string' ? input.content : ''; - detail = ` ${path} → ${content.split('\n').length} lines`; - } else if (name === 'Edit' || name === 'edit_file') { - detail = ` ${input.file_path ?? input.path ?? ''}`; - } else if (name === 'Bash' || name === 'execute_bash') { - const cmd = typeof input.command === 'string' ? input.command.slice(0, 80) : ''; - detail = ` \`${cmd}\``; - } else if (name === 'Glob' || name === 'Grep') { - const pattern = input.pattern ?? ''; - detail = ` ${pattern}`; - } - } - - // Add compact result indicator - let resultNote = ''; - if (result) { - const output = result.content as ToolResultContent; - const outStr = typeof output.output === 'string' ? output.output : JSON.stringify(output.output ?? ''); - const lineCount = outStr.split('\n').length; - resultNote = ` → ${lineCount} lines`; - } - - return `[${name}${detail}${resultNote}]`; -} - -/** Serialize a single turn to observation-masked text */ -function serializeTurn( - index: number, - turn: { userPrompt: string; assistantText: string; toolSummaries: string[] }, -): string { - const parts: string[] = []; - - if (turn.userPrompt) { - parts.push(`[Turn ${index + 1}] User: "${turn.userPrompt}"`); - } - if (turn.assistantText) { - // Preserve assistant reasoning in full — research consensus (JetBrains NeurIPS 2025, - // SWE-agent, OpenDev ACC) is that agent reasoning must never be truncated; - // only tool outputs (observations) get masked - parts.push(`[Turn ${index + 1}] Assistant: "${turn.assistantText}"`); - } - if (turn.toolSummaries.length > 0) { - parts.push(`[Turn ${index + 1}] Tools: ${turn.toolSummaries.join(' ')}`); - } - - return parts.join('\n'); -} - -/** - * Serialize turns into anchor text for system prompt re-injection. - * Respects token budget — stops adding turns when budget would be exceeded. - */ -export function serializeAnchorsForInjection( - turns: TurnGroup[], - tokenBudget: number, - projectName?: string, -): string { - const header = ``; - const footer = ''; - const headerTokens = estimateTokens(header + '\n' + footer); - - let remaining = tokenBudget - headerTokens; - const lines: string[] = [header]; - lines.push('Key decisions and context from earlier in this project:'); - lines.push(''); - - for (const turn of turns) { - const text = serializeTurn(turn.index, turn); - const tokens = estimateTokens(text); - if (tokens > remaining) break; - lines.push(text); - lines.push(''); - remaining -= tokens; - } - - lines.push(footer); - return lines.join('\n'); -} - -/** - * Select turns for auto-anchoring on first compaction. - * Takes first N turns up to token budget, using the session's original prompt as turn 0 user prompt. - */ -export function selectAutoAnchors( - messages: AgentMessage[], - sessionPrompt: string, - maxTurns: number, - tokenBudget: number, -): { turns: TurnGroup[]; totalTokens: number } { - const allTurns = groupMessagesIntoTurns(messages); - - // Inject session prompt as user prompt for turn 0 - if (allTurns.length > 0 && !allTurns[0].userPrompt) { - allTurns[0].userPrompt = sessionPrompt; - } - - const selected: TurnGroup[] = []; - let totalTokens = 0; - - for (const turn of allTurns) { - if (selected.length >= maxTurns) break; - if (totalTokens + turn.estimatedTokens > tokenBudget) break; - selected.push(turn); - totalTokens += turn.estimatedTokens; - } - - return { turns: selected, totalTokens }; -} diff --git a/v2/src/lib/utils/attention-scorer.test.ts b/v2/src/lib/utils/attention-scorer.test.ts deleted file mode 100644 index 0f61d7e..0000000 --- a/v2/src/lib/utils/attention-scorer.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { scoreAttention, type AttentionInput } from './attention-scorer'; - -function makeInput(overrides: Partial = {}): AttentionInput { - return { - sessionStatus: undefined, - sessionError: undefined, - activityState: 'inactive', - idleDurationMs: 0, - contextPressure: null, - fileConflictCount: 0, - externalConflictCount: 0, - ...overrides, - }; -} - -describe('scoreAttention', () => { - it('returns zero score when no attention needed', () => { - const result = scoreAttention(makeInput()); - expect(result.score).toBe(0); - expect(result.reason).toBeNull(); - }); - - it('scores error highest after stalled', () => { - const result = scoreAttention(makeInput({ - sessionStatus: 'error', - sessionError: 'Connection refused', - })); - expect(result.score).toBe(90); - expect(result.reason).toContain('Connection refused'); - }); - - it('truncates long error messages to 60 chars', () => { - const longError = 'A'.repeat(100); - const result = scoreAttention(makeInput({ - sessionStatus: 'error', - sessionError: longError, - })); - expect(result.reason!.length).toBeLessThanOrEqual(68); // "Error: " + 60 chars + null safety - }); - - it('scores stalled at 100', () => { - const result = scoreAttention(makeInput({ - activityState: 'stalled', - idleDurationMs: 20 * 60_000, - })); - expect(result.score).toBe(100); - expect(result.reason).toContain('20 min'); - }); - - it('scores critical context pressure (>90%) at 80', () => { - const result = scoreAttention(makeInput({ - activityState: 'running', - contextPressure: 0.95, - })); - expect(result.score).toBe(80); - expect(result.reason).toContain('95%'); - }); - - it('scores file conflicts at 70', () => { - const result = scoreAttention(makeInput({ - activityState: 'running', - fileConflictCount: 3, - })); - expect(result.score).toBe(70); - expect(result.reason).toContain('3 file conflicts'); - }); - - it('includes external conflict note when present', () => { - const result = scoreAttention(makeInput({ - activityState: 'running', - fileConflictCount: 2, - externalConflictCount: 1, - })); - expect(result.reason).toContain('(1 external)'); - }); - - it('scores high context pressure (>75%) at 40', () => { - const result = scoreAttention(makeInput({ - activityState: 'running', - contextPressure: 0.80, - })); - expect(result.score).toBe(40); - expect(result.reason).toContain('80%'); - }); - - it('error takes priority over stalled', () => { - const result = scoreAttention(makeInput({ - sessionStatus: 'error', - sessionError: 'fail', - activityState: 'stalled', - idleDurationMs: 30 * 60_000, - })); - expect(result.score).toBe(90); - }); - - it('stalled takes priority over context pressure', () => { - const result = scoreAttention(makeInput({ - activityState: 'stalled', - idleDurationMs: 20 * 60_000, - contextPressure: 0.95, - })); - expect(result.score).toBe(100); - }); - - it('critical context takes priority over file conflicts', () => { - const result = scoreAttention(makeInput({ - activityState: 'running', - contextPressure: 0.92, - fileConflictCount: 5, - })); - expect(result.score).toBe(80); - }); - - it('file conflicts take priority over high context', () => { - const result = scoreAttention(makeInput({ - activityState: 'running', - contextPressure: 0.78, - fileConflictCount: 1, - })); - expect(result.score).toBe(70); - }); - - it('singular file conflict uses singular grammar', () => { - const result = scoreAttention(makeInput({ - activityState: 'running', - fileConflictCount: 1, - })); - expect(result.reason).toBe('1 file conflict'); - }); - - it('handles undefined session error gracefully', () => { - const result = scoreAttention(makeInput({ - sessionStatus: 'error', - sessionError: undefined, - })); - expect(result.reason).toContain('Unknown'); - }); -}); diff --git a/v2/src/lib/utils/attention-scorer.ts b/v2/src/lib/utils/attention-scorer.ts deleted file mode 100644 index 10c59a7..0000000 --- a/v2/src/lib/utils/attention-scorer.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Attention scoring — pure function extracted from health store -// Determines which project needs attention most urgently - -import type { ActivityState } from '../stores/health.svelte'; - -// Attention score weights (higher = more urgent) -const SCORE_STALLED = 100; -const SCORE_ERROR = 90; -const SCORE_CONTEXT_CRITICAL = 80; // >90% context -const SCORE_FILE_CONFLICT = 70; -const SCORE_CONTEXT_HIGH = 40; // >75% context - -export interface AttentionInput { - sessionStatus: string | undefined; - sessionError: string | undefined; - activityState: ActivityState; - idleDurationMs: number; - contextPressure: number | null; - fileConflictCount: number; - externalConflictCount: number; -} - -export interface AttentionResult { - score: number; - reason: string | null; -} - -/** Score how urgently a project needs human attention. Highest-priority signal wins. */ -export function scoreAttention(input: AttentionInput): AttentionResult { - if (input.sessionStatus === 'error') { - return { - score: SCORE_ERROR, - reason: `Error: ${input.sessionError?.slice(0, 60) ?? 'Unknown'}`, - }; - } - - if (input.activityState === 'stalled') { - const mins = Math.floor(input.idleDurationMs / 60_000); - return { - score: SCORE_STALLED, - reason: `Stalled — ${mins} min since last activity`, - }; - } - - if (input.contextPressure !== null && input.contextPressure > 0.9) { - return { - score: SCORE_CONTEXT_CRITICAL, - reason: `Context ${Math.round(input.contextPressure * 100)}% — near limit`, - }; - } - - if (input.fileConflictCount > 0) { - const extNote = input.externalConflictCount > 0 ? ` (${input.externalConflictCount} external)` : ''; - return { - score: SCORE_FILE_CONFLICT, - reason: `${input.fileConflictCount} file conflict${input.fileConflictCount > 1 ? 's' : ''}${extNote}`, - }; - } - - if (input.contextPressure !== null && input.contextPressure > 0.75) { - return { - score: SCORE_CONTEXT_HIGH, - reason: `Context ${Math.round(input.contextPressure * 100)}%`, - }; - } - - return { score: 0, reason: null }; -} diff --git a/v2/src/lib/utils/auto-anchoring.ts b/v2/src/lib/utils/auto-anchoring.ts deleted file mode 100644 index 604e5c0..0000000 --- a/v2/src/lib/utils/auto-anchoring.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Auto-anchoring — creates session anchors on first compaction event -// Extracted from agent-dispatcher.ts (SRP: anchor creation concern) - -import type { ProjectId as ProjectIdType } from '../types/ids'; -import type { AgentMessage } from '../adapters/claude-messages'; -import type { SessionAnchor } from '../types/anchors'; -import { getAnchorSettings, addAnchors } from '../stores/anchors.svelte'; -import { selectAutoAnchors, serializeAnchorsForInjection } from '../utils/anchor-serializer'; -import { getEnabledProjects } from '../stores/workspace.svelte'; -import { tel } from '../adapters/telemetry-bridge'; -import { notify } from '../stores/notifications.svelte'; - -/** Auto-anchor first N turns on first compaction event for a project */ -export function triggerAutoAnchor( - projectId: ProjectIdType, - messages: AgentMessage[], - sessionPrompt: string, -): void { - const project = getEnabledProjects().find(p => p.id === projectId); - const settings = getAnchorSettings(project?.anchorBudgetScale); - const { turns, totalTokens } = selectAutoAnchors( - messages, - sessionPrompt, - settings.anchorTurns, - settings.anchorTokenBudget, - ); - - if (turns.length === 0) return; - - const nowSecs = Math.floor(Date.now() / 1000); - const anchors: SessionAnchor[] = turns.map((turn) => { - const content = serializeAnchorsForInjection([turn], settings.anchorTokenBudget); - return { - id: crypto.randomUUID(), - projectId, - messageId: `turn-${turn.index}`, - anchorType: 'auto' as const, - content: content, - estimatedTokens: turn.estimatedTokens, - turnIndex: turn.index, - createdAt: nowSecs, - }; - }); - - addAnchors(projectId, anchors); - tel.info('auto_anchor_created', { - projectId, - anchorCount: anchors.length, - totalTokens, - }); - notify('info', `Anchored ${anchors.length} turns (${totalTokens} tokens) for context preservation`); -} diff --git a/v2/src/lib/utils/detach.ts b/v2/src/lib/utils/detach.ts deleted file mode 100644 index 0c57561..0000000 --- a/v2/src/lib/utils/detach.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Detachable pane support — opens panes in separate OS windows -// Uses Tauri's WebviewWindow API - -import { WebviewWindow } from '@tauri-apps/api/webviewWindow'; -import type { Pane } from '../stores/layout.svelte'; - -let detachCounter = 0; - -export async function detachPane(pane: Pane): Promise { - detachCounter++; - const label = `detached-${detachCounter}`; - - const params = new URLSearchParams({ - detached: 'true', - type: pane.type, - title: pane.title, - }); - - if (pane.shell) params.set('shell', pane.shell); - if (pane.cwd) params.set('cwd', pane.cwd); - if (pane.args) params.set('args', JSON.stringify(pane.args)); - if (pane.type === 'agent') params.set('sessionId', pane.id); - - const webview = new WebviewWindow(label, { - url: `index.html?${params.toString()}`, - title: `BTerminal — ${pane.title}`, - width: 800, - height: 600, - decorations: true, - resizable: true, - }); - - // Wait for the window to be created - await webview.once('tauri://created', () => { - // Window created successfully - }); - - await webview.once('tauri://error', (e) => { - console.error('Failed to create detached window:', e); - }); -} - -export function isDetachedMode(): boolean { - const params = new URLSearchParams(window.location.search); - return params.get('detached') === 'true'; -} - -export function getDetachedConfig(): { - type: string; - title: string; - shell?: string; - cwd?: string; - args?: string[]; - sessionId?: string; -} | null { - const params = new URLSearchParams(window.location.search); - if (params.get('detached') !== 'true') return null; - - const argsStr = params.get('args'); - return { - type: params.get('type') ?? 'terminal', - title: params.get('title') ?? 'Detached', - shell: params.get('shell') ?? undefined, - cwd: params.get('cwd') ?? undefined, - args: argsStr ? JSON.parse(argsStr) : undefined, - sessionId: params.get('sessionId') ?? undefined, - }; -} diff --git a/v2/src/lib/utils/highlight.ts b/v2/src/lib/utils/highlight.ts deleted file mode 100644 index 4b15697..0000000 --- a/v2/src/lib/utils/highlight.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { createHighlighter, type Highlighter } from 'shiki'; - -let highlighter: Highlighter | null = null; -let initPromise: Promise | null = null; - -// Use catppuccin-mocha theme (bundled with shiki) -const THEME = 'catppuccin-mocha'; - -// Common languages to preload -const LANGS = [ - 'typescript', 'javascript', 'rust', 'python', 'bash', - 'json', 'html', 'css', 'svelte', 'sql', 'yaml', 'toml', 'markdown', -]; - -export async function getHighlighter(): Promise { - if (highlighter) return highlighter; - if (initPromise) return initPromise; - - initPromise = createHighlighter({ - themes: [THEME], - langs: LANGS, - }); - - highlighter = await initPromise; - return highlighter; -} - -export function highlightCode(code: string, lang: string): string { - if (!highlighter) return escapeHtml(code); - - try { - const loadedLangs = highlighter.getLoadedLanguages(); - if (!loadedLangs.includes(lang as any)) { - return escapeHtml(code); - } - - return highlighter.codeToHtml(code, { - lang, - theme: THEME, - }); - } catch { - return escapeHtml(code); - } -} - -export function escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>'); -} diff --git a/v2/src/lib/utils/session-persistence.ts b/v2/src/lib/utils/session-persistence.ts deleted file mode 100644 index da1825f..0000000 --- a/v2/src/lib/utils/session-persistence.ts +++ /dev/null @@ -1,118 +0,0 @@ -// Session persistence — maps session IDs to projects/providers and persists state to SQLite -// Extracted from agent-dispatcher.ts (SRP: persistence concern) - -import type { SessionId as SessionIdType, ProjectId as ProjectIdType } from '../types/ids'; -import type { ProviderId } from '../providers/types'; -import { getAgentSession } from '../stores/agents.svelte'; -import { - saveProjectAgentState, - saveAgentMessages, - saveSessionMetric, - type AgentMessageRecord, -} from '../adapters/groups-bridge'; - -// Map sessionId -> projectId for persistence routing -const sessionProjectMap = new Map(); - -// Map sessionId -> provider for message adapter routing -const sessionProviderMap = new Map(); - -// Map sessionId -> start timestamp for metrics -const sessionStartTimes = new Map(); - -// In-flight persistence counter — prevents teardown from racing with async saves -let pendingPersistCount = 0; - -export function registerSessionProject(sessionId: SessionIdType, projectId: ProjectIdType, provider: ProviderId = 'claude'): void { - sessionProjectMap.set(sessionId, projectId); - sessionProviderMap.set(sessionId, provider); -} - -export function getSessionProjectId(sessionId: SessionIdType): ProjectIdType | undefined { - return sessionProjectMap.get(sessionId); -} - -export function getSessionProvider(sessionId: SessionIdType): ProviderId { - return sessionProviderMap.get(sessionId) ?? 'claude'; -} - -export function recordSessionStart(sessionId: SessionIdType): void { - sessionStartTimes.set(sessionId, Date.now()); -} - -/** Wait until all in-flight persistence operations complete */ -export async function waitForPendingPersistence(): Promise { - while (pendingPersistCount > 0) { - await new Promise(r => setTimeout(r, 10)); - } -} - -/** Persist session state + messages to SQLite for the project that owns this session */ -export async function persistSessionForProject(sessionId: SessionIdType): Promise { - const projectId = sessionProjectMap.get(sessionId); - if (!projectId) return; // Not a project-scoped session - - const session = getAgentSession(sessionId); - if (!session) return; - - pendingPersistCount++; - try { - // Save agent state - await saveProjectAgentState({ - project_id: projectId, - last_session_id: sessionId, - sdk_session_id: session.sdkSessionId ?? null, - status: session.status, - cost_usd: session.costUsd, - input_tokens: session.inputTokens, - output_tokens: session.outputTokens, - last_prompt: session.prompt, - updated_at: Math.floor(Date.now() / 1000), - }); - - // Save messages (use seconds to match session.rs convention) - const nowSecs = Math.floor(Date.now() / 1000); - const records: AgentMessageRecord[] = session.messages.map((m, i) => ({ - id: i, - session_id: sessionId, - project_id: projectId, - sdk_session_id: session.sdkSessionId ?? null, - message_type: m.type, - content: JSON.stringify(m.content), - parent_id: m.parentId ?? null, - created_at: nowSecs, - })); - - if (records.length > 0) { - await saveAgentMessages(sessionId, projectId, session.sdkSessionId, records); - } - - // Persist session metric for historical tracking - const toolCallCount = session.messages.filter(m => m.type === 'tool_call').length; - const startTime = sessionStartTimes.get(sessionId) ?? Math.floor(Date.now() / 1000); - await saveSessionMetric({ - project_id: projectId, - session_id: sessionId, - start_time: Math.floor(startTime / 1000), - end_time: nowSecs, - peak_tokens: session.inputTokens + session.outputTokens, - turn_count: session.numTurns, - tool_call_count: toolCallCount, - cost_usd: session.costUsd, - model: session.model ?? null, - status: session.status, - error_message: session.error ?? null, - }); - } catch (e) { - console.warn('Failed to persist agent session:', e); - } finally { - pendingPersistCount--; - } -} - -/** Clear all session maps — called on dispatcher shutdown */ -export function clearSessionMaps(): void { - sessionProjectMap.clear(); - sessionProviderMap.clear(); - sessionStartTimes.clear(); -} diff --git a/v2/src/lib/utils/subagent-router.ts b/v2/src/lib/utils/subagent-router.ts deleted file mode 100644 index 5576a15..0000000 --- a/v2/src/lib/utils/subagent-router.ts +++ /dev/null @@ -1,78 +0,0 @@ -// Subagent routing — manages subagent pane creation and message routing -// Extracted from agent-dispatcher.ts (SRP: subagent lifecycle concern) - -import type { ToolCallContent } from '../adapters/claude-messages'; -import { - createAgentSession, - updateAgentStatus, - findChildByToolUseId, -} from '../stores/agents.svelte'; -import { addPane, getPanes } from '../stores/layout.svelte'; -import { getSessionProjectId } from './session-persistence'; - -// Tool names that indicate a subagent spawn -const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task', 'dispatch_agent']); - -// Map toolUseId -> child session pane id for routing -const toolUseToChildPane = new Map(); - -/** Check if a tool call is a subagent spawn */ -export function isSubagentToolCall(toolName: string): boolean { - return SUBAGENT_TOOL_NAMES.has(toolName); -} - -/** Get the child pane ID for a given toolUseId */ -export function getChildPaneId(toolUseId: string): string | undefined { - return toolUseToChildPane.get(toolUseId); -} - -/** Check if a toolUseId has been mapped to a child pane */ -export function hasChildPane(toolUseId: string): boolean { - return toolUseToChildPane.has(toolUseId); -} - -export function spawnSubagentPane(parentSessionId: string, tc: ToolCallContent): void { - // Don't create duplicate pane for same tool_use - if (toolUseToChildPane.has(tc.toolUseId)) return; - const existing = findChildByToolUseId(parentSessionId, tc.toolUseId); - if (existing) { - toolUseToChildPane.set(tc.toolUseId, existing.id); - return; - } - - const childId = crypto.randomUUID(); - const prompt = typeof tc.input === 'object' && tc.input !== null - ? (tc.input as Record).prompt as string ?? tc.name - : tc.name; - const label = typeof tc.input === 'object' && tc.input !== null - ? (tc.input as Record).name as string ?? tc.name - : tc.name; - - // Register routing - toolUseToChildPane.set(tc.toolUseId, childId); - - // Create agent session with parent link - createAgentSession(childId, prompt, { - sessionId: parentSessionId, - toolUseId: tc.toolUseId, - }); - updateAgentStatus(childId, 'running'); - - // For project-scoped sessions, subagents render in TeamAgentsPanel (no layout pane) - // For non-project sessions (detached mode), create a layout pane - if (!getSessionProjectId(parentSessionId)) { - const parentPane = getPanes().find(p => p.id === parentSessionId); - const groupName = parentPane?.title ?? `Agent ${parentSessionId.slice(0, 8)}`; - addPane({ - id: childId, - type: 'agent', - title: `Sub: ${label}`, - group: groupName, - }); - } -} - -/** Clear subagent routing maps — called on dispatcher shutdown */ -export function clearSubagentRoutes(): void { - toolUseToChildPane.clear(); -} diff --git a/v2/src/lib/utils/tool-files.test.ts b/v2/src/lib/utils/tool-files.test.ts deleted file mode 100644 index be20735..0000000 --- a/v2/src/lib/utils/tool-files.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { extractFilePaths, extractWritePaths, extractWorktreePath } from './tool-files'; -import type { ToolCallContent } from '../adapters/claude-messages'; - -function makeTc(name: string, input: unknown): ToolCallContent { - return { toolUseId: `tu-${Math.random()}`, name, input }; -} - -describe('extractFilePaths', () => { - it('extracts Read file_path', () => { - const result = extractFilePaths(makeTc('Read', { file_path: '/src/main.ts' })); - expect(result).toEqual([{ path: '/src/main.ts', op: 'read' }]); - }); - - it('extracts Write file_path as write op', () => { - const result = extractFilePaths(makeTc('Write', { file_path: '/src/out.ts' })); - expect(result).toEqual([{ path: '/src/out.ts', op: 'write' }]); - }); - - it('extracts Edit file_path as write op', () => { - const result = extractFilePaths(makeTc('Edit', { file_path: '/src/edit.ts' })); - expect(result).toEqual([{ path: '/src/edit.ts', op: 'write' }]); - }); - - it('extracts Glob pattern', () => { - const result = extractFilePaths(makeTc('Glob', { pattern: '**/*.ts' })); - expect(result).toEqual([{ path: '**/*.ts', op: 'glob' }]); - }); - - it('extracts Grep path', () => { - const result = extractFilePaths(makeTc('Grep', { path: '/src', pattern: 'TODO' })); - expect(result).toEqual([{ path: '/src', op: 'grep' }]); - }); - - it('extracts Bash read paths from common commands', () => { - const result = extractFilePaths(makeTc('Bash', { command: 'cat /etc/hosts' })); - expect(result).toEqual([{ path: '/etc/hosts', op: 'bash' }]); - }); - - it('handles lowercase tool names', () => { - const result = extractFilePaths(makeTc('read', { file_path: '/foo' })); - expect(result).toEqual([{ path: '/foo', op: 'read' }]); - }); - - it('returns empty for unknown tool', () => { - const result = extractFilePaths(makeTc('Agent', { prompt: 'do stuff' })); - expect(result).toEqual([]); - }); - - it('returns empty when input has no file_path', () => { - const result = extractFilePaths(makeTc('Read', {})); - expect(result).toEqual([]); - }); - - // Bash write detection - it('detects echo > redirect as write', () => { - const result = extractFilePaths(makeTc('Bash', { command: 'echo "hello" > /tmp/out.txt' })); - expect(result).toEqual([{ path: '/tmp/out.txt', op: 'write' }]); - }); - - it('detects >> append redirect as write', () => { - const result = extractFilePaths(makeTc('Bash', { command: 'echo "data" >> /tmp/log.txt' })); - expect(result).toEqual([{ path: '/tmp/log.txt', op: 'write' }]); - }); - - it('detects sed -i as write', () => { - const result = extractFilePaths(makeTc('Bash', { command: "sed -i 's/foo/bar/g' /src/config.ts" })); - expect(result).toEqual([{ path: '/src/config.ts', op: 'write' }]); - }); - - it('detects tee as write', () => { - const result = extractFilePaths(makeTc('Bash', { command: 'echo "content" | tee /tmp/output.log' })); - expect(result).toEqual([{ path: '/tmp/output.log', op: 'write' }]); - }); - - it('detects tee -a as write', () => { - const result = extractFilePaths(makeTc('Bash', { command: 'echo "append" | tee -a /tmp/output.log' })); - expect(result).toEqual([{ path: '/tmp/output.log', op: 'write' }]); - }); - - it('detects cp destination as write', () => { - const result = extractFilePaths(makeTc('Bash', { command: 'cp /src/a.ts /src/b.ts' })); - expect(result).toEqual([{ path: '/src/b.ts', op: 'write' }]); - }); - - it('detects mv destination as write', () => { - const result = extractFilePaths(makeTc('Bash', { command: 'mv /old/file.ts /new/file.ts' })); - expect(result).toEqual([{ path: '/new/file.ts', op: 'write' }]); - }); - - it('ignores /dev/null redirects', () => { - const result = extractFilePaths(makeTc('Bash', { command: 'echo "test" > /dev/null' })); - expect(result).toEqual([]); - }); - - it('prefers write detection over read for ambiguous commands', () => { - // "cat file > out" should detect the write target, not the read source - const result = extractFilePaths(makeTc('Bash', { command: 'cat /src/input.ts > /tmp/output.ts' })); - expect(result).toEqual([{ path: '/tmp/output.ts', op: 'write' }]); - }); -}); - -describe('extractWritePaths', () => { - it('returns only write-op paths for Write/Edit tools', () => { - expect(extractWritePaths(makeTc('Write', { file_path: '/a.ts' }))).toEqual(['/a.ts']); - expect(extractWritePaths(makeTc('Edit', { file_path: '/b.ts' }))).toEqual(['/b.ts']); - }); - - it('returns empty for read-only tools', () => { - expect(extractWritePaths(makeTc('Read', { file_path: '/c.ts' }))).toEqual([]); - expect(extractWritePaths(makeTc('Glob', { pattern: '*.ts' }))).toEqual([]); - expect(extractWritePaths(makeTc('Grep', { path: '/src' }))).toEqual([]); - }); - - it('returns empty for bash read commands', () => { - expect(extractWritePaths(makeTc('Bash', { command: 'cat /foo' }))).toEqual([]); - }); - - it('detects bash write commands', () => { - expect(extractWritePaths(makeTc('Bash', { command: 'echo "x" > /tmp/out.ts' }))).toEqual(['/tmp/out.ts']); - expect(extractWritePaths(makeTc('Bash', { command: "sed -i 's/a/b/' /src/file.ts" }))).toEqual(['/src/file.ts']); - expect(extractWritePaths(makeTc('Bash', { command: 'cp /a.ts /b.ts' }))).toEqual(['/b.ts']); - }); -}); - -describe('extractWorktreePath', () => { - it('detects Agent tool with isolation: worktree', () => { - const result = extractWorktreePath(makeTc('Agent', { prompt: 'do stuff', isolation: 'worktree' })); - expect(result).toMatch(/^worktree:/); - }); - - it('detects Task tool with isolation: worktree', () => { - const result = extractWorktreePath(makeTc('Task', { prompt: 'do stuff', isolation: 'worktree' })); - expect(result).toMatch(/^worktree:/); - }); - - it('returns null for Agent without isolation', () => { - expect(extractWorktreePath(makeTc('Agent', { prompt: 'do stuff' }))).toBeNull(); - }); - - it('detects EnterWorktree with path', () => { - expect(extractWorktreePath(makeTc('EnterWorktree', { path: '/tmp/wt-1' }))).toBe('/tmp/wt-1'); - }); - - it('returns null for unrelated tool', () => { - expect(extractWorktreePath(makeTc('Read', { file_path: '/foo' }))).toBeNull(); - }); -}); diff --git a/v2/src/lib/utils/tool-files.ts b/v2/src/lib/utils/tool-files.ts deleted file mode 100644 index 05ba8aa..0000000 --- a/v2/src/lib/utils/tool-files.ts +++ /dev/null @@ -1,120 +0,0 @@ -// Extracts file paths from agent tool_call inputs -// Used by ContextTab (all file ops) and conflicts store (write ops only) - -import type { ToolCallContent } from '../adapters/claude-messages'; - -export interface ToolFileRef { - path: string; - op: 'read' | 'write' | 'glob' | 'grep' | 'bash'; -} - -// Patterns for read-like bash commands -const BASH_READ_RE = /(?:cat|head|tail|less|vim|nano|code)\s+["']?([^\s"'|;&]+)/; - -// Patterns for bash commands that write to files -const BASH_WRITE_PATTERNS: RegExp[] = [ - // Redirection: echo/printf/cat ... > file or >> file - /(?:>>?)\s*["']?([^\s"'|;&]+)/, - // sed -i (in-place edit) - /\bsed\s+(?:-[^i\s]*)?-i[^-]?\s*(?:'[^']*'|"[^"]*"|[^\s]+\s+)["']?([^\s"'|;&]+)/, - // tee file - /\btee\s+(?:-a\s+)?["']?([^\s"'|;&]+)/, - // cp source dest — last arg is destination - /\bcp\s+(?:-[^\s]*\s+)*[^\s]+\s+["']?([^\s"'|;&]+)/, - // mv source dest — last arg is destination - /\bmv\s+(?:-[^\s]*\s+)*[^\s]+\s+["']?([^\s"'|;&]+)/, - // chmod/chown — modifies file metadata - /\b(?:chmod|chown)\s+(?:-[^\s]*\s+)*[^\s]+\s+["']?([^\s"'|;&]+)/, -]; - -/** Extract file paths referenced by a tool call */ -export function extractFilePaths(tc: ToolCallContent): ToolFileRef[] { - const results: ToolFileRef[] = []; - const input = tc.input as Record; - - switch (tc.name) { - case 'Read': - case 'read': - if (input?.file_path) results.push({ path: String(input.file_path), op: 'read' }); - break; - case 'Write': - case 'write': - if (input?.file_path) results.push({ path: String(input.file_path), op: 'write' }); - break; - case 'Edit': - case 'edit': - if (input?.file_path) results.push({ path: String(input.file_path), op: 'write' }); - break; - case 'Glob': - case 'glob': - if (input?.pattern) results.push({ path: String(input.pattern), op: 'glob' }); - break; - case 'Grep': - case 'grep': - if (input?.path) results.push({ path: String(input.path), op: 'grep' }); - break; - case 'Bash': - case 'bash': { - const cmd = String(input?.command ?? ''); - // Check for write patterns first - const writeRefs = extractBashWritePaths(cmd); - for (const path of writeRefs) { - results.push({ path, op: 'write' }); - } - // Check for read patterns (only if no write detected to avoid double-counting) - if (writeRefs.length === 0) { - const readMatch = cmd.match(BASH_READ_RE); - if (readMatch) results.push({ path: readMatch[1], op: 'bash' }); - } - break; - } - } - return results; -} - -/** Extract write-target file paths from a bash command string */ -function extractBashWritePaths(cmd: string): string[] { - const paths: string[] = []; - const seen = new Set(); - - for (const pattern of BASH_WRITE_PATTERNS) { - const match = cmd.match(pattern); - if (match && match[1] && !seen.has(match[1])) { - // Filter out obvious non-file targets (flags, -, /dev/null) - const target = match[1]; - if (target === '-' || target.startsWith('-') || target === '/dev/null') continue; - seen.add(target); - paths.push(target); - } - } - - return paths; -} - -/** Extract only write-operation file paths (Write, Edit, and Bash writes) */ -export function extractWritePaths(tc: ToolCallContent): string[] { - return extractFilePaths(tc) - .filter(r => r.op === 'write') - .map(r => r.path); -} - -/** Extract worktree path from an Agent/Task tool call with isolation: "worktree", or EnterWorktree */ -export function extractWorktreePath(tc: ToolCallContent): string | null { - const input = tc.input as Record | null; - if (!input) return null; - - const name = tc.name; - // Agent/Task tool with isolation: "worktree" - if ((name === 'Agent' || name === 'Task' || name === 'dispatch_agent') && input.isolation === 'worktree') { - // The worktree path comes from the tool_result, not the tool_call. - // But we can flag this session as "worktree-isolated" with a synthetic marker. - return `worktree:${tc.toolUseId}`; - } - - // EnterWorktree tool call carries the path directly - if (name === 'EnterWorktree' && typeof input.path === 'string') { - return input.path; - } - - return null; -} diff --git a/v2/src/lib/utils/type-guards.ts b/v2/src/lib/utils/type-guards.ts deleted file mode 100644 index 8af4b72..0000000 --- a/v2/src/lib/utils/type-guards.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Runtime type guards for safely extracting values from untyped wire formats - -/** Returns value if it's a string, fallback otherwise */ -export function str(v: unknown, fallback = ''): string { - return typeof v === 'string' ? v : fallback; -} - -/** Returns value if it's a number, fallback otherwise */ -export function num(v: unknown, fallback = 0): number { - return typeof v === 'number' ? v : fallback; -} diff --git a/v2/src/lib/utils/updater.ts b/v2/src/lib/utils/updater.ts deleted file mode 100644 index 5a1f0c9..0000000 --- a/v2/src/lib/utils/updater.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Auto-update checker — uses Tauri updater plugin -// Requires signing key to be configured in tauri.conf.json before use - -import { check } from '@tauri-apps/plugin-updater'; - -export async function checkForUpdates(): Promise<{ - available: boolean; - version?: string; - notes?: string; -}> { - try { - const update = await check(); - if (update) { - return { - available: true, - version: update.version, - notes: update.body ?? undefined, - }; - } - return { available: false }; - } catch { - // Updater not configured or network error — silently skip - return { available: false }; - } -} - -export async function installUpdate(): Promise { - const update = await check(); - if (update) { - await update.downloadAndInstall(); - } -} diff --git a/v2/src/lib/utils/worktree-detection.test.ts b/v2/src/lib/utils/worktree-detection.test.ts deleted file mode 100644 index 10af0dd..0000000 --- a/v2/src/lib/utils/worktree-detection.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { detectWorktreeFromCwd } from './worktree-detection'; - -describe('detectWorktreeFromCwd', () => { - it('detects Claude Code worktree path', () => { - const result = detectWorktreeFromCwd('/home/user/project/.claude/worktrees/my-session'); - expect(result).toBe('/.claude/worktrees/my-session'); - }); - - it('detects Codex worktree path', () => { - const result = detectWorktreeFromCwd('/home/user/project/.codex/worktrees/task-1'); - expect(result).toBe('/.codex/worktrees/task-1'); - }); - - it('detects Cursor worktree path', () => { - const result = detectWorktreeFromCwd('/home/user/project/.cursor/worktrees/feature-x'); - expect(result).toBe('/.cursor/worktrees/feature-x'); - }); - - it('returns null for non-worktree CWD', () => { - expect(detectWorktreeFromCwd('/home/user/project')).toBeNull(); - expect(detectWorktreeFromCwd('/tmp/work')).toBeNull(); - }); - - it('returns null for empty string', () => { - expect(detectWorktreeFromCwd('')).toBeNull(); - }); -}); diff --git a/v2/src/lib/utils/worktree-detection.ts b/v2/src/lib/utils/worktree-detection.ts deleted file mode 100644 index 745696b..0000000 --- a/v2/src/lib/utils/worktree-detection.ts +++ /dev/null @@ -1,17 +0,0 @@ -// Worktree path detection — extracts worktree paths from CWD strings -// Used by agent-dispatcher for conflict suppression (agents in different worktrees don't conflict) - -const WORKTREE_CWD_PATTERNS = [ - /\/\.claude\/worktrees\/([^/]+)/, // Claude Code: /.claude/worktrees// - /\/\.codex\/worktrees\/([^/]+)/, // Codex - /\/\.cursor\/worktrees\/([^/]+)/, // Cursor -]; - -/** Extract worktree path from CWD if it matches a known worktree pattern */ -export function detectWorktreeFromCwd(cwd: string): string | null { - for (const pattern of WORKTREE_CWD_PATTERNS) { - const match = cwd.match(pattern); - if (match) return match[0]; // Return the full worktree path segment - } - return null; -} diff --git a/v2/src/main.ts b/v2/src/main.ts deleted file mode 100644 index 664a057..0000000 --- a/v2/src/main.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { mount } from 'svelte' -import './app.css' -import App from './App.svelte' - -const app = mount(App, { - target: document.getElementById('app')!, -}) - -export default app diff --git a/v2/svelte.config.js b/v2/svelte.config.js deleted file mode 100644 index 96b3455..0000000 --- a/v2/svelte.config.js +++ /dev/null @@ -1,8 +0,0 @@ -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' - -/** @type {import("@sveltejs/vite-plugin-svelte").SvelteConfig} */ -export default { - // Consult https://svelte.dev/docs#compile-time-svelte-preprocess - // for more information about preprocessors - preprocess: vitePreprocess(), -} diff --git a/v2/tests/e2e/README.md b/v2/tests/e2e/README.md deleted file mode 100644 index 4955997..0000000 --- a/v2/tests/e2e/README.md +++ /dev/null @@ -1,71 +0,0 @@ -# E2E Tests (WebDriver) - -Tauri apps use the WebDriver protocol for E2E testing (not Playwright directly). -The app runs inside WebKit2GTK on Linux, so tests interact with the real WebView. - -## Prerequisites - -- Rust toolchain (for building the Tauri app) -- Display server (X11 or Wayland) — headless Xvfb works for CI -- `tauri-driver` installed: `cargo install tauri-driver` -- `webkit2gtk-driver` system package: `sudo apt install webkit2gtk-driver` -- npm devDeps already in package.json (`@wdio/cli`, `@wdio/local-runner`, `@wdio/mocha-framework`, `@wdio/spec-reporter`) - -## Running - -```bash -# From v2/ directory — builds debug binary automatically, spawns tauri-driver -npm run test:e2e -``` - -The `wdio.conf.js` handles: -1. Building the debug binary (`cargo tauri build --debug --no-bundle`) in `onPrepare` -2. Spawning `tauri-driver` before each session -3. Killing `tauri-driver` after each session - -## CI setup (headless) - -```bash -# Install virtual framebuffer + WebKit driver -sudo apt install xvfb webkit2gtk-driver - -# Run with Xvfb wrapper -xvfb-run npm run test:e2e -``` - -## Writing tests - -Tests use WebdriverIO with Mocha. Specs go in `specs/`: - -```typescript -import { browser, expect } from '@wdio/globals'; - -describe('BTerminal', () => { - it('should show the status bar', async () => { - const statusBar = await browser.$('.status-bar'); - await expect(statusBar).toBeDisplayed(); - }); -}); -``` - -Key constraints: -- `maxInstances: 1` — Tauri doesn't support parallel WebDriver sessions -- Mocha timeout is 60s — the app needs time to initialize -- Tests interact with the real WebKit2GTK WebView, not a browser - -## File structure - -``` -tests/e2e/ -├── README.md # This file -├── wdio.conf.js # WebdriverIO config with tauri-driver lifecycle -├── tsconfig.json # TypeScript config for test specs -└── specs/ - └── smoke.test.ts # Basic smoke tests (app renders, sidebar toggle) -``` - -## References - -- Tauri WebDriver docs: https://v2.tauri.app/develop/tests/webdriver/ -- WebdriverIO docs: https://webdriver.io/ -- tauri-driver: https://crates.io/crates/tauri-driver diff --git a/v2/tests/e2e/specs/bterminal.test.ts b/v2/tests/e2e/specs/bterminal.test.ts deleted file mode 100644 index ced3c5e..0000000 --- a/v2/tests/e2e/specs/bterminal.test.ts +++ /dev/null @@ -1,705 +0,0 @@ -import { browser, expect } from '@wdio/globals'; - -// All E2E tests run in a single spec file because Tauri launches one app -// instance per session, and tauri-driver doesn't support re-creating sessions. - -describe('BTerminal — Smoke Tests', () => { - it('should render the application window', async () => { - const title = await browser.getTitle(); - expect(title).toBe('BTerminal'); - }); - - it('should display the status bar', async () => { - const statusBar = await browser.$('.status-bar'); - await expect(statusBar).toBeDisplayed(); - }); - - it('should show version text in status bar', async () => { - const version = await browser.$('.status-bar .version'); - await expect(version).toBeDisplayed(); - const text = await version.getText(); - expect(text).toContain('BTerminal'); - }); - - it('should display the sidebar rail', async () => { - const sidebarRail = await browser.$('.sidebar-rail'); - await expect(sidebarRail).toBeDisplayed(); - }); - - it('should display the workspace area', async () => { - const workspace = await browser.$('.workspace'); - await expect(workspace).toBeDisplayed(); - }); - - it('should toggle sidebar with settings button', async () => { - const settingsBtn = await browser.$('.rail-btn'); - await settingsBtn.click(); - - const sidebarPanel = await browser.$('.sidebar-panel'); - await expect(sidebarPanel).toBeDisplayed(); - - // Click again to close - await settingsBtn.click(); - await expect(sidebarPanel).not.toBeDisplayed(); - }); -}); - -describe('BTerminal — Workspace & Projects', () => { - it('should display the project grid', async () => { - const grid = await browser.$('.project-grid'); - await expect(grid).toBeDisplayed(); - }); - - it('should render at least one project box', async () => { - const boxes = await browser.$$('.project-box'); - expect(boxes.length).toBeGreaterThanOrEqual(1); - }); - - it('should show project header with name', async () => { - const header = await browser.$('.project-header'); - await expect(header).toBeDisplayed(); - - const name = await browser.$('.project-name'); - const text = await name.getText(); - expect(text.length).toBeGreaterThan(0); - }); - - it('should show project-level tabs (Claude, Files, Context)', async () => { - const box = await browser.$('.project-box'); - const tabs = await box.$$('.ptab'); - expect(tabs.length).toBe(3); - }); - - it('should highlight active project on click', async () => { - const header = await browser.$('.project-header'); - await header.click(); - - const activeBox = await browser.$('.project-box.active'); - await expect(activeBox).toBeDisplayed(); - }); - - it('should switch project tabs', async () => { - // Use JS click — WebDriver clicks don't always trigger Svelte onclick - // on buttons inside complex components via WebKit2GTK/tauri-driver - const switched = await browser.execute(() => { - const box = document.querySelector('.project-box'); - if (!box) return false; - const tabs = box.querySelectorAll('.ptab'); - if (tabs.length < 2) return false; - (tabs[1] as HTMLElement).click(); - return true; - }); - expect(switched).toBe(true); - await browser.pause(500); - - const box = await browser.$('.project-box'); - const activeTab = await box.$('.ptab.active'); - const text = await activeTab.getText(); - expect(text.toLowerCase()).toContain('files'); - - // Switch back to Claude tab - await browser.execute(() => { - const tab = document.querySelector('.project-box .ptab'); - if (tab) (tab as HTMLElement).click(); - }); - await browser.pause(300); - }); - - it('should display the status bar with project count', async () => { - const statusBar = await browser.$('.status-bar .left'); - const text = await statusBar.getText(); - expect(text).toContain('projects'); - }); - - it('should display agent count in status bar', async () => { - const statusBar = await browser.$('.status-bar .left'); - const text = await statusBar.getText(); - expect(text).toContain('agents'); - }); -}); - -describe('BTerminal — Settings Panel', () => { - before(async () => { - // Open settings panel - const settingsBtn = await browser.$('.rail-btn'); - await settingsBtn.click(); - const panel = await browser.$('.sidebar-panel'); - await panel.waitForDisplayed({ timeout: 5000 }); - }); - - after(async () => { - // Close settings if still open — use keyboard shortcut as most reliable method - const panel = await browser.$('.sidebar-panel'); - if (await panel.isDisplayed()) { - await browser.keys('Escape'); - await browser.pause(500); - } - }); - - it('should display the settings tab container', async () => { - const settingsTab = await browser.$('.settings-tab'); - await expect(settingsTab).toBeDisplayed(); - }); - - it('should show settings sections', async () => { - const sections = await browser.$$('.settings-section'); - expect(sections.length).toBeGreaterThanOrEqual(1); - }); - - it('should display theme dropdown', async () => { - const dropdown = await browser.$('.custom-dropdown .dropdown-trigger'); - await expect(dropdown).toBeDisplayed(); - }); - - it('should open theme dropdown and show options', async () => { - // Use JS click — WebDriver clicks don't reliably trigger Svelte onclick - // on buttons inside scrollable panels via WebKit2GTK/tauri-driver - await browser.execute(() => { - const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); - if (trigger) (trigger as HTMLElement).click(); - }); - await browser.pause(500); - - const menu = await browser.$('.dropdown-menu'); - await menu.waitForExist({ timeout: 3000 }); - - const options = await browser.$$('.dropdown-option'); - expect(options.length).toBeGreaterThan(0); - - // Close dropdown by clicking trigger again - await browser.execute(() => { - const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); - if (trigger) (trigger as HTMLElement).click(); - }); - await browser.pause(300); - }); - - it('should display group list', async () => { - const groupList = await browser.$('.group-list'); - await expect(groupList).toBeDisplayed(); - }); - - it('should close settings panel with close button', async () => { - // Ensure settings is open - const panel = await browser.$('.sidebar-panel'); - if (!(await panel.isDisplayed())) { - const settingsBtn = await browser.$('.rail-btn'); - await settingsBtn.click(); - await panel.waitForDisplayed({ timeout: 3000 }); - } - - // Use JS click for reliability - await browser.execute(() => { - const btn = document.querySelector('.panel-close'); - if (btn) (btn as HTMLElement).click(); - }); - await browser.pause(500); - - await expect(panel).not.toBeDisplayed(); - }); -}); - -describe('BTerminal — Command Palette', () => { - beforeEach(async () => { - // Ensure palette is closed before each test - const palette = await browser.$('.palette'); - if (await palette.isExisting() && await palette.isDisplayed()) { - await browser.keys('Escape'); - await browser.pause(300); - } - }); - - it('should show palette input with autofocus', async () => { - await browser.execute(() => document.body.focus()); - await browser.pause(200); - await browser.keys(['Control', 'k']); - - const palette = await browser.$('.palette'); - await palette.waitForDisplayed({ timeout: 3000 }); - - const input = await browser.$('.palette-input'); - await expect(input).toBeDisplayed(); - - // Input should have focus (check by verifying it's the active element) - const isFocused = await browser.execute(() => { - return document.activeElement?.classList.contains('palette-input'); - }); - expect(isFocused).toBe(true); - - await browser.keys('Escape'); - await browser.pause(300); - }); - - it('should show palette items with group names and project counts', async () => { - await browser.keys(['Control', 'k']); - const palette = await browser.$('.palette'); - await palette.waitForDisplayed({ timeout: 3000 }); - - const items = await browser.$$('.palette-item'); - expect(items.length).toBeGreaterThanOrEqual(1); - - // Each item should have group name and project count - const groupName = await browser.$('.palette-item .group-name'); - await expect(groupName).toBeDisplayed(); - const nameText = await groupName.getText(); - expect(nameText.length).toBeGreaterThan(0); - - const projectCount = await browser.$('.palette-item .project-count'); - await expect(projectCount).toBeDisplayed(); - - await browser.keys('Escape'); - await browser.pause(300); - }); - - it('should mark active group in palette', async () => { - await browser.keys(['Control', 'k']); - const palette = await browser.$('.palette'); - await palette.waitForDisplayed({ timeout: 3000 }); - - const activeItem = await browser.$('.palette-item.active'); - await expect(activeItem).toBeDisplayed(); - - await browser.keys('Escape'); - await browser.pause(300); - }); - - it('should filter palette items by typing', async () => { - await browser.keys(['Control', 'k']); - const palette = await browser.$('.palette'); - await palette.waitForDisplayed({ timeout: 3000 }); - - const itemsBefore = await browser.$$('.palette-item'); - const countBefore = itemsBefore.length; - - // Type a nonsense string that won't match any group name - const input = await browser.$('.palette-input'); - await input.setValue('zzz_nonexistent_group_xyz'); - await browser.pause(300); - - // Should show no results or fewer items - const noResults = await browser.$('.no-results'); - const itemsAfter = await browser.$$('.palette-item'); - // Either no-results message appears OR item count decreased - const filtered = (await noResults.isExisting()) || itemsAfter.length < countBefore; - expect(filtered).toBe(true); - - await browser.keys('Escape'); - await browser.pause(300); - }); - - it('should close palette by clicking backdrop', async () => { - await browser.keys(['Control', 'k']); - const palette = await browser.$('.palette'); - await palette.waitForDisplayed({ timeout: 3000 }); - - // Click the backdrop (outside the palette) - await browser.execute(() => { - const backdrop = document.querySelector('.palette-backdrop'); - if (backdrop) (backdrop as HTMLElement).click(); - }); - await browser.pause(500); - - await expect(palette).not.toBeDisplayed(); - }); -}); - -describe('BTerminal — Terminal Tabs', () => { - before(async () => { - // Ensure Claude tab is active so terminal section is visible - await browser.execute(() => { - const tab = document.querySelector('.project-box .ptab'); - if (tab) (tab as HTMLElement).click(); - }); - await browser.pause(300); - }); - - it('should show terminal toggle on Claude tab', async () => { - const toggle = await browser.$('.terminal-toggle'); - await expect(toggle).toBeDisplayed(); - - const label = await browser.$('.toggle-label'); - const text = await label.getText(); - expect(text.toLowerCase()).toContain('terminal'); - }); - - it('should expand terminal area on toggle click', async () => { - // Click terminal toggle via JS - await browser.execute(() => { - const toggle = document.querySelector('.terminal-toggle'); - if (toggle) (toggle as HTMLElement).click(); - }); - await browser.pause(500); - - const termArea = await browser.$('.project-terminal-area'); - await expect(termArea).toBeDisplayed(); - - // Chevron should have expanded class - const chevron = await browser.$('.toggle-chevron.expanded'); - await expect(chevron).toBeExisting(); - }); - - it('should show add tab button when terminal expanded', async () => { - const addBtn = await browser.$('.tab-add'); - await expect(addBtn).toBeDisplayed(); - }); - - it('should add a shell tab', async () => { - // Click add tab button via JS (Svelte onclick) - await browser.execute(() => { - const btn = document.querySelector('.tab-bar .tab-add'); - if (btn) (btn as HTMLElement).click(); - }); - await browser.pause(500); - - // Verify tab title via JS to avoid stale element issues - const title = await browser.execute(() => { - const el = document.querySelector('.tab-bar .tab-title'); - return el ? el.textContent : ''; - }); - expect((title as string).toLowerCase()).toContain('shell'); - }); - - it('should show active tab styling', async () => { - const activeTab = await browser.$('.tab.active'); - await expect(activeTab).toBeExisting(); - }); - - it('should add a second shell tab and switch between them', async () => { - // Add second tab via JS - await browser.execute(() => { - const btn = document.querySelector('.tab-bar .tab-add'); - if (btn) (btn as HTMLElement).click(); - }); - await browser.pause(500); - - const tabCount = await browser.execute(() => { - return document.querySelectorAll('.tab-bar .tab').length; - }); - expect(tabCount as number).toBeGreaterThanOrEqual(2); - - // Click first tab and verify it becomes active with Shell title - await browser.execute(() => { - const tabs = document.querySelectorAll('.tab-bar .tab'); - if (tabs[0]) (tabs[0] as HTMLElement).click(); - }); - await browser.pause(300); - - const activeTitle = await browser.execute(() => { - const active = document.querySelector('.tab-bar .tab.active .tab-title'); - return active ? active.textContent : ''; - }); - expect(activeTitle as string).toContain('Shell'); - }); - - it('should close a tab', async () => { - const tabsBefore = await browser.$$('.tab'); - const countBefore = tabsBefore.length; - - // Close the last tab - await browser.execute(() => { - const closeBtns = document.querySelectorAll('.tab-close'); - if (closeBtns.length > 0) { - (closeBtns[closeBtns.length - 1] as HTMLElement).click(); - } - }); - await browser.pause(500); - - const tabsAfter = await browser.$$('.tab'); - expect(tabsAfter.length).toBe(Number(countBefore) - 1); - }); - - after(async () => { - // Clean up: close remaining tabs and collapse terminal - await browser.execute(() => { - // Close all tabs - const closeBtns = document.querySelectorAll('.tab-close'); - closeBtns.forEach(btn => (btn as HTMLElement).click()); - }); - await browser.pause(300); - - // Collapse terminal - await browser.execute(() => { - const toggle = document.querySelector('.terminal-toggle'); - if (toggle) { - const chevron = toggle.querySelector('.toggle-chevron.expanded'); - if (chevron) (toggle as HTMLElement).click(); - } - }); - await browser.pause(300); - }); -}); - -describe('BTerminal — Theme Switching', () => { - before(async () => { - // Open settings - const settingsBtn = await browser.$('.rail-btn'); - await settingsBtn.click(); - const panel = await browser.$('.sidebar-panel'); - await panel.waitForDisplayed({ timeout: 5000 }); - }); - - after(async () => { - // Close settings - const panel = await browser.$('.sidebar-panel'); - if (await panel.isDisplayed()) { - await browser.keys('Escape'); - await browser.pause(500); - } - }); - - it('should show theme dropdown with group labels', async () => { - await browser.execute(() => { - const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); - if (trigger) (trigger as HTMLElement).click(); - }); - await browser.pause(500); - - const menu = await browser.$('.dropdown-menu'); - await menu.waitForExist({ timeout: 3000 }); - - // Should have group labels (Catppuccin, Editor, Deep Dark) - const groupLabels = await browser.$$('.dropdown-group-label'); - expect(groupLabels.length).toBeGreaterThanOrEqual(2); - - // Close dropdown - await browser.execute(() => { - const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); - if (trigger) (trigger as HTMLElement).click(); - }); - await browser.pause(300); - }); - - it('should switch theme and update CSS variables', async () => { - // Get current base color - const baseBefore = await browser.execute(() => { - return getComputedStyle(document.documentElement).getPropertyValue('--ctp-base').trim(); - }); - - // Open theme dropdown and click a different theme - await browser.execute(() => { - const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); - if (trigger) (trigger as HTMLElement).click(); - }); - await browser.pause(500); - - // Click the first non-active theme option - const changed = await browser.execute(() => { - const options = document.querySelectorAll('.dropdown-option:not(.active)'); - if (options.length > 0) { - (options[0] as HTMLElement).click(); - return true; - } - return false; - }); - expect(changed).toBe(true); - await browser.pause(500); - - // Verify CSS variable changed - const baseAfter = await browser.execute(() => { - return getComputedStyle(document.documentElement).getPropertyValue('--ctp-base').trim(); - }); - expect(baseAfter).not.toBe(baseBefore); - - // Switch back to Catppuccin Mocha (first option) to restore state - await browser.execute(() => { - const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); - if (trigger) (trigger as HTMLElement).click(); - }); - await browser.pause(500); - await browser.execute(() => { - const options = document.querySelectorAll('.dropdown-option'); - if (options.length > 0) (options[0] as HTMLElement).click(); - }); - await browser.pause(300); - }); - - it('should show active theme option', async () => { - await browser.execute(() => { - const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); - if (trigger) (trigger as HTMLElement).click(); - }); - await browser.pause(500); - - const activeOption = await browser.$('.dropdown-option.active'); - await expect(activeOption).toBeExisting(); - - await browser.execute(() => { - const trigger = document.querySelector('.custom-dropdown .dropdown-trigger'); - if (trigger) (trigger as HTMLElement).click(); - }); - await browser.pause(300); - }); -}); - -describe('BTerminal — Settings Interaction', () => { - before(async () => { - // Ensure settings is open - const panel = await browser.$('.sidebar-panel'); - if (!(await panel.isDisplayed())) { - const settingsBtn = await browser.$('.rail-btn'); - await settingsBtn.click(); - await panel.waitForDisplayed({ timeout: 5000 }); - } - }); - - after(async () => { - const panel = await browser.$('.sidebar-panel'); - if (await panel.isDisplayed()) { - await browser.keys('Escape'); - await browser.pause(500); - } - }); - - it('should show font size controls with increment/decrement', async () => { - const sizeControls = await browser.$$('.size-control'); - expect(sizeControls.length).toBeGreaterThanOrEqual(1); - - const sizeBtns = await browser.$$('.size-btn'); - expect(sizeBtns.length).toBeGreaterThanOrEqual(2); // at least - and + for one control - - const sizeInput = await browser.$('.size-input'); - await expect(sizeInput).toBeExisting(); - }); - - it('should increment font size', async () => { - const sizeInput = await browser.$('.size-input'); - const valueBefore = await sizeInput.getValue(); - - // Click the + button (second .size-btn in first .size-control) - await browser.execute(() => { - const btns = document.querySelectorAll('.size-control .size-btn'); - // Second button is + (first is -) - if (btns.length >= 2) (btns[1] as HTMLElement).click(); - }); - await browser.pause(300); - - const afterEl = await browser.$('.size-input'); - const valueAfter = await afterEl.getValue(); - expect(parseInt(valueAfter as string)).toBe(parseInt(valueBefore as string) + 1); - }); - - it('should decrement font size back', async () => { - const sizeInput = await browser.$('.size-input'); - const valueBefore = await sizeInput.getValue(); - - // Click the - button (first .size-btn) - await browser.execute(() => { - const btns = document.querySelectorAll('.size-control .size-btn'); - if (btns.length >= 1) (btns[0] as HTMLElement).click(); - }); - await browser.pause(300); - - const afterEl = await browser.$('.size-input'); - const valueAfter = await afterEl.getValue(); - expect(parseInt(valueAfter as string)).toBe(parseInt(valueBefore as string) - 1); - }); - - it('should display group rows with active indicator', async () => { - const groupRows = await browser.$$('.group-row'); - expect(groupRows.length).toBeGreaterThanOrEqual(1); - - const activeGroup = await browser.$('.group-row.active'); - await expect(activeGroup).toBeExisting(); - }); - - it('should show project cards', async () => { - const cards = await browser.$$('.project-card'); - expect(cards.length).toBeGreaterThanOrEqual(1); - }); - - it('should display project card with name and path', async () => { - const nameInput = await browser.$('.card-name-input'); - await expect(nameInput).toBeExisting(); - const name = await nameInput.getValue() as string; - expect(name.length).toBeGreaterThan(0); - - const cwdInput = await browser.$('.cwd-input'); - await expect(cwdInput).toBeExisting(); - const cwd = await cwdInput.getValue() as string; - expect(cwd.length).toBeGreaterThan(0); - }); - - it('should show project toggle switch', async () => { - const toggle = await browser.$('.card-toggle'); - await expect(toggle).toBeExisting(); - - const track = await browser.$('.toggle-track'); - await expect(track).toBeDisplayed(); - }); - - it('should show add project form', async () => { - const addForm = await browser.$('.add-project-form'); - await expect(addForm).toBeDisplayed(); - - const addBtn = await browser.$('.add-project-form .btn-primary'); - await expect(addBtn).toBeExisting(); - }); -}); - -describe('BTerminal — Keyboard Shortcuts', () => { - it('should open command palette with Ctrl+K', async () => { - // Focus the app window via JS to ensure keyboard events are received - await browser.execute(() => document.body.focus()); - await browser.pause(200); - await browser.keys(['Control', 'k']); - - const palette = await browser.$('.palette'); - await palette.waitForDisplayed({ timeout: 3000 }); - - const input = await browser.$('.palette-input'); - await expect(input).toBeDisplayed(); - - // Close with Escape - await browser.keys('Escape'); - await palette.waitForDisplayed({ timeout: 3000, reverse: true }); - }); - - it('should toggle settings with Ctrl+,', async () => { - await browser.keys(['Control', ',']); - - const panel = await browser.$('.sidebar-panel'); - await panel.waitForDisplayed({ timeout: 3000 }); - - // Close with Ctrl+, - await browser.keys(['Control', ',']); - await panel.waitForDisplayed({ timeout: 3000, reverse: true }); - }); - - it('should toggle sidebar with Ctrl+B', async () => { - // Open sidebar first - await browser.keys(['Control', ',']); - const panel = await browser.$('.sidebar-panel'); - await panel.waitForDisplayed({ timeout: 3000 }); - - // Toggle off with Ctrl+B - await browser.keys(['Control', 'b']); - await panel.waitForDisplayed({ timeout: 3000, reverse: true }); - }); - - it('should close sidebar with Escape', async () => { - // Open sidebar - await browser.keys(['Control', ',']); - const panel = await browser.$('.sidebar-panel'); - await panel.waitForDisplayed({ timeout: 3000 }); - - // Close with Escape - await browser.keys('Escape'); - await panel.waitForDisplayed({ timeout: 3000, reverse: true }); - }); - - it('should show command palette with group list', async () => { - await browser.keys(['Control', 'k']); - - const palette = await browser.$('.palette'); - await palette.waitForDisplayed({ timeout: 3000 }); - - const items = await browser.$$('.palette-item'); - expect(items.length).toBeGreaterThanOrEqual(1); - - const groupName = await browser.$('.palette-item .group-name'); - await expect(groupName).toBeDisplayed(); - - await browser.keys('Escape'); - await palette.waitForDisplayed({ timeout: 3000, reverse: true }); - }); -}); diff --git a/v2/tests/e2e/tsconfig.json b/v2/tests/e2e/tsconfig.json deleted file mode 100644 index f7e974d..0000000 --- a/v2/tests/e2e/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "compilerOptions": { - "module": "ESNext", - "moduleResolution": "bundler", - "target": "ESNext", - "types": ["@wdio/mocha-framework", "@wdio/globals/types"], - "esModuleInterop": true, - "skipLibCheck": true - }, - "include": ["specs/**/*.ts"] -} diff --git a/v2/tests/e2e/wdio.conf.js b/v2/tests/e2e/wdio.conf.js deleted file mode 100644 index 35a71f5..0000000 --- a/v2/tests/e2e/wdio.conf.js +++ /dev/null @@ -1,129 +0,0 @@ -import { spawn } from 'node:child_process'; -import { resolve, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const projectRoot = resolve(__dirname, '../..'); - -// Debug binary path (built with `cargo tauri build --debug --no-bundle`) -// Cargo workspace target dir is at v2/target/, not v2/src-tauri/target/ -const tauriBinary = resolve(projectRoot, 'target/debug/bterminal'); - -let tauriDriver; - -export const config = { - // ── Runner ── - runner: 'local', - maxInstances: 1, // Tauri doesn't support parallel sessions - - // ── Connection (external tauri-driver on port 4444) ── - hostname: 'localhost', - port: 4444, - path: '/', - - // ── Specs ── - // Single spec file — Tauri launches one app instance per session, - // and tauri-driver can't re-create sessions between spec files. - specs: [resolve(__dirname, 'specs/bterminal.test.ts')], - - // ── Capabilities ── - capabilities: [{ - // Disable BiDi negotiation — tauri-driver doesn't support webSocketUrl - 'wdio:enforceWebDriverClassic': true, - 'tauri:options': { - application: tauriBinary, - }, - }], - - // ── Framework ── - framework: 'mocha', - mochaOpts: { - ui: 'bdd', - timeout: 60_000, - }, - - // ── Reporter ── - reporters: ['spec'], - - // ── Logging ── - logLevel: 'warn', - - // ── Timeouts ── - waitforTimeout: 10_000, - connectionRetryTimeout: 30_000, - connectionRetryCount: 3, - - // ── Hooks ── - - /** - * Build the debug binary before the test run. - * Uses --debug --no-bundle for fastest build time. - */ - onPrepare() { - if (process.env.SKIP_BUILD) { - console.log('SKIP_BUILD set — using existing debug binary.'); - return Promise.resolve(); - } - return new Promise((resolve, reject) => { - console.log('Building Tauri debug binary...'); - const build = spawn('cargo', ['tauri', 'build', '--debug', '--no-bundle'], { - cwd: projectRoot, - stdio: 'inherit', - }); - build.on('close', (code) => { - if (code === 0) { - console.log('Debug binary ready.'); - resolve(); - } else { - reject(new Error(`Tauri build failed with exit code ${code}`)); - } - }); - build.on('error', reject); - }); - }, - - /** - * Spawn tauri-driver before the session. - * tauri-driver bridges WebDriver protocol to WebKit2GTK's inspector. - */ - beforeSession() { - return new Promise((resolve, reject) => { - tauriDriver = spawn('tauri-driver', [], { - stdio: ['ignore', 'pipe', 'pipe'], - }); - - tauriDriver.on('error', (err) => { - reject(new Error( - `Failed to start tauri-driver: ${err.message}. ` + - 'Install it with: cargo install tauri-driver' - )); - }); - - // Wait for tauri-driver to be ready (listens on port 4444) - const timeout = setTimeout(() => resolve(), 2000); - tauriDriver.stdout.on('data', (data) => { - if (data.toString().includes('4444')) { - clearTimeout(timeout); - resolve(); - } - }); - }); - }, - - /** - * Kill tauri-driver after the test run. - */ - afterSession() { - if (tauriDriver) { - tauriDriver.kill(); - tauriDriver = null; - } - }, - - // ── TypeScript (auto-compile via tsx) ── - autoCompileOpts: { - tsNodeOpts: { - project: resolve(__dirname, 'tsconfig.json'), - }, - }, -}; diff --git a/v2/tsconfig.app.json b/v2/tsconfig.app.json deleted file mode 100644 index 31c18cf..0000000 --- a/v2/tsconfig.app.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "@tsconfig/svelte/tsconfig.json", - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", - "target": "ES2022", - "useDefineForClassFields": true, - "module": "ESNext", - "types": ["svelte", "vite/client"], - "noEmit": true, - /** - * Typecheck JS in `.svelte` and `.js` files by default. - * Disable checkJs if you'd like to use dynamic types in JS. - * Note that setting allowJs false does not prevent the use - * of JS in `.svelte` files. - */ - "allowJs": true, - "checkJs": true, - "moduleDetection": "force" - }, - "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] -} diff --git a/v2/tsconfig.json b/v2/tsconfig.json deleted file mode 100644 index 1ffef60..0000000 --- a/v2/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] -} diff --git a/v2/tsconfig.node.json b/v2/tsconfig.node.json deleted file mode 100644 index 8a67f62..0000000 --- a/v2/tsconfig.node.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "compilerOptions": { - "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", - "target": "ES2023", - "lib": ["ES2023"], - "module": "ESNext", - "types": ["node"], - "skipLibCheck": true, - - /* Bundler mode */ - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "verbatimModuleSyntax": true, - "moduleDetection": "force", - "noEmit": true, - - /* Linting */ - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "erasableSyntaxOnly": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["vite.config.ts"] -} diff --git a/v2/vite.config.ts b/v2/vite.config.ts deleted file mode 100644 index 6a5bb20..0000000 --- a/v2/vite.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'vite' -import { svelte } from '@sveltejs/vite-plugin-svelte' - -export default defineConfig({ - plugins: [svelte()], - server: { - port: 9700, - strictPort: true, - }, - clearScreen: false, - test: { - include: ['src/**/*.test.ts'], - }, -})