commit 3672e92b7e20577a608f97400b4c1715ba8e8c56 Author: DexterFromLab Date: Sun Mar 15 15:45:27 2026 +0100 feat: Agent Orchestrator — multi-project agent dashboard Tauri + Svelte 5 + Rust application for orchestrating multiple AI coding agents. Includes Claude, Aider, Codex, and Ollama provider support, multi-agent communication (btmsg/bttask), session anchors, plugin sandbox, FTS5 search, Landlock sandboxing, and 507 vitest + 110 cargo tests. diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..3550cb9 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,126 @@ +# Agent Orchestrator — Claude Behavioral Guide + +## Workflow + +- v1 is a single-file Python app (`bterminal.py`). Changes are localized. +- v2 docs are in `docs/`. Architecture in `docs/architecture.md`. +- v2 Phases 1-7 + multi-machine (A-D) + profiles/skills complete. Extras: SSH, ctx, themes, detached mode, auto-updater, shiki, copy/paste, session resume, drag-resize, session groups, Deno sidecar, Claude profiles, skill discovery. +- v3 Mission Control (All Phases 1-10 + Production Readiness Complete): project groups, workspace store, 15+ Workspace components, session continuity, multi-provider adapter pattern, worktree isolation, session anchors, Memora adapter, SOLID refactoring, multi-agent orchestration (btmsg/bttask, 4 Tier 1 roles, role-specific tabs), dashboard metrics, auto-wake scheduler, reviewer agent. Production: sidecar supervisor (auto-restart, exponential backoff), FTS5 search (3 virtual tables, Spotlight overlay), plugin system (Web Worker sandbox, permission-gated), Landlock sandbox (kernel 6.2+), secrets management (system keyring), OS+in-app notifications, keyboard-first UX (18+ palette commands, vi-nav), agent health monitoring (heartbeats, dead letter queue), audit logging, error classification (6 types), optimistic locking (bttask). Hardening: TLS relay, SPKI pinning (TOFU), WAL checkpoint (5min), subagent delegation fix, plugin sandbox tests (26), SidecarManager actor pattern, per-message btmsg acknowledgment, Aider autonomous mode. 507 vitest + 110 cargo + 109 E2E. +- Consult Memora (tag: `bterminal`) before making architectural changes. + +## Documentation References + +- System architecture: [docs/architecture.md](../docs/architecture.md) +- Architecture decisions: [docs/decisions.md](../docs/decisions.md) +- Sidecar architecture: [docs/sidecar.md](../docs/sidecar.md) +- Multi-agent orchestration: [docs/orchestration.md](../docs/orchestration.md) +- Production hardening: [docs/production.md](../docs/production.md) +- Implementation phases: [docs/phases.md](../docs/phases.md) +- Research findings: [docs/findings.md](../docs/findings.md) +- Progress logs: [docs/progress/](../docs/progress/) + +## Rules + +- Do not modify v1 code (`bterminal.py`) unless explicitly asked — it is production-stable. +- v2/v3 work goes on the `hib_changes` branch (repo: agent-orchestrator), not master. +- Architecture decisions must reference `docs/decisions.md`. +- When adding new decisions, append to the appropriate category table with date. +- Update `docs/progress/` after each significant work session. + +## Key Technical Constraints + +- 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). 7 operations: list_tasks, create_task, update_task_status, delete_task, add_comment, task_comments, review_queue_count. Frontend: TaskBoardTab.svelte (kanban 5 columns, 5s poll). CLI `bttask` tool gives agents direct access; Manager has full CRUD, Reviewer has read + status + comments, other roles have read-only + comments. On task→review transition, auto-posts to #review-queue btmsg channel (ensure_review_channels creates #review-queue + #review-log idempotently). Reviewer agent gets Tasks tab in ProjectBox (reuses TaskBoardTab). reviewQueueDepth in AttentionInput: 10pts per review task, capped at 50 (priority between file_conflict 70 and context_high 40). ProjectBox polls review_queue_count every 10s for reviewer agents → setReviewQueueDepth() in health store. +- btmsg/bttask SQLite conventions: Both btmsg.rs and bttask.rs open shared btmsg.db with WAL mode + 5s busy_timeout (concurrent access from Python CLIs + Rust backend). All queries use named column access (`row.get("column_name")`) — never positional indices. Rust structs use `#[serde(rename_all = "camelCase")]`; TypeScript interfaces MUST match camelCase wire format. TestingTab uses `convertFileSrc()` for Tauri 2.x asset URLs (not `asset://localhost/`). +- ArchitectureTab: PlantUML diagram viewer/editor. Stores .puml files in `.architecture/` project dir. Renders via plantuml.com server using ~h hex encoding (no Java dependency). 4 templates: Class, Sequence, State, Component. Editor + SVG preview toggle. +- TestingTab: Dual-mode component (mode='selenium'|'tests'). Selenium: watches `.selenium/screenshots/` for PNG/JPG, displays in gallery with session log, 3s poll. Tests: discovers files in standard dirs (tests/, test/, spec/, __tests__/, e2e/), shows content. +- Worktree isolation (S-1 Phase 3): Per-project `useWorktrees` toggle in SettingsTab. When enabled, AgentPane passes `worktree_name=sessionId` in queryAgent(). Agent runs in `/.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, Metrics, Tasks, Architecture, Selenium, Tests — mount on first activation via {#if everActivated} + display:flex/none). Tab type: `'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memories' | 'metrics' | 'tasks' | 'architecture' | 'selenium' | 'tests'`. Role-specific tabs: Manager gets Tasks (kanban), Architect gets Arch (PlantUML), Tester gets Selenium+Tests. Metrics tab (all projects): MetricsPanel.svelte — Live view (fleet aggregates, project health grid, task board summary, attention queue) + History view (SVG sparklines for cost/tokens/turns/tools/duration, stats row, session table from session_metrics_load). Conditional on `isAgent && agentRole`. Model tab = AgentSession+TeamAgentsPanel. Docs tab = ProjectFiles (markdown viewer). Context tab = ContextTab.svelte (LLM context window visualization: stats bar, segmented token meter, file references, turn breakdown; reads from agent store via sessionId prop; replaced old ContextPane ctx database viewer). Files tab = FilesTab.svelte (VSCode-style directory tree + CodeMirror 6 editor with 15 language modes, dirty tracking, Ctrl+S save, save-on-blur setting, image display via convertFileSrc, 10MB gate; CodeEditor.svelte wrapper; PdfViewer.svelte for PDF files via pdfjs-dist with canvas multi-page rendering + zoom 0.5x–3x; CsvTable.svelte for CSV with RFC 4180 parser, delimiter auto-detect, sortable columns). SSH tab = SshTab.svelte (CRUD for SSH connections, launch spawns terminal tab in Model tab). Memory tab = MemoriesTab.svelte (pluggable via MemoryAdapter interface in memory-adapter.ts; MemoraAdapter registered at startup, reads ~/.local/share/memora/memories.db via Rust memora.rs). Tasks tab = TaskBoardTab.svelte (kanban board, 5 columns, 5s poll, Manager only). Arch tab = ArchitectureTab.svelte (PlantUML viewer/editor, .architecture/ dir, plantuml.com ~h hex encoding, Architect only). Selenium tab = TestingTab.svelte mode=selenium (screenshot gallery, session log, 3s poll, Tester only). Tests tab = TestingTab.svelte mode=tests (test file discovery, content viewer, Tester only). Rust backend: list_directory_children + read_file_content + write_file_content (FileContent tagged union: Text/Binary/TooLarge). Frontend bridge: files-bridge.ts. +- ProjectHeader shows CWD (ellipsized from START via `direction: rtl`) + profile name as info-only text on right side. AgentPane no longer has DIR/ACC toolbar — CWD and profile are props from parent. +- Skill discovery: claude_list_skills() reads ~/.claude/skills/ (dirs with SKILL.md or .md files). claude_read_skill() reads content. AgentPane `/` prefix triggers autocomplete menu. Skill content injected as prompt via expandSkillPrompt(). +- claude-bridge.ts adapter wraps profile/skill Tauri commands (ClaudeProfile, ClaudeSkill interfaces). provider-bridge.ts wraps claude-bridge as generic provider bridge (delegates by ProviderId). +- Provider adapter pattern: ProviderId = 'claude' | 'codex' | 'ollama'. ProviderCapabilities flags gate UI (hasProfiles, hasSkills, hasModelSelection, hasSandbox, supportsSubagents, supportsCost, supportsResume). ProviderMeta registered via registerProvider() in App.svelte onMount. AgentPane receives provider + capabilities props. SettingsTab has Providers section with collapsible per-provider config panels. ProjectConfig.provider field for per-project selection. Settings persisted as `provider_settings` JSON blob. +- Sidecar build: `npm run build:sidecar` builds all 3 runners via esbuild (claude-runner.mjs, codex-runner.mjs, ollama-runner.mjs). Each is a standalone ESM bundle. Codex runner dynamically imports @openai/codex-sdk (graceful failure if not installed). Ollama runner uses native fetch (zero deps). +- Agent preview terminal: `AgentPreviewPane.svelte` is a read-only xterm.js terminal (disableStdin:true) that subscribes to an agent session's messages via `$derived(getAgentSession(sessionId))` and renders tool calls/results in real-time. Bash commands shown as cyan `❯ cmd`, file ops as yellow `[Read] path`, results as plain text (80-line truncation), errors in red. Spawned via 👁 button in TerminalTabs (appears when agentSessionId prop is set). TerminalTab type: `'agent-preview'` with `agentSessionId` field. Deduplicates — won't create two previews for the same session. ProjectBox passes mainSessionId to TerminalTabs. +- Maximum 4 active xterm.js instances to avoid WebKit2GTK memory issues. Agent preview uses disableStdin and no PTY so is lighter, but still counts. +- Store files using Svelte 5 runes (`$state`, `$derived`) MUST have `.svelte.ts` extension (not `.ts`). Import with `.svelte` suffix. Plain `.ts` compiles but fails at runtime with "rune_outside_svelte". +- Session persistence uses rusqlite (bundled) with WAL mode. Data dir: `dirs::data_dir()/bterminal/sessions.db`. +- Layout store persists to SQLite on every addPane/removePane/setPreset/setPaneGroup change (fire-and-forget). Restores on app startup via `restoreFromDb()`. +- Session groups: Pane.group? field in layout store, group_name column in sessions table, collapsible group headers in sidebar. Right-click pane to set group. +- File watcher uses notify crate v6, watches parent directory (NonRecursive), emits `file-changed` Tauri events. +- Settings use key-value `settings` table in SQLite (session/settings.rs). Frontend: `settings-bridge.ts` adapter. v3 uses SettingsTab.svelte rendered in sidebar drawer panel (v2 SettingsDialog.svelte deleted in P10). SettingsTab has two sections: Global (single-column layout, split into Appearance [theme dropdown, UI font dropdown with sans-serif options + size stepper, Terminal font dropdown with monospace options + size stepper] and Defaults [shell, CWD] — all custom themed dropdowns, no native ` + + + + + + diff --git a/src/lib/components/Agent/AgentTree.svelte b/src/lib/components/Agent/AgentTree.svelte new file mode 100644 index 0000000..8b3e8bb --- /dev/null +++ b/src/lib/components/Agent/AgentTree.svelte @@ -0,0 +1,173 @@ + + +
+ + {#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/src/lib/components/Agent/UsageMeter.svelte b/src/lib/components/Agent/UsageMeter.svelte new file mode 100644 index 0000000..86c8b9a --- /dev/null +++ b/src/lib/components/Agent/UsageMeter.svelte @@ -0,0 +1,146 @@ + + +{#if totalTokens > 0} + +
showTooltip = true} + onmouseleave={() => showTooltip = false} + > +
+
+
+ {formatTokens(totalTokens)} + + {#if showTooltip} +
+
+ Input + {formatTokens(inputTokens)} +
+
+ Output + {formatTokens(outputTokens)} +
+
+ Total + {formatTokens(totalTokens)} +
+
+
+ Limit + {formatTokens(contextLimit)} +
+
+ Used + {pct.toFixed(1)}% +
+
+ {/if} +
+{/if} + + diff --git a/src/lib/components/Context/ContextPane.svelte b/src/lib/components/Context/ContextPane.svelte new file mode 100644 index 0000000..98666b8 --- /dev/null +++ b/src/lib/components/Context/ContextPane.svelte @@ -0,0 +1,396 @@ + + +
+
+

{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/src/lib/components/Markdown/MarkdownPane.svelte b/src/lib/components/Markdown/MarkdownPane.svelte new file mode 100644 index 0000000..0d03a03 --- /dev/null +++ b/src/lib/components/Markdown/MarkdownPane.svelte @@ -0,0 +1,428 @@ + + +
+ {#if error} +
{error}
+ {:else} + + +
+
+ {@html renderedHtml} +
+
+ {/if} +
{filePath}
+
+ + diff --git a/src/lib/components/Notifications/NotificationCenter.svelte b/src/lib/components/Notifications/NotificationCenter.svelte new file mode 100644 index 0000000..ace5ee3 --- /dev/null +++ b/src/lib/components/Notifications/NotificationCenter.svelte @@ -0,0 +1,300 @@ + + + + +
+ + + {#if open} + +
+
+
+ Notifications +
+ {#if unreadCount > 0} + + {/if} + {#if history.length > 0} + + {/if} +
+
+
+ {#if history.length === 0} +
No notifications
+ {:else} + {#each [...history].reverse() as item (item.id)} + + {/each} + {/if} +
+
+ {/if} +
+ + diff --git a/src/lib/components/Notifications/ToastContainer.svelte b/src/lib/components/Notifications/ToastContainer.svelte new file mode 100644 index 0000000..2602b16 --- /dev/null +++ b/src/lib/components/Notifications/ToastContainer.svelte @@ -0,0 +1,94 @@ + + +{#if toasts.length > 0} +
+ {#each toasts as toast (toast.id)} + + {/each} +
+{/if} + + diff --git a/src/lib/components/SplashScreen.svelte b/src/lib/components/SplashScreen.svelte new file mode 100644 index 0000000..830f716 --- /dev/null +++ b/src/lib/components/SplashScreen.svelte @@ -0,0 +1,138 @@ + + +
+ +
+ +
+
+

Agent Orchestrator

+ {version} + Pandora's Box +
+ +
+
+
+
+
{currentStep}
+
+
+
+ + diff --git a/src/lib/components/StatusBar/StatusBar.svelte b/src/lib/components/StatusBar/StatusBar.svelte new file mode 100644 index 0000000..ff9f0bf --- /dev/null +++ b/src/lib/components/StatusBar/StatusBar.svelte @@ -0,0 +1,375 @@ + + +
+
+ {#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} + + + {#if updateInfo?.available} + + + {/if} + Agent Orchestrator v3 +
+
+ + +{#if showAttention && attentionQueue.length > 0} +
+ {#each attentionQueue as item (item.projectId)} + + {/each} +
+{/if} + + diff --git a/src/lib/components/Terminal/AgentPreviewPane.svelte b/src/lib/components/Terminal/AgentPreviewPane.svelte new file mode 100644 index 0000000..f65bb90 --- /dev/null +++ b/src/lib/components/Terminal/AgentPreviewPane.svelte @@ -0,0 +1,197 @@ + + +
+ + diff --git a/src/lib/components/Terminal/TerminalPane.svelte b/src/lib/components/Terminal/TerminalPane.svelte new file mode 100644 index 0000000..258c352 --- /dev/null +++ b/src/lib/components/Terminal/TerminalPane.svelte @@ -0,0 +1,134 @@ + + +
+ + diff --git a/src/lib/components/Workspace/AgentCard.svelte b/src/lib/components/Workspace/AgentCard.svelte new file mode 100644 index 0000000..e511f5e --- /dev/null +++ b/src/lib/components/Workspace/AgentCard.svelte @@ -0,0 +1,100 @@ + + +
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/src/lib/components/Workspace/AgentSession.svelte b/src/lib/components/Workspace/AgentSession.svelte new file mode 100644 index 0000000..9e749e6 --- /dev/null +++ b/src/lib/components/Workspace/AgentSession.svelte @@ -0,0 +1,382 @@ + + +
+ {#if loading} +
Loading session...
+ {:else} + + {/if} +
+ + diff --git a/src/lib/components/Workspace/ArchitectureTab.svelte b/src/lib/components/Workspace/ArchitectureTab.svelte new file mode 100644 index 0000000..ce2ef79 --- /dev/null +++ b/src/lib/components/Workspace/ArchitectureTab.svelte @@ -0,0 +1,479 @@ + + +
+
+ + + {#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/src/lib/components/Workspace/AuditLogTab.svelte b/src/lib/components/Workspace/AuditLogTab.svelte new file mode 100644 index 0000000..ef73761 --- /dev/null +++ b/src/lib/components/Workspace/AuditLogTab.svelte @@ -0,0 +1,300 @@ + + +
+
+
+ {#each EVENT_TYPES as type} + + {/each} +
+ +
+ +
+ {#if loading} +
Loading audit log...
+ {:else if error} +
Error: {error}
+ {:else if filteredEntries.length === 0} +
No audit events yet
+ {:else} + {#each filteredEntries as entry (entry.id)} +
+ {formatTime(entry.createdAt)} + + {agentName(entry.agentId)} + + + {entry.eventType.replace(/_/g, ' ')} + + {entry.detail} +
+ {/each} + {/if} +
+
+ + diff --git a/src/lib/components/Workspace/CodeEditor.svelte b/src/lib/components/Workspace/CodeEditor.svelte new file mode 100644 index 0000000..5a124ee --- /dev/null +++ b/src/lib/components/Workspace/CodeEditor.svelte @@ -0,0 +1,332 @@ + + +
+ + diff --git a/src/lib/components/Workspace/CommandPalette.svelte b/src/lib/components/Workspace/CommandPalette.svelte new file mode 100644 index 0000000..ede93bf --- /dev/null +++ b/src/lib/components/Workspace/CommandPalette.svelte @@ -0,0 +1,539 @@ + + +{#if open} + +
+ +
e.stopPropagation()} onkeydown={handleKeydown}> + {#if showShortcuts} +
+

Keyboard Shortcuts

+ +
+
+
+

Global

+
Ctrl+KCommand Palette
+
Ctrl+,Toggle Settings
+
Ctrl+MToggle Messages
+
Ctrl+BToggle Sidebar
+
EscapeClose Panel / Palette
+
+
+

Project Navigation

+
Alt+1Alt+5Focus Project 1–5
+
Ctrl+HPrevious Project
+
Ctrl+LNext Project
+
Ctrl+JToggle Terminal
+
Ctrl+Shift+KFocus Agent Pane
+
+
+

Project Tabs

+
Ctrl+Shift+1Model
+
Ctrl+Shift+2Docs
+
Ctrl+Shift+3Context
+
Ctrl+Shift+4Files
+
Ctrl+Shift+5SSH
+
Ctrl+Shift+6Memory
+
Ctrl+Shift+7Metrics
+
+
+ {:else} + +
    + {#each grouped as [category, items], gi} +
  • {category}
  • + {#each items as cmd, ci} + {@const flatIdx = getFlatIndex(gi, ci)} +
  • + +
  • + {/each} + {/each} + {#if filtered.length === 0} +
  • No commands match "{query}"
  • + {/if} +
+ {/if} +
+
+{/if} + + diff --git a/src/lib/components/Workspace/CommsTab.svelte b/src/lib/components/Workspace/CommsTab.svelte new file mode 100644 index 0000000..2202134 --- /dev/null +++ b/src/lib/components/Workspace/CommsTab.svelte @@ -0,0 +1,690 @@ + + +
+ +
+
+ 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.fromAgent === ADMIN_ID} +
+
+ {isMe ? 'You' : (msg.senderName ?? msg.fromAgent)} + {formatTime(msg.createdAt)} +
+
{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/src/lib/components/Workspace/ContextTab.svelte b/src/lib/components/Workspace/ContextTab.svelte new file mode 100644 index 0000000..c69dd4a --- /dev/null +++ b/src/lib/components/Workspace/ContextTab.svelte @@ -0,0 +1,1807 @@ + + +
+ {#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} +
+ + + {#if totalCost.costUsd > 0} +
+
+ Cost Analytics +
+
+
+ {formatCost(totalCost.costUsd)} + Total Cost +
+
+ {formatCost(avgCostPerTurn)} + Avg / Turn +
+
+ {tokenEfficiency.toFixed(2)} + Out/In Ratio +
+
+ ${burnRatePerHr.toFixed(2)}/hr + Burn Rate +
+ {#if costProjection > 0} +
+ {formatCost(costProjection)} + Est. Full Context +
+ {/if} +
+
+ {/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/src/lib/components/Workspace/CsvTable.svelte b/src/lib/components/Workspace/CsvTable.svelte new file mode 100644 index 0000000..5b0a12d --- /dev/null +++ b/src/lib/components/Workspace/CsvTable.svelte @@ -0,0 +1,253 @@ + + +
+
+ + {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/src/lib/components/Workspace/DocsTab.svelte b/src/lib/components/Workspace/DocsTab.svelte new file mode 100644 index 0000000..299adf9 --- /dev/null +++ b/src/lib/components/Workspace/DocsTab.svelte @@ -0,0 +1,160 @@ + + +
+ + +
+ {#if selectedPath} + + {:else} +
Select a document from the sidebar
+ {/if} +
+
+ + diff --git a/src/lib/components/Workspace/FilesTab.svelte b/src/lib/components/Workspace/FilesTab.svelte new file mode 100644 index 0000000..0cc4f37 --- /dev/null +++ b/src/lib/components/Workspace/FilesTab.svelte @@ -0,0 +1,700 @@ + + +
+ {#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/src/lib/components/Workspace/GlobalTabBar.svelte b/src/lib/components/Workspace/GlobalTabBar.svelte new file mode 100644 index 0000000..fb7864b --- /dev/null +++ b/src/lib/components/Workspace/GlobalTabBar.svelte @@ -0,0 +1,103 @@ + + + + + diff --git a/src/lib/components/Workspace/GroupAgentsPanel.svelte b/src/lib/components/Workspace/GroupAgentsPanel.svelte new file mode 100644 index 0000000..a08ba89 --- /dev/null +++ b/src/lib/components/Workspace/GroupAgentsPanel.svelte @@ -0,0 +1,433 @@ + + +{#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/src/lib/components/Workspace/MemoriesTab.svelte b/src/lib/components/Workspace/MemoriesTab.svelte new file mode 100644 index 0000000..1b7bab2 --- /dev/null +++ b/src/lib/components/Workspace/MemoriesTab.svelte @@ -0,0 +1,375 @@ + + +
+ {#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/src/lib/components/Workspace/MetricsPanel.svelte b/src/lib/components/Workspace/MetricsPanel.svelte new file mode 100644 index 0000000..945d22b --- /dev/null +++ b/src/lib/components/Workspace/MetricsPanel.svelte @@ -0,0 +1,808 @@ + + +
+ +
+ + +
+ + {#if activeView === 'live'} +
+ +
+
+ Fleet + + {#if aggregates.running > 0} + {aggregates.running} running + {/if} + {#if aggregates.idle > 0} + {aggregates.idle} idle + {/if} + {#if aggregates.stalled > 0} + {aggregates.stalled} stalled + {/if} + +
+
+ Burn + {fmtBurnRate(aggregates.totalBurnRatePerHour)} +
+
+ + + {#if health} +
This Project
+
+
+ Status + + {health.activityState} + {#if health.activeTool} + ({health.activeTool}) + {/if} + +
+
+ Burn Rate + {fmtBurnRate(health.burnRatePerHour)} +
+
+ Context + {fmtPressure(health.contextPressure)} +
+
+ Idle + {fmtIdle(health.idleDurationMs)} +
+ {#if session} +
+ Tokens + {(session.inputTokens + session.outputTokens).toLocaleString()} +
+
+ Cost + ${session.costUsd.toFixed(4)} +
+
+ Turns + {session.numTurns} +
+
+ Model + {session.model ?? '—'} +
+ {/if} + {#if health.fileConflictCount > 0} +
+ Conflicts + {health.fileConflictCount} +
+ {/if} + {#if health.externalConflictCount > 0} +
+ External + {health.externalConflictCount} +
+ {/if} + {#if health.attentionScore > 0} +
+ Attention + {health.attentionScore} + {#if health.attentionReason} + {health.attentionReason} + {/if} +
+ {/if} +
+ {:else} +
No health data — start an agent session
+ {/if} + + + {#if groupId} +
Task Board
+
+ {#each ['todo', 'progress', 'review', 'done', 'blocked'] as status} +
0}> + {taskCounts[status]} + {status === 'progress' ? 'In Prog' : status === 'todo' ? 'To Do' : status.charAt(0).toUpperCase() + status.slice(1)} +
+ {/each} +
+ {/if} + + + {#if allHealth.filter(h => h.attentionScore > 0).length > 0} +
Attention Queue
+
+ {#each allHealth.filter(h => h.attentionScore > 0).slice(0, 5) as item} +
+ {item.attentionScore} + {item.projectId.slice(0, 8)} + {item.attentionReason ?? '—'} +
+ {/each} +
+ {/if} +
+ {:else} + +
+ {#if historyLoading} +
Loading history...
+ {:else if historyData.length === 0} +
No session history for this project
+ {:else} + +
+ {#each (['cost', 'tokens', 'turns', 'tools', 'duration'] as const) as metric} + + {/each} +
+ + + {@const values = getHistoryValues(selectedHistoryMetric)} + {@const maxVal = Math.max(...values, 0.001)} + {@const minVal = Math.min(...values)} + {@const lastVal = values[values.length - 1] ?? 0} + {@const avgVal = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0} + +
+ + + + + + + + + + + + + + {#if values.length > 0} + {@const lastX = 400} + {@const lastY = 110 - (lastVal / maxVal) * 110} + + {/if} + +
+ + +
+
+ Last + {formatMetricValue(selectedHistoryMetric, lastVal)} +
+
+ Avg + {formatMetricValue(selectedHistoryMetric, avgVal)} +
+
+ Max + {formatMetricValue(selectedHistoryMetric, maxVal)} +
+
+ Min + {formatMetricValue(selectedHistoryMetric, minVal)} +
+
+ Sessions + {historyData.length} +
+
+ + +
Recent Sessions
+
+
+ Time + Dur + Cost + Tokens + Turns + Tools +
+ {#each historyData.slice(-10).reverse() as row} +
+ {new Date(row.endTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + {row.durationMin.toFixed(0)}m + ${row.costUsd.toFixed(3)} + {row.peakTokens >= 1000 ? `${(row.peakTokens / 1000).toFixed(0)}K` : row.peakTokens} + {row.turnCount} + {row.toolCallCount} +
+ {/each} +
+ + + {/if} +
+ {/if} +
+ + diff --git a/src/lib/components/Workspace/MetricsPanel.test.ts b/src/lib/components/Workspace/MetricsPanel.test.ts new file mode 100644 index 0000000..db9ada9 --- /dev/null +++ b/src/lib/components/Workspace/MetricsPanel.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect } from 'vitest'; + +// Test the pure utility functions used in MetricsPanel +// These are extracted for testability since the component uses them internally + +// --- Sparkline path generator (same logic as in MetricsPanel.svelte) --- +function sparklinePath(points: number[], width: number, height: number): string { + if (points.length < 2) return ''; + const max = Math.max(...points, 0.001); + const step = width / (points.length - 1); + return points + .map((v, i) => { + const x = i * step; + const y = height - (v / max) * height; + return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`; + }) + .join(' '); +} + +// --- Format helpers (same logic as in MetricsPanel.svelte) --- +type HistoryMetric = 'cost' | 'tokens' | 'turns' | 'tools' | 'duration'; + +function formatMetricValue(metric: HistoryMetric, value: number): string { + switch (metric) { + case 'cost': return `$${value.toFixed(4)}`; + case 'tokens': return value >= 1000 ? `${(value / 1000).toFixed(1)}K` : `${value}`; + case 'turns': return `${value}`; + case 'tools': return `${value}`; + case 'duration': return `${value.toFixed(1)}m`; + } +} + +function fmtBurnRate(rate: number): string { + if (rate === 0) return '$0/hr'; + if (rate < 0.01) return `$${(rate * 100).toFixed(1)}c/hr`; + return `$${rate.toFixed(2)}/hr`; +} + +function fmtPressure(p: number | null): string { + if (p === null) return '—'; + return `${Math.round(p * 100)}%`; +} + +function fmtIdle(ms: number): string { + if (ms === 0) return '—'; + const sec = Math.floor(ms / 1000); + if (sec < 60) return `${sec}s`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m`; + return `${Math.floor(min / 60)}h ${min % 60}m`; +} + +function pressureColor(p: number | null): string { + if (p === null) return 'var(--ctp-overlay0)'; + if (p > 0.9) return 'var(--ctp-red)'; + if (p > 0.75) return 'var(--ctp-peach)'; + if (p > 0.5) return 'var(--ctp-yellow)'; + return 'var(--ctp-green)'; +} + +function stateColor(state: string): string { + switch (state) { + case 'running': return 'var(--ctp-green)'; + case 'idle': return 'var(--ctp-overlay1)'; + case 'stalled': return 'var(--ctp-peach)'; + default: return 'var(--ctp-overlay0)'; + } +} + +describe('MetricsPanel — sparklinePath', () => { + it('returns empty string for fewer than 2 points', () => { + expect(sparklinePath([], 400, 120)).toBe(''); + expect(sparklinePath([5], 400, 120)).toBe(''); + }); + + it('generates valid SVG path for 2 points', () => { + const path = sparklinePath([0, 10], 400, 120); + expect(path).toMatch(/^M0\.0,120\.0 L400\.0,0\.0$/); + }); + + it('generates path with correct number of segments', () => { + const path = sparklinePath([1, 2, 3, 4, 5], 400, 100); + const segments = path.split(' '); + expect(segments).toHaveLength(5); + expect(segments[0]).toMatch(/^M/); + expect(segments[1]).toMatch(/^L/); + }); + + it('scales Y axis to max value', () => { + const path = sparklinePath([50, 100], 400, 100); + // Point 1: x=0, y=100 - (50/100)*100 = 50 + // Point 2: x=400, y=100 - (100/100)*100 = 0 + expect(path).toBe('M0.0,50.0 L400.0,0.0'); + }); + + it('handles all-zero values without division by zero', () => { + const path = sparklinePath([0, 0, 0], 400, 100); + expect(path).not.toBe(''); + expect(path).not.toContain('NaN'); + }); +}); + +describe('MetricsPanel — formatMetricValue', () => { + it('formats cost with 4 decimals', () => { + expect(formatMetricValue('cost', 1.2345)).toBe('$1.2345'); + expect(formatMetricValue('cost', 0)).toBe('$0.0000'); + }); + + it('formats tokens with K suffix for large values', () => { + expect(formatMetricValue('tokens', 150000)).toBe('150.0K'); + expect(formatMetricValue('tokens', 1500)).toBe('1.5K'); + expect(formatMetricValue('tokens', 500)).toBe('500'); + }); + + it('formats turns as integer', () => { + expect(formatMetricValue('turns', 42)).toBe('42'); + }); + + it('formats tools as integer', () => { + expect(formatMetricValue('tools', 7)).toBe('7'); + }); + + it('formats duration with minutes suffix', () => { + expect(formatMetricValue('duration', 5.3)).toBe('5.3m'); + }); +}); + +describe('MetricsPanel — fmtBurnRate', () => { + it('shows $0/hr for zero rate', () => { + expect(fmtBurnRate(0)).toBe('$0/hr'); + }); + + it('shows cents format for tiny rates', () => { + expect(fmtBurnRate(0.005)).toBe('$0.5c/hr'); + }); + + it('shows dollar format for normal rates', () => { + expect(fmtBurnRate(2.5)).toBe('$2.50/hr'); + }); +}); + +describe('MetricsPanel — fmtPressure', () => { + it('shows dash for null', () => { + expect(fmtPressure(null)).toBe('—'); + }); + + it('formats as percentage', () => { + expect(fmtPressure(0.75)).toBe('75%'); + expect(fmtPressure(0.5)).toBe('50%'); + expect(fmtPressure(1)).toBe('100%'); + }); +}); + +describe('MetricsPanel — fmtIdle', () => { + it('shows dash for zero', () => { + expect(fmtIdle(0)).toBe('—'); + }); + + it('shows seconds for short durations', () => { + expect(fmtIdle(5000)).toBe('5s'); + expect(fmtIdle(30000)).toBe('30s'); + }); + + it('shows minutes for medium durations', () => { + expect(fmtIdle(120_000)).toBe('2m'); + expect(fmtIdle(3_599_000)).toBe('59m'); + }); + + it('shows hours and minutes for long durations', () => { + expect(fmtIdle(3_600_000)).toBe('1h 0m'); + expect(fmtIdle(5_400_000)).toBe('1h 30m'); + }); +}); + +describe('MetricsPanel — pressureColor', () => { + it('returns overlay0 for null', () => { + expect(pressureColor(null)).toBe('var(--ctp-overlay0)'); + }); + + it('returns red for critical pressure', () => { + expect(pressureColor(0.95)).toBe('var(--ctp-red)'); + }); + + it('returns peach for high pressure', () => { + expect(pressureColor(0.8)).toBe('var(--ctp-peach)'); + }); + + it('returns yellow for moderate pressure', () => { + expect(pressureColor(0.6)).toBe('var(--ctp-yellow)'); + }); + + it('returns green for low pressure', () => { + expect(pressureColor(0.3)).toBe('var(--ctp-green)'); + }); +}); + +describe('MetricsPanel — stateColor', () => { + it('maps activity states to correct colors', () => { + expect(stateColor('running')).toBe('var(--ctp-green)'); + expect(stateColor('idle')).toBe('var(--ctp-overlay1)'); + expect(stateColor('stalled')).toBe('var(--ctp-peach)'); + expect(stateColor('inactive')).toBe('var(--ctp-overlay0)'); + expect(stateColor('unknown')).toBe('var(--ctp-overlay0)'); + }); +}); diff --git a/src/lib/components/Workspace/PdfViewer.svelte b/src/lib/components/Workspace/PdfViewer.svelte new file mode 100644 index 0000000..b63159d --- /dev/null +++ b/src/lib/components/Workspace/PdfViewer.svelte @@ -0,0 +1,292 @@ + + +
+
+ + {#if loading} + Loading… + {:else if error} + Error + {:else} + {pageCount} page{pageCount !== 1 ? 's' : ''} + {/if} + +
+ + + +
+
+ + {#if error} +
{error}
+ {:else} +
+ {/if} +
+ + diff --git a/src/lib/components/Workspace/ProjectBox.svelte b/src/lib/components/Workspace/ProjectBox.svelte new file mode 100644 index 0000000..2afb602 --- /dev/null +++ b/src/lib/components/Workspace/ProjectBox.svelte @@ -0,0 +1,542 @@ + + +
+ + +
+ + + + + + + + {#if isAgent && agentRole === 'manager'} + + {/if} + {#if isAgent && agentRole === 'architect'} + + {/if} + {#if isAgent && agentRole === 'reviewer'} + + {/if} + {#if isAgent && agentRole === 'tester'} + + + {/if} + {#if isAgent && agentRole === 'manager'} + + {/if} +
+ +
+ +
+ mainSessionId = id} /> + {#if mainSessionId} + + {/if} +
+
+ +
+
+ +
+ + + {#if everActivated['files']} +
+ +
+ {/if} + {#if everActivated['ssh']} +
+ +
+ {/if} + {#if everActivated['memories']} +
+ +
+ {/if} + {#if everActivated['metrics']} +
+ +
+ {/if} + {#if everActivated['tasks'] && activeGroup} +
+ +
+ {/if} + {#if everActivated['architecture']} +
+ +
+ {/if} + {#if everActivated['selenium']} +
+ +
+ {/if} + {#if everActivated['tests']} +
+ +
+ {/if} + {#if everActivated['audit'] && activeGroup} +
+ +
+ {/if} +
+ +
+ + + {#if terminalExpanded} +
+ +
+ {/if} +
+
+ + diff --git a/src/lib/components/Workspace/ProjectFiles.svelte b/src/lib/components/Workspace/ProjectFiles.svelte new file mode 100644 index 0000000..1d3d806 --- /dev/null +++ b/src/lib/components/Workspace/ProjectFiles.svelte @@ -0,0 +1,152 @@ + + +
+ + +
+ {#if selectedPath} + + {:else} +
Select a file
+ {/if} +
+
+ + diff --git a/src/lib/components/Workspace/ProjectGrid.svelte b/src/lib/components/Workspace/ProjectGrid.svelte new file mode 100644 index 0000000..1efa4ac --- /dev/null +++ b/src/lib/components/Workspace/ProjectGrid.svelte @@ -0,0 +1,108 @@ + + +
+ {#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/src/lib/components/Workspace/ProjectHeader.svelte b/src/lib/components/Workspace/ProjectHeader.svelte new file mode 100644 index 0000000..2e3b9ff --- /dev/null +++ b/src/lib/components/Workspace/ProjectHeader.svelte @@ -0,0 +1,321 @@ + + + + · + {/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/src/lib/components/Workspace/SearchOverlay.svelte b/src/lib/components/Workspace/SearchOverlay.svelte new file mode 100644 index 0000000..8285503 --- /dev/null +++ b/src/lib/components/Workspace/SearchOverlay.svelte @@ -0,0 +1,350 @@ + + +{#if open} + + +
+
+
+ + + + + + {#if loading} +
+ {/if} + Esc +
+ +
+ {#if results.length === 0 && !loading && query.trim()} +
No results for "{query}"
+ {:else if results.length === 0 && !loading} +
Search across sessions, tasks, and messages
+ {:else} + {#each [...groupedResults()] as [type, items] (type)} +
+
+ {TYPE_ICONS[type] ?? '?'} + {TYPE_LABELS[type] ?? type} + {items.length} +
+ {#each items as item (item.id + item.snippet)} + + {/each} +
+ {/each} + {/if} +
+
+
+{/if} + + diff --git a/src/lib/components/Workspace/SettingsTab.svelte b/src/lib/components/Workspace/SettingsTab.svelte new file mode 100644 index 0000000..8a29fe0 --- /dev/null +++ b/src/lib/components/Workspace/SettingsTab.svelte @@ -0,0 +1,2959 @@ + + + + +
+
+

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 +
+
+
+ +
+

Updates

+
+
+ Current version + {appVersion || '...'} +
+ {#if updateLastCheck} +
+ Last checked + {updateLastCheck} +
+ {/if} + {#if updateCheckResult?.available} +
+ Available + v{updateCheckResult.version} +
+ {/if} +
+ +
+
+
+ +
+

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} +
+
+ +
+

Secrets

+
+ + + {keyringAvailable ? 'System keyring available' : 'System keyring unavailable'} + +
+ + {#if !keyringAvailable} +
+ + System keyring not available. Secrets cannot be stored securely. +
+ {:else} + {#if storedKeys.length > 0} +
+ {#each storedKeys as key} +
+
+ {getSecretKeyLabel(key)} + {key} +
+
+ {#if revealedKey === key} + + {:else} + {'\u25CF'.repeat(8)} + {/if} +
+
+ + +
+
+ {/each} +
+ {/if} + +
+
+
+ + {#if secretsKeyDropdownOpen} + + {/if} +
+ + +
+
+ {/if} +
+ +
+

Plugins

+ {#if pluginEntries.length === 0} +

No plugins found in ~/.config/bterminal/plugins/

+ {:else} +
+ {#each pluginEntries as entry (entry.meta.id)} +
+
+ {entry.meta.name} + v{entry.meta.version} + {#if entry.status === 'loaded'} + loaded + {:else if entry.status === 'error'} + error + {:else if entry.status === 'disabled'} + disabled + {:else} + discovered + {/if} +
+ {#if entry.meta.description} +

{entry.meta.description}

+ {/if} + {#if entry.meta.permissions.length > 0} +
+ {#each entry.meta.permissions as perm} + {perm} + {/each} +
+ {/if} + {#if entry.error} +

{entry.error}

+ {/if} + +
+ {/each} +
+ {/if} + +
+ +
+

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)} + {@const agentProvider = registeredProviders.find(p => p.id === (agent.provider ?? 'claude'))} +
+
+ {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 })} + /> + +
+
+ + {#if registeredProviders.length > 1} +
+ + + Provider + +
+ + {#if providerDropdownOpenFor === agent.id} + + {/if} +
+
+ {/if} + +
+ + + Model + +
+ + {#if modelDropdownOpenFor === agent.id} + + {/if} +
+
+ + {#if agent.role === 'manager'} +
+ + + Wake Interval + +
+ updateAgent(activeGroupId, agent.id, { wakeIntervalMin: parseInt((e.target as HTMLInputElement).value) })} + /> + {agent.wakeIntervalMin ?? 3} min +
+
+ +
+ + + Wake Strategy + +
+ {#each WAKE_STRATEGIES as strat} + + {/each} +
+ {WAKE_STRATEGY_DESCRIPTIONS[agent.wakeStrategy ?? 'smart']} +
+ + {#if (agent.wakeStrategy ?? 'smart') === 'smart'} +
+ + + Wake Threshold + +
+ updateAgent(activeGroupId, agent.id, { wakeThreshold: parseFloat((e.target as HTMLInputElement).value) })} + /> + {((agent.wakeThreshold ?? 0.5) * 100).toFixed(0)}% +
+ Only wakes when signal score exceeds this level +
+ {/if} + {/if} + +
+ + + Shell Execution + +
+ + +
+
+ +
+ + + 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} + {@const projProvider = registeredProviders.find(p => p.id === (project.provider ?? 'claude'))} +
+
+
+ + {#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} + +
+ + + Model + +
+ + {#if modelDropdownOpenFor === project.id} + + {/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 + + +
+ +
+ + + Sandbox (Landlock) + + +
+ +
+ + + Shell Execution + +
+ + +
+
+ +
+ + + 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/src/lib/components/Workspace/SshTab.svelte b/src/lib/components/Workspace/SshTab.svelte new file mode 100644 index 0000000..91c698a --- /dev/null +++ b/src/lib/components/Workspace/SshTab.svelte @@ -0,0 +1,425 @@ + + +
+
+

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/src/lib/components/Workspace/TaskBoardTab.svelte b/src/lib/components/Workspace/TaskBoardTab.svelte new file mode 100644 index 0000000..4042bdc --- /dev/null +++ b/src/lib/components/Workspace/TaskBoardTab.svelte @@ -0,0 +1,582 @@ + + +
+
+ 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/src/lib/components/Workspace/TeamAgentsPanel.svelte b/src/lib/components/Workspace/TeamAgentsPanel.svelte new file mode 100644 index 0000000..5707693 --- /dev/null +++ b/src/lib/components/Workspace/TeamAgentsPanel.svelte @@ -0,0 +1,91 @@ + + +{#if hasAgents} +
+ + + {#if expanded} +
+ {#each childSessions as child (child.id)} + + {/each} +
+ {/if} +
+{/if} + + diff --git a/src/lib/components/Workspace/TerminalTabs.svelte b/src/lib/components/Workspace/TerminalTabs.svelte new file mode 100644 index 0000000..a316179 --- /dev/null +++ b/src/lib/components/Workspace/TerminalTabs.svelte @@ -0,0 +1,275 @@ + + +
+
+ {#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/src/lib/components/Workspace/TestingTab.svelte b/src/lib/components/Workspace/TestingTab.svelte new file mode 100644 index 0000000..7a258b6 --- /dev/null +++ b/src/lib/components/Workspace/TestingTab.svelte @@ -0,0 +1,428 @@ + + +
+ {#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/src/lib/plugins/plugin-host.test.ts b/src/lib/plugins/plugin-host.test.ts new file mode 100644 index 0000000..0d9eb45 --- /dev/null +++ b/src/lib/plugins/plugin-host.test.ts @@ -0,0 +1,534 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// --- Mocks --- + +const { mockInvoke } = vi.hoisted(() => ({ + mockInvoke: vi.fn(), +})); + +vi.mock('@tauri-apps/api/core', () => ({ + invoke: mockInvoke, +})); + +// Mock the plugins store to avoid Svelte 5 rune issues in test context +vi.mock('../stores/plugins.svelte', () => { + const commands: Array<{ pluginId: string; label: string; callback: () => void }> = []; + return { + addPluginCommand: vi.fn((pluginId: string, label: string, callback: () => void) => { + commands.push({ pluginId, label, callback }); + }), + removePluginCommands: vi.fn((pluginId: string) => { + const toRemove = commands.filter(c => c.pluginId === pluginId); + for (const cmd of toRemove) { + const idx = commands.indexOf(cmd); + if (idx >= 0) commands.splice(idx, 1); + } + }), + pluginEventBus: { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + clear: vi.fn(), + }, + getPluginCommands: () => [...commands], + }; +}); + +import { + loadPlugin, + unloadPlugin, + getLoadedPlugins, + unloadAllPlugins, +} from './plugin-host'; +import { addPluginCommand, removePluginCommands, pluginEventBus } from '../stores/plugins.svelte'; +import type { PluginMeta } from '../adapters/plugins-bridge'; +import type { GroupId, AgentId } from '../types/ids'; + +// --- Mock Worker --- + +/** + * Simulates a Web Worker that runs the plugin host's worker script. + * Instead of actually creating a Blob + Worker, we intercept postMessage + * and simulate the worker-side logic inline. + */ +class MockWorker { + onmessage: ((e: MessageEvent) => void) | null = null; + onerror: ((e: ErrorEvent) => void) | null = null; + private terminated = false; + + postMessage(msg: unknown): void { + if (this.terminated) return; + const data = msg as Record; + + if (data.type === 'init') { + this.handleInit(data); + } else if (data.type === 'invoke-callback') { + // Callback invocations from main → worker: no-op in mock + // (the real worker would call the stored callback) + } + } + + private handleInit(data: Record): void { + const code = data.code as string; + const permissions = (data.permissions as string[]) || []; + const meta = data.meta as Record; + + // Build a mock bterminal API that mimics worker-side behavior + // by sending messages back to the main thread (this.sendToMain) + const bterminal: Record = { + meta: Object.freeze({ ...meta }), + }; + + if (permissions.includes('palette')) { + let cbId = 0; + bterminal.palette = { + registerCommand: (label: string, callback: () => void) => { + if (typeof label !== 'string' || !label.trim()) { + throw new Error('Command label must be a non-empty string'); + } + if (typeof callback !== 'function') { + throw new Error('Command callback must be a function'); + } + const id = '__cb_' + (++cbId); + this.sendToMain({ type: 'palette-register', label, callbackId: id }); + }, + }; + } + + if (permissions.includes('bttask:read')) { + bterminal.tasks = { + list: () => this.rpc('tasks.list', {}), + comments: (taskId: string) => this.rpc('tasks.comments', { taskId }), + }; + } + + if (permissions.includes('btmsg:read')) { + bterminal.messages = { + inbox: () => this.rpc('messages.inbox', {}), + channels: () => this.rpc('messages.channels', {}), + }; + } + + if (permissions.includes('events')) { + let cbId = 0; + bterminal.events = { + on: (event: string, callback: (data: unknown) => void) => { + if (typeof event !== 'string' || typeof callback !== 'function') { + throw new Error('event.on requires (string, function)'); + } + const id = '__cb_' + (++cbId); + this.sendToMain({ type: 'event-on', event, callbackId: id }); + }, + off: (event: string) => { + this.sendToMain({ type: 'event-off', event }); + }, + }; + } + + Object.freeze(bterminal); + + // Execute the plugin code + try { + const fn = new Function('bterminal', `"use strict"; ${code}`); + fn(bterminal); + this.sendToMain({ type: 'loaded' }); + } catch (err) { + this.sendToMain({ type: 'error', message: String(err) }); + } + } + + private rpcId = 0; + private rpc(method: string, args: Record): Promise { + const id = '__rpc_' + (++this.rpcId); + this.sendToMain({ type: 'rpc', id, method, args }); + // In real worker, this would be a pending promise resolved by rpc-result message. + // For tests, return a resolved promise since we test RPC routing separately. + return Promise.resolve([]); + } + + private sendToMain(data: unknown): void { + if (this.terminated) return; + // Schedule on microtask to simulate async Worker message delivery + queueMicrotask(() => { + if (this.onmessage) { + this.onmessage(new MessageEvent('message', { data })); + } + }); + } + + terminate(): void { + this.terminated = true; + this.onmessage = null; + this.onerror = null; + } + + addEventListener(): void { /* stub */ } + removeEventListener(): void { /* stub */ } + dispatchEvent(): boolean { return false; } +} + +// Install global Worker mock +const originalWorker = globalThis.Worker; +const originalURL = globalThis.URL; + +beforeEach(() => { + vi.clearAllMocks(); + unloadAllPlugins(); + + // Mock Worker constructor + (globalThis as Record).Worker = MockWorker; + + // Mock URL.createObjectURL + if (!globalThis.URL) { + (globalThis as Record).URL = {} as typeof URL; + } + globalThis.URL.createObjectURL = vi.fn(() => 'blob:mock-worker-url'); + globalThis.URL.revokeObjectURL = vi.fn(); +}); + +afterEach(() => { + (globalThis as Record).Worker = originalWorker; + if (originalURL) { + globalThis.URL.createObjectURL = originalURL.createObjectURL; + globalThis.URL.revokeObjectURL = originalURL.revokeObjectURL; + } +}); + +// --- Helpers --- + +function makeMeta(overrides: Partial = {}): PluginMeta { + return { + id: overrides.id ?? 'test-plugin', + name: overrides.name ?? 'Test Plugin', + version: overrides.version ?? '1.0.0', + description: overrides.description ?? 'A test plugin', + main: overrides.main ?? 'index.js', + permissions: overrides.permissions ?? [], + }; +} + +function mockPluginCode(code: string): void { + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'plugin_read_file') return Promise.resolve(code); + return Promise.reject(new Error(`Unexpected invoke: ${cmd}`)); + }); +} + +const GROUP_ID = 'test-group' as GroupId; +const AGENT_ID = 'test-agent' as AgentId; + +// --- Worker isolation tests --- + +describe('plugin-host Worker isolation', () => { + it('plugin code runs in Worker (cannot access main thread globals)', async () => { + // In a real Worker, window/document/globalThis are unavailable. + // Our MockWorker simulates this by running in strict mode. + const meta = makeMeta({ id: 'isolation-test' }); + mockPluginCode('// no-op — isolation verified by Worker boundary'); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + + it('Worker is terminated on unload', async () => { + const meta = makeMeta({ id: 'terminate-test' }); + mockPluginCode('// no-op'); + await loadPlugin(meta, GROUP_ID, AGENT_ID); + + expect(getLoadedPlugins()).toHaveLength(1); + unloadPlugin('terminate-test'); + expect(getLoadedPlugins()).toHaveLength(0); + }); + + it('API object is frozen (cannot add properties)', async () => { + const meta = makeMeta({ id: 'freeze-test', permissions: [] }); + mockPluginCode(` + try { + bterminal.hacked = true; + throw new Error('FREEZE FAILED: could add property'); + } catch (e) { + if (e.message === 'FREEZE FAILED: could add property') throw e; + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + + it('API object is frozen (cannot delete properties)', async () => { + const meta = makeMeta({ id: 'freeze-delete-test', permissions: [] }); + mockPluginCode(` + try { + delete bterminal.meta; + throw new Error('FREEZE FAILED: could delete property'); + } catch (e) { + if (e.message === 'FREEZE FAILED: could delete property') throw e; + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + + it('meta is accessible and frozen', async () => { + const meta = makeMeta({ id: 'meta-access', permissions: [] }); + mockPluginCode(` + if (bterminal.meta.id !== 'meta-access') { + throw new Error('meta.id mismatch'); + } + if (bterminal.meta.name !== 'Test Plugin') { + throw new Error('meta.name mismatch'); + } + try { + bterminal.meta.id = 'hacked'; + throw new Error('META FREEZE FAILED'); + } catch (e) { + if (e.message === 'META FREEZE FAILED') throw e; + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); +}); + +// --- Permission-gated API tests --- + +describe('plugin-host permissions', () => { + describe('palette permission', () => { + it('plugin with palette permission can register commands', async () => { + const meta = makeMeta({ id: 'palette-plugin', permissions: ['palette'] }); + mockPluginCode(` + bterminal.palette.registerCommand('Test Command', function() {}); + `); + + await loadPlugin(meta, GROUP_ID, AGENT_ID); + + expect(addPluginCommand).toHaveBeenCalledWith( + 'palette-plugin', + 'Test Command', + expect.any(Function), + ); + }); + + it('plugin without palette permission has no palette API', async () => { + const meta = makeMeta({ id: 'no-palette-plugin', permissions: [] }); + mockPluginCode(` + if (bterminal.palette !== undefined) { + throw new Error('palette API should not be available'); + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + + it('palette.registerCommand rejects non-string label', async () => { + const meta = makeMeta({ id: 'bad-label-plugin', permissions: ['palette'] }); + mockPluginCode(` + bterminal.palette.registerCommand(123, function() {}); + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow( + 'execution failed', + ); + }); + + it('palette.registerCommand rejects non-function callback', async () => { + const meta = makeMeta({ id: 'bad-cb-plugin', permissions: ['palette'] }); + mockPluginCode(` + bterminal.palette.registerCommand('Test', 'not-a-function'); + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow( + 'execution failed', + ); + }); + + it('palette.registerCommand rejects empty label', async () => { + const meta = makeMeta({ id: 'empty-label-plugin', permissions: ['palette'] }); + mockPluginCode(` + bterminal.palette.registerCommand(' ', function() {}); + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow( + 'execution failed', + ); + }); + }); + + describe('bttask:read permission', () => { + it('plugin with bttask:read can call tasks.list', async () => { + const meta = makeMeta({ id: 'task-plugin', permissions: ['bttask:read'] }); + mockPluginCode(` + bterminal.tasks.list(); + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + + it('plugin without bttask:read has no tasks API', async () => { + const meta = makeMeta({ id: 'no-task-plugin', permissions: [] }); + mockPluginCode(` + if (bterminal.tasks !== undefined) { + throw new Error('tasks API should not be available'); + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + }); + + describe('btmsg:read permission', () => { + it('plugin with btmsg:read can call messages.inbox', async () => { + const meta = makeMeta({ id: 'msg-plugin', permissions: ['btmsg:read'] }); + mockPluginCode(` + bterminal.messages.inbox(); + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + + it('plugin without btmsg:read has no messages API', async () => { + const meta = makeMeta({ id: 'no-msg-plugin', permissions: [] }); + mockPluginCode(` + if (bterminal.messages !== undefined) { + throw new Error('messages API should not be available'); + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + }); + + describe('events permission', () => { + it('plugin with events permission can subscribe', async () => { + const meta = makeMeta({ id: 'events-plugin', permissions: ['events'] }); + mockPluginCode(` + bterminal.events.on('test-event', function(data) {}); + `); + await loadPlugin(meta, GROUP_ID, AGENT_ID); + expect(pluginEventBus.on).toHaveBeenCalledWith('test-event', expect.any(Function)); + }); + + it('plugin without events permission has no events API', async () => { + const meta = makeMeta({ id: 'no-events-plugin', permissions: [] }); + mockPluginCode(` + if (bterminal.events !== undefined) { + throw new Error('events API should not be available'); + } + `); + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + }); +}); + +// --- Lifecycle tests --- + +describe('plugin-host lifecycle', () => { + it('loadPlugin registers the plugin', async () => { + const meta = makeMeta({ id: 'lifecycle-load' }); + mockPluginCode('// no-op'); + + await loadPlugin(meta, GROUP_ID, AGENT_ID); + + const loaded = getLoadedPlugins(); + expect(loaded).toHaveLength(1); + expect(loaded[0].id).toBe('lifecycle-load'); + }); + + it('loadPlugin warns on duplicate load and returns early', async () => { + const meta = makeMeta({ id: 'duplicate-load' }); + mockPluginCode('// no-op'); + + await loadPlugin(meta, GROUP_ID, AGENT_ID); + + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await loadPlugin(meta, GROUP_ID, AGENT_ID); + expect(consoleSpy).toHaveBeenCalledWith("Plugin 'duplicate-load' is already loaded"); + consoleSpy.mockRestore(); + + expect(getLoadedPlugins()).toHaveLength(1); + }); + + it('unloadPlugin removes the plugin and cleans up commands', async () => { + const meta = makeMeta({ id: 'lifecycle-unload', permissions: ['palette'] }); + mockPluginCode(` + bterminal.palette.registerCommand('Cmd1', function() {}); + `); + + await loadPlugin(meta, GROUP_ID, AGENT_ID); + expect(getLoadedPlugins()).toHaveLength(1); + + unloadPlugin('lifecycle-unload'); + expect(getLoadedPlugins()).toHaveLength(0); + expect(removePluginCommands).toHaveBeenCalledWith('lifecycle-unload'); + }); + + it('unloadPlugin is no-op for unknown plugin', () => { + unloadPlugin('nonexistent'); + expect(getLoadedPlugins()).toHaveLength(0); + }); + + it('unloadAllPlugins clears all loaded plugins', async () => { + mockPluginCode('// no-op'); + + const meta1 = makeMeta({ id: 'all-1' }); + await loadPlugin(meta1, GROUP_ID, AGENT_ID); + + const meta2 = makeMeta({ id: 'all-2' }); + await loadPlugin(meta2, GROUP_ID, AGENT_ID); + + expect(getLoadedPlugins()).toHaveLength(2); + + unloadAllPlugins(); + expect(getLoadedPlugins()).toHaveLength(0); + }); + + it('loadPlugin cleans up commands on execution error', async () => { + const meta = makeMeta({ id: 'error-cleanup' }); + mockPluginCode('throw new Error("plugin crash");'); + + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow( + "Plugin 'error-cleanup' execution failed", + ); + expect(removePluginCommands).toHaveBeenCalledWith('error-cleanup'); + expect(getLoadedPlugins()).toHaveLength(0); + }); + + it('loadPlugin throws on file read failure', async () => { + const meta = makeMeta({ id: 'read-fail' }); + mockInvoke.mockRejectedValue(new Error('file not found')); + + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow( + "Failed to read plugin 'read-fail'", + ); + }); + + it('unloadPlugin cleans up event subscriptions', async () => { + const meta = makeMeta({ id: 'events-cleanup', permissions: ['events'] }); + mockPluginCode(` + bterminal.events.on('my-event', function() {}); + `); + + await loadPlugin(meta, GROUP_ID, AGENT_ID); + expect(pluginEventBus.on).toHaveBeenCalledWith('my-event', expect.any(Function)); + + unloadPlugin('events-cleanup'); + expect(pluginEventBus.off).toHaveBeenCalledWith('my-event', expect.any(Function)); + }); +}); + +// --- RPC routing tests --- + +describe('plugin-host RPC routing', () => { + it('tasks.list RPC is routed to main thread', async () => { + const meta = makeMeta({ id: 'rpc-tasks', permissions: ['bttask:read'] }); + mockPluginCode(`bterminal.tasks.list();`); + + // Mock the bttask bridge + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'plugin_read_file') return Promise.resolve('bterminal.tasks.list();'); + if (cmd === 'bttask_list') return Promise.resolve([]); + return Promise.reject(new Error(`Unexpected: ${cmd}`)); + }); + + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); + + it('messages.inbox RPC is routed to main thread', async () => { + const meta = makeMeta({ id: 'rpc-messages', permissions: ['btmsg:read'] }); + mockPluginCode(`bterminal.messages.inbox();`); + + mockInvoke.mockImplementation((cmd: string) => { + if (cmd === 'plugin_read_file') return Promise.resolve('bterminal.messages.inbox();'); + if (cmd === 'btmsg_get_unread') return Promise.resolve([]); + return Promise.reject(new Error(`Unexpected: ${cmd}`)); + }); + + await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); + }); +}); diff --git a/src/lib/plugins/plugin-host.ts b/src/lib/plugins/plugin-host.ts new file mode 100644 index 0000000..f44b916 --- /dev/null +++ b/src/lib/plugins/plugin-host.ts @@ -0,0 +1,339 @@ +/** + * Plugin Host — Web Worker sandbox for BTerminal plugins. + * + * Each plugin runs in a dedicated Web Worker, providing true process-level + * isolation from the main thread. The Worker has no access to the DOM, + * Tauri IPC, or any main-thread state. + * + * Communication: + * - Main → Worker: plugin code, permissions, callback invocations + * - Worker → Main: API call proxies (palette, tasks, messages, events) + * + * On unload, the Worker is terminated — all plugin state is destroyed. + */ + +import type { PluginMeta } from '../adapters/plugins-bridge'; +import { readPluginFile } from '../adapters/plugins-bridge'; +import { listTasks, getTaskComments } from '../adapters/bttask-bridge'; +import { + getUnreadMessages, + getChannels, +} from '../adapters/btmsg-bridge'; +import { + addPluginCommand, + removePluginCommands, + pluginEventBus, +} from '../stores/plugins.svelte'; +import type { GroupId, AgentId } from '../types/ids'; + +interface LoadedPlugin { + meta: PluginMeta; + worker: Worker; + callbacks: Map void>; + eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }>; + cleanup: () => void; +} + +const loadedPlugins = new Map(); + +/** + * Build the Worker script as an inline blob. + * The Worker receives plugin code + permissions and builds a sandboxed bterminal API + * that proxies all calls to the main thread via postMessage. + */ +function buildWorkerScript(): string { + return ` +"use strict"; + +// Callback registry for palette commands and event handlers +const _callbacks = new Map(); +let _callbackId = 0; + +function _nextCallbackId() { + return '__cb_' + (++_callbackId); +} + +// Pending RPC calls (for async APIs like tasks.list) +const _pending = new Map(); +let _rpcId = 0; + +function _rpc(method, args) { + return new Promise((resolve, reject) => { + const id = '__rpc_' + (++_rpcId); + _pending.set(id, { resolve, reject }); + self.postMessage({ type: 'rpc', id, method, args }); + }); +} + +// Handle messages from main thread +self.onmessage = function(e) { + const msg = e.data; + + if (msg.type === 'init') { + const permissions = msg.permissions || []; + const meta = msg.meta; + + // Build the bterminal API based on permissions + const api = { meta: Object.freeze(meta) }; + + if (permissions.includes('palette')) { + api.palette = { + registerCommand(label, callback) { + if (typeof label !== 'string' || !label.trim()) { + throw new Error('Command label must be a non-empty string'); + } + if (typeof callback !== 'function') { + throw new Error('Command callback must be a function'); + } + const cbId = _nextCallbackId(); + _callbacks.set(cbId, callback); + self.postMessage({ type: 'palette-register', label, callbackId: cbId }); + }, + }; + } + + if (permissions.includes('bttask:read')) { + api.tasks = { + list() { return _rpc('tasks.list', {}); }, + comments(taskId) { return _rpc('tasks.comments', { taskId }); }, + }; + } + + if (permissions.includes('btmsg:read')) { + api.messages = { + inbox() { return _rpc('messages.inbox', {}); }, + channels() { return _rpc('messages.channels', {}); }, + }; + } + + if (permissions.includes('events')) { + api.events = { + on(event, callback) { + if (typeof event !== 'string' || typeof callback !== 'function') { + throw new Error('event.on requires (string, function)'); + } + const cbId = _nextCallbackId(); + _callbacks.set(cbId, callback); + self.postMessage({ type: 'event-on', event, callbackId: cbId }); + }, + off(event, callbackId) { + // Worker-side off is a no-op for now (main thread handles cleanup on terminate) + self.postMessage({ type: 'event-off', event, callbackId }); + }, + }; + } + + Object.freeze(api); + + // Execute the plugin code + try { + const fn = (0, eval)( + '(function(bterminal) { "use strict"; ' + msg.code + '\\n})' + ); + fn(api); + self.postMessage({ type: 'loaded' }); + } catch (err) { + self.postMessage({ type: 'error', message: String(err) }); + } + } + + if (msg.type === 'invoke-callback') { + const cb = _callbacks.get(msg.callbackId); + if (cb) { + try { + cb(msg.data); + } catch (err) { + self.postMessage({ type: 'callback-error', callbackId: msg.callbackId, message: String(err) }); + } + } + } + + if (msg.type === 'rpc-result') { + const pending = _pending.get(msg.id); + if (pending) { + _pending.delete(msg.id); + if (msg.error) { + pending.reject(new Error(msg.error)); + } else { + pending.resolve(msg.result); + } + } + } +}; +`; +} + +let workerBlobUrl: string | null = null; + +function getWorkerBlobUrl(): string { + if (!workerBlobUrl) { + const blob = new Blob([buildWorkerScript()], { type: 'application/javascript' }); + workerBlobUrl = URL.createObjectURL(blob); + } + return workerBlobUrl; +} + +/** + * Load and execute a plugin in a Web Worker sandbox. + */ +export async function loadPlugin( + meta: PluginMeta, + groupId: GroupId, + agentId: AgentId, +): Promise { + if (loadedPlugins.has(meta.id)) { + console.warn(`Plugin '${meta.id}' is already loaded`); + return; + } + + // Read the plugin's entry file + let code: string; + try { + code = await readPluginFile(meta.id, meta.main); + } catch (e) { + throw new Error(`Failed to read plugin '${meta.id}' entry file '${meta.main}': ${e}`); + } + + const worker = new Worker(getWorkerBlobUrl(), { type: 'classic' }); + const callbacks = new Map void>(); + const eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }> = []; + + // Set up message handler before sending init + const loadResult = await new Promise((resolve, reject) => { + const onMessage = async (e: MessageEvent) => { + const msg = e.data; + + switch (msg.type) { + case 'loaded': + resolve(); + break; + + case 'error': + // Clean up any commands/events registered before the crash + removePluginCommands(meta.id); + for (const sub of eventSubscriptions) { + pluginEventBus.off(sub.event, sub.handler); + } + worker.terminate(); + reject(new Error(`Plugin '${meta.id}' execution failed: ${msg.message}`)); + break; + + case 'palette-register': { + const cbId = msg.callbackId as string; + const invokeCallback = () => { + worker.postMessage({ type: 'invoke-callback', callbackId: cbId }); + }; + callbacks.set(cbId, invokeCallback); + addPluginCommand(meta.id, msg.label, invokeCallback); + break; + } + + case 'event-on': { + const cbId = msg.callbackId as string; + const handler = (data: unknown) => { + worker.postMessage({ type: 'invoke-callback', callbackId: cbId, data }); + }; + eventSubscriptions.push({ event: msg.event, handler }); + pluginEventBus.on(msg.event, handler); + break; + } + + case 'event-off': { + const idx = eventSubscriptions.findIndex(s => s.event === msg.event); + if (idx >= 0) { + pluginEventBus.off(eventSubscriptions[idx].event, eventSubscriptions[idx].handler); + eventSubscriptions.splice(idx, 1); + } + break; + } + + case 'rpc': { + const { id, method, args } = msg; + try { + let result: unknown; + switch (method) { + case 'tasks.list': + result = await listTasks(groupId); + break; + case 'tasks.comments': + result = await getTaskComments(args.taskId); + break; + case 'messages.inbox': + result = await getUnreadMessages(agentId); + break; + case 'messages.channels': + result = await getChannels(groupId); + break; + default: + throw new Error(`Unknown RPC method: ${method}`); + } + worker.postMessage({ type: 'rpc-result', id, result }); + } catch (err) { + worker.postMessage({ + type: 'rpc-result', + id, + error: err instanceof Error ? err.message : String(err), + }); + } + break; + } + + case 'callback-error': + console.error(`Plugin '${meta.id}' callback error:`, msg.message); + break; + } + }; + + worker.onmessage = onMessage; + worker.onerror = (err) => { + reject(new Error(`Plugin '${meta.id}' worker error: ${err.message}`)); + }; + + // Send init message with plugin code, permissions, and meta + worker.postMessage({ + type: 'init', + code, + permissions: meta.permissions, + meta: { id: meta.id, name: meta.name, version: meta.version, description: meta.description }, + }); + }); + + // If we get here, the plugin loaded successfully + const cleanup = () => { + removePluginCommands(meta.id); + for (const sub of eventSubscriptions) { + pluginEventBus.off(sub.event, sub.handler); + } + eventSubscriptions.length = 0; + callbacks.clear(); + worker.terminate(); + }; + + loadedPlugins.set(meta.id, { meta, worker, callbacks, eventSubscriptions, cleanup }); +} + +/** + * Unload a plugin, terminating its Worker. + */ +export function unloadPlugin(id: string): void { + const plugin = loadedPlugins.get(id); + if (!plugin) return; + plugin.cleanup(); + loadedPlugins.delete(id); +} + +/** + * Get all currently loaded plugins. + */ +export function getLoadedPlugins(): PluginMeta[] { + return Array.from(loadedPlugins.values()).map(p => p.meta); +} + +/** + * Unload all plugins. + */ +export function unloadAllPlugins(): void { + for (const [id] of loadedPlugins) { + unloadPlugin(id); + } +} diff --git a/src/lib/providers/aider.ts b/src/lib/providers/aider.ts new file mode 100644 index 0000000..24dae7e --- /dev/null +++ b/src/lib/providers/aider.ts @@ -0,0 +1,32 @@ +// Aider Provider — metadata and capabilities for Aider (OpenRouter / multi-model agent) + +import type { ProviderMeta } from './types'; + +export const AIDER_PROVIDER: ProviderMeta = { + id: 'aider', + name: 'Aider', + description: 'Aider AI coding agent — supports OpenRouter, OpenAI, Anthropic and local models', + capabilities: { + hasProfiles: false, + hasSkills: false, + hasModelSelection: true, + hasSandbox: false, + supportsSubagents: false, + supportsCost: false, + supportsResume: false, + }, + sidecarRunner: 'aider-runner.mjs', + defaultModel: 'openrouter/anthropic/claude-sonnet-4', + models: [ + { id: 'openrouter/anthropic/claude-sonnet-4', label: 'Claude Sonnet 4 (OpenRouter)' }, + { id: 'openrouter/anthropic/claude-haiku-4', label: 'Claude Haiku 4 (OpenRouter)' }, + { id: 'openrouter/openai/gpt-4.1', label: 'GPT-4.1 (OpenRouter)' }, + { id: 'openrouter/openai/o3', label: 'o3 (OpenRouter)' }, + { id: 'openrouter/google/gemini-2.5-pro', label: 'Gemini 2.5 Pro (OpenRouter)' }, + { id: 'openrouter/deepseek/deepseek-r1', label: 'DeepSeek R1 (OpenRouter)' }, + { id: 'openrouter/meta-llama/llama-4-maverick', label: 'Llama 4 Maverick (OpenRouter)' }, + { id: 'anthropic/claude-sonnet-4-5-20250514', label: 'Claude Sonnet 4.5 (direct)' }, + { id: 'o3', label: 'o3 (OpenAI direct)' }, + { id: 'ollama/qwen3:8b', label: 'Qwen3 8B (Ollama)' }, + ], +}; diff --git a/src/lib/providers/claude.ts b/src/lib/providers/claude.ts new file mode 100644 index 0000000..8bf8baf --- /dev/null +++ b/src/lib/providers/claude.ts @@ -0,0 +1,25 @@ +// 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', + models: [ + { id: 'claude-opus-4-6', label: 'Opus 4.6' }, + { id: 'claude-sonnet-4-6', label: 'Sonnet 4.6' }, + { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' }, + ], +}; diff --git a/src/lib/providers/codex.ts b/src/lib/providers/codex.ts new file mode 100644 index 0000000..ee55982 --- /dev/null +++ b/src/lib/providers/codex.ts @@ -0,0 +1,25 @@ +// 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', + models: [ + { id: 'gpt-5.4', label: 'GPT-5.4' }, + { id: 'o3', label: 'o3' }, + { id: 'o4-mini', label: 'o4-mini' }, + ], +}; diff --git a/src/lib/providers/ollama.ts b/src/lib/providers/ollama.ts new file mode 100644 index 0000000..fb371ff --- /dev/null +++ b/src/lib/providers/ollama.ts @@ -0,0 +1,27 @@ +// 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', + models: [ + { id: 'qwen3:8b', label: 'Qwen3 8B' }, + { id: 'qwen3:32b', label: 'Qwen3 32B' }, + { id: 'llama3.3:70b', label: 'Llama 3.3 70B' }, + { id: 'deepseek-r1:14b', label: 'DeepSeek R1 14B' }, + { id: 'codellama:13b', label: 'Code Llama 13B' }, + ], +}; diff --git a/src/lib/providers/registry.svelte.ts b/src/lib/providers/registry.svelte.ts new file mode 100644 index 0000000..90e80ac --- /dev/null +++ b/src/lib/providers/registry.svelte.ts @@ -0,0 +1,26 @@ +// 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/src/lib/providers/types.ts b/src/lib/providers/types.ts new file mode 100644 index 0000000..e8fe138 --- /dev/null +++ b/src/lib/providers/types.ts @@ -0,0 +1,36 @@ +// Provider abstraction types — defines the interface for multi-provider agent support + +export type ProviderId = 'claude' | 'codex' | 'ollama' | 'aider'; + +/** 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; + /** Available model presets for dropdown selection */ + models?: { id: string; label: string }[]; +} + +/** Per-provider configuration (stored in settings) */ +export interface ProviderSettings { + enabled: boolean; + defaultModel?: string; + /** Provider-specific config blob */ + config: Record; +} diff --git a/src/lib/stores/agents.svelte.ts b/src/lib/stores/agents.svelte.ts new file mode 100644 index 0000000..b301f6a --- /dev/null +++ b/src/lib/stores/agents.svelte.ts @@ -0,0 +1,148 @@ +// 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/src/lib/stores/anchors.svelte.ts b/src/lib/stores/anchors.svelte.ts new file mode 100644 index 0000000..4b4144a --- /dev/null +++ b/src/lib/stores/anchors.svelte.ts @@ -0,0 +1,129 @@ +// 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/src/lib/stores/conflicts.svelte.ts b/src/lib/stores/conflicts.svelte.ts new file mode 100644 index 0000000..e5427f7 --- /dev/null +++ b/src/lib/stores/conflicts.svelte.ts @@ -0,0 +1,284 @@ +// 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/src/lib/stores/conflicts.test.ts b/src/lib/stores/conflicts.test.ts new file mode 100644 index 0000000..86a1991 --- /dev/null +++ b/src/lib/stores/conflicts.test.ts @@ -0,0 +1,344 @@ +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/src/lib/stores/health.svelte.ts b/src/lib/stores/health.svelte.ts new file mode 100644 index 0000000..c7cb0bb --- /dev/null +++ b/src/lib/stores/health.svelte.ts @@ -0,0 +1,329 @@ +// 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]>; + /** Number of tasks in 'review' status (for reviewer agents) */ + reviewQueueDepth: 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: [], + reviewQueueDepth: 0, + }); +} + +/** 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; + } +} + +/** Set review queue depth for a project (used by reviewer agents) */ +export function setReviewQueueDepth(projectId: ProjectIdType, depth: number): void { + const t = trackers.get(projectId); + if (t) t.reviewQueueDepth = depth; +} + +/** 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, + reviewQueueDepth: tracker.reviewQueueDepth, + }); + + 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/src/lib/stores/layout.svelte.ts b/src/lib/stores/layout.svelte.ts new file mode 100644 index 0000000..acfe905 --- /dev/null +++ b/src/lib/stores/layout.svelte.ts @@ -0,0 +1,193 @@ +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/src/lib/stores/layout.test.ts b/src/lib/stores/layout.test.ts new file mode 100644 index 0000000..ffd4b1b --- /dev/null +++ b/src/lib/stores/layout.test.ts @@ -0,0 +1,299 @@ +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/src/lib/stores/machines.svelte.ts b/src/lib/stores/machines.svelte.ts new file mode 100644 index 0000000..c035ce9 --- /dev/null +++ b/src/lib/stores/machines.svelte.ts @@ -0,0 +1,131 @@ +// 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/src/lib/stores/notifications.svelte.ts b/src/lib/stores/notifications.svelte.ts new file mode 100644 index 0000000..8206890 --- /dev/null +++ b/src/lib/stores/notifications.svelte.ts @@ -0,0 +1,152 @@ +// Notification store — ephemeral toasts + persistent notification history + +import { sendDesktopNotification } from '../adapters/notifications-bridge'; + +// --- Toast types (existing) --- + +export type ToastType = 'info' | 'success' | 'warning' | 'error'; + +export interface Toast { + id: string; + type: ToastType; + message: string; + timestamp: number; +} + +// --- Notification history types (new) --- + +export type NotificationType = + | 'agent_complete' + | 'agent_error' + | 'task_review' + | 'wake_event' + | 'conflict' + | 'system'; + +export interface HistoryNotification { + id: string; + title: string; + body: string; + type: NotificationType; + timestamp: number; + read: boolean; + projectId?: string; +} + +// --- State --- + +let toasts = $state([]); +let notificationHistory = $state([]); + +const MAX_TOASTS = 5; +const TOAST_DURATION_MS = 4000; +const MAX_HISTORY = 100; + +// --- Toast API (preserved from original) --- + +export function getNotifications(): Toast[] { + return toasts; +} + +export function notify(type: ToastType, message: string): string { + const id = crypto.randomUUID(); + toasts.push({ id, type, message, timestamp: Date.now() }); + + // Cap visible toasts + if (toasts.length > MAX_TOASTS) { + toasts = toasts.slice(-MAX_TOASTS); + } + + // Auto-dismiss + setTimeout(() => dismissNotification(id), TOAST_DURATION_MS); + + return id; +} + +export function dismissNotification(id: string): void { + toasts = toasts.filter(n => n.id !== id); +} + +// --- Notification History API (new) --- + +/** Map NotificationType to a toast type for the ephemeral toast */ +function notificationTypeToToast(type: NotificationType): ToastType { + switch (type) { + case 'agent_complete': return 'success'; + case 'agent_error': return 'error'; + case 'task_review': return 'info'; + case 'wake_event': return 'info'; + case 'conflict': return 'warning'; + case 'system': return 'info'; + } +} + +/** Map NotificationType to OS notification urgency */ +function notificationUrgency(type: NotificationType): 'low' | 'normal' | 'critical' { + switch (type) { + case 'agent_error': return 'critical'; + case 'conflict': return 'normal'; + case 'system': return 'normal'; + default: return 'low'; + } +} + +/** + * Add a notification to history, show a toast, and send an OS desktop notification. + */ +export function addNotification( + title: string, + body: string, + type: NotificationType, + projectId?: string, +): string { + const id = crypto.randomUUID(); + + // Add to history + notificationHistory.push({ + id, + title, + body, + type, + timestamp: Date.now(), + read: false, + projectId, + }); + + // Cap history + if (notificationHistory.length > MAX_HISTORY) { + notificationHistory = notificationHistory.slice(-MAX_HISTORY); + } + + // Show ephemeral toast + const toastType = notificationTypeToToast(type); + notify(toastType, `${title}: ${body}`); + + // Send OS desktop notification (fire-and-forget) + sendDesktopNotification(title, body, notificationUrgency(type)); + + return id; +} + +export function getNotificationHistory(): HistoryNotification[] { + return notificationHistory; +} + +export function getUnreadCount(): number { + return notificationHistory.filter(n => !n.read).length; +} + +export function markRead(id: string): void { + const entry = notificationHistory.find(n => n.id === id); + if (entry) entry.read = true; +} + +export function markAllRead(): void { + for (const entry of notificationHistory) { + entry.read = true; + } +} + +export function clearHistory(): void { + notificationHistory = []; +} diff --git a/src/lib/stores/plugins.svelte.ts b/src/lib/stores/plugins.svelte.ts new file mode 100644 index 0000000..fa10463 --- /dev/null +++ b/src/lib/stores/plugins.svelte.ts @@ -0,0 +1,203 @@ +/** + * Plugin store — tracks plugin commands, event bus, and plugin state. + * Uses Svelte 5 runes for reactivity. + */ + +import type { PluginMeta } from '../adapters/plugins-bridge'; +import { discoverPlugins } from '../adapters/plugins-bridge'; +import { getSetting, setSetting } from '../adapters/settings-bridge'; +import { loadPlugin, unloadPlugin, unloadAllPlugins, getLoadedPlugins } from '../plugins/plugin-host'; +import type { GroupId, AgentId } from '../types/ids'; + +// --- Plugin command registry (for CommandPalette) --- + +export interface PluginCommand { + pluginId: string; + label: string; + callback: () => void; +} + +let commands = $state([]); + +/** Get all plugin-registered commands (reactive). */ +export function getPluginCommands(): PluginCommand[] { + return commands; +} + +/** Register a command from a plugin. Called by plugin-host. */ +export function addPluginCommand(pluginId: string, label: string, callback: () => void): void { + commands = [...commands, { pluginId, label, callback }]; +} + +/** Remove all commands registered by a specific plugin. Called on unload. */ +export function removePluginCommands(pluginId: string): void { + commands = commands.filter(c => c.pluginId !== pluginId); +} + +// --- Plugin event bus (simple pub/sub) --- + +type EventCallback = (data: unknown) => void; + +class PluginEventBusImpl { + private listeners = new Map>(); + + on(event: string, callback: EventCallback): void { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(callback); + } + + off(event: string, callback: EventCallback): void { + const set = this.listeners.get(event); + if (set) { + set.delete(callback); + if (set.size === 0) this.listeners.delete(event); + } + } + + emit(event: string, data?: unknown): void { + const set = this.listeners.get(event); + if (!set) return; + for (const cb of set) { + try { + cb(data); + } catch (e) { + console.error(`Plugin event handler error for '${event}':`, e); + } + } + } + + clear(): void { + this.listeners.clear(); + } +} + +export const pluginEventBus = new PluginEventBusImpl(); + +// --- Plugin discovery and lifecycle --- + +export type PluginStatus = 'discovered' | 'loaded' | 'error' | 'disabled'; + +export interface PluginEntry { + meta: PluginMeta; + status: PluginStatus; + error?: string; +} + +let pluginEntries = $state([]); + +/** Get all discovered plugins with their status (reactive). */ +export function getPluginEntries(): PluginEntry[] { + return pluginEntries; +} + +/** Settings key for plugin enabled state */ +function pluginEnabledKey(pluginId: string): string { + return `plugin_enabled_${pluginId}`; +} + +/** Check if a plugin is enabled in settings (default: true for new plugins) */ +async function isPluginEnabled(pluginId: string): Promise { + const val = await getSetting(pluginEnabledKey(pluginId)); + if (val === null || val === undefined) return true; // enabled by default + return val === 'true' || val === '1'; +} + +/** Set plugin enabled state */ +export async function setPluginEnabled(pluginId: string, enabled: boolean): Promise { + await setSetting(pluginEnabledKey(pluginId), enabled ? 'true' : 'false'); + + // Update in-memory state + if (enabled) { + const entry = pluginEntries.find(e => e.meta.id === pluginId); + if (entry && entry.status === 'disabled') { + await loadSinglePlugin(entry); + } + } else { + unloadPlugin(pluginId); + pluginEntries = pluginEntries.map(e => + e.meta.id === pluginId ? { ...e, status: 'disabled' as PluginStatus, error: undefined } : e, + ); + } +} + +/** Load a single plugin entry, updating its status */ +async function loadSinglePlugin( + entry: PluginEntry, + groupId?: GroupId, + agentId?: AgentId, +): Promise { + const gid = groupId ?? ('' as GroupId); + const aid = agentId ?? ('admin' as AgentId); + + try { + await loadPlugin(entry.meta, gid, aid); + pluginEntries = pluginEntries.map(e => + e.meta.id === entry.meta.id ? { ...e, status: 'loaded' as PluginStatus, error: undefined } : e, + ); + } catch (e) { + const errorMsg = e instanceof Error ? e.message : String(e); + console.error(`Failed to load plugin '${entry.meta.id}':`, errorMsg); + pluginEntries = pluginEntries.map(e => + e.meta.id === entry.meta.id ? { ...e, status: 'error' as PluginStatus, error: errorMsg } : e, + ); + } +} + +/** + * Discover and load all enabled plugins. + * Called at app startup or when reloading plugins. + */ +export async function loadAllPlugins(groupId?: GroupId, agentId?: AgentId): Promise { + // Unload any currently loaded plugins first + unloadAllPlugins(); + pluginEventBus.clear(); + commands = []; + + let discovered: PluginMeta[]; + try { + discovered = await discoverPlugins(); + } catch (e) { + console.error('Failed to discover plugins:', e); + pluginEntries = []; + return; + } + + // Build entries with initial status + const entries: PluginEntry[] = []; + for (const meta of discovered) { + const enabled = await isPluginEnabled(meta.id); + entries.push({ + meta, + status: enabled ? 'discovered' : 'disabled', + }); + } + pluginEntries = entries; + + // Load enabled plugins + for (const entry of pluginEntries) { + if (entry.status === 'discovered') { + await loadSinglePlugin(entry, groupId, agentId); + } + } +} + +/** + * Reload all plugins (re-discover and re-load). + */ +export async function reloadAllPlugins(groupId?: GroupId, agentId?: AgentId): Promise { + await loadAllPlugins(groupId, agentId); +} + +/** + * Clean up all plugins and state. + */ +export function destroyAllPlugins(): void { + unloadAllPlugins(); + pluginEventBus.clear(); + commands = []; + pluginEntries = []; +} diff --git a/src/lib/stores/sessions.svelte.ts b/src/lib/stores/sessions.svelte.ts new file mode 100644 index 0000000..ab1ecef --- /dev/null +++ b/src/lib/stores/sessions.svelte.ts @@ -0,0 +1,26 @@ +// 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/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts new file mode 100644 index 0000000..8c32966 --- /dev/null +++ b/src/lib/stores/theme.svelte.ts @@ -0,0 +1,98 @@ +// 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/src/lib/stores/wake-scheduler.svelte.ts b/src/lib/stores/wake-scheduler.svelte.ts new file mode 100644 index 0000000..1ccc512 --- /dev/null +++ b/src/lib/stores/wake-scheduler.svelte.ts @@ -0,0 +1,269 @@ +// Wake scheduler — manages per-manager wake timers and signal evaluation +// Supports 3 strategies: persistent, on-demand, smart (threshold-gated) + +import type { WakeStrategy, WakeContext, WakeProjectSnapshot, WakeTaskSummary } from '../types/wake'; +import type { AgentId } from '../types/ids'; +import { evaluateWakeSignals, shouldWake } from '../utils/wake-scorer'; +import { getAllProjectHealth, getHealthAggregates } from './health.svelte'; +import { getAllWorkItems } from './workspace.svelte'; +import { listTasks } from '../adapters/bttask-bridge'; +import { getAgentSession } from './agents.svelte'; +import { logAuditEvent } from '../adapters/audit-bridge'; +import type { GroupId } from '../types/ids'; + +// --- Types --- + +interface ManagerRegistration { + agentId: AgentId; + groupId: GroupId; + sessionId: string; + strategy: WakeStrategy; + intervalMs: number; + threshold: number; + timerId: ReturnType | null; + /** Burn rate samples for anomaly detection: [timestamp, totalRate] */ + burnRateSamples: Array<[number, number]>; +} + +export interface WakeEvent { + agentId: AgentId; + strategy: WakeStrategy; + context: WakeContext; + /** For persistent: resume with context. For on-demand/smart: fresh session with context. */ + mode: 'resume' | 'fresh'; +} + +// --- State --- + +let registrations = $state>(new Map()); +let pendingWakes = $state>(new Map()); +/** When true, registerManager() becomes a no-op (set in test mode) */ +let schedulerDisabled = false; + +// --- Public API --- + +/** Disable the wake scheduler (call during app init in test mode) */ +export function disableWakeScheduler(): void { + schedulerDisabled = true; + clearWakeScheduler(); +} + +/** Register a Manager agent for wake scheduling */ +export function registerManager( + agentId: AgentId, + groupId: GroupId, + sessionId: string, + strategy: WakeStrategy, + intervalMin: number, + threshold: number, +): void { + if (schedulerDisabled) return; + + // Unregister first to clear any existing timer + unregisterManager(agentId); + + const reg: ManagerRegistration = { + agentId, + groupId, + sessionId, + strategy, + intervalMs: intervalMin * 60 * 1000, + threshold, + timerId: null, + burnRateSamples: [], + }; + + registrations.set(agentId, reg); + startTimer(reg); +} + +/** Unregister a Manager agent and stop its timer */ +export function unregisterManager(agentId: string): void { + const reg = registrations.get(agentId); + if (reg?.timerId) { + clearInterval(reg.timerId); + } + registrations.delete(agentId); + pendingWakes.delete(agentId); +} + +/** Update wake config for an already-registered manager */ +export function updateManagerConfig( + agentId: string, + strategy: WakeStrategy, + intervalMin: number, + threshold: number, +): void { + const reg = registrations.get(agentId); + if (!reg) return; + + const needsRestart = reg.strategy !== strategy || reg.intervalMs !== intervalMin * 60 * 1000; + reg.strategy = strategy; + reg.intervalMs = intervalMin * 60 * 1000; + reg.threshold = threshold; + + if (needsRestart) { + if (reg.timerId) clearInterval(reg.timerId); + startTimer(reg); + } +} + +/** Update session ID for a registered manager (e.g., after session reset) */ +export function updateManagerSession(agentId: string, sessionId: string): void { + const reg = registrations.get(agentId); + if (reg) { + reg.sessionId = sessionId; + } +} + +/** Get pending wake event for a manager (consumed by AgentSession) */ +export function getWakeEvent(agentId: string): WakeEvent | undefined { + return pendingWakes.get(agentId); +} + +/** Consume (clear) a pending wake event after AgentSession handles it */ +export function consumeWakeEvent(agentId: string): void { + pendingWakes.delete(agentId); +} + +/** Get all registered managers (for debugging/UI) */ +export function getRegisteredManagers(): Array<{ + agentId: string; + strategy: WakeStrategy; + intervalMin: number; + threshold: number; + hasPendingWake: boolean; +}> { + const result: Array<{ + agentId: string; + strategy: WakeStrategy; + intervalMin: number; + threshold: number; + hasPendingWake: boolean; + }> = []; + for (const [id, reg] of registrations) { + result.push({ + agentId: id, + strategy: reg.strategy, + intervalMin: reg.intervalMs / 60_000, + threshold: reg.threshold, + hasPendingWake: pendingWakes.has(id), + }); + } + return result; +} + +/** Force a manual wake evaluation for a manager (for testing/UI) */ +export function forceWake(agentId: string): void { + const reg = registrations.get(agentId); + if (reg) { + evaluateAndEmit(reg); + } +} + +/** Clear all registrations (for workspace teardown) */ +export function clearWakeScheduler(): void { + for (const reg of registrations.values()) { + if (reg.timerId) clearInterval(reg.timerId); + } + registrations = new Map(); + pendingWakes = new Map(); +} + +// --- Internal --- + +function startTimer(reg: ManagerRegistration): void { + reg.timerId = setInterval(() => { + evaluateAndEmit(reg); + }, reg.intervalMs); +} + +async function evaluateAndEmit(reg: ManagerRegistration): Promise { + // Don't queue a new wake if one is already pending + if (pendingWakes.has(reg.agentId)) return; + + // For persistent strategy, skip if session is actively running a query + if (reg.strategy === 'persistent') { + const session = getAgentSession(reg.sessionId); + if (session && session.status === 'running') return; + } + + // Build project snapshots from health store + const healthItems = getAllProjectHealth(); + const workItems = getAllWorkItems(); + const projectSnapshots: WakeProjectSnapshot[] = healthItems.map(h => { + const workItem = workItems.find(w => w.id === h.projectId); + return { + projectId: h.projectId, + projectName: workItem?.name ?? String(h.projectId), + activityState: h.activityState, + idleMinutes: Math.floor(h.idleDurationMs / 60_000), + burnRatePerHour: h.burnRatePerHour, + contextPressurePercent: h.contextPressure !== null ? Math.round(h.contextPressure * 100) : null, + fileConflicts: h.fileConflictCount + h.externalConflictCount, + attentionScore: h.attentionScore, + attentionReason: h.attentionReason, + }; + }); + + // Fetch task summary (best-effort) + let taskSummary: WakeTaskSummary | undefined; + try { + const tasks = await listTasks(reg.groupId); + taskSummary = { + total: tasks.length, + todo: tasks.filter(t => t.status === 'todo').length, + inProgress: tasks.filter(t => t.status === 'progress').length, + blocked: tasks.filter(t => t.status === 'blocked').length, + review: tasks.filter(t => t.status === 'review').length, + done: tasks.filter(t => t.status === 'done').length, + }; + } catch { + // bttask may not be available — continue without task data + } + + // Compute average burn rate for anomaly detection + const aggregates = getHealthAggregates(); + const now = Date.now(); + reg.burnRateSamples.push([now, aggregates.totalBurnRatePerHour]); + // Keep 1 hour of samples + const hourAgo = now - 3_600_000; + reg.burnRateSamples = reg.burnRateSamples.filter(([ts]) => ts > hourAgo); + const averageBurnRate = reg.burnRateSamples.length > 1 + ? reg.burnRateSamples.reduce((sum, [, r]) => sum + r, 0) / reg.burnRateSamples.length + : undefined; + + // Evaluate signals + const evaluation = evaluateWakeSignals({ + projects: projectSnapshots, + taskSummary, + averageBurnRate, + }); + + // Check if we should actually wake based on strategy + if (!shouldWake(evaluation, reg.strategy, reg.threshold)) return; + + // Build wake context + const context: WakeContext = { + evaluation, + projectSnapshots, + taskSummary, + }; + + // Determine mode + const mode: 'resume' | 'fresh' = reg.strategy === 'persistent' ? 'resume' : 'fresh'; + + pendingWakes.set(reg.agentId, { + agentId: reg.agentId, + strategy: reg.strategy, + context, + mode, + }); + + // Audit: log wake event + logAuditEvent( + reg.agentId, + 'wake_event', + `Auto-wake triggered (strategy=${reg.strategy}, mode=${mode}, score=${evaluation.totalScore.toFixed(2)})`, + ).catch(() => {}); +} diff --git a/src/lib/stores/workspace.svelte.ts b/src/lib/stores/workspace.svelte.ts new file mode 100644 index 0000000..1f4aa24 --- /dev/null +++ b/src/lib/stores/workspace.svelte.ts @@ -0,0 +1,357 @@ +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 { clearWakeScheduler } from '../stores/wake-scheduler.svelte'; +import { waitForPendingPersistence } from '../agent-dispatcher'; +import { registerAgents } from '../adapters/btmsg-bridge'; + +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>({}); + +// --- Focus flash event (keyboard quick-jump visual feedback) --- + +let focusFlashProjectId = $state(null); + +export function getFocusFlashProjectId(): string | null { + return focusFlashProjectId; +} + +export function triggerFocusFlash(projectId: string): void { + focusFlashProjectId = projectId; + // Auto-clear after animation duration + setTimeout(() => { + focusFlashProjectId = null; + }, 400); +} + +// --- Project tab switching (keyboard-driven) --- + +type ProjectTabSwitchCallback = (projectId: string, tabIndex: number) => void; +let projectTabSwitchCallbacks: ProjectTabSwitchCallback[] = []; + +export function onProjectTabSwitch(cb: ProjectTabSwitchCallback): () => void { + projectTabSwitchCallbacks.push(cb); + return () => { + projectTabSwitchCallbacks = projectTabSwitchCallbacks.filter(c => c !== cb); + }; +} + +export function emitProjectTabSwitch(projectId: string, tabIndex: number): void { + for (const cb of projectTabSwitchCallbacks) { + cb(projectId, tabIndex); + } +} + +// --- Terminal toggle (keyboard-driven) --- + +type TerminalToggleCallback = (projectId: string) => void; +let terminalToggleCallbacks: TerminalToggleCallback[] = []; + +export function onTerminalToggle(cb: TerminalToggleCallback): () => void { + terminalToggleCallbacks.push(cb); + return () => { + terminalToggleCallbacks = terminalToggleCallbacks.filter(c => c !== cb); + }; +} + +export function emitTerminalToggle(projectId: string): void { + for (const cb of terminalToggleCallbacks) { + cb(projectId); + } +} + +// --- Agent start event (play button in GroupAgentsPanel) --- + +type AgentStartCallback = (projectId: string) => void; +let agentStartCallbacks: AgentStartCallback[] = []; + +export function onAgentStart(cb: AgentStartCallback): () => void { + agentStartCallbacks.push(cb); + return () => { + agentStartCallbacks = agentStartCallbacks.filter(c => c !== cb); + }; +} + +export function emitAgentStart(projectId: string): void { + for (const cb of agentStartCallbacks) { + cb(projectId); + } +} + +// --- Agent stop event (stop button in GroupAgentsPanel) --- + +type AgentStopCallback = (projectId: string) => void; +let agentStopCallbacks: AgentStopCallback[] = []; + +export function onAgentStop(cb: AgentStopCallback): () => void { + agentStopCallbacks.push(cb); + return () => { + agentStopCallbacks = agentStopCallbacks.filter(c => c !== cb); + }; +} + +export function emitAgentStop(projectId: string): void { + for (const cb of agentStopCallbacks) { + cb(projectId); + } +} + +// --- 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, health tracking, and wake schedulers for the old group + projectTerminals = {}; + clearAllAgentSessions(); + clearHealthTracking(); + clearAllConflicts(); + clearWakeScheduler(); + + 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 = {}; + + // Register all agents from config into btmsg database + // (creates agent records, contact permissions, review channels) + registerAgents(config).catch(e => console.warn('Failed to register agents:', e)); + + // 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); + // Re-register agents after config changes (new agents, permission updates) + registerAgents(groupsConfig).catch(e => console.warn('Failed to register agents:', e)); +} + +// --- 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/src/lib/stores/workspace.test.ts b/src/lib/stores/workspace.test.ts new file mode 100644 index 0000000..4365d32 --- /dev/null +++ b/src/lib/stores/workspace.test.ts @@ -0,0 +1,251 @@ +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/src/lib/styles/catppuccin.css b/src/lib/styles/catppuccin.css new file mode 100644 index 0000000..73a3ccd --- /dev/null +++ b/src/lib/styles/catppuccin.css @@ -0,0 +1,61 @@ +/* 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/src/lib/styles/themes.ts b/src/lib/styles/themes.ts new file mode 100644 index 0000000..cdeaa17 --- /dev/null +++ b/src/lib/styles/themes.ts @@ -0,0 +1,383 @@ +// 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/src/lib/types/anchors.ts b/src/lib/types/anchors.ts new file mode 100644 index 0000000..cc61270 --- /dev/null +++ b/src/lib/types/anchors.ts @@ -0,0 +1,72 @@ +// 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/src/lib/types/groups.ts b/src/lib/types/groups.ts new file mode 100644 index 0000000..e685236 --- /dev/null +++ b/src/lib/types/groups.ts @@ -0,0 +1,119 @@ +import type { ProviderId } from '../providers/types'; +import type { AnchorBudgetScale } from './anchors'; +import type { WakeStrategy } from './wake'; +import type { ProjectId, GroupId, AgentId } from './ids'; + +export interface ProjectConfig { + id: ProjectId; + name: string; + identifier: string; + description: string; + icon: string; + cwd: string; + profile: string; + enabled: boolean; + /** Agent provider for this project (defaults to 'claude') */ + provider?: ProviderId; + /** Model override (e.g. 'claude-sonnet-4-5-20250514'). Falls back to provider default. */ + model?: string; + /** When true, agents for this project use git worktrees for isolation */ + useWorktrees?: boolean; + /** When true, sidecar process is sandboxed via Landlock (Linux 5.13+, restricts filesystem access) */ + sandboxEnabled?: boolean; + /** Shell execution mode for AI agents. 'restricted' (default) surfaces commands for approval; 'autonomous' auto-executes with audit logging */ + autonomousMode?: 'restricted' | 'autonomous'; + /** Anchor token budget scale (defaults to 'medium' = 6K tokens) */ + anchorBudgetScale?: AnchorBudgetScale; + /** Stall detection threshold in minutes (defaults to 15) */ + 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 { + // Agent IDs serve as project IDs in the workspace (agents render as project boxes) + return { + id: agent.id as unknown as ProjectId, + 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, + provider: agent.provider, + model: agent.model, + isAgent: true, + agentRole: agent.role, + systemPrompt: agent.systemPrompt, + autonomousMode: agent.autonomousMode, + }; +} + +/** 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: AgentId; + name: string; + role: GroupAgentRole; + /** Agent provider (defaults to 'claude') */ + provider?: ProviderId; + /** Model override (e.g. 'claude-sonnet-4-5-20250514'). Falls back to provider default. */ + model?: string; + cwd?: string; + systemPrompt?: string; + enabled: boolean; + /** Auto-wake interval in minutes (Manager only, default 3) */ + wakeIntervalMin?: number; + /** Wake strategy: persistent (always-on), on-demand (fresh session), smart (threshold-gated) */ + wakeStrategy?: WakeStrategy; + /** Wake threshold 0..1 for smart strategy (default 0.5) */ + wakeThreshold?: number; + /** Shell execution mode. 'restricted' (default) surfaces commands for approval; 'autonomous' auto-executes with audit logging */ + autonomousMode?: 'restricted' | 'autonomous'; +} + +export interface GroupConfig { + id: GroupId; + name: string; + projects: ProjectConfig[]; + /** Group-level orchestration agents (Tier 1) */ + agents?: GroupAgentConfig[]; +} + +export interface GroupsFile { + version: number; + groups: GroupConfig[]; + activeGroupId: GroupId; +} + +/** 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/src/lib/types/ids.test.ts b/src/lib/types/ids.test.ts new file mode 100644 index 0000000..9cd1f41 --- /dev/null +++ b/src/lib/types/ids.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { + SessionId, ProjectId, GroupId, AgentId, + type SessionId as SessionIdType, + type ProjectId as ProjectIdType, + type GroupId as GroupIdType, + type AgentId as AgentIdType, +} 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('GroupId', () => { + it('creates a GroupId from a string', () => { + const id = GroupId('grp-abc'); + expect(id).toBe('grp-abc'); + }); + + it('is usable as a Map key', () => { + const map = new Map(); + const id = GroupId('grp-1'); + map.set(id, 'test-group'); + expect(map.get(id)).toBe('test-group'); + }); + }); + + describe('AgentId', () => { + it('creates an AgentId from a string', () => { + const id = AgentId('agent-manager'); + expect(id).toBe('agent-manager'); + }); + + it('is usable as a Map key', () => { + const map = new Map(); + const id = AgentId('a1'); + map.set(id, 99); + expect(map.get(id)).toBe(99); + }); + + it('equality works between two AgentIds with same value', () => { + const a = AgentId('a1'); + const b = AgentId('a1'); + expect(a === b).toBe(true); + }); + }); + + describe('type safety (compile-time)', () => { + it('all four types are strings at runtime', () => { + const sid = SessionId('s1'); + const pid = ProjectId('p1'); + const gid = GroupId('g1'); + const aid = AgentId('a1'); + expect(typeof sid).toBe('string'); + expect(typeof pid).toBe('string'); + expect(typeof gid).toBe('string'); + expect(typeof aid).toBe('string'); + }); + }); +}); diff --git a/src/lib/types/ids.ts b/src/lib/types/ids.ts new file mode 100644 index 0000000..f704e91 --- /dev/null +++ b/src/lib/types/ids.ts @@ -0,0 +1,34 @@ +// Branded types for domain identifiers — prevents accidental swapping of IDs across domains. +// 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' }; + +/** Unique identifier for a project group */ +export type GroupId = string & { readonly __brand: 'GroupId' }; + +/** Unique identifier for an agent in the btmsg/bttask system */ +export type AgentId = string & { readonly __brand: 'AgentId' }; + +/** 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; +} + +/** Create a GroupId from a raw string */ +export function GroupId(value: string): GroupId { + return value as GroupId; +} + +/** Create an AgentId from a raw string */ +export function AgentId(value: string): AgentId { + return value as AgentId; +} diff --git a/src/lib/types/wake.ts b/src/lib/types/wake.ts new file mode 100644 index 0000000..7898561 --- /dev/null +++ b/src/lib/types/wake.ts @@ -0,0 +1,70 @@ +import type { ProjectId as ProjectIdType } from './ids'; + +/** How the Manager agent session is managed between wake events */ +export type WakeStrategy = 'persistent' | 'on-demand' | 'smart'; + +export const WAKE_STRATEGIES: WakeStrategy[] = ['persistent', 'on-demand', 'smart']; + +export const WAKE_STRATEGY_LABELS: Record = { + persistent: 'Persistent', + 'on-demand': 'On-demand', + smart: 'Smart', +}; + +export const WAKE_STRATEGY_DESCRIPTIONS: Record = { + persistent: 'Manager stays running, receives periodic context refreshes', + 'on-demand': 'Manager wakes on every interval, gets fresh context each time', + smart: 'Manager only wakes when signal score exceeds threshold', +}; + +/** Individual wake signal with score and description */ +export interface WakeSignal { + id: string; + score: number; // 0..1 + reason: string; +} + +/** Aggregated wake evaluation result */ +export interface WakeEvaluation { + /** Total score (max of individual signals, not sum) */ + score: number; + /** All triggered signals sorted by score descending */ + signals: WakeSignal[]; + /** Whether the wake should fire (always true for persistent/on-demand, threshold-gated for smart) */ + shouldWake: boolean; + /** Human-readable summary for the Manager prompt */ + summary: string; +} + +/** Context passed to the Manager when waking */ +export interface WakeContext { + /** Wake evaluation that triggered this event */ + evaluation: WakeEvaluation; + /** Per-project health snapshot */ + projectSnapshots: WakeProjectSnapshot[]; + /** Task board summary (if available) */ + taskSummary?: WakeTaskSummary; +} + +/** Per-project health snapshot included in wake context */ +export interface WakeProjectSnapshot { + projectId: ProjectIdType; + projectName: string; + activityState: string; + idleMinutes: number; + burnRatePerHour: number; + contextPressurePercent: number | null; + fileConflicts: number; + attentionScore: number; + attentionReason: string | null; +} + +/** Task board summary included in wake context */ +export interface WakeTaskSummary { + total: number; + todo: number; + inProgress: number; + blocked: number; + review: number; + done: number; +} diff --git a/src/lib/utils/agent-prompts.ts b/src/lib/utils/agent-prompts.ts new file mode 100644 index 0000000..47c2a31 --- /dev/null +++ b/src/lib/utils/agent-prompts.ts @@ -0,0 +1,429 @@ +/** + * 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 if (role === 'reviewer') { + // Reviewer gets full read + status update + comment access + parts.push(` +## Tool: bttask — Task Board (review access) + +You have full read access plus the ability to update task status and add comments. +You CANNOT create, assign, or delete tasks (Manager only). + +\`\`\`bash +bttask board # Kanban board view +bttask show # Full task details + comments +bttask list # List all tasks +bttask status done # Approve — mark as done +bttask status progress # Request changes — send back +bttask status blocked # Block — explain in comment! +bttask comment "verdict" # Add review verdict/feedback +\`\`\` + +### Review workflow with bttask +- Tasks in the **review** column are waiting for YOUR review +- After reviewing, either move to **done** (approved) or **progress** (needs changes) +- ALWAYS add a comment with your verdict before changing status +- When a task moves to review, a notification is auto-posted to \`#review-queue\``); + } 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 `## Multi-Agent Delegation + +You can spawn child agents to parallelize work across multiple tasks: + +\`\`\` +Use the Agent tool to launch a subagent: +Agent "task description for the child agent" +\`\`\` + +Child agents run independently with their own context. Use delegation for: +- **Parallel research** — send multiple agents to explore different approaches simultaneously +- **Specialized subtasks** — delegate code review, test writing, or documentation to focused agents +- **Exploratory analysis** — let a child agent investigate while you continue coordinating + +Child agent results appear in the Team Agents panel. You can monitor their progress +and incorporate their findings into your coordination work. + +**When to delegate vs. do it yourself:** +- Delegate when a task is self-contained and doesn't need your ongoing attention +- Do it yourself when the task requires cross-project coordination or decision-making +- Prefer delegation for tasks that would block your main coordination loop + +## 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. **Delegate:** Spawn child agents for parallelizable work (see above) +5. **Monitor:** Check agent status (\`btmsg status\`), follow up on stalled work +6. **Report:** Summarize progress to the Operator when asked +7. **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`; + } + + if (role === 'reviewer') { + return `## Your Workflow + +1. **Check inbox:** \`btmsg inbox\` — read review requests and messages +2. **Check review queue:** \`btmsg channel history review-queue\` — see newly submitted reviews +3. **Review tasks:** \`bttask board\` — find tasks in the **review** column +4. **Analyze:** For each review task: + a. Read the task description and comments (\`bttask show \`) + b. Read the relevant code changes + c. Check for security issues, bugs, style violations, and test coverage +5. **Verdict:** Add your review as a comment (\`bttask comment "APPROVED: ..."\` or \`"CHANGES REQUESTED: ..."\`) +6. **Update status:** Move task to **done** (approved) or **progress** (needs changes) +7. **Log verdict:** Post summary to \`btmsg channel send review-log "Task : APPROVED/REJECTED — reason"\` +8. **Report:** Notify the Manager of review outcomes if significant + +**Review standards:** +- Code quality: readability, naming, structure +- Security: input validation, auth checks, injection risks +- Error handling: all errors caught and handled visibly +- Tests: adequate coverage for new/changed code +- Performance: no N+1 queries, unbounded fetches, or memory leaks`; + } + + 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/src/lib/utils/agent-tree.test.ts b/src/lib/utils/agent-tree.test.ts new file mode 100644 index 0000000..2268244 --- /dev/null +++ b/src/lib/utils/agent-tree.test.ts @@ -0,0 +1,297 @@ +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/src/lib/utils/agent-tree.ts b/src/lib/utils/agent-tree.ts new file mode 100644 index 0000000..0f11bb2 --- /dev/null +++ b/src/lib/utils/agent-tree.ts @@ -0,0 +1,88 @@ +// 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/src/lib/utils/anchor-serializer.test.ts b/src/lib/utils/anchor-serializer.test.ts new file mode 100644 index 0000000..f664c18 --- /dev/null +++ b/src/lib/utils/anchor-serializer.test.ts @@ -0,0 +1,229 @@ +// 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/src/lib/utils/anchor-serializer.ts b/src/lib/utils/anchor-serializer.ts new file mode 100644 index 0000000..e2f48f2 --- /dev/null +++ b/src/lib/utils/anchor-serializer.ts @@ -0,0 +1,211 @@ +// 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/src/lib/utils/attention-scorer.test.ts b/src/lib/utils/attention-scorer.test.ts new file mode 100644 index 0000000..d5dc4c6 --- /dev/null +++ b/src/lib/utils/attention-scorer.test.ts @@ -0,0 +1,201 @@ +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'); + }); + + // --- Review queue depth scoring --- + + it('scores review queue depth at 10 per task', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + reviewQueueDepth: 3, + })); + expect(result.score).toBe(30); + expect(result.reason).toContain('3 tasks awaiting review'); + }); + + it('caps review queue score at 50', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + reviewQueueDepth: 8, + })); + expect(result.score).toBe(50); + expect(result.reason).toContain('8 tasks'); + }); + + it('uses singular grammar for 1 review task', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + reviewQueueDepth: 1, + })); + expect(result.score).toBe(10); + expect(result.reason).toBe('1 task awaiting review'); + }); + + it('review queue has lower priority than file conflicts', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + fileConflictCount: 2, + reviewQueueDepth: 5, + })); + expect(result.score).toBe(70); // file conflicts win + }); + + it('review queue has higher priority than context high', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + contextPressure: 0.80, + reviewQueueDepth: 2, + })); + expect(result.score).toBe(20); // review queue wins over context high (40) + }); + + it('ignores review queue when depth is 0', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + reviewQueueDepth: 0, + })); + expect(result.score).toBe(0); + }); + + it('ignores review queue when undefined', () => { + const result = scoreAttention(makeInput({ + activityState: 'running', + })); + expect(result.score).toBe(0); + }); +}); diff --git a/src/lib/utils/attention-scorer.ts b/src/lib/utils/attention-scorer.ts new file mode 100644 index 0000000..daee32c --- /dev/null +++ b/src/lib/utils/attention-scorer.ts @@ -0,0 +1,82 @@ +// 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 + +// Review queue scoring: 10pts per stale review, capped at 50 +const SCORE_REVIEW_PER_TASK = 10; +const SCORE_REVIEW_CAP = 50; + +export interface AttentionInput { + sessionStatus: string | undefined; + sessionError: string | undefined; + activityState: ActivityState; + idleDurationMs: number; + contextPressure: number | null; + fileConflictCount: number; + externalConflictCount: number; + /** Number of tasks in 'review' status (for reviewer agents) */ + reviewQueueDepth?: 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.reviewQueueDepth && input.reviewQueueDepth > 0) { + const score = Math.min(input.reviewQueueDepth * SCORE_REVIEW_PER_TASK, SCORE_REVIEW_CAP); + return { + score, + reason: `${input.reviewQueueDepth} task${input.reviewQueueDepth > 1 ? 's' : ''} awaiting review`, + }; + } + + 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/src/lib/utils/auto-anchoring.ts b/src/lib/utils/auto-anchoring.ts new file mode 100644 index 0000000..604e5c0 --- /dev/null +++ b/src/lib/utils/auto-anchoring.ts @@ -0,0 +1,52 @@ +// 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/src/lib/utils/detach.ts b/src/lib/utils/detach.ts new file mode 100644 index 0000000..0c57561 --- /dev/null +++ b/src/lib/utils/detach.ts @@ -0,0 +1,68 @@ +// 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/src/lib/utils/error-classifier.test.ts b/src/lib/utils/error-classifier.test.ts new file mode 100644 index 0000000..5e79e0e --- /dev/null +++ b/src/lib/utils/error-classifier.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { classifyError, type ApiErrorType } from './error-classifier'; + +describe('classifyError', () => { + // --- Rate limit --- + it('classifies "rate_limit_error" as rate_limit', () => { + const result = classifyError('rate_limit_error: Too many requests'); + expect(result.type).toBe('rate_limit'); + expect(result.retryable).toBe(true); + expect(result.retryDelaySec).toBeGreaterThan(0); + }); + + it('classifies "429" as rate_limit', () => { + const result = classifyError('HTTP 429 Too Many Requests'); + expect(result.type).toBe('rate_limit'); + }); + + it('classifies "too many requests" as rate_limit', () => { + const result = classifyError('Error: too many requests, please slow down'); + expect(result.type).toBe('rate_limit'); + }); + + it('classifies "throttled" as rate_limit', () => { + const result = classifyError('Request throttled by API'); + expect(result.type).toBe('rate_limit'); + }); + + // --- Auth --- + it('classifies "invalid_api_key" as auth', () => { + const result = classifyError('invalid_api_key: The provided API key is invalid'); + expect(result.type).toBe('auth'); + expect(result.retryable).toBe(false); + }); + + it('classifies "401" as auth', () => { + const result = classifyError('HTTP 401 Unauthorized'); + expect(result.type).toBe('auth'); + }); + + it('classifies "authentication failed" as auth', () => { + const result = classifyError('Authentication failed for this request'); + expect(result.type).toBe('auth'); + }); + + // --- Quota --- + it('classifies "insufficient_quota" as quota', () => { + const result = classifyError('insufficient_quota: You have exceeded your usage limit'); + expect(result.type).toBe('quota'); + expect(result.retryable).toBe(false); + }); + + it('classifies "billing" as quota', () => { + const result = classifyError('Error: billing issue with your account'); + expect(result.type).toBe('quota'); + }); + + it('classifies "credit" as quota', () => { + const result = classifyError('No remaining credit on your account'); + expect(result.type).toBe('quota'); + }); + + // --- Overloaded --- + it('classifies "overloaded" as overloaded', () => { + const result = classifyError('The API is temporarily overloaded'); + expect(result.type).toBe('overloaded'); + expect(result.retryable).toBe(true); + }); + + it('classifies "503" as overloaded', () => { + const result = classifyError('HTTP 503 Service Unavailable'); + expect(result.type).toBe('overloaded'); + }); + + // --- Network --- + it('classifies "ECONNREFUSED" as network', () => { + const result = classifyError('connect ECONNREFUSED 127.0.0.1:443'); + expect(result.type).toBe('network'); + expect(result.retryable).toBe(true); + }); + + it('classifies "ETIMEDOUT" as network', () => { + const result = classifyError('connect ETIMEDOUT'); + expect(result.type).toBe('network'); + }); + + it('classifies "fetch failed" as network', () => { + const result = classifyError('TypeError: fetch failed'); + expect(result.type).toBe('network'); + }); + + // --- Unknown --- + it('classifies unrecognized errors as unknown', () => { + const result = classifyError('Something weird happened'); + expect(result.type).toBe('unknown'); + expect(result.retryable).toBe(false); + expect(result.message).toBe('Something weird happened'); + }); + + it('preserves original message for unknown errors', () => { + const msg = 'Internal server error: null pointer'; + const result = classifyError(msg); + expect(result.message).toBe(msg); + }); + + // --- Message quality --- + it('provides actionable messages for rate_limit', () => { + const result = classifyError('rate_limit_error'); + expect(result.message).toContain('Rate limited'); + }); + + it('provides actionable messages for auth', () => { + const result = classifyError('invalid_api_key'); + expect(result.message).toContain('Settings'); + }); + + it('provides actionable messages for quota', () => { + const result = classifyError('insufficient_quota'); + expect(result.message).toContain('billing'); + }); +}); diff --git a/src/lib/utils/error-classifier.ts b/src/lib/utils/error-classifier.ts new file mode 100644 index 0000000..f8cc48f --- /dev/null +++ b/src/lib/utils/error-classifier.ts @@ -0,0 +1,121 @@ +// Error classifier — categorizes API errors for actionable user messaging + +export type ApiErrorType = + | 'rate_limit' + | 'auth' + | 'quota' + | 'overloaded' + | 'network' + | 'unknown'; + +export interface ClassifiedError { + type: ApiErrorType; + message: string; + retryable: boolean; + /** Suggested retry delay in seconds (0 = no retry) */ + retryDelaySec: number; +} + +const RATE_LIMIT_PATTERNS = [ + /rate.?limit/i, + /429/, + /too many requests/i, + /rate_limit_error/i, + /throttl/i, +]; + +const AUTH_PATTERNS = [ + /401/, + /invalid.?api.?key/i, + /authentication/i, + /unauthorized/i, + /invalid.?x-api-key/i, + /api_key/i, +]; + +const QUOTA_PATTERNS = [ + /insufficient.?quota/i, + /billing/i, + /payment/i, + /exceeded.*quota/i, + /credit/i, + /usage.?limit/i, +]; + +const OVERLOADED_PATTERNS = [ + /overloaded/i, + /503/, + /service.?unavailable/i, + /capacity/i, + /busy/i, +]; + +const NETWORK_PATTERNS = [ + /ECONNREFUSED/, + /ECONNRESET/, + /ETIMEDOUT/, + /network/i, + /fetch.?failed/i, + /dns/i, +]; + +function matchesAny(text: string, patterns: RegExp[]): boolean { + return patterns.some(p => p.test(text)); +} + +/** + * Classify an error message into an actionable category. + */ +export function classifyError(errorMessage: string): ClassifiedError { + if (matchesAny(errorMessage, RATE_LIMIT_PATTERNS)) { + return { + type: 'rate_limit', + message: 'Rate limited. The API will auto-retry shortly.', + retryable: true, + retryDelaySec: 30, + }; + } + + if (matchesAny(errorMessage, AUTH_PATTERNS)) { + return { + type: 'auth', + message: 'API key invalid or expired. Check Settings.', + retryable: false, + retryDelaySec: 0, + }; + } + + if (matchesAny(errorMessage, QUOTA_PATTERNS)) { + return { + type: 'quota', + message: 'API quota exceeded. Check your billing.', + retryable: false, + retryDelaySec: 0, + }; + } + + if (matchesAny(errorMessage, OVERLOADED_PATTERNS)) { + return { + type: 'overloaded', + message: 'API overloaded. Retrying shortly...', + retryable: true, + retryDelaySec: 15, + }; + } + + if (matchesAny(errorMessage, NETWORK_PATTERNS)) { + return { + type: 'network', + message: 'Network error. Check your connection.', + retryable: true, + retryDelaySec: 5, + }; + } + + return { + type: 'unknown', + message: errorMessage, + retryable: false, + retryDelaySec: 0, + }; +} diff --git a/src/lib/utils/highlight.ts b/src/lib/utils/highlight.ts new file mode 100644 index 0000000..4b15697 --- /dev/null +++ b/src/lib/utils/highlight.ts @@ -0,0 +1,51 @@ +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/src/lib/utils/plantuml-encode.test.ts b/src/lib/utils/plantuml-encode.test.ts new file mode 100644 index 0000000..ef8d1d6 --- /dev/null +++ b/src/lib/utils/plantuml-encode.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; + +// ---- REGRESSION: PlantUML hex encoding ---- +// Bug: ArchitectureTab had a broken encoding chain (rawDeflate returned input unchanged, +// encode64 was hex encoding masquerading as base64). Fixed by collapsing to single +// plantumlEncode function using ~h hex prefix (plantuml.com text encoding standard). +// +// This test validates the encoding algorithm matches what ArchitectureTab.svelte uses. + +/** Reimplementation of the plantumlEncode function from ArchitectureTab.svelte */ +function plantumlEncode(text: string): string { + const bytes = unescape(encodeURIComponent(text)); + let hex = '~h'; + for (let i = 0; i < bytes.length; i++) { + hex += bytes.charCodeAt(i).toString(16).padStart(2, '0'); + } + return hex; +} + +describe('plantumlEncode', () => { + it('produces ~h prefix for hex encoding', () => { + const result = plantumlEncode('@startuml\n@enduml'); + expect(result.startsWith('~h')).toBe(true); + }); + + it('encodes ASCII correctly', () => { + const result = plantumlEncode('AB'); + // A=0x41, B=0x42 + expect(result).toBe('~h4142'); + }); + + it('encodes simple PlantUML source', () => { + const result = plantumlEncode('@startuml\n@enduml'); + // Each character maps to its hex code + const expected = '~h' + Array.from('@startuml\n@enduml') + .map(c => c.charCodeAt(0).toString(16).padStart(2, '0')) + .join(''); + expect(result).toBe(expected); + }); + + it('handles Unicode characters', () => { + // UTF-8 multi-byte: é = 0xc3 0xa9 + const result = plantumlEncode('café'); + expect(result.startsWith('~h')).toBe(true); + // c=63, a=61, f=66, é=c3a9 + expect(result).toBe('~h636166c3a9'); + }); + + it('handles empty string', () => { + expect(plantumlEncode('')).toBe('~h'); + }); + + it('produces valid URL-safe output (no special chars beyond hex digits)', () => { + const result = plantumlEncode('@startuml\ntitle Test\nA -> B\n@enduml'); + // After ~h prefix, only hex digits [0-9a-f] + const hexPart = result.slice(2); + expect(hexPart).toMatch(/^[0-9a-f]+$/); + }); + + it('generates correct URL for plantuml.com', () => { + const source = '@startuml\nA -> B\n@enduml'; + const encoded = plantumlEncode(source); + const url = `https://www.plantuml.com/plantuml/svg/${encoded}`; + expect(url).toContain('plantuml.com/plantuml/svg/~h'); + expect(url.length).toBeGreaterThan(50); + }); +}); diff --git a/src/lib/utils/session-persistence.ts b/src/lib/utils/session-persistence.ts new file mode 100644 index 0000000..da1825f --- /dev/null +++ b/src/lib/utils/session-persistence.ts @@ -0,0 +1,118 @@ +// 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/src/lib/utils/subagent-router.ts b/src/lib/utils/subagent-router.ts new file mode 100644 index 0000000..5576a15 --- /dev/null +++ b/src/lib/utils/subagent-router.ts @@ -0,0 +1,78 @@ +// 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/src/lib/utils/tool-files.test.ts b/src/lib/utils/tool-files.test.ts new file mode 100644 index 0000000..be20735 --- /dev/null +++ b/src/lib/utils/tool-files.test.ts @@ -0,0 +1,148 @@ +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/src/lib/utils/tool-files.ts b/src/lib/utils/tool-files.ts new file mode 100644 index 0000000..05ba8aa --- /dev/null +++ b/src/lib/utils/tool-files.ts @@ -0,0 +1,120 @@ +// 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/src/lib/utils/type-guards.ts b/src/lib/utils/type-guards.ts new file mode 100644 index 0000000..8af4b72 --- /dev/null +++ b/src/lib/utils/type-guards.ts @@ -0,0 +1,11 @@ +// 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/src/lib/utils/updater.ts b/src/lib/utils/updater.ts new file mode 100644 index 0000000..5b9313f --- /dev/null +++ b/src/lib/utils/updater.ts @@ -0,0 +1,75 @@ +// Auto-update checker — uses Tauri updater plugin +// Requires signing key to be configured in tauri.conf.json before use + +import { check, type Update } from '@tauri-apps/plugin-updater'; +import { getVersion } from '@tauri-apps/api/app'; + +export interface UpdateInfo { + available: boolean; + version?: string; + notes?: string; + date?: string; + currentVersion?: string; +} + +// Cache the last check result for UI access +let lastCheckResult: UpdateInfo | null = null; +let lastCheckTimestamp: number | null = null; +let cachedUpdate: Update | null = null; + +export function getLastCheckResult(): UpdateInfo | null { + return lastCheckResult; +} + +export function getLastCheckTimestamp(): number | null { + return lastCheckTimestamp; +} + +export async function getCurrentVersion(): Promise { + try { + return await getVersion(); + } catch { + return '0.0.0'; + } +} + +export async function checkForUpdates(): Promise { + try { + const [update, currentVersion] = await Promise.all([check(), getCurrentVersion()]); + lastCheckTimestamp = Date.now(); + + if (update) { + cachedUpdate = update; + lastCheckResult = { + available: true, + version: update.version, + notes: update.body ?? undefined, + date: update.date ?? undefined, + currentVersion, + }; + } else { + cachedUpdate = null; + lastCheckResult = { + available: false, + currentVersion, + }; + } + + return lastCheckResult; + } catch { + // Updater not configured or network error — silently skip + lastCheckResult = { available: false }; + lastCheckTimestamp = Date.now(); + return lastCheckResult; + } +} + +export async function installUpdate(): Promise { + // Use cached update from last check if available + const update = cachedUpdate ?? (await check()); + if (update) { + // downloadAndInstall will restart the app after installation + await update.downloadAndInstall(); + // If we reach here, the app should relaunch automatically + } +} diff --git a/src/lib/utils/wake-scorer.test.ts b/src/lib/utils/wake-scorer.test.ts new file mode 100644 index 0000000..15640cc --- /dev/null +++ b/src/lib/utils/wake-scorer.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect } from 'vitest'; +import { evaluateWakeSignals, shouldWake, type WakeScorerInput } from './wake-scorer'; +import type { WakeProjectSnapshot, WakeTaskSummary } from '../types/wake'; + +function makeProject(overrides: Partial = {}): WakeProjectSnapshot { + return { + projectId: 'proj-1' as any, + projectName: 'TestProject', + activityState: 'running', + idleMinutes: 0, + burnRatePerHour: 0.50, + contextPressurePercent: 30, + fileConflicts: 0, + attentionScore: 0, + attentionReason: null, + ...overrides, + }; +} + +function makeInput(overrides: Partial = {}): WakeScorerInput { + return { + projects: [makeProject()], + ...overrides, + }; +} + +describe('wake-scorer — evaluateWakeSignals', () => { + it('always includes PeriodicFloor signal', () => { + const result = evaluateWakeSignals(makeInput()); + const periodic = result.signals.find(s => s.id === 'PeriodicFloor'); + expect(periodic).toBeDefined(); + expect(periodic!.score).toBe(0.1); + }); + + it('returns PeriodicFloor as top signal when no issues', () => { + const result = evaluateWakeSignals(makeInput()); + expect(result.score).toBe(0.1); + expect(result.signals[0].id).toBe('PeriodicFloor'); + }); + + it('detects AttentionSpike when projects have attention score > 0', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ attentionScore: 100, attentionReason: 'Stalled — 20 min' }), + makeProject({ projectName: 'Proj2', attentionScore: 0 }), + ], + })); + expect(result.score).toBe(1.0); + const spike = result.signals.find(s => s.id === 'AttentionSpike'); + expect(spike).toBeDefined(); + expect(spike!.reason).toContain('1 project'); + expect(spike!.reason).toContain('TestProject'); + }); + + it('AttentionSpike reports multiple projects', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ attentionScore: 100, attentionReason: 'Stalled' }), + makeProject({ projectName: 'B', attentionScore: 80, attentionReason: 'Error' }), + ], + })); + const spike = result.signals.find(s => s.id === 'AttentionSpike'); + expect(spike!.reason).toContain('2 projects'); + }); + + it('detects ContextPressureCluster when 2+ projects above 75%', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ contextPressurePercent: 80 }), + makeProject({ projectName: 'B', contextPressurePercent: 85 }), + ], + })); + const cluster = result.signals.find(s => s.id === 'ContextPressureCluster'); + expect(cluster).toBeDefined(); + expect(cluster!.score).toBe(0.9); + }); + + it('does not trigger ContextPressureCluster with only 1 project above 75%', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ contextPressurePercent: 80 }), + makeProject({ projectName: 'B', contextPressurePercent: 50 }), + ], + })); + const cluster = result.signals.find(s => s.id === 'ContextPressureCluster'); + expect(cluster).toBeUndefined(); + }); + + it('detects BurnRateAnomaly when current rate is 3x+ average', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [makeProject({ burnRatePerHour: 6.0 })], + averageBurnRate: 1.5, + })); + const anomaly = result.signals.find(s => s.id === 'BurnRateAnomaly'); + expect(anomaly).toBeDefined(); + expect(anomaly!.score).toBe(0.8); + expect(anomaly!.reason).toContain('4.0x'); + }); + + it('does not trigger BurnRateAnomaly when rate is below 3x', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [makeProject({ burnRatePerHour: 2.0 })], + averageBurnRate: 1.5, + })); + const anomaly = result.signals.find(s => s.id === 'BurnRateAnomaly'); + expect(anomaly).toBeUndefined(); + }); + + it('does not trigger BurnRateAnomaly when averageBurnRate is 0', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [makeProject({ burnRatePerHour: 5.0 })], + averageBurnRate: 0, + })); + const anomaly = result.signals.find(s => s.id === 'BurnRateAnomaly'); + expect(anomaly).toBeUndefined(); + }); + + it('detects TaskQueuePressure when 3+ tasks blocked', () => { + const result = evaluateWakeSignals(makeInput({ + taskSummary: { total: 10, todo: 2, inProgress: 2, blocked: 4, review: 1, done: 1 }, + })); + const pressure = result.signals.find(s => s.id === 'TaskQueuePressure'); + expect(pressure).toBeDefined(); + expect(pressure!.score).toBe(0.7); + }); + + it('does not trigger TaskQueuePressure when fewer than 3 blocked', () => { + const result = evaluateWakeSignals(makeInput({ + taskSummary: { total: 10, todo: 2, inProgress: 4, blocked: 2, review: 1, done: 1 }, + })); + const pressure = result.signals.find(s => s.id === 'TaskQueuePressure'); + expect(pressure).toBeUndefined(); + }); + + it('detects ReviewBacklog when 5+ tasks in review', () => { + const result = evaluateWakeSignals(makeInput({ + taskSummary: { total: 10, todo: 0, inProgress: 0, blocked: 0, review: 5, done: 5 }, + })); + const backlog = result.signals.find(s => s.id === 'ReviewBacklog'); + expect(backlog).toBeDefined(); + expect(backlog!.score).toBe(0.6); + }); + + it('does not trigger ReviewBacklog when fewer than 5 in review', () => { + const result = evaluateWakeSignals(makeInput({ + taskSummary: { total: 10, todo: 2, inProgress: 2, blocked: 0, review: 4, done: 2 }, + })); + const backlog = result.signals.find(s => s.id === 'ReviewBacklog'); + expect(backlog).toBeUndefined(); + }); + + it('signals are sorted by score descending', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ attentionScore: 100, attentionReason: 'Stalled', contextPressurePercent: 80 }), + makeProject({ projectName: 'B', contextPressurePercent: 85, attentionScore: 0 }), + ], + taskSummary: { total: 10, todo: 0, inProgress: 0, blocked: 5, review: 0, done: 5 }, + })); + for (let i = 1; i < result.signals.length; i++) { + expect(result.signals[i - 1].score).toBeGreaterThanOrEqual(result.signals[i].score); + } + }); + + it('score is the maximum signal score', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ attentionScore: 100, attentionReason: 'Error', contextPressurePercent: 80 }), + makeProject({ projectName: 'B', contextPressurePercent: 85 }), + ], + })); + expect(result.score).toBe(1.0); // AttentionSpike + }); + + it('summary includes fleet stats', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ activityState: 'running' }), + makeProject({ projectName: 'B', activityState: 'idle' }), + makeProject({ projectName: 'C', activityState: 'stalled' }), + ], + })); + expect(result.summary).toContain('1 running'); + expect(result.summary).toContain('1 idle'); + expect(result.summary).toContain('1 stalled'); + }); + + it('summary includes task summary when provided', () => { + const result = evaluateWakeSignals(makeInput({ + taskSummary: { total: 15, todo: 3, inProgress: 4, blocked: 2, review: 1, done: 5 }, + })); + expect(result.summary).toContain('15 total'); + expect(result.summary).toContain('2 blocked'); + }); + + it('handles empty project list', () => { + const result = evaluateWakeSignals(makeInput({ projects: [] })); + expect(result.score).toBe(0.1); // Only PeriodicFloor + expect(result.signals).toHaveLength(1); + }); + + it('handles null contextPressurePercent gracefully', () => { + const result = evaluateWakeSignals(makeInput({ + projects: [ + makeProject({ contextPressurePercent: null }), + makeProject({ projectName: 'B', contextPressurePercent: null }), + ], + })); + const cluster = result.signals.find(s => s.id === 'ContextPressureCluster'); + expect(cluster).toBeUndefined(); + }); +}); + +describe('wake-scorer — shouldWake', () => { + const lowEval = { + score: 0.1, + signals: [{ id: 'PeriodicFloor', score: 0.1, reason: 'Periodic' }], + shouldWake: true, + summary: 'test', + }; + + const highEval = { + score: 0.8, + signals: [{ id: 'BurnRateAnomaly', score: 0.8, reason: 'Spike' }], + shouldWake: true, + summary: 'test', + }; + + it('persistent always wakes', () => { + expect(shouldWake(lowEval, 'persistent', 0.5)).toBe(true); + expect(shouldWake(highEval, 'persistent', 0.5)).toBe(true); + }); + + it('on-demand always wakes', () => { + expect(shouldWake(lowEval, 'on-demand', 0.5)).toBe(true); + expect(shouldWake(highEval, 'on-demand', 0.5)).toBe(true); + }); + + it('smart wakes only when score >= threshold', () => { + expect(shouldWake(lowEval, 'smart', 0.5)).toBe(false); + expect(shouldWake(highEval, 'smart', 0.5)).toBe(true); + }); + + it('smart with threshold 0 always wakes', () => { + expect(shouldWake(lowEval, 'smart', 0)).toBe(true); + }); + + it('smart with threshold 1.0 only wakes on max signal', () => { + expect(shouldWake(highEval, 'smart', 1.0)).toBe(false); + const maxEval = { ...highEval, score: 1.0 }; + expect(shouldWake(maxEval, 'smart', 1.0)).toBe(true); + }); +}); diff --git a/src/lib/utils/wake-scorer.ts b/src/lib/utils/wake-scorer.ts new file mode 100644 index 0000000..ae65356 --- /dev/null +++ b/src/lib/utils/wake-scorer.ts @@ -0,0 +1,163 @@ +// Wake signal scorer — pure function +// Evaluates fleet health signals to determine if the Manager should wake +// Signal IDs from tribunal S-3 hybrid: AttentionSpike, ContextPressureCluster, +// BurnRateAnomaly, TaskQueuePressure, ReviewBacklog, PeriodicFloor + +import type { WakeSignal, WakeEvaluation, WakeProjectSnapshot, WakeTaskSummary } from '../types/wake'; + +// --- Signal weights (0..1, higher = more urgent) --- + +const WEIGHT_ATTENTION_SPIKE = 1.0; +const WEIGHT_CONTEXT_PRESSURE_CLUSTER = 0.9; +const WEIGHT_BURN_RATE_ANOMALY = 0.8; +const WEIGHT_TASK_QUEUE_PRESSURE = 0.7; +const WEIGHT_REVIEW_BACKLOG = 0.6; +const WEIGHT_PERIODIC_FLOOR = 0.1; + +// --- Thresholds --- + +const CONTEXT_PRESSURE_HIGH = 0.75; +const CONTEXT_PRESSURE_CLUSTER_MIN = 2; // 2+ projects above threshold +const BURN_RATE_SPIKE_MULTIPLIER = 3; // 3x average = anomaly +const TASK_BLOCKED_CRITICAL = 3; // 3+ blocked tasks = pressure +const REVIEW_BACKLOG_CRITICAL = 5; // 5+ tasks in review = backlog + +export interface WakeScorerInput { + projects: WakeProjectSnapshot[]; + taskSummary?: WakeTaskSummary; + /** Average burn rate over last hour (for anomaly detection) */ + averageBurnRate?: number; +} + +/** Evaluate all wake signals and produce a wake evaluation */ +export function evaluateWakeSignals(input: WakeScorerInput): WakeEvaluation { + const signals: WakeSignal[] = []; + + // Signal 1: AttentionSpike — any project in attention queue (score > 0) + const attentionProjects = input.projects.filter(p => p.attentionScore > 0); + if (attentionProjects.length > 0) { + const top = attentionProjects.sort((a, b) => b.attentionScore - a.attentionScore)[0]; + signals.push({ + id: 'AttentionSpike', + score: WEIGHT_ATTENTION_SPIKE, + reason: `${attentionProjects.length} project${attentionProjects.length > 1 ? 's' : ''} need attention: ${top.projectName} (${top.attentionReason ?? 'urgent'})`, + }); + } + + // Signal 2: ContextPressureCluster — 2+ projects above 75% context + const highContextProjects = input.projects.filter( + p => p.contextPressurePercent !== null && p.contextPressurePercent > CONTEXT_PRESSURE_HIGH * 100, + ); + if (highContextProjects.length >= CONTEXT_PRESSURE_CLUSTER_MIN) { + signals.push({ + id: 'ContextPressureCluster', + score: WEIGHT_CONTEXT_PRESSURE_CLUSTER, + reason: `${highContextProjects.length} projects above ${CONTEXT_PRESSURE_HIGH * 100}% context pressure`, + }); + } + + // Signal 3: BurnRateAnomaly — current total burn rate >> average + if (input.averageBurnRate !== undefined && input.averageBurnRate > 0) { + const currentTotal = input.projects.reduce((sum, p) => sum + p.burnRatePerHour, 0); + if (currentTotal > input.averageBurnRate * BURN_RATE_SPIKE_MULTIPLIER) { + signals.push({ + id: 'BurnRateAnomaly', + score: WEIGHT_BURN_RATE_ANOMALY, + reason: `Burn rate $${currentTotal.toFixed(2)}/hr is ${(currentTotal / input.averageBurnRate).toFixed(1)}x average ($${input.averageBurnRate.toFixed(2)}/hr)`, + }); + } + } + + // Signal 4: TaskQueuePressure — too many blocked tasks + if (input.taskSummary) { + if (input.taskSummary.blocked >= TASK_BLOCKED_CRITICAL) { + signals.push({ + id: 'TaskQueuePressure', + score: WEIGHT_TASK_QUEUE_PRESSURE, + reason: `${input.taskSummary.blocked} blocked tasks on the board`, + }); + } + } + + // Signal 5: ReviewBacklog — too many tasks waiting for review + if (input.taskSummary) { + if (input.taskSummary.review >= REVIEW_BACKLOG_CRITICAL) { + signals.push({ + id: 'ReviewBacklog', + score: WEIGHT_REVIEW_BACKLOG, + reason: `${input.taskSummary.review} tasks pending review`, + }); + } + } + + // Signal 6: PeriodicFloor — always present (lowest priority) + signals.push({ + id: 'PeriodicFloor', + score: WEIGHT_PERIODIC_FLOOR, + reason: 'Periodic check-in', + }); + + // Sort by score descending + signals.sort((a, b) => b.score - a.score); + + const topScore = signals[0]?.score ?? 0; + + // Build summary for Manager prompt + const summary = buildWakeSummary(signals, input); + + return { + score: topScore, + signals, + shouldWake: true, // Caller (scheduler) gates this based on strategy + threshold + summary, + }; +} + +/** Check if wake should fire based on strategy and threshold */ +export function shouldWake( + evaluation: WakeEvaluation, + strategy: 'persistent' | 'on-demand' | 'smart', + threshold: number, +): boolean { + if (strategy === 'persistent' || strategy === 'on-demand') return true; + // Smart: only wake if score exceeds threshold + return evaluation.score >= threshold; +} + +function buildWakeSummary(signals: WakeSignal[], input: WakeScorerInput): string { + const parts: string[] = []; + + // Headline + const urgentSignals = signals.filter(s => s.score >= 0.5); + if (urgentSignals.length > 0) { + parts.push(`**Wake reason:** ${urgentSignals.map(s => s.reason).join('; ')}`); + } else { + parts.push('**Wake reason:** Periodic check-in (no urgent signals)'); + } + + // Fleet snapshot + const running = input.projects.filter(p => p.activityState === 'running').length; + const idle = input.projects.filter(p => p.activityState === 'idle').length; + const stalled = input.projects.filter(p => p.activityState === 'stalled').length; + const totalBurn = input.projects.reduce((sum, p) => sum + p.burnRatePerHour, 0); + parts.push(`\n**Fleet:** ${running} running, ${idle} idle, ${stalled} stalled | $${totalBurn.toFixed(2)}/hr`); + + // Project details (only those needing attention) + const needsAttention = input.projects.filter(p => p.attentionScore > 0); + if (needsAttention.length > 0) { + parts.push('\n**Needs attention:**'); + for (const p of needsAttention) { + const ctx = p.contextPressurePercent !== null ? ` | ctx ${p.contextPressurePercent}%` : ''; + const conflicts = p.fileConflicts > 0 ? ` | ${p.fileConflicts} conflicts` : ''; + parts.push(`- ${p.projectName}: ${p.activityState}${p.idleMinutes > 0 ? ` (${p.idleMinutes}m idle)` : ''}${ctx}${conflicts} — ${p.attentionReason ?? 'check needed'}`); + } + } + + // Task summary + if (input.taskSummary) { + const ts = input.taskSummary; + parts.push(`\n**Tasks:** ${ts.total} total (${ts.todo} todo, ${ts.inProgress} in progress, ${ts.blocked} blocked, ${ts.review} in review, ${ts.done} done)`); + } + + return parts.join('\n'); +} diff --git a/src/lib/utils/worktree-detection.test.ts b/src/lib/utils/worktree-detection.test.ts new file mode 100644 index 0000000..10af0dd --- /dev/null +++ b/src/lib/utils/worktree-detection.test.ts @@ -0,0 +1,28 @@ +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/src/lib/utils/worktree-detection.ts b/src/lib/utils/worktree-detection.ts new file mode 100644 index 0000000..745696b --- /dev/null +++ b/src/lib/utils/worktree-detection.ts @@ -0,0 +1,17 @@ +// 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/src/main.ts b/src/main.ts new file mode 100644 index 0000000..664a057 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,9 @@ +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/svelte.config.js b/svelte.config.js new file mode 100644 index 0000000..96b3455 --- /dev/null +++ b/svelte.config.js @@ -0,0 +1,8 @@ +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/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 0000000..5e33708 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,143 @@ +# 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 + +# Skip rebuild (use existing binary) +SKIP_BUILD=1 npm run test:e2e + +# With test isolation (custom data/config dirs) +BTERMINAL_TEST_DATA_DIR=/tmp/bt-test/data BTERMINAL_TEST_CONFIG_DIR=/tmp/bt-test/config 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 (TCP readiness probe, 10s deadline) +3. Killing `tauri-driver` after each session +4. Passing `BTERMINAL_TEST=1` env var to the app for test mode isolation + +## Test Mode (`BTERMINAL_TEST=1`) + +When `BTERMINAL_TEST=1` is set: +- File watchers (watcher.rs, fs_watcher.rs) are disabled to avoid inotify noise +- Wake scheduler is disabled (no auto-wake timers) +- Data/config directories can be overridden via `BTERMINAL_TEST_DATA_DIR` / `BTERMINAL_TEST_CONFIG_DIR` + +## 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.$('[data-testid="status-bar"]'); + await expect(statusBar).toBeDisplayed(); + }); +}); +``` + +### Stable selectors + +Prefer `data-testid` attributes over CSS class selectors: + +| Element | Selector | +|---------|----------| +| Status bar | `[data-testid="status-bar"]` | +| Sidebar rail | `[data-testid="sidebar-rail"]` | +| Settings button | `[data-testid="settings-btn"]` | +| Project box | `[data-testid="project-box"]` | +| Project ID | `[data-project-id="..."]` | +| Project tabs | `[data-testid="project-tabs"]` | +| Agent session | `[data-testid="agent-session"]` | +| Agent pane | `[data-testid="agent-pane"]` | +| Agent status | `[data-agent-status="idle\|running\|..."]` | +| Agent messages | `[data-testid="agent-messages"]` | +| Agent prompt | `[data-testid="agent-prompt"]` | +| Agent submit | `[data-testid="agent-submit"]` | +| Agent stop | `[data-testid="agent-stop"]` | +| Terminal tabs | `[data-testid="terminal-tabs"]` | +| Add tab button | `[data-testid="tab-add"]` | +| Terminal toggle | `[data-testid="terminal-toggle"]` | +| Command palette | `[data-testid="command-palette"]` | +| Palette input | `[data-testid="palette-input"]` | + +### 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 +- Use `browser.execute()` for JS clicks when WebDriver clicks don't trigger Svelte handlers +- Agent tests (Scenario 7) require a real Claude CLI install + API key — they skip gracefully if unavailable + +## Test infrastructure + +### Fixtures (`fixtures.ts`) + +Creates isolated test environments with temp data/config dirs and git repos: + +```typescript +import { createTestFixture, destroyTestFixture } from '../fixtures'; + +const fixture = createTestFixture('my-test'); +// fixture.dataDir, fixture.configDir, fixture.projectDir, fixture.env +destroyTestFixture(fixture); +``` + +### Results DB (`results-db.ts`) + +JSON-based test results store for tracking runs and steps: + +```typescript +import { ResultsDb } from '../results-db'; + +const db = new ResultsDb(); +db.startRun('run-001', 'v2-mission-control', 'abc123'); +db.recordStep({ run_id: 'run-001', scenario_name: 'Smoke', step_name: 'renders', status: 'passed', ... }); +db.finishRun('run-001', 'passed', 5000); +``` + +## File structure + +``` +tests/e2e/ +├── README.md # This file +├── wdio.conf.js # WebdriverIO config with tauri-driver lifecycle +├── tsconfig.json # TypeScript config for test specs +├── fixtures.ts # Test fixture generator (isolated environments) +├── results-db.ts # JSON test results store +└── specs/ + ├── bterminal.test.ts # Smoke tests (CSS class selectors, 50+ tests) + └── agent-scenarios.test.ts # Phase A scenarios (data-testid selectors, 22 tests) +``` + +## 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/tests/e2e/fixtures.ts b/tests/e2e/fixtures.ts new file mode 100644 index 0000000..11ff9c0 --- /dev/null +++ b/tests/e2e/fixtures.ts @@ -0,0 +1,142 @@ +// Test fixture generator — creates isolated test environments +// Used by E2E tests to set up temp data/config dirs with valid groups.json + +import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { execSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; + +export interface TestFixture { + /** Root temp directory for this test run */ + rootDir: string; + /** BTERMINAL_TEST_DATA_DIR — isolated data dir */ + dataDir: string; + /** BTERMINAL_TEST_CONFIG_DIR — isolated config dir */ + configDir: string; + /** Path to a minimal git repo for agent testing */ + projectDir: string; + /** Environment variables to pass to the app */ + env: Record; +} + +/** + * Create an isolated test fixture with: + * - Temp data dir (sessions.db, btmsg.db created at runtime) + * - Temp config dir with a minimal groups.json + * - A simple git repo with one file for agent testing + */ +export function createTestFixture(name = 'bterminal-e2e'): TestFixture { + const rootDir = join(tmpdir(), `${name}-${Date.now()}`); + const dataDir = join(rootDir, 'data'); + const configDir = join(rootDir, 'config'); + const projectDir = join(rootDir, 'test-project'); + + // Create directory structure + mkdirSync(dataDir, { recursive: true }); + mkdirSync(configDir, { recursive: true }); + mkdirSync(projectDir, { recursive: true }); + + // Create a minimal git repo for agent testing + execSync('git init', { cwd: projectDir, stdio: 'ignore' }); + execSync('git config user.email "test@bterminal.dev"', { cwd: projectDir, stdio: 'ignore' }); + execSync('git config user.name "BTerminal Test"', { cwd: projectDir, stdio: 'ignore' }); + writeFileSync(join(projectDir, 'README.md'), '# Test Project\n\nA simple test project for BTerminal E2E tests.\n'); + writeFileSync(join(projectDir, 'hello.py'), 'def greet(name: str) -> str:\n return f"Hello, {name}!"\n'); + execSync('git add -A && git commit -m "initial commit"', { cwd: projectDir, stdio: 'ignore' }); + + // Write groups.json with one group containing the test project + const groupsJson = { + version: 1, + groups: [ + { + id: 'test-group', + name: 'Test Group', + projects: [ + { + id: 'test-project', + name: 'Test Project', + identifier: 'test-project', + description: 'E2E test project', + icon: '\uf120', + cwd: projectDir, + profile: 'default', + enabled: true, + }, + ], + agents: [], + }, + ], + activeGroupId: 'test-group', + }; + + writeFileSync( + join(configDir, 'groups.json'), + JSON.stringify(groupsJson, null, 2), + ); + + const env: Record = { + BTERMINAL_TEST: '1', + BTERMINAL_TEST_DATA_DIR: dataDir, + BTERMINAL_TEST_CONFIG_DIR: configDir, + }; + + return { rootDir, dataDir, configDir, projectDir, env }; +} + +/** + * Clean up a test fixture's temporary directories. + */ +export function destroyTestFixture(fixture: TestFixture): void { + if (existsSync(fixture.rootDir)) { + rmSync(fixture.rootDir, { recursive: true, force: true }); + } +} + +/** + * Create a groups.json with multiple projects for multi-project testing. + */ +export function createMultiProjectFixture(projectCount = 3): TestFixture { + const fixture = createTestFixture('bterminal-multi'); + + const projects = []; + for (let i = 0; i < projectCount; i++) { + const projDir = join(fixture.rootDir, `project-${i}`); + mkdirSync(projDir, { recursive: true }); + execSync('git init', { cwd: projDir, stdio: 'ignore' }); + execSync('git config user.email "test@bterminal.dev"', { cwd: projDir, stdio: 'ignore' }); + execSync('git config user.name "BTerminal Test"', { cwd: projDir, stdio: 'ignore' }); + writeFileSync(join(projDir, 'README.md'), `# Project ${i}\n`); + execSync('git add -A && git commit -m "init"', { cwd: projDir, stdio: 'ignore' }); + + projects.push({ + id: `project-${i}`, + name: `Project ${i}`, + identifier: `project-${i}`, + description: `Test project ${i}`, + icon: '\uf120', + cwd: projDir, + profile: 'default', + enabled: true, + }); + } + + const groupsJson = { + version: 1, + groups: [ + { + id: 'multi-group', + name: 'Multi Project Group', + projects, + agents: [], + }, + ], + activeGroupId: 'multi-group', + }; + + writeFileSync( + join(fixture.configDir, 'groups.json'), + JSON.stringify(groupsJson, null, 2), + ); + + return fixture; +} diff --git a/tests/e2e/llm-judge.ts b/tests/e2e/llm-judge.ts new file mode 100644 index 0000000..f23ccba --- /dev/null +++ b/tests/e2e/llm-judge.ts @@ -0,0 +1,231 @@ +// LLM Judge — evaluates test outcomes via Claude. +// +// Two backends, configurable via LLM_JUDGE_BACKEND env var: +// "cli" — Claude CLI (default, no API key needed) +// "api" — Anthropic REST API (requires ANTHROPIC_API_KEY) +// +// CLI backend: spawns `claude` with --output-format text, parses JSON verdict. +// API backend: raw fetch to messages API, same JSON verdict parsing. +// +// Skips gracefully when neither backend is available. + +import { execFileSync, execSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +const MODEL = 'claude-haiku-4-5-20251001'; +const API_URL = 'https://api.anthropic.com/v1/messages'; +const MAX_TOKENS = 512; + +// CLI search paths (in order) +const CLI_PATHS = [ + `${process.env.HOME}/.local/bin/claude`, + `${process.env.HOME}/.claude/local/claude`, + '/usr/local/bin/claude', + '/usr/bin/claude', +]; + +export type JudgeBackend = 'cli' | 'api'; + +export interface JudgeVerdict { + pass: boolean; + reasoning: string; + confidence: number; // 0-1 +} + +/** + * Find the Claude CLI binary path, or null if not installed. + */ +function findClaudeCli(): string | null { + for (const p of CLI_PATHS) { + if (existsSync(p)) return p; + } + // Fallback: check PATH + try { + const which = execSync('which claude 2>/dev/null', { encoding: 'utf-8' }).trim(); + if (which) return which; + } catch { + // not found + } + return null; +} + +/** + * Determine which backend to use. + * Env var LLM_JUDGE_BACKEND overrides auto-detection. + * Auto: CLI if available, then API if key set, else null. + */ +function resolveBackend(): JudgeBackend | null { + const explicit = process.env.LLM_JUDGE_BACKEND?.toLowerCase(); + if (explicit === 'cli') return findClaudeCli() ? 'cli' : null; + if (explicit === 'api') return process.env.ANTHROPIC_API_KEY ? 'api' : null; + + // Auto-detect: CLI first, API fallback + if (findClaudeCli()) return 'cli'; + if (process.env.ANTHROPIC_API_KEY) return 'api'; + return null; +} + +/** + * Check if the LLM judge is available (CLI installed or API key set). + */ +export function isJudgeAvailable(): boolean { + return resolveBackend() !== null; +} + +/** + * Build the prompt for the judge. + */ +function buildPrompt(criteria: string, actual: string, context?: string): { system: string; user: string } { + const system = `You are a test assertion judge for a terminal emulator application called BTerminal. +Your job is to evaluate whether actual output from the application meets the given criteria. +Respond with EXACTLY this JSON format, nothing else: +{"pass": true/false, "reasoning": "brief explanation", "confidence": 0.0-1.0}`; + + const user = [ + '## Criteria', + criteria, + '', + '## Actual Output', + actual, + ...(context ? ['', '## Additional Context', context] : []), + '', + 'Does the actual output satisfy the criteria? Respond with JSON only.', + ].join('\n'); + + return { system, user }; +} + +/** + * Extract and validate a JudgeVerdict from raw text output. + */ +function parseVerdict(text: string): JudgeVerdict { + const jsonMatch = text.match(/\{[\s\S]*\}/); + if (!jsonMatch) { + throw new Error(`LLM judge returned non-JSON: ${text}`); + } + + const verdict = JSON.parse(jsonMatch[0]) as JudgeVerdict; + + if (typeof verdict.pass !== 'boolean') { + throw new Error(`LLM judge returned invalid verdict: ${text}`); + } + verdict.confidence = Number(verdict.confidence) || 0; + verdict.reasoning = String(verdict.reasoning || ''); + + return verdict; +} + +/** + * Judge via Claude CLI (spawns subprocess). + * Unsets CLAUDECODE to avoid nested session errors. + */ +async function judgeCli( + criteria: string, + actual: string, + context?: string, +): Promise { + const cliPath = findClaudeCli(); + if (!cliPath) throw new Error('Claude CLI not found'); + + const { system, user } = buildPrompt(criteria, actual, context); + + const output = execFileSync(cliPath, [ + '-p', user, + '--model', MODEL, + '--output-format', 'text', + '--system-prompt', system, + '--setting-sources', 'user', // skip project CLAUDE.md + ], { + encoding: 'utf-8', + timeout: 60_000, + cwd: '/tmp', // avoid loading project CLAUDE.md + env: { ...process.env, CLAUDECODE: '' }, + maxBuffer: 1024 * 1024, + }); + + return parseVerdict(output); +} + +/** + * Judge via Anthropic REST API (raw fetch). + */ +async function judgeApi( + criteria: string, + actual: string, + context?: string, +): Promise { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) throw new Error('ANTHROPIC_API_KEY not set'); + + const { system, user } = buildPrompt(criteria, actual, context); + + const response = await fetch(API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: MODEL, + max_tokens: MAX_TOKENS, + system, + messages: [{ role: 'user', content: user }], + }), + }); + + if (!response.ok) { + const body = await response.text(); + throw new Error(`Anthropic API error ${response.status}: ${body}`); + } + + const data = await response.json(); + const text = data.content?.[0]?.text ?? ''; + + return parseVerdict(text); +} + +/** + * Ask Claude to evaluate whether `actual` output satisfies `criteria`. + * + * Uses CLI backend by default, falls back to API. Override with + * LLM_JUDGE_BACKEND env var ("cli" or "api"). + * + * Returns a structured verdict with pass/fail, reasoning, and confidence. + * Throws if no backend available or call fails. + */ +export async function judge( + criteria: string, + actual: string, + context?: string, +): Promise { + const backend = resolveBackend(); + if (!backend) { + throw new Error('LLM judge unavailable — no Claude CLI found and ANTHROPIC_API_KEY not set'); + } + + if (backend === 'cli') { + return judgeCli(criteria, actual, context); + } + return judgeApi(criteria, actual, context); +} + +/** + * Convenience: judge with a minimum confidence threshold. + * Returns pass=true only if verdict.pass=true AND confidence >= threshold. + */ +export async function assertWithJudge( + criteria: string, + actual: string, + options: { context?: string; minConfidence?: number } = {}, +): Promise { + const { context, minConfidence = 0.7 } = options; + const verdict = await judge(criteria, actual, context); + + if (verdict.pass && verdict.confidence < minConfidence) { + verdict.pass = false; + verdict.reasoning += ` (confidence ${verdict.confidence} below threshold ${minConfidence})`; + } + + return verdict; +} diff --git a/tests/e2e/results-db.ts b/tests/e2e/results-db.ts new file mode 100644 index 0000000..513088c --- /dev/null +++ b/tests/e2e/results-db.ts @@ -0,0 +1,113 @@ +// Test results store — persists test run outcomes as JSON for analysis +// No native deps needed — reads/writes a JSON file + +import { resolve, dirname } from 'node:path'; +import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_PATH = resolve(__dirname, '../../test-results/results.json'); + +export interface TestRunRow { + run_id: string; + started_at: string; + finished_at: string | null; + status: 'running' | 'passed' | 'failed' | 'error'; + total_tests: number; + passed_tests: number; + failed_tests: number; + duration_ms: number | null; + git_branch: string | null; + git_sha: string | null; +} + +export interface TestStepRow { + run_id: string; + scenario_name: string; + step_name: string; + status: 'passed' | 'failed' | 'skipped' | 'error'; + duration_ms: number | null; + error_message: string | null; + screenshot_path: string | null; + agent_cost_usd: number | null; + created_at: string; +} + +interface ResultsStore { + runs: TestRunRow[]; + steps: TestStepRow[]; +} + +export class ResultsDb { + private filePath: string; + private store: ResultsStore; + + constructor(filePath = DEFAULT_PATH) { + this.filePath = filePath; + mkdirSync(dirname(filePath), { recursive: true }); + this.store = this.load(); + } + + private load(): ResultsStore { + if (existsSync(this.filePath)) { + try { + return JSON.parse(readFileSync(this.filePath, 'utf-8')); + } catch { + return { runs: [], steps: [] }; + } + } + return { runs: [], steps: [] }; + } + + private save(): void { + writeFileSync(this.filePath, JSON.stringify(this.store, null, 2)); + } + + startRun(runId: string, gitBranch?: string, gitSha?: string): void { + this.store.runs.push({ + run_id: runId, + started_at: new Date().toISOString(), + finished_at: null, + status: 'running', + total_tests: 0, + passed_tests: 0, + failed_tests: 0, + duration_ms: null, + git_branch: gitBranch ?? null, + git_sha: gitSha ?? null, + }); + this.save(); + } + + finishRun(runId: string, status: 'passed' | 'failed' | 'error', durationMs: number): void { + const run = this.store.runs.find(r => r.run_id === runId); + if (!run) return; + + const steps = this.store.steps.filter(s => s.run_id === runId); + run.finished_at = new Date().toISOString(); + run.status = status; + run.duration_ms = durationMs; + run.total_tests = steps.length; + run.passed_tests = steps.filter(s => s.status === 'passed').length; + run.failed_tests = steps.filter(s => s.status === 'failed' || s.status === 'error').length; + this.save(); + } + + recordStep(step: Omit): void { + this.store.steps.push({ + ...step, + created_at: new Date().toISOString(), + }); + this.save(); + } + + getRecentRuns(limit = 20): TestRunRow[] { + return this.store.runs + .sort((a, b) => b.started_at.localeCompare(a.started_at)) + .slice(0, limit); + } + + getStepsForRun(runId: string): TestStepRow[] { + return this.store.steps.filter(s => s.run_id === runId); + } +} diff --git a/tests/e2e/specs/agent-scenarios.test.ts b/tests/e2e/specs/agent-scenarios.test.ts new file mode 100644 index 0000000..b568077 --- /dev/null +++ b/tests/e2e/specs/agent-scenarios.test.ts @@ -0,0 +1,429 @@ +import { browser, expect } from '@wdio/globals'; + +// Phase A: Human-authored E2E scenarios with deterministic assertions. +// These test the agent UI flow end-to-end using stable data-testid selectors. +// Agent-interaction tests require a real Claude CLI install + API key. + +// ─── Helpers ────────────────────────────────────────────────────────── + +/** Wait for agent status to reach a target value within timeout. */ +async function waitForAgentStatus( + status: string, + timeout = 30_000, +): Promise { + await browser.waitUntil( + async () => { + const attr = await browser.execute(() => { + const el = document.querySelector('[data-testid="agent-pane"]'); + return el?.getAttribute('data-agent-status') ?? 'idle'; + }); + return attr === status; + }, + { timeout, timeoutMsg: `Agent did not reach status "${status}" within ${timeout}ms` }, + ); +} + +/** Check if an agent pane exists and is visible. */ +async function agentPaneExists(): Promise { + const el = await browser.$('[data-testid="agent-pane"]'); + return el.isExisting(); +} + +/** Type a prompt into the agent textarea and submit. */ +async function sendAgentPrompt(text: string): Promise { + const textarea = await browser.$('[data-testid="agent-prompt"]'); + await textarea.waitForDisplayed({ timeout: 5000 }); + await textarea.setValue(text); + // Small delay for Svelte reactivity + await browser.pause(200); + const submitBtn = await browser.$('[data-testid="agent-submit"]'); + await browser.execute((el) => (el as HTMLElement).click(), submitBtn); +} + +// ─── Scenario 1: App renders with project grid and data-testid anchors ─── + +describe('Scenario 1 — App Structural Integrity', () => { + it('should render the status bar with data-testid', async () => { + const bar = await browser.$('[data-testid="status-bar"]'); + await expect(bar).toBeDisplayed(); + }); + + it('should render the sidebar rail with data-testid', async () => { + const rail = await browser.$('[data-testid="sidebar-rail"]'); + await expect(rail).toBeDisplayed(); + }); + + it('should render at least one project box with data-testid', async () => { + const boxes = await browser.$$('[data-testid="project-box"]'); + expect(boxes.length).toBeGreaterThanOrEqual(1); + }); + + it('should have data-project-id on project boxes', async () => { + const projectId = await browser.execute(() => { + const box = document.querySelector('[data-testid="project-box"]'); + return box?.getAttribute('data-project-id') ?? null; + }); + expect(projectId).not.toBeNull(); + expect((projectId as string).length).toBeGreaterThan(0); + }); + + it('should render project tabs with data-testid', async () => { + const tabs = await browser.$('[data-testid="project-tabs"]'); + await expect(tabs).toBeDisplayed(); + }); + + it('should render agent session component', async () => { + const session = await browser.$('[data-testid="agent-session"]'); + await expect(session).toBeDisplayed(); + }); +}); + +// ─── Scenario 2: Settings panel via data-testid ────────────────────── + +describe('Scenario 2 — Settings Panel (data-testid)', () => { + it('should open settings via data-testid button', async () => { + // Use JS click for reliability with WebKit2GTK/tauri-driver + await browser.execute(() => { + const btn = document.querySelector('[data-testid="settings-btn"]'); + if (btn) (btn as HTMLElement).click(); + }); + + const panel = await browser.$('.sidebar-panel'); + await panel.waitForDisplayed({ timeout: 5000 }); + // Wait for settings content to mount + await browser.waitUntil( + async () => { + const count = await browser.execute(() => + document.querySelectorAll('.settings-tab .settings-section').length, + ); + return (count as number) >= 1; + }, + { timeout: 5000 }, + ); + await expect(panel).toBeDisplayed(); + }); + + it('should close settings with Escape', async () => { + await browser.keys('Escape'); + const panel = await browser.$('.sidebar-panel'); + await panel.waitForDisplayed({ timeout: 3000, reverse: true }); + }); +}); + +// ─── Scenario 3: Agent pane initial state ──────────────────────────── + +describe('Scenario 3 — Agent Pane Initial State', () => { + it('should display agent pane in idle status', async () => { + const exists = await agentPaneExists(); + if (!exists) { + // Agent pane might not be visible until Model tab is active + await browser.execute(() => { + const tab = document.querySelector('[data-testid="project-tabs"] .ptab'); + if (tab) (tab as HTMLElement).click(); + }); + await browser.pause(300); + } + + const pane = await browser.$('[data-testid="agent-pane"]'); + await expect(pane).toBeExisting(); + + const status = await browser.execute(() => { + const el = document.querySelector('[data-testid="agent-pane"]'); + return el?.getAttribute('data-agent-status') ?? 'unknown'; + }); + expect(status).toBe('idle'); + }); + + it('should show prompt textarea', async () => { + const textarea = await browser.$('[data-testid="agent-prompt"]'); + await expect(textarea).toBeDisplayed(); + }); + + it('should show submit button', async () => { + const btn = await browser.$('[data-testid="agent-submit"]'); + await expect(btn).toBeExisting(); + }); + + it('should have empty messages area initially', async () => { + const msgArea = await browser.$('[data-testid="agent-messages"]'); + await expect(msgArea).toBeExisting(); + + // No message bubbles should exist in a fresh session + const msgCount = await browser.execute(() => { + const area = document.querySelector('[data-testid="agent-messages"]'); + if (!area) return 0; + return area.querySelectorAll('.message').length; + }); + expect(msgCount).toBe(0); + }); +}); + +// ─── Scenario 4: Terminal tab management ───────────────────────────── + +describe('Scenario 4 — Terminal Tab Management (data-testid)', () => { + before(async () => { + // Ensure Model tab is active and terminal section visible + await browser.execute(() => { + const tab = document.querySelector('[data-testid="project-tabs"] .ptab'); + if (tab) (tab as HTMLElement).click(); + }); + await browser.pause(300); + + // Expand terminal section + await browser.execute(() => { + const toggle = document.querySelector('[data-testid="terminal-toggle"]'); + if (toggle) (toggle as HTMLElement).click(); + }); + await browser.pause(500); + }); + + it('should display terminal tabs container', async () => { + const tabs = await browser.$('[data-testid="terminal-tabs"]'); + await expect(tabs).toBeDisplayed(); + }); + + it('should add a shell tab via data-testid button', async () => { + await browser.execute(() => { + const btn = document.querySelector('[data-testid="tab-add"]'); + if (btn) (btn as HTMLElement).click(); + }); + await browser.pause(500); + + const tabTitle = await browser.execute(() => { + const el = document.querySelector('.tab-bar .tab-title'); + return el?.textContent ?? ''; + }); + expect(tabTitle.toLowerCase()).toContain('shell'); + }); + + it('should show active tab styling', async () => { + const activeTab = await browser.$('.tab.active'); + await expect(activeTab).toBeExisting(); + }); + + it('should close tab and show empty state', async () => { + // Close all tabs + await browser.execute(() => { + const closeBtns = document.querySelectorAll('.tab-close'); + closeBtns.forEach(btn => (btn as HTMLElement).click()); + }); + await browser.pause(500); + + // Should show empty terminal area with "Open terminal" button + const emptyBtn = await browser.$('.add-first'); + await expect(emptyBtn).toBeDisplayed(); + }); + + after(async () => { + // Collapse terminal section + await browser.execute(() => { + const toggle = document.querySelector('[data-testid="terminal-toggle"]'); + const chevron = toggle?.querySelector('.toggle-chevron.expanded'); + if (chevron) (toggle as HTMLElement).click(); + }); + await browser.pause(300); + }); +}); + +// ─── Scenario 5: Command palette with data-testid ─────────────────── + +describe('Scenario 5 — Command Palette (data-testid)', () => { + it('should open palette and show data-testid input', async () => { + await browser.execute(() => document.body.focus()); + await browser.pause(200); + await browser.keys(['Control', 'k']); + + const palette = await browser.$('[data-testid="command-palette"]'); + await palette.waitForDisplayed({ timeout: 3000 }); + + const input = await browser.$('[data-testid="palette-input"]'); + await expect(input).toBeDisplayed(); + }); + + it('should have focused input', async () => { + // Use programmatic focus check (auto-focus may not work in WebKit2GTK/tauri-driver) + const isFocused = await browser.execute(() => { + const el = document.querySelector('[data-testid="palette-input"]') as HTMLInputElement | null; + if (!el) return false; + el.focus(); // Ensure focus programmatically + return el === document.activeElement; + }); + expect(isFocused).toBe(true); + }); + + it('should show at least one group item', async () => { + const items = await browser.$$('.palette-item'); + expect(items.length).toBeGreaterThanOrEqual(1); + }); + + it('should filter and show no-results for nonsense query', async () => { + const input = await browser.$('[data-testid="palette-input"]'); + await input.setValue('zzz_no_match_xyz'); + await browser.pause(300); + + const noResults = await browser.$('.no-results'); + await expect(noResults).toBeDisplayed(); + }); + + it('should close on Escape', async () => { + await browser.keys('Escape'); + const palette = await browser.$('[data-testid="command-palette"]'); + await browser.waitUntil( + async () => !(await palette.isDisplayed()), + { timeout: 3000 }, + ); + }); +}); + +// ─── Scenario 6: Project focus and tab switching ───────────────────── + +describe('Scenario 6 — Project Focus & Tab Switching', () => { + it('should focus project on header click', async () => { + await browser.execute(() => { + const header = document.querySelector('.project-header'); + if (header) (header as HTMLElement).click(); + }); + await browser.pause(300); + + const activeBox = await browser.$('.project-box.active'); + await expect(activeBox).toBeDisplayed(); + }); + + it('should switch to Files tab and back without losing agent session', async () => { + // Get current agent session element reference + const sessionBefore = await browser.execute(() => { + const el = document.querySelector('[data-testid="agent-session"]'); + return el !== null; + }); + expect(sessionBefore).toBe(true); + + // Switch to Files tab (second tab) + await browser.execute(() => { + const tabs = document.querySelectorAll('[data-testid="project-tabs"] .ptab'); + if (tabs.length >= 2) (tabs[1] as HTMLElement).click(); + }); + await browser.pause(500); + + // AgentSession should still exist in DOM (display:none, not unmounted) + const sessionDuring = await browser.execute(() => { + const el = document.querySelector('[data-testid="agent-session"]'); + return el !== null; + }); + expect(sessionDuring).toBe(true); + + // Switch back to Model tab + await browser.execute(() => { + const tab = document.querySelector('[data-testid="project-tabs"] .ptab'); + if (tab) (tab as HTMLElement).click(); + }); + await browser.pause(300); + + // Agent session should be visible again + const session = await browser.$('[data-testid="agent-session"]'); + await expect(session).toBeDisplayed(); + }); + + it('should preserve agent status across tab switches', async () => { + const statusBefore = await browser.execute(() => { + const el = document.querySelector('[data-testid="agent-pane"]'); + return el?.getAttribute('data-agent-status') ?? 'unknown'; + }); + + // Switch to Context tab (third tab) and back + await browser.execute(() => { + const tabs = document.querySelectorAll('[data-testid="project-tabs"] .ptab'); + if (tabs.length >= 3) (tabs[2] as HTMLElement).click(); + }); + await browser.pause(300); + await browser.execute(() => { + const tab = document.querySelector('[data-testid="project-tabs"] .ptab'); + if (tab) (tab as HTMLElement).click(); + }); + await browser.pause(300); + + const statusAfter = await browser.execute(() => { + const el = document.querySelector('[data-testid="agent-pane"]'); + return el?.getAttribute('data-agent-status') ?? 'unknown'; + }); + expect(statusAfter).toBe(statusBefore); + }); +}); + +// ─── Scenario 7: Agent prompt interaction (requires Claude CLI) ────── + +describe('Scenario 7 — Agent Prompt Submission', () => { + // This scenario requires a real Claude CLI + API key. + // Skip gracefully if agent doesn't transition to "running" within timeout. + + it('should accept text in prompt textarea', async () => { + const textarea = await browser.$('[data-testid="agent-prompt"]'); + await textarea.waitForDisplayed({ timeout: 5000 }); + await textarea.setValue('Say hello'); + await browser.pause(200); + + const value = await textarea.getValue(); + expect(value).toBe('Say hello'); + + // Clear without submitting + await textarea.clearValue(); + }); + + it('should enable submit button when prompt has text', async () => { + const textarea = await browser.$('[data-testid="agent-prompt"]'); + await textarea.setValue('Test prompt'); + await browser.pause(200); + + // Submit button should be interactable (not disabled) + const isDisabled = await browser.execute(() => { + const btn = document.querySelector('[data-testid="agent-submit"]'); + if (!btn) return true; + return (btn as HTMLButtonElement).disabled; + }); + expect(isDisabled).toBe(false); + + await textarea.clearValue(); + }); + + it('should show stop button during agent execution (if Claude available)', async function () { + // Send a minimal prompt + await sendAgentPrompt('Reply with exactly: BTERMINAL_TEST_OK'); + + // Wait for running status (generous timeout for sidecar spin-up) + try { + await waitForAgentStatus('running', 15_000); + } catch { + // Claude CLI not available — skip remaining assertions + console.log('Agent did not start — Claude CLI may not be available. Skipping.'); + this.skip(); + return; + } + + // If agent is still running, check for stop button + const status = await browser.execute(() => { + const el = document.querySelector('[data-testid="agent-pane"]'); + return el?.getAttribute('data-agent-status') ?? 'unknown'; + }); + + if (status === 'running') { + const stopBtn = await browser.$('[data-testid="agent-stop"]'); + await expect(stopBtn).toBeDisplayed(); + } + + // Wait for completion (with shorter timeout to avoid mocha timeout) + try { + await waitForAgentStatus('idle', 40_000); + } catch { + console.log('Agent did not complete within 40s — skipping completion checks.'); + this.skip(); + return; + } + + // Messages area should now have content + const msgCount = await browser.execute(() => { + const area = document.querySelector('[data-testid="agent-messages"]'); + if (!area) return 0; + return area.children.length; + }); + expect(msgCount).toBeGreaterThan(0); + }); +}); diff --git a/tests/e2e/specs/bterminal.test.ts b/tests/e2e/specs/bterminal.test.ts new file mode 100644 index 0000000..a556e14 --- /dev/null +++ b/tests/e2e/specs/bterminal.test.ts @@ -0,0 +1,799 @@ +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 () => { + // Wait for the app to fully load before any tests + await browser.waitUntil( + async () => (await browser.getTitle()) === 'BTerminal', + { timeout: 10_000, timeoutMsg: 'App did not load within 10s' }, + ); + 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 (Model, Docs, Context, Files, SSH, Memory, ...)', async () => { + const box = await browser.$('.project-box'); + const tabs = await box.$$('.ptab'); + // v3 has 6+ tabs: Model, Docs, Context, Files, SSH, Memory (+ role-specific) + expect(tabs.length).toBeGreaterThanOrEqual(6); + }); + + 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(); + // Tab[1] is "Docs" in v3 tab bar (Model, Docs, Context, Files, ...) + expect(text.toLowerCase()).toContain('docs'); + + // Switch back to Model 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 project and agent info in status bar', async () => { + const statusBar = await browser.$('.status-bar .left'); + const text = await statusBar.getText(); + // Status bar always shows project count; agent counts only when > 0 + // (shows "X running", "X idle", "X stalled" — not the word "agents") + expect(text).toContain('projects'); + }); +}); + +/** Open the settings panel, waiting for content to render. */ +async function openSettings(): Promise { + const panel = await browser.$('.sidebar-panel'); + const isOpen = await panel.isDisplayed().catch(() => false); + if (!isOpen) { + // Use data-testid for unambiguous selection + await browser.execute(() => { + const btn = document.querySelector('[data-testid="settings-btn"]'); + if (btn) (btn as HTMLElement).click(); + }); + await panel.waitForDisplayed({ timeout: 5000 }); + } + // Wait for settings content to mount + await browser.waitUntil( + async () => { + const count = await browser.execute(() => + document.querySelectorAll('.settings-tab .settings-section').length, + ); + return (count as number) >= 1; + }, + { timeout: 5000, timeoutMsg: 'Settings sections did not render within 5s' }, + ); + await browser.pause(200); +} + +/** Close the settings panel if open. */ +async function closeSettings(): Promise { + const panel = await browser.$('.sidebar-panel'); + if (await panel.isDisplayed().catch(() => false)) { + await browser.execute(() => { + const btn = document.querySelector('.panel-close'); + if (btn) (btn as HTMLElement).click(); + }); + await browser.pause(500); + } +} + +describe('BTerminal — Settings Panel', () => { + before(async () => { + await openSettings(); + }); + + after(async () => { + await closeSettings(); + }); + + 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 () => { + // Groups section is below Appearance/Defaults/Providers — scroll into view + await browser.execute(() => { + const el = document.querySelector('.group-list'); + if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' }); + }); + await browser.pause(300); + const groupList = await browser.$('.group-list'); + await expect(groupList).toBeDisplayed(); + }); + + it('should close settings panel with close button', async () => { + // Ensure settings is open + await openSettings(); + + // Use JS click for reliability + await browser.execute(() => { + const btn = document.querySelector('.panel-close'); + if (btn) (btn as HTMLElement).click(); + }); + await browser.pause(500); + + const panel = await browser.$('.sidebar-panel'); + await expect(panel).not.toBeDisplayed(); + }); +}); + +/** Open command palette — idempotent (won't toggle-close if already open). */ +async function openCommandPalette(): Promise { + // Ensure sidebar is closed first (it can intercept keyboard events) + await closeSettings(); + + // Check if already open + const alreadyOpen = await browser.execute(() => { + const p = document.querySelector('.palette'); + return p !== null && getComputedStyle(p).display !== 'none'; + }); + if (alreadyOpen) return; + + // Dispatch Ctrl+K via JS for reliability with WebKit2GTK/tauri-driver + await browser.execute(() => { + document.dispatchEvent(new KeyboardEvent('keydown', { + key: 'k', code: 'KeyK', ctrlKey: true, bubbles: true, cancelable: true, + })); + }); + await browser.pause(300); + + const palette = await browser.$('.palette'); + await palette.waitForDisplayed({ timeout: 5000 }); +} + +/** Close command palette if open — uses backdrop click (more reliable than Escape). */ +async function closeCommandPalette(): Promise { + const isOpen = await browser.execute(() => { + const p = document.querySelector('.palette'); + return p !== null && getComputedStyle(p).display !== 'none'; + }); + if (!isOpen) return; + + // Click backdrop to close (more reliable than dispatching Escape) + await browser.execute(() => { + const backdrop = document.querySelector('.palette-backdrop'); + if (backdrop) (backdrop as HTMLElement).click(); + }); + await browser.pause(500); +} + +describe('BTerminal — Command Palette', () => { + beforeEach(async () => { + await closeCommandPalette(); + }); + + it('should show palette input', async () => { + await openCommandPalette(); + + const input = await browser.$('.palette-input'); + await expect(input).toBeDisplayed(); + + // Verify input accepts text (functional focus test, not activeElement check + // which is unreliable in WebKit2GTK/tauri-driver) + const canType = await browser.execute(() => { + const el = document.querySelector('.palette-input') as HTMLInputElement | null; + if (!el) return false; + el.focus(); + return el === document.activeElement; + }); + expect(canType).toBe(true); + + await closeCommandPalette(); + }); + + it('should show palette items with command labels and categories', async () => { + await openCommandPalette(); + + const items = await browser.$$('.palette-item'); + expect(items.length).toBeGreaterThanOrEqual(1); + + // Each command item should have a label + const cmdLabel = await browser.$('.palette-item .cmd-label'); + await expect(cmdLabel).toBeDisplayed(); + const labelText = await cmdLabel.getText(); + expect(labelText.length).toBeGreaterThan(0); + + // Commands should be grouped under category headers + const categories = await browser.$$('.palette-category'); + expect(categories.length).toBeGreaterThanOrEqual(1); + + await closeCommandPalette(); + }); + + it('should highlight selected item in palette', async () => { + await openCommandPalette(); + + // First item should be selected by default + const selectedItem = await browser.$('.palette-item.selected'); + await expect(selectedItem).toBeExisting(); + + await closeCommandPalette(); + }); + + it('should filter palette items by typing', async () => { + await openCommandPalette(); + + 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 closeCommandPalette(); + }); + + it('should close palette by clicking backdrop', async () => { + await openCommandPalette(); + const palette = await browser.$('.palette'); + + // 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 () => { + await openSettings(); + // Scroll to top for theme dropdown + await browser.execute(() => { + const content = document.querySelector('.panel-content') || document.querySelector('.sidebar-panel'); + if (content) content.scrollTop = 0; + }); + await browser.pause(300); + }); + + after(async () => { + await closeSettings(); + }); + + it('should show theme dropdown with group labels', async () => { + // Close any open dropdowns first + await browser.execute(() => { + const openMenu = document.querySelector('.dropdown-menu'); + if (openMenu) { + const trigger = openMenu.closest('.custom-dropdown')?.querySelector('.dropdown-trigger'); + if (trigger) (trigger as HTMLElement).click(); + } + }); + await browser.pause(200); + + // Click the first dropdown trigger (theme dropdown) + await browser.execute(() => { + const trigger = document.querySelector('.settings-tab .custom-dropdown .dropdown-trigger'); + if (trigger) (trigger as HTMLElement).click(); + }); + await browser.pause(500); + + const menu = await browser.$('.dropdown-menu'); + await menu.waitForExist({ timeout: 5000 }); + + // 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('.settings-tab .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 (first custom-dropdown in settings) + await browser.execute(() => { + const trigger = document.querySelector('.settings-tab .custom-dropdown .dropdown-trigger'); + if (trigger) (trigger as HTMLElement).click(); + }); + await browser.pause(500); + + // Wait for dropdown menu + const menu = await browser.$('.dropdown-menu'); + await menu.waitForExist({ timeout: 5000 }); + + // Click the first non-active theme option + const changed = await browser.execute(() => { + const options = document.querySelectorAll('.dropdown-menu .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('.settings-tab .custom-dropdown .dropdown-trigger'); + if (trigger) (trigger as HTMLElement).click(); + }); + await browser.pause(500); + await browser.execute(() => { + const options = document.querySelectorAll('.dropdown-menu .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('.settings-tab .custom-dropdown .dropdown-trigger'); + if (trigger) (trigger as HTMLElement).click(); + }); + await browser.pause(500); + + const menu = await browser.$('.dropdown-menu'); + await menu.waitForExist({ timeout: 5000 }); + + const activeOption = await browser.$('.dropdown-option.active'); + await expect(activeOption).toBeExisting(); + + await browser.execute(() => { + const trigger = document.querySelector('.settings-tab .custom-dropdown .dropdown-trigger'); + if (trigger) (trigger as HTMLElement).click(); + }); + await browser.pause(300); + }); +}); + +describe('BTerminal — Settings Interaction', () => { + before(async () => { + await openSettings(); + // Scroll to top for font controls + await browser.execute(() => { + const content = document.querySelector('.panel-content') || document.querySelector('.sidebar-panel'); + if (content) content.scrollTop = 0; + }); + await browser.pause(300); + }); + + after(async () => { + await closeSettings(); + }); + + 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 () => { + // Scroll to Groups section (below Appearance, Defaults, Providers) + await browser.execute(() => { + const el = document.querySelector('.group-list'); + if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' }); + }); + await browser.pause(300); + + 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 () => { + // Scroll to Projects section + await browser.execute(() => { + const el = document.querySelector('.project-cards'); + if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' }); + }); + await browser.pause(300); + + 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 () => { + // Scroll to add project form (at bottom of Projects section) + await browser.execute(() => { + const el = document.querySelector('.add-project-form'); + if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' }); + }); + await browser.pause(300); + + 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', () => { + before(async () => { + await closeSettings(); + await closeCommandPalette(); + }); + + it('should open command palette with Ctrl+K', async () => { + await openCommandPalette(); + + const input = await browser.$('.palette-input'); + await expect(input).toBeDisplayed(); + + // Close with Escape + await closeCommandPalette(); + const palette = await browser.$('.palette'); + const isGone = !(await palette.isDisplayed().catch(() => false)); + expect(isGone).toBe(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 categorized commands', async () => { + await openCommandPalette(); + + const items = await browser.$$('.palette-item'); + expect(items.length).toBeGreaterThanOrEqual(1); + + // Commands should have labels + const cmdLabel = await browser.$('.palette-item .cmd-label'); + await expect(cmdLabel).toBeDisplayed(); + + await closeCommandPalette(); + }); +}); diff --git a/tests/e2e/specs/phase-b.test.ts b/tests/e2e/specs/phase-b.test.ts new file mode 100644 index 0000000..568abf7 --- /dev/null +++ b/tests/e2e/specs/phase-b.test.ts @@ -0,0 +1,377 @@ +import { browser, expect } from '@wdio/globals'; +import { isJudgeAvailable, assertWithJudge } from '../llm-judge'; + +// Phase B: Multi-project scenarios + LLM-judged assertions. +// Extends Phase A with tests that exercise multiple project boxes simultaneously +// and use Claude API to evaluate agent response quality. +// +// Prerequisites: +// - Built debug binary (or SKIP_BUILD=1) +// - groups.json with 2+ projects (use BTERMINAL_TEST_CONFIG_DIR or default) +// - ANTHROPIC_API_KEY env var for LLM-judged tests (skipped if absent) + +// ─── Helpers ────────────────────────────────────────────────────────── + +/** Get all project box IDs currently rendered. */ +async function getProjectIds(): Promise { + return browser.execute(() => { + const boxes = document.querySelectorAll('[data-testid="project-box"]'); + return Array.from(boxes).map( + (b) => b.getAttribute('data-project-id') ?? '', + ).filter(Boolean); + }); +} + +/** Focus a specific project box by its project ID. */ +async function focusProject(projectId: string): Promise { + await browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + const header = box?.querySelector('.project-header'); + if (header) (header as HTMLElement).click(); + }, projectId); + await browser.pause(300); +} + +/** Get the agent status for a specific project box. */ +async function getAgentStatus(projectId: string): Promise { + return browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + const pane = box?.querySelector('[data-testid="agent-pane"]'); + return pane?.getAttribute('data-agent-status') ?? 'not-found'; + }, projectId); +} + +/** Send a prompt to the agent in a specific project box. */ +async function sendPromptInProject(projectId: string, text: string): Promise { + await focusProject(projectId); + await browser.execute((id, prompt) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + const textarea = box?.querySelector('[data-testid="agent-prompt"]') as HTMLTextAreaElement | null; + if (textarea) { + textarea.value = prompt; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + } + }, projectId, text); + await browser.pause(200); + await browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + const btn = box?.querySelector('[data-testid="agent-submit"]') as HTMLElement | null; + if (btn) btn.click(); + }, projectId); +} + +/** Wait for agent in a specific project to reach target status. */ +async function waitForProjectAgentStatus( + projectId: string, + status: string, + timeout = 60_000, +): Promise { + await browser.waitUntil( + async () => (await getAgentStatus(projectId)) === status, + { timeout, timeoutMsg: `Agent in project ${projectId} did not reach "${status}" within ${timeout}ms` }, + ); +} + +/** Get all message text from an agent pane in a specific project. */ +async function getAgentMessages(projectId: string): Promise { + return browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + const area = box?.querySelector('[data-testid="agent-messages"]'); + return area?.textContent ?? ''; + }, projectId); +} + +/** Switch to a tab in a specific project box. Tab index: 0=Model, 1=Docs, 2=Context, etc. */ +async function switchProjectTab(projectId: string, tabIndex: number): Promise { + await browser.execute((id, idx) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + const tabs = box?.querySelectorAll('[data-testid="project-tabs"] .ptab'); + if (tabs && tabs[idx]) (tabs[idx] as HTMLElement).click(); + }, projectId, tabIndex); + await browser.pause(300); +} + +// ─── Scenario B1: Multi-project grid renders correctly ──────────────── + +describe('Scenario B1 — Multi-Project Grid', () => { + it('should render multiple project boxes', async () => { + // Wait for app to fully render project boxes + await browser.waitUntil( + async () => { + const count = await browser.execute(() => + document.querySelectorAll('[data-testid="project-box"]').length, + ); + return (count as number) >= 1; + }, + { timeout: 10_000, timeoutMsg: 'No project boxes rendered within 10s' }, + ); + + const ids = await getProjectIds(); + // May be 1 project in minimal fixture; test structure regardless + expect(ids.length).toBeGreaterThanOrEqual(1); + // Each ID should be unique + const unique = new Set(ids); + expect(unique.size).toBe(ids.length); + }); + + it('should show project headers with CWD paths', async () => { + const headers = await browser.execute(() => { + const els = document.querySelectorAll('.project-header .cwd'); + return Array.from(els).map((e) => e.textContent?.trim() ?? ''); + }); + // Each header should have a non-empty CWD + for (const cwd of headers) { + expect(cwd.length).toBeGreaterThan(0); + } + }); + + it('should have independent agent panes per project', async () => { + const ids = await getProjectIds(); + for (const id of ids) { + const status = await getAgentStatus(id); + expect(['idle', 'running', 'stalled']).toContain(status); + } + }); + + it('should focus project on click and show active styling', async () => { + const ids = await getProjectIds(); + if (ids.length < 1) return; + + await focusProject(ids[0]); + const isActive = await browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + return box?.classList.contains('active') ?? false; + }, ids[0]); + expect(isActive).toBe(true); + }); +}); + +// ─── Scenario B2: Independent tab switching across projects ─────────── + +describe('Scenario B2 — Independent Tab Switching', () => { + it('should allow different tabs active in different projects', async () => { + const ids = await getProjectIds(); + if (ids.length < 2) { + console.log('Skipping B2 — need 2+ projects'); + return; + } + + // Switch first project to Files tab (index 3) + await switchProjectTab(ids[0], 3); + // Keep second project on Model tab (index 0) + await switchProjectTab(ids[1], 0); + + // Verify first project has Files tab active + const firstActiveTab = await browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + const active = box?.querySelector('[data-testid="project-tabs"] .ptab.active'); + return active?.textContent?.trim() ?? ''; + }, ids[0]); + + const secondActiveTab = await browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + const active = box?.querySelector('[data-testid="project-tabs"] .ptab.active'); + return active?.textContent?.trim() ?? ''; + }, ids[1]); + + // They should be different tabs + expect(firstActiveTab).not.toBe(secondActiveTab); + + // Restore first project to Model tab + await switchProjectTab(ids[0], 0); + }); +}); + +// ─── Scenario B3: Status bar reflects fleet state ──────────────────── + +describe('Scenario B3 — Status Bar Fleet State', () => { + it('should show agent count in status bar', async () => { + const barText = await browser.execute(() => { + const bar = document.querySelector('[data-testid="status-bar"]'); + return bar?.textContent ?? ''; + }); + // Status bar should contain at least one count (idle agents) + expect(barText.length).toBeGreaterThan(0); + }); + + it('should show no burn rate when all agents idle', async () => { + // When all agents are idle, burn-rate and cost elements are not rendered + // (they only appear when totalBurnRatePerHour > 0 or totalCost > 0) + const hasBurnRate = await browser.execute(() => { + const bar = document.querySelector('[data-testid="status-bar"]'); + const burnEl = bar?.querySelector('.burn-rate'); + const costEl = bar?.querySelector('.cost'); + return { burn: burnEl?.textContent ?? null, cost: costEl?.textContent ?? null }; + }); + // Either no burn rate shown (idle) or it shows $0 + if (hasBurnRate.burn !== null) { + expect(hasBurnRate.burn).toMatch(/\$0|0\.00/); + } + if (hasBurnRate.cost !== null) { + expect(hasBurnRate.cost).toMatch(/\$0|0\.00/); + } + // If both are null, agents are idle — that's the expected state + }); +}); + +// ─── Scenario B4: LLM-judged agent response (requires API key) ────── + +describe('Scenario B4 — LLM-Judged Agent Response', () => { + const SKIP_MSG = 'Skipping — LLM judge not available (no CLI or API key)'; + + it('should send prompt and get meaningful response', async function () { + this.timeout(180_000); // agent needs time to start + run + respond + if (!isJudgeAvailable()) { + console.log(SKIP_MSG); + this.skip(); + return; + } + + const ids = await getProjectIds(); + if (ids.length < 1) { + this.skip(); + return; + } + const projectId = ids[0]; + + // Send a prompt that requires a specific kind of response + await sendPromptInProject(projectId, 'List the files in the current directory. Just list them, nothing else.'); + + // Wait for agent to start + try { + await waitForProjectAgentStatus(projectId, 'running', 15_000); + } catch { + console.log('Agent did not start — Claude CLI may not be available'); + this.skip(); + return; + } + + // Wait for completion + await waitForProjectAgentStatus(projectId, 'idle', 120_000); + + // Get the agent's output + const messages = await getAgentMessages(projectId); + + // Use LLM judge to evaluate the response + const verdict = await assertWithJudge( + 'The output should contain a file listing that includes at least one filename (like README.md or hello.py). It should look like a directory listing, not an error message.', + messages, + { context: 'BTerminal agent was asked to list files in a test project directory containing README.md and hello.py' }, + ); + + expect(verdict.pass).toBe(true); + if (!verdict.pass) { + console.log(`LLM Judge: ${verdict.reasoning} (confidence: ${verdict.confidence})`); + } + }); + + it('should produce response with appropriate tool usage', async function () { + if (!isJudgeAvailable()) { + console.log(SKIP_MSG); + this.skip(); + return; + } + + const ids = await getProjectIds(); + if (ids.length < 1) { + this.skip(); + return; + } + const projectId = ids[0]; + + // Check that the previous response (from prior test) involved tool calls + const messages = await getAgentMessages(projectId); + + const verdict = await assertWithJudge( + 'The output should show evidence that the agent used tools (like Bash, Read, Glob, or LS commands) to list files. Tool usage typically appears as tool call names, command text, or file paths in the output.', + messages, + { context: 'BTerminal renders agent tool calls in collapsible sections showing the tool name and output' }, + ); + + expect(verdict.pass).toBe(true); + if (!verdict.pass) { + console.log(`LLM Judge: ${verdict.reasoning} (confidence: ${verdict.confidence})`); + } + }); +}); + +// ─── Scenario B5: LLM-judged code generation quality ───────────────── + +describe('Scenario B5 — LLM-Judged Code Generation', () => { + const SKIP_MSG = 'Skipping — LLM judge not available (no CLI or API key)'; + + it('should generate valid code when asked', async function () { + this.timeout(180_000); // agent needs time to start + run + respond + if (!isJudgeAvailable()) { + console.log(SKIP_MSG); + this.skip(); + return; + } + + const ids = await getProjectIds(); + if (ids.length < 1) { + this.skip(); + return; + } + const projectId = ids[0]; + + // Ask agent to read and explain existing code + await sendPromptInProject( + projectId, + 'Read hello.py and tell me what the greet function does. One sentence answer.', + ); + + try { + await waitForProjectAgentStatus(projectId, 'running', 15_000); + } catch { + console.log('Agent did not start — Claude CLI may not be available'); + this.skip(); + return; + } + + await waitForProjectAgentStatus(projectId, 'idle', 120_000); + + const messages = await getAgentMessages(projectId); + + const verdict = await assertWithJudge( + 'The response should correctly describe that the greet function takes a name parameter and returns a greeting string like "Hello, {name}!". The explanation should be roughly one sentence as requested.', + messages, + { context: 'hello.py contains: def greet(name: str) -> str:\n return f"Hello, {name}!"' }, + ); + + expect(verdict.pass).toBe(true); + if (!verdict.pass) { + console.log(`LLM Judge: ${verdict.reasoning} (confidence: ${verdict.confidence})`); + } + }); +}); + +// ─── Scenario B6: Context tab reflects agent activity ──────────────── + +describe('Scenario B6 — Context Tab After Agent Activity', () => { + it('should show token usage in Context tab after agent ran', async () => { + const ids = await getProjectIds(); + if (ids.length < 1) return; + const projectId = ids[0]; + + // Switch to Context tab (index 2) + await switchProjectTab(projectId, 2); + + // Check if context tab has any content + const contextContent = await browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + // Look for stats or token meter elements + const stats = box?.querySelector('.context-stats, .token-meter, .stat-value'); + return stats?.textContent ?? ''; + }, projectId); + + // If an agent has run, context tab should have data + // If no agent ran (skipped), this may be empty — that's OK + if (contextContent) { + expect(contextContent.length).toBeGreaterThan(0); + } + + // Switch back to Model tab + await switchProjectTab(projectId, 0); + }); +}); diff --git a/tests/e2e/specs/phase-c.test.ts b/tests/e2e/specs/phase-c.test.ts new file mode 100644 index 0000000..00771d0 --- /dev/null +++ b/tests/e2e/specs/phase-c.test.ts @@ -0,0 +1,626 @@ +import { browser, expect } from '@wdio/globals'; +import { isJudgeAvailable, assertWithJudge } from '../llm-judge'; + +// Phase C: Hardening feature tests. +// Tests the v3 production-readiness features added in the hardening sprint: +// - Command palette new commands +// - Search overlay (Ctrl+Shift+F) +// - Notification center +// - Keyboard shortcuts (vi-nav, project jump) +// - Settings panel new sections +// - Error states and recovery UI + +// ─── Helpers ────────────────────────────────────────────────────────── + +/** Get all project box IDs currently rendered. */ +async function getProjectIds(): Promise { + return browser.execute(() => { + const boxes = document.querySelectorAll('[data-testid="project-box"]'); + return Array.from(boxes).map( + (b) => b.getAttribute('data-project-id') ?? '', + ).filter(Boolean); + }); +} + +/** Focus a specific project box by its project ID. */ +async function focusProject(projectId: string): Promise { + await browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + const header = box?.querySelector('.project-header'); + if (header) (header as HTMLElement).click(); + }, projectId); + await browser.pause(300); +} + +/** Switch to a tab in a specific project box. */ +async function switchProjectTab(projectId: string, tabIndex: number): Promise { + await browser.execute((id, idx) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + const tabs = box?.querySelectorAll('[data-testid="project-tabs"] .ptab'); + if (tabs && tabs[idx]) (tabs[idx] as HTMLElement).click(); + }, projectId, tabIndex); + await browser.pause(300); +} + +/** Open command palette via Ctrl+K. */ +async function openPalette(): Promise { + await browser.execute(() => document.body.focus()); + await browser.pause(100); + await browser.keys(['Control', 'k']); + const palette = await browser.$('[data-testid="command-palette"]'); + await palette.waitForDisplayed({ timeout: 3000 }); +} + +/** Close command palette via Escape. */ +async function closePalette(): Promise { + await browser.keys('Escape'); + await browser.pause(300); +} + +/** Type into palette input and get filtered results. */ +async function paletteSearch(query: string): Promise { + const input = await browser.$('[data-testid="palette-input"]'); + await input.setValue(query); + await browser.pause(300); + return browser.execute(() => { + const items = document.querySelectorAll('.palette-item .cmd-label'); + return Array.from(items).map(el => el.textContent?.trim() ?? ''); + }); +} + +// ─── Scenario C1: Command Palette — Hardening Commands ──────────────── + +describe('Scenario C1 — Command Palette Hardening Commands', () => { + afterEach(async () => { + // Ensure palette is closed after each test + try { + const isVisible = await browser.execute(() => { + const el = document.querySelector('[data-testid="command-palette"]'); + return el !== null && window.getComputedStyle(el).display !== 'none'; + }); + if (isVisible) { + await closePalette(); + } + } catch { + // Ignore if palette doesn't exist + } + }); + + it('should find settings command in palette', async () => { + await openPalette(); + const results = await paletteSearch('settings'); + expect(results.length).toBeGreaterThanOrEqual(1); + const hasSettings = results.some(r => r.toLowerCase().includes('settings')); + expect(hasSettings).toBe(true); + }); + + it('should find terminal command in palette', async () => { + await openPalette(); + const results = await paletteSearch('terminal'); + expect(results.length).toBeGreaterThanOrEqual(1); + const hasTerminal = results.some(r => r.toLowerCase().includes('terminal')); + expect(hasTerminal).toBe(true); + }); + + it('should find keyboard shortcuts command in palette', async () => { + await openPalette(); + const results = await paletteSearch('keyboard'); + expect(results.length).toBeGreaterThanOrEqual(1); + const hasShortcuts = results.some(r => r.toLowerCase().includes('keyboard')); + expect(hasShortcuts).toBe(true); + }); + + it('should list all commands grouped by category when input is empty', async () => { + await openPalette(); + const input = await browser.$('[data-testid="palette-input"]'); + await input.clearValue(); + await browser.pause(200); + + const itemCount = await browser.execute(() => + document.querySelectorAll('.palette-item').length, + ); + // v3 has 18+ commands + expect(itemCount).toBeGreaterThanOrEqual(10); + + // Commands should be organized in groups (categories) + const groups = await browser.execute(() => { + const headers = document.querySelectorAll('.palette-category'); + return Array.from(headers).map(h => h.textContent?.trim() ?? ''); + }); + // Should have at least 2 command groups + expect(groups.length).toBeGreaterThanOrEqual(2); + }); +}); + +// ─── Scenario C2: Search Overlay (Ctrl+Shift+F) ────────────────────── + +describe('Scenario C2 — Search Overlay (FTS5)', () => { + it('should open search overlay with Ctrl+Shift+F', async () => { + await browser.execute(() => document.body.focus()); + await browser.pause(100); + await browser.keys(['Control', 'Shift', 'f']); + await browser.pause(500); + + const overlay = await browser.execute(() => { + // SearchOverlay uses .search-overlay class + const el = document.querySelector('.search-overlay, [data-testid="search-overlay"]'); + return el !== null; + }); + expect(overlay).toBe(true); + }); + + it('should have search input focused', async () => { + const isFocused = await browser.execute(() => { + const input = document.querySelector('.search-overlay input, [data-testid="search-input"]') as HTMLInputElement | null; + if (!input) return false; + input.focus(); + return input === document.activeElement; + }); + expect(isFocused).toBe(true); + }); + + it('should show no results for nonsense query', async () => { + await browser.execute(() => { + const input = document.querySelector('.search-overlay input, [data-testid="search-input"]') as HTMLInputElement | null; + if (input) { + input.value = 'zzz_nonexistent_xyz_999'; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + }); + await browser.pause(500); // 300ms debounce + render time + + const resultCount = await browser.execute(() => { + const results = document.querySelectorAll('.search-result, .search-result-item'); + return results.length; + }); + expect(resultCount).toBe(0); + }); + + it('should close search overlay with Escape', async () => { + await browser.keys('Escape'); + await browser.pause(300); + + const overlay = await browser.execute(() => { + const el = document.querySelector('.search-overlay, [data-testid="search-overlay"]'); + if (!el) return false; + const style = window.getComputedStyle(el); + return style.display !== 'none' && style.visibility !== 'hidden'; + }); + expect(overlay).toBe(false); + }); +}); + +// ─── Scenario C3: Notification Center ───────────────────────────────── + +describe('Scenario C3 — Notification Center', () => { + it('should render notification bell in status bar', async () => { + const hasBell = await browser.execute(() => { + const bar = document.querySelector('[data-testid="status-bar"]'); + // NotificationCenter is in status bar with bell icon + const bell = bar?.querySelector('.notification-bell, .bell-icon, [data-testid="notification-bell"]'); + return bell !== null; + }); + expect(hasBell).toBe(true); + }); + + it('should open notification panel on bell click', async () => { + await browser.execute(() => { + const bell = document.querySelector('.notification-bell, .bell-icon, [data-testid="notification-bell"]'); + if (bell) (bell as HTMLElement).click(); + }); + await browser.pause(300); + + const panelOpen = await browser.execute(() => { + const panel = document.querySelector('.notification-panel, .notification-dropdown, [data-testid="notification-panel"]'); + if (!panel) return false; + const style = window.getComputedStyle(panel); + return style.display !== 'none'; + }); + expect(panelOpen).toBe(true); + }); + + it('should show empty state or notification history', async () => { + const content = await browser.execute(() => { + const panel = document.querySelector('.notification-panel, .notification-dropdown, [data-testid="notification-panel"]'); + return panel?.textContent ?? ''; + }); + // Panel should have some text content (either "No notifications" or actual notifications) + expect(content.length).toBeGreaterThan(0); + }); + + it('should close notification panel on outside click', async () => { + // Click the backdrop overlay to close the panel + await browser.execute(() => { + const backdrop = document.querySelector('.notification-center .backdrop'); + if (backdrop) (backdrop as HTMLElement).click(); + }); + await browser.pause(300); + + const panelOpen = await browser.execute(() => { + const panel = document.querySelector('.notification-panel, .notification-dropdown, [data-testid="notification-panel"]'); + if (!panel) return false; + const style = window.getComputedStyle(panel); + return style.display !== 'none'; + }); + expect(panelOpen).toBe(false); + }); +}); + +// ─── Scenario C4: Keyboard Navigation ──────────────────────────────── + +describe('Scenario C4 — Keyboard-First Navigation', () => { + it('should toggle settings with Ctrl+Comma', async () => { + await browser.execute(() => document.body.focus()); + await browser.pause(100); + await browser.keys(['Control', ',']); + await browser.pause(500); + + const settingsVisible = await browser.execute(() => { + const panel = document.querySelector('.sidebar-panel'); + if (!panel) return false; + const style = window.getComputedStyle(panel); + return style.display !== 'none'; + }); + expect(settingsVisible).toBe(true); + + // Close it + await browser.keys('Escape'); + await browser.pause(300); + }); + + it('should toggle sidebar with Ctrl+B', async () => { + await browser.execute(() => document.body.focus()); + await browser.pause(100); + + // First open settings to have sidebar content + await browser.keys(['Control', ',']); + await browser.pause(300); + + const initialState = await browser.execute(() => { + const panel = document.querySelector('.sidebar-panel'); + return panel !== null && window.getComputedStyle(panel).display !== 'none'; + }); + + // Toggle sidebar + await browser.keys(['Control', 'b']); + await browser.pause(300); + + const afterToggle = await browser.execute(() => { + const panel = document.querySelector('.sidebar-panel'); + if (!panel) return false; + return window.getComputedStyle(panel).display !== 'none'; + }); + + // State should have changed + if (initialState) { + expect(afterToggle).toBe(false); + } + + // Clean up — close sidebar if still open + await browser.keys('Escape'); + await browser.pause(200); + }); + + it('should focus project with Alt+1', async () => { + await browser.execute(() => document.body.focus()); + await browser.pause(100); + await browser.keys(['Alt', '1']); + await browser.pause(300); + + const hasActive = await browser.execute(() => { + const active = document.querySelector('.project-box.active'); + return active !== null; + }); + expect(hasActive).toBe(true); + }); +}); + +// ─── Scenario C5: Settings Panel Sections ───────────────────────────── + +describe('Scenario C5 — Settings Panel Sections', () => { + before(async () => { + // Open settings + await browser.execute(() => { + const btn = document.querySelector('[data-testid="settings-btn"]'); + if (btn) (btn as HTMLElement).click(); + }); + await browser.pause(500); + }); + + it('should show Appearance section with theme dropdown', async () => { + const hasTheme = await browser.execute(() => { + const panel = document.querySelector('.sidebar-panel, .settings-tab'); + if (!panel) return false; + const text = panel.textContent ?? ''; + return text.toLowerCase().includes('theme') || text.toLowerCase().includes('appearance'); + }); + expect(hasTheme).toBe(true); + }); + + it('should show font settings (UI font and Terminal font)', async () => { + const hasFonts = await browser.execute(() => { + const panel = document.querySelector('.sidebar-panel, .settings-tab'); + if (!panel) return false; + const text = panel.textContent ?? ''; + return text.toLowerCase().includes('font'); + }); + expect(hasFonts).toBe(true); + }); + + it('should show default shell setting', async () => { + const hasShell = await browser.execute(() => { + const panel = document.querySelector('.sidebar-panel, .settings-tab'); + if (!panel) return false; + const text = panel.textContent ?? ''; + return text.toLowerCase().includes('shell'); + }); + expect(hasShell).toBe(true); + }); + + it('should have theme dropdown with 17 themes', async () => { + // Click the theme dropdown to see options + const themeCount = await browser.execute(() => { + // Find the theme dropdown (custom dropdown, not native select) + const dropdowns = document.querySelectorAll('.settings-tab .custom-dropdown, .settings-tab .dropdown'); + for (const dd of dropdowns) { + const label = dd.closest('.settings-row, .setting-row')?.textContent ?? ''; + if (label.toLowerCase().includes('theme')) { + // Click to open it + const trigger = dd.querySelector('.dropdown-trigger, .dropdown-selected, button'); + if (trigger) (trigger as HTMLElement).click(); + return -1; // Flag: opened dropdown + } + } + return 0; + }); + + if (themeCount === -1) { + // Dropdown was opened, wait and count options + await browser.pause(300); + const optionCount = await browser.execute(() => { + const options = document.querySelectorAll('.dropdown-option, .dropdown-item, .theme-option'); + return options.length; + }); + // Should have 17 themes + expect(optionCount).toBeGreaterThanOrEqual(15); + + // Close dropdown + await browser.keys('Escape'); + await browser.pause(200); + } + }); + + after(async () => { + await browser.keys('Escape'); + await browser.pause(300); + }); +}); + +// ─── Scenario C6: Project Health Indicators ─────────────────────────── + +describe('Scenario C6 — Project Health Indicators', () => { + it('should show status dots on project headers', async () => { + const hasDots = await browser.execute(() => { + const dots = document.querySelectorAll('.project-header .status-dot, .project-header .health-dot'); + return dots.length; + }); + // At least one project should have a status dot + expect(hasDots).toBeGreaterThanOrEqual(1); + }); + + it('should show idle status when no agents running', async () => { + const ids = await getProjectIds(); + if (ids.length < 1) return; + + const dotColor = await browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + const dot = box?.querySelector('.status-dot, .health-dot'); + if (!dot) return 'not-found'; + const style = window.getComputedStyle(dot); + return style.backgroundColor || style.color || 'unknown'; + }, ids[0]); + + // Should have some color value (not 'not-found') + expect(dotColor).not.toBe('not-found'); + }); + + it('should show status bar agent counts', async () => { + const counts = await browser.execute(() => { + const bar = document.querySelector('[data-testid="status-bar"]'); + if (!bar) return ''; + // Status bar shows running/idle/stalled counts + return bar.textContent ?? ''; + }); + // Should contain at least idle count + expect(counts).toMatch(/idle|running|stalled|\d/i); + }); +}); + +// ─── Scenario C7: Metrics Tab ───────────────────────────────────────── + +describe('Scenario C7 — Metrics Tab', () => { + it('should show Metrics tab in project tab bar', async () => { + const ids = await getProjectIds(); + if (ids.length < 1) return; + + const hasMetrics = await browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + const tabs = box?.querySelectorAll('[data-testid="project-tabs"] .ptab'); + if (!tabs) return false; + return Array.from(tabs).some(t => t.textContent?.trim().toLowerCase().includes('metric')); + }, ids[0]); + + expect(hasMetrics).toBe(true); + }); + + it('should render Metrics panel content when tab clicked', async () => { + const ids = await getProjectIds(); + if (ids.length < 1) return; + const projectId = ids[0]; + + // Find and click Metrics tab + await browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + const tabs = box?.querySelectorAll('[data-testid="project-tabs"] .ptab'); + if (!tabs) return; + for (const tab of tabs) { + if (tab.textContent?.trim().toLowerCase().includes('metric')) { + (tab as HTMLElement).click(); + break; + } + } + }, projectId); + await browser.pause(500); + + const hasContent = await browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + // MetricsPanel has live view with fleet stats + const panel = box?.querySelector('.metrics-panel, .metrics-tab'); + return panel !== null; + }, projectId); + + expect(hasContent).toBe(true); + + // Switch back to Model tab + await switchProjectTab(projectId, 0); + }); +}); + +// ─── Scenario C8: Context Tab ───────────────────────────────────────── + +describe('Scenario C8 — Context Tab Visualization', () => { + it('should render Context tab with token meter', async () => { + const ids = await getProjectIds(); + if (ids.length < 1) return; + const projectId = ids[0]; + + // Switch to Context tab (index 2) + await switchProjectTab(projectId, 2); + + const hasContextUI = await browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + // ContextTab has stats, token meter, file references + const ctx = box?.querySelector('.context-tab, .context-stats, .token-meter, .stat-value'); + return ctx !== null; + }, projectId); + + expect(hasContextUI).toBe(true); + + // Switch back to Model tab + await switchProjectTab(projectId, 0); + }); +}); + +// ─── Scenario C9: Files Tab with Editor ─────────────────────────────── + +describe('Scenario C9 — Files Tab & Code Editor', () => { + it('should render Files tab with directory tree', async () => { + const ids = await getProjectIds(); + if (ids.length < 1) return; + const projectId = ids[0]; + + // Switch to Files tab (index 3) + await switchProjectTab(projectId, 3); + await browser.pause(500); + + const hasTree = await browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + // FilesTab has a directory tree + const tree = box?.querySelector('.file-tree, .directory-tree, .files-tab'); + return tree !== null; + }, projectId); + + expect(hasTree).toBe(true); + }); + + it('should list files from the project directory', async () => { + const ids = await getProjectIds(); + if (ids.length < 1) return; + + const fileNames = await browser.execute((id) => { + const box = document.querySelector(`[data-project-id="${id}"]`); + const items = box?.querySelectorAll('.tree-name'); + return Array.from(items ?? []).map(el => el.textContent?.trim() ?? ''); + }, ids[0]); + + // Test project has README.md and hello.py + const hasFiles = fileNames.some(f => + f.includes('README') || f.includes('hello') || f.includes('.py') || f.includes('.md'), + ); + expect(hasFiles).toBe(true); + + // Switch back to Model tab + await switchProjectTab(ids[0], 0); + }); +}); + +// ─── Scenario C10: LLM-Judged Settings Completeness ────────────────── + +describe('Scenario C10 — LLM-Judged Settings Completeness', () => { + it('should have comprehensive settings panel', async function () { + if (!isJudgeAvailable()) { + console.log('Skipping — LLM judge not available (no CLI or API key)'); + this.skip(); + return; + } + + // Open settings + await browser.execute(() => { + const btn = document.querySelector('[data-testid="settings-btn"]'); + if (btn) (btn as HTMLElement).click(); + }); + await browser.pause(500); + + const settingsContent = await browser.execute(() => { + const panel = document.querySelector('.sidebar-panel, .settings-tab'); + return panel?.textContent ?? ''; + }); + + const verdict = await assertWithJudge( + 'The settings panel should contain configuration options for: (1) theme/appearance, (2) font settings (UI and terminal), (3) default shell, and optionally (4) provider settings. It should look like a real settings UI, not an error message.', + settingsContent, + { context: 'BTerminal v3 settings panel with Appearance section (theme dropdown, UI font, terminal font) and Defaults section (shell, CWD). May also have Providers section.' }, + ); + + expect(verdict.pass).toBe(true); + if (!verdict.pass) { + console.log(`LLM Judge: ${verdict.reasoning} (confidence: ${verdict.confidence})`); + } + + await browser.keys('Escape'); + await browser.pause(300); + }); +}); + +// ─── Scenario C11: LLM-Judged Status Bar ────────────────────────────── + +describe('Scenario C11 — LLM-Judged Status Bar Completeness', () => { + it('should render a comprehensive status bar', async function () { + if (!isJudgeAvailable()) { + console.log('Skipping — LLM judge not available (no CLI or API key)'); + this.skip(); + return; + } + + const statusBarContent = await browser.execute(() => { + const bar = document.querySelector('[data-testid="status-bar"]'); + return bar?.textContent ?? ''; + }); + + const statusBarHtml = await browser.execute(() => { + const bar = document.querySelector('[data-testid="status-bar"]'); + return bar?.innerHTML ?? ''; + }); + + const verdict = await assertWithJudge( + 'The status bar should display agent fleet information including: agent status counts (idle/running/stalled with numbers), and optionally burn rate ($/hr) and cost tracking. It should look like a real monitoring dashboard status bar.', + `Text: ${statusBarContent}\n\nHTML structure: ${statusBarHtml.substring(0, 2000)}`, + { context: 'BTerminal Mission Control status bar shows running/idle/stalled agent counts, total $/hr burn rate, attention queue, and total cost.' }, + ); + + expect(verdict.pass).toBe(true); + if (!verdict.pass) { + console.log(`LLM Judge: ${verdict.reasoning} (confidence: ${verdict.confidence})`); + } + }); +}); diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 0000000..a1c3b70 --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "target": "ESNext", + "types": ["@wdio/mocha-framework", "@wdio/globals/types"], + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["specs/**/*.ts", "*.ts"] +} diff --git a/tests/e2e/wdio.conf.js b/tests/e2e/wdio.conf.js new file mode 100644 index 0000000..3b68d68 --- /dev/null +++ b/tests/e2e/wdio.conf.js @@ -0,0 +1,213 @@ +import { spawn, execSync } from 'node:child_process'; +import { createConnection } from 'node:net'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; + +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; + +// ── Test Fixture (created eagerly so env vars are available for capabilities) ── +const fixtureRoot = join(tmpdir(), `bterminal-e2e-${Date.now()}`); +const fixtureDataDir = join(fixtureRoot, 'data'); +const fixtureConfigDir = join(fixtureRoot, 'config'); +const fixtureProjectDir = join(fixtureRoot, 'test-project'); + +mkdirSync(fixtureDataDir, { recursive: true }); +mkdirSync(fixtureConfigDir, { recursive: true }); +mkdirSync(fixtureProjectDir, { recursive: true }); + +// Create a minimal git repo for agent testing +execSync('git init', { cwd: fixtureProjectDir, stdio: 'ignore' }); +execSync('git config user.email "test@bterminal.dev"', { cwd: fixtureProjectDir, stdio: 'ignore' }); +execSync('git config user.name "BTerminal Test"', { cwd: fixtureProjectDir, stdio: 'ignore' }); +writeFileSync(join(fixtureProjectDir, 'README.md'), '# Test Project\n\nA simple test project for BTerminal E2E tests.\n'); +writeFileSync(join(fixtureProjectDir, 'hello.py'), 'def greet(name: str) -> str:\n return f"Hello, {name}!"\n'); +execSync('git add -A && git commit -m "initial commit"', { cwd: fixtureProjectDir, stdio: 'ignore' }); + +// Write groups.json with one group containing the test project +writeFileSync( + join(fixtureConfigDir, 'groups.json'), + JSON.stringify({ + version: 1, + groups: [{ + id: 'test-group', + name: 'Test Group', + projects: [{ + id: 'test-project', + name: 'Test Project', + identifier: 'test-project', + description: 'E2E test project', + icon: '\uf120', + cwd: fixtureProjectDir, + profile: 'default', + enabled: true, + }], + agents: [], + }], + activeGroupId: 'test-group', + }, null, 2), +); + +// Inject env vars into process.env so tauri-driver inherits them +// (tauri:options.env may not reliably set process-level env vars) +process.env.BTERMINAL_TEST = '1'; +process.env.BTERMINAL_TEST_DATA_DIR = fixtureDataDir; +process.env.BTERMINAL_TEST_CONFIG_DIR = fixtureConfigDir; + +console.log(`Test fixture created at ${fixtureRoot}`); + +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'), + resolve(__dirname, 'specs/agent-scenarios.test.ts'), + resolve(__dirname, 'specs/phase-b.test.ts'), + resolve(__dirname, 'specs/phase-c.test.ts'), + ], + + // ── Capabilities ── + capabilities: [{ + // Disable BiDi negotiation — tauri-driver doesn't support webSocketUrl + 'wdio:enforceWebDriverClassic': true, + 'tauri:options': { + application: tauriBinary, + // Test isolation: fixture-created data/config dirs, disable watchers/telemetry + env: { + BTERMINAL_TEST: '1', + BTERMINAL_TEST_DATA_DIR: fixtureDataDir, + BTERMINAL_TEST_CONFIG_DIR: fixtureConfigDir, + }, + }, + }], + + // ── Framework ── + framework: 'mocha', + mochaOpts: { + ui: 'bdd', + timeout: 180_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. + * Uses TCP probe to confirm port 4444 is accepting connections. + */ + beforeSession() { + return new Promise((res, 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' + )); + }); + + // TCP readiness probe — poll port 4444 until it accepts a connection + const maxWaitMs = 10_000; + const intervalMs = 200; + const deadline = Date.now() + maxWaitMs; + + function probe() { + if (Date.now() > deadline) { + reject(new Error('tauri-driver did not become ready within 10s')); + return; + } + const sock = createConnection({ port: 4444, host: 'localhost' }, () => { + sock.destroy(); + res(); + }); + sock.on('error', () => { + sock.destroy(); + setTimeout(probe, intervalMs); + }); + } + + // Give it a moment before first probe + setTimeout(probe, 300); + }); + }, + + /** + * Kill tauri-driver after the test run. + */ + afterSession() { + if (tauriDriver) { + tauriDriver.kill(); + tauriDriver = null; + } + // Clean up test fixture + try { + rmSync(fixtureRoot, { recursive: true, force: true }); + console.log('Test fixture cleaned up.'); + } catch { /* best-effort cleanup */ } + }, + + // ── TypeScript (auto-compile via tsx) ── + autoCompileOpts: { + tsNodeOpts: { + project: resolve(__dirname, 'tsconfig.json'), + }, + }, +}; diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..31c18cf --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "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/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "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/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..379edce --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,14 @@ +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', 'sidecar/**/*.test.ts'], + }, +})