Compare commits

..

No commits in common. "hib_changes_v2" and "main" have entirely different histories.

578 changed files with 6260 additions and 102187 deletions

View file

@ -2,10 +2,11 @@
## Workflow
- Docs are in `docs/`. Architecture in `docs/architecture.md`.
- 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: `agor`) before making architectural changes.
- Consult Memora (tag: `bterminal`) before making architectural changes.
## Documentation References
@ -20,7 +21,8 @@
## Rules
- Work goes on the `hib_changes` branch (repo: agent-orchestrator), not master.
- 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.
@ -34,7 +36,7 @@
- Agent dispatcher (`src/lib/agent-dispatcher.ts`) is a thin coordinator (260 lines) routing sidecar events to the agent store. Delegates to extracted modules: `utils/session-persistence.ts` (session-project maps, persistSessionForProject), `utils/subagent-router.ts` (spawn + route subagent panes), `utils/auto-anchoring.ts` (triggerAutoAnchor on compaction), `utils/worktree-detection.ts` (detectWorktreeFromCwd pure function). Provider-aware via message-adapters.ts.
- AgentQueryOptions supports `provider` field (defaults to 'claude', flows Rust -> sidecar), `provider_config` blob (Rust passes through as serde_json::Value), `permission_mode` (defaults to 'bypassPermissions'), `setting_sources` (defaults to ['user', 'project']), `system_prompt`, `model`, `claude_config_dir` (for multi-account), `additional_directories`, `worktree_name` (when set, passed as `extraArgs: { worktree: name }` to SDK → `--worktree <name>` CLI flag), `extra_env` (HashMap<String,String>, injected into sidecar process env; used for BTMSG_AGENT_ID).
- Multi-agent orchestration: Tier 1 (management agents: Manager, Architect, Tester, Reviewer) defined in groups.json `agents[]`, converted to ProjectConfig via `agentToProject()`, rendered as full ProjectBoxes. Tier 2 (project agents) are regular ProjectConfig entries. Both tiers get system prompts. Tier 1 prompt built by `generateAgentPrompt()` (utils/agent-prompts.ts): 7 sections (Identity, Environment, Team, btmsg docs, bttask docs, Custom context, Workflow). Tier 2 gets optional `project.systemPrompt` as custom context. BTMSG_AGENT_ID env var injected for Tier 1 agents only (enables btmsg/bttask CLI usage). Periodic re-injection: AgentSession runs 1-hour timer, sends context refresh prompt when agent is idle (autoPrompt → AgentPane → startQuery with resume=true).
- bttask kanban: Rust bttask.rs module reads/writes tasks table in shared btmsg.db (~/.local/share/agor/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.
- 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.
@ -49,7 +51,7 @@
- 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()/agor/sessions.db`.
- 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.
@ -69,29 +71,29 @@
- Theme system: 17 themes in 3 groups — 4 Catppuccin + 7 Editor (VSCode Dark+, Atom One Dark, Monokai, Dracula, Nord, Solarized Dark, GitHub Dark) + 6 Deep Dark (Tokyo Night, Gruvbox Dark, Ayu Dark, Poimandres, Vesper, Midnight). All map to same 26 --ctp-* CSS custom properties — zero component changes needed. ThemeId replaces CatppuccinFlavor. getCurrentTheme()/setTheme() are primary API (deprecated wrappers exist). THEME_LIST has ThemeMeta with group metadata for custom dropdown UI. Open terminals hot-swap via onThemeChange() callback registry in theme.svelte.ts. Typography uses --ui-font-family/--ui-font-size (UI elements, sans-serif fallback) and --term-font-family/--term-font-size (terminal, monospace fallback) CSS custom properties (defined in catppuccin.css). initTheme() restores all 4 font settings (ui_font_family, ui_font_size, term_font_family, term_font_size) from SQLite on startup.
- Detached pane mode: App.svelte checks URL param `?detached=1` and renders a single pane without sidebar/grid chrome. Used for pop-out windows.
- Shiki syntax highlighting uses lazy singleton pattern (avoid repeated WASM init). 13 languages preloaded. Used in MarkdownPane and AgentPane text messages.
- Cargo workspace at repo root: members = [src-tauri, agor-core, agor-relay]. Cargo.lock is at workspace root, not in src-tauri/.
- EventSink trait (agor-core/src/event.rs) abstracts event emission. PtyManager and SidecarManager are in agor-core, not src-tauri. src-tauri has thin re-exports.
- RemoteManager (src-tauri/src/remote.rs) manages WebSocket client connections to agor-relay instances. 12 Tauri commands prefixed with `remote_`.
- Cargo workspace at v2/ level: members = [src-tauri, bterminal-core, bterminal-relay]. Cargo.lock is at workspace root (v2/), not in src-tauri/.
- EventSink trait (bterminal-core/src/event.rs) abstracts event emission. PtyManager and SidecarManager are in bterminal-core, not src-tauri. src-tauri has thin re-exports.
- RemoteManager (src-tauri/src/remote.rs) manages WebSocket client connections to bterminal-relay instances. 12 Tauri commands prefixed with `remote_`.
- remote-bridge.ts adapter wraps remote machine management IPC. machines.svelte.ts store tracks remote machine state.
- Pane.remoteMachineId?: string routes operations through RemoteManager instead of local managers. Bridge adapters (pty-bridge, agent-bridge) check this field.
- agor-relay binary (agor-relay/) is a standalone WebSocket server with token auth, rate limiting, and per-connection isolated managers. Commands return structured responses (pty_created, pong, error) with commandId for correlation via send_error() helper.
- bterminal-relay binary (v2/bterminal-relay/) is a standalone WebSocket server with token auth, rate limiting, and per-connection isolated managers. Commands return structured responses (pty_created, pong, error) with commandId for correlation via send_error() helper.
- RemoteManager reconnection: exponential backoff (1s-30s cap) on disconnect, attempt_tcp_probe() (TCP-only, no WS upgrade), emits remote-machine-reconnecting and remote-machine-reconnect-ready events. Frontend listeners in remote-bridge.ts; machines store auto-reconnects on ready.
- v3 workspace store (`workspace.svelte.ts`) replaces layout store for v3. Groups loaded from `~/.config/agor/groups.json` via `groups-bridge.ts`. State: groups, activeGroupId, activeTab, focusedProjectId. Derived: activeGroup, activeProjects.
- v3 workspace store (`workspace.svelte.ts`) replaces layout store for v3. Groups loaded from `~/.config/bterminal/groups.json` via `groups-bridge.ts`. State: groups, activeGroupId, activeTab, focusedProjectId. Derived: activeGroup, activeProjects.
- v3 groups backend (`groups.rs`): load_groups(), save_groups(), default_groups(). Tauri commands: groups_load, groups_save.
- Telemetry (`telemetry.rs`): tracing + optional OTLP export to Tempo. `AGOR_OTLP_ENDPOINT` env var controls (absent = console-only). TelemetryGuard in AppState with Drop-based shutdown. Frontend events route through `frontend_log` Tauri command → Rust tracing (no browser OTEL SDK — WebKit2GTK incompatible). `telemetry-bridge.ts` provides `tel.info/warn/error()` convenience API. Docker stack at `docker/tempo/` (Grafana port 9715).
- E2E test mode (`AGOR_TEST=1`): watcher.rs and fs_watcher.rs skip file watchers, wake-scheduler disabled via `disableWakeScheduler()`, `is_test_mode` Tauri command bridges to frontend. Data/config dirs overridable via `AGOR_TEST_DATA_DIR`/`AGOR_TEST_CONFIG_DIR`. E2E uses WebDriverIO + tauri-driver, single session, TCP readiness probe. Phase A: 7 data-testid-based scenarios split across `phase-a-structure.test.ts`, `phase-a-agent.test.ts`, `phase-a-navigation.test.ts` (42 tests, deterministic assertions). Phase B: 6 scenarios in `phase-b.test.ts` (multi-project grid, independent tab switching, status bar fleet state, LLM-judged agent responses/code generation, context tab verification). LLM judge (`llm-judge.ts`): raw fetch to Anthropic API using claude-haiku-4-5, structured verdict (pass/fail + reasoning + confidence), `assertWithJudge()` with configurable threshold, skips when `ANTHROPIC_API_KEY` absent. CI workflow (`.github/workflows/e2e.yml`): unit + cargo + e2e jobs, xvfb-run, path-filtered triggers, LLM tests gated on secret. Test fixtures in `fixtures.ts` create isolated temp environments. Results tracked via JSON store in `results-db.ts`.
- Telemetry (`telemetry.rs`): tracing + optional OTLP export to Tempo. `BTERMINAL_OTLP_ENDPOINT` env var controls (absent = console-only). TelemetryGuard in AppState with Drop-based shutdown. Frontend events route through `frontend_log` Tauri command → Rust tracing (no browser OTEL SDK — WebKit2GTK incompatible). `telemetry-bridge.ts` provides `tel.info/warn/error()` convenience API. Docker stack at `docker/tempo/` (Grafana port 9715).
- E2E test mode (`BTERMINAL_TEST=1`): watcher.rs and fs_watcher.rs skip file watchers, wake-scheduler disabled via `disableWakeScheduler()`, `is_test_mode` Tauri command bridges to frontend. Data/config dirs overridable via `BTERMINAL_TEST_DATA_DIR`/`BTERMINAL_TEST_CONFIG_DIR`. E2E uses WebDriverIO + tauri-driver, single session, TCP readiness probe. Phase A: 7 data-testid-based scenarios in `agent-scenarios.test.ts` (deterministic assertions). Phase B: 6 scenarios in `phase-b.test.ts` (multi-project grid, independent tab switching, status bar fleet state, LLM-judged agent responses/code generation, context tab verification). LLM judge (`llm-judge.ts`): raw fetch to Anthropic API using claude-haiku-4-5, structured verdict (pass/fail + reasoning + confidence), `assertWithJudge()` with configurable threshold, skips when `ANTHROPIC_API_KEY` absent. CI workflow (`.github/workflows/e2e.yml`): unit + cargo + e2e jobs, xvfb-run, path-filtered triggers, LLM tests gated on secret. Test fixtures in `fixtures.ts` create isolated temp environments. Results tracked via JSON store in `results-db.ts`.
- v3 SQLite additions: agent_messages table (per-project message persistence), project_agent_state table (sdkSessionId, cost, status per project), sessions.project_id column.
- v3 App.svelte: VSCode-style sidebar layout. Horizontal: left icon rail (GlobalTabBar, 2.75rem, single Settings gear icon) + expandable drawer panel (Settings only, content-driven width, max 50%) + main workspace (ProjectGrid always visible) + StatusBar. Sidebar has Settings only — Sessions/Docs/Context are project-specific (in ProjectBox tabs). Keyboard: Ctrl+B (toggle sidebar), Ctrl+, (settings), Escape (close).
- v3 component tree: App -> GlobalTabBar (settings icon) + sidebar-panel? (SettingsTab) + workspace (ProjectGrid) + StatusBar. See `docs/architecture.md` for full tree.
- MarkdownPane reactively watches filePath changes via $effect (not onMount-only). Uses sans-serif font (Inter, system-ui), all --ctp-* theme vars. Styled blockquotes with translucent backgrounds, table row hover, link hover underlines. Inner `.markdown-pane-scroll` wrapper with `container-type: inline-size` for responsive padding via `--agor-pane-padding-inline`.
- AgentPane UI (redesigned 2026-03-09): sans-serif root font (`system-ui, -apple-system, sans-serif`), monospace only on code/tool names. Tool calls paired with results in collapsible `<details>` groups via `$derived.by` toolResultMap (cache-guarded by tool_result count). Hook messages collapsed into compact `<details>` with gear icon. Context window meter inline in status strip. Cost bar minimal (no background, subtle border-top). Session summary with translucent surface background. Two-phase scroll anchoring (`$effect.pre` + `$effect`). Tool-aware output truncation (Bash 500 lines, Read/Write 50, Glob/Grep 20, default 30). Colors softened via `color-mix()`. Inner `.agent-pane-scroll` wrapper with `container-type: inline-size` for responsive padding via shared `--agor-pane-padding-inline` variable.
- MarkdownPane reactively watches filePath changes via $effect (not onMount-only). Uses sans-serif font (Inter, system-ui), all --ctp-* theme vars. Styled blockquotes with translucent backgrounds, table row hover, link hover underlines. Inner `.markdown-pane-scroll` wrapper with `container-type: inline-size` for responsive padding via `--bterminal-pane-padding-inline`.
- AgentPane UI (redesigned 2026-03-09): sans-serif root font (`system-ui, -apple-system, sans-serif`), monospace only on code/tool names. Tool calls paired with results in collapsible `<details>` groups via `$derived.by` toolResultMap (cache-guarded by tool_result count). Hook messages collapsed into compact `<details>` with gear icon. Context window meter inline in status strip. Cost bar minimal (no background, subtle border-top). Session summary with translucent surface background. Two-phase scroll anchoring (`$effect.pre` + `$effect`). Tool-aware output truncation (Bash 500 lines, Read/Write 50, Glob/Grep 20, default 30). Colors softened via `color-mix()`. Inner `.agent-pane-scroll` wrapper with `container-type: inline-size` for responsive padding via shared `--bterminal-pane-padding-inline` variable.
- ProjectBox uses CSS `style:display` (flex/none) instead of `{#if}` for tab content panes — keeps AgentSession mounted across tab switches (prevents session ID reset and message loss). Terminal section also uses `style:display`. Grid rows: auto auto 1fr auto.
- Svelte 5 event syntax: use `onclick` not `on:click`. Svelte 5 requires lowercase event handler attributes (no colon syntax).
## Memora Tags
Project tag: `agor`
Common tag combinations: `agor,architecture`, `agor,research`, `agor,tech-stack`
Project tag: `bterminal`
Common tag combinations: `bterminal,architecture`, `bterminal,research`, `bterminal,tech-stack`
## Operational Rules

View file

@ -1,188 +0,0 @@
#!/usr/bin/env node
/**
* agor-launcher MCP manages Electrobun app lifecycle.
* Tools: start, stop, restart, clean, rebuild, status, kill-stale, build-native
*/
import { execSync, spawnSync, spawn } from "child_process";
import { createInterface } from "readline";
const SCRIPT = "/home/hibryda/code/ai/agent-orchestrator/scripts/launch.sh";
const ROOT = "/home/hibryda/code/ai/agent-orchestrator";
const EBUN = `${ROOT}/ui-electrobun`;
const PTYD = `${ROOT}/agor-pty/target/release/agor-ptyd`;
function run(cmd, timeout = 30000) {
try {
return execSync(`bash ${SCRIPT} ${cmd}`, {
timeout, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"],
}).trim();
} catch (e) {
return `Error: ${e.stderr || e.message}`;
}
}
/** Non-blocking start: spawn everything detached, return immediately */
function startApp(clean = false) {
const steps = [];
// Step 1: Kill old Electrobun/WebKit (NOT node — that's us)
spawnSync("bash", ["-c",
'pkill -f "AgentOrch" 2>/dev/null; pkill -f "WebKitWebProcess.*9760" 2>/dev/null; pkill -f "electrobun dev" 2>/dev/null; fuser -k 9760/tcp 2>/dev/null; true'
], { timeout: 3000, stdio: "ignore" });
steps.push("[1/4] Stopped old instances");
// Step 1.5: Clean if requested
if (clean) {
spawnSync("rm", ["-rf", `${EBUN}/build/`, `${EBUN}/node_modules/.electrobun-cache/`], { timeout: 3000 });
steps.push("[1.5] Cleaned build cache");
}
// Step 2: Start PTY daemon (if not running)
try {
const r = spawnSync("pgrep", ["-fc", "agor-ptyd"], { encoding: "utf-8", timeout: 2000 });
const count = (r.stdout || "0").trim();
if (count === "0" || count === "") {
const p = spawn(PTYD, [], { detached: true, stdio: "ignore" });
p.unref();
steps.push("[2/4] PTY daemon started");
} else {
steps.push(`[2/4] PTY daemon already running (${count})`);
}
} catch (e) { steps.push(`[2/4] PTY error: ${e.message}`); }
// Step 3: Start Vite (if port 9760 not in use)
try {
const r = spawnSync("fuser", ["9760/tcp"], { encoding: "utf-8", timeout: 2000 });
if (r.status !== 0) {
const v = spawn("npx", ["vite", "dev", "--port", "9760", "--host", "localhost"], {
cwd: EBUN, detached: true, stdio: "ignore",
});
v.unref();
steps.push("[3/4] Vite started on :9760");
} else {
steps.push("[3/4] Vite already on :9760");
}
} catch (e) { steps.push(`[3/4] Vite error: ${e.message}`); }
// Step 4: Launch Electrobun (detached) after a brief pause via a wrapper script
const wrapper = spawn("bash", ["-c", `sleep 4; cd "${EBUN}" && npx electrobun dev`], {
detached: true, stdio: "ignore",
});
wrapper.unref();
steps.push(`[4/4] Electrobun launching in 4s (PID ${wrapper.pid})`);
return steps.join("\n");
}
const TOOLS = {
"agor-start": {
description: "Start Electrobun app (kills old instances first). Pass clean=true to remove build cache.",
schema: { type: "object", properties: { clean: { type: "boolean", default: false } } },
handler: ({ clean }) => startApp(clean),
},
"agor-stop": {
description: "Stop all running Electrobun/PTY/Vite instances.",
schema: { type: "object", properties: {} },
handler: () => {
spawnSync("bash", ["-c",
'pkill -f "AgentOrch" 2>/dev/null; pkill -f "electrobun dev" 2>/dev/null; pkill -f "WebKitWebProcess" 2>/dev/null; fuser -k 9760/tcp 2>/dev/null; true'
], { timeout: 5000, stdio: "ignore" });
return "Stopped all instances.";
},
},
"agor-restart": {
description: "Restart Electrobun app. Pass clean=true for clean restart.",
schema: { type: "object", properties: { clean: { type: "boolean", default: false } } },
handler: ({ clean }) => {
spawnSync("bash", ["-c",
'pkill -f "AgentOrch" 2>/dev/null; pkill -f "electrobun dev" 2>/dev/null; pkill -f "WebKitWebProcess" 2>/dev/null; fuser -k 9760/tcp 2>/dev/null; true'
], { timeout: 5000, stdio: "ignore" });
return startApp(clean);
},
},
"agor-clean": {
description: "Remove build artifacts, caches, and temp files.",
schema: { type: "object", properties: {} },
handler: () => run("clean"),
},
"agor-rebuild": {
description: "Full rebuild: clean + npm install + vite build + native C library + PTY daemon.",
schema: { type: "object", properties: {} },
handler: () => {
// Stop app (targeted kill — don't kill this Node/MCP process)
spawnSync("bash", ["-c",
'pkill -f "AgentOrch" 2>/dev/null; pkill -f "electrobun dev" 2>/dev/null; pkill -f "WebKitWebProcess" 2>/dev/null; fuser -k 9760/tcp 2>/dev/null; true'
], { timeout: 5000, stdio: "ignore" });
// Clean
spawnSync("rm", ["-rf", `${EBUN}/build/`, `${EBUN}/node_modules/.electrobun-cache/`], { timeout: 5000 });
// npm install
const npm = spawnSync("npm", ["install", "--legacy-peer-deps"], { cwd: EBUN, timeout: 60000, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
// vite build
const vite = spawnSync("npx", ["vite", "build"], { cwd: EBUN, timeout: 60000, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
// native build
const native = spawnSync("bash", ["-c", `cd ${ROOT}/agor-pty/native && gcc -shared -fPIC -o libagor-resize.so agor_resize.c $(pkg-config --cflags --libs gtk+-3.0) 2>&1`], { timeout: 30000, encoding: "utf-8" });
const cargo = spawnSync("cargo", ["build", "--release"], { cwd: `${ROOT}/agor-pty`, timeout: 120000, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
return [
"Rebuild complete:",
`npm: ${npm.status === 0 ? 'ok' : 'FAIL'}`,
`vite: ${vite.status === 0 ? 'ok' : 'FAIL'}`,
`native: ${native.status === 0 ? 'ok' : 'FAIL'}`,
`cargo: ${cargo.status === 0 ? 'ok' : 'FAIL'}`,
].join("\n");
},
},
"agor-status": {
description: "Show running processes, ports, and window status.",
schema: { type: "object", properties: {} },
handler: () => run("status"),
},
"agor-kill-stale": {
description: "Kill ALL stale agor-ptyd instances that accumulated over sessions.",
schema: { type: "object", properties: {} },
handler: () => {
spawnSync("pkill", ["-9", "-f", "agor-ptyd"], { timeout: 3000, stdio: "ignore" });
const r = spawnSync("pgrep", ["-fc", "agor-ptyd"], { encoding: "utf-8", timeout: 2000 });
return `Killed stale ptyd. Remaining: ${(r.stdout || "0").trim()}`;
},
},
"agor-build-native": {
description: "Rebuild libagor-resize.so (GTK resize) and agor-ptyd (PTY daemon).",
schema: { type: "object", properties: {} },
handler: () => run("build-native", 120000),
},
};
// Minimal MCP stdio server
const rl = createInterface({ input: process.stdin });
function send(msg) { process.stdout.write(JSON.stringify(msg) + "\n"); }
rl.on("line", (line) => {
let msg;
try { msg = JSON.parse(line); } catch { return; }
if (msg.method === "initialize") {
send({ jsonrpc: "2.0", id: msg.id, result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "agor-launcher", version: "1.1.0" },
}});
} else if (msg.method === "notifications/initialized") {
// no-op
} else if (msg.method === "tools/list") {
send({ jsonrpc: "2.0", id: msg.id, result: {
tools: Object.entries(TOOLS).map(([name, t]) => ({
name, description: t.description, inputSchema: t.schema,
})),
}});
} else if (msg.method === "tools/call") {
const tool = TOOLS[msg.params.name];
if (!tool) {
send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: `Unknown tool: ${msg.params.name}` }});
return;
}
const result = tool.handler(msg.params.arguments || {});
send({ jsonrpc: "2.0", id: msg.id, result: {
content: [{ type: "text", text: typeof result === "string" ? result : JSON.stringify(result) }],
}});
}
});

View file

@ -1,22 +0,0 @@
# Deployment Cleanup (MANDATORY)
**ALWAYS kill previous instances before launching a new one. NO EXCEPTIONS.**
## The One Command
Use `scripts/launch.sh` for ALL app launches. It handles kill → build → launch atomically.
If not using the script, run this EXACT sequence in a SINGLE bash command:
```bash
pkill -f "launcher|AgentOrchestrator|agor-ptyd|vite.*9760" 2>/dev/null; fuser -k 9760/tcp 2>/dev/null; sleep 2 && ./agor-pty/target/release/agor-ptyd &>/dev/null & sleep 1 && cd ui-electrobun && npx vite dev --port 9760 --host localhost &>/dev/null & sleep 3 && ./node_modules/.bin/electrobun dev &
```
## Rules
- **NEVER** launch without killing first — this causes duplicate windows
- **NEVER** split kill and launch into separate bash calls — the `cd` between them breaks the path
- Kill command MUST include: launcher, AgentOrchestrator, agor-ptyd, vite port 9760
- Free port 9760 with `fuser -k` before starting Vite
- Wait 2s after kill before launching (process cleanup time)
- The `electrobun dev` command MUST run from `ui-electrobun/` directory

View file

@ -1,41 +0,0 @@
# Electrobun Launch Sequence (MANDATORY)
**Use the `agor-launcher` MCP tools** for ALL app lifecycle operations. Do NOT use raw bash commands for launch/stop/rebuild.
## MCP Tools (preferred)
| Tool | Use When |
| --------------------------------------- | ------------------------------------------------------------------ |
| `mcp__agor-launcher__agor-start` | Launch the app (`clean: true` to rebuild from scratch) |
| `mcp__agor-launcher__agor-stop` | Stop all running instances |
| `mcp__agor-launcher__agor-restart` | Stop + start (`clean: true` for clean restart) |
| `mcp__agor-launcher__agor-clean` | Remove build caches without launching |
| `mcp__agor-launcher__agor-rebuild` | Full rebuild: npm install + vite build + native C lib + PTY daemon |
| `mcp__agor-launcher__agor-status` | Check running processes, ports, window |
| `mcp__agor-launcher__agor-kill-stale` | Kill accumulated stale agor-ptyd processes |
| `mcp__agor-launcher__agor-build-native` | Rebuild libagor-resize.so + agor-ptyd |
## Fallback (if MCP unavailable)
```bash
./scripts/launch.sh start # normal launch
./scripts/launch.sh start --clean # clean build + launch
./scripts/launch.sh stop # stop all
./scripts/launch.sh rebuild # full rebuild
./scripts/launch.sh status # check state
./scripts/launch.sh kill-stale # clean up stale ptyd
```
## What Happens If You Skip Steps
- **No PTY daemon**: App crashes with `Connection timeout (5s). Is agor-ptyd running?`
- **No Vite**: WebView loads blank page (no frontend bundle)
- **No kill first**: Duplicate windows, port conflicts
## Rules
- ALWAYS use `agor-stop` before `agor-start` when relaunching
- Use `clean: true` when `src/bun/` files changed (Electrobun caches bundles)
- Use `agor-rebuild` after dependency changes or native code changes
- Use `agor-kill-stale` periodically (ptyd accumulates across sessions)
- NEVER launch manually with raw bash — always use MCP or scripts/launch.sh

View file

@ -1,81 +0,0 @@
# Svelte 5 Reactivity Safety (PARAMOUNT)
Svelte 5's reactive system triggers re-evaluation when it detects a new reference. Misuse causes infinite loops that pin CPU at 100%+.
## Rules
### `$derived` — NEVER create new objects or arrays
```typescript
// WRONG — .filter()/.map()/.reduce()/??[] create new references every evaluation
let messages = $derived(session?.messages ?? []); // new [] each time
let fileRefs = $derived(messages.filter(m => m.toolPath)); // new array each time
let grouped = $derived(items.reduce((a, i) => ..., {})); // new object each time
// RIGHT — plain getter functions called in template
function getMessages() { return session?.messages ?? EMPTY; }
function getFileRefs() { return getMessages().filter(m => m.toolPath); }
```
`$derived` is safe ONLY for primitives (`number`, `string`, `boolean`) or stable references (reading a field from a `$state` object).
### `$effect` — NEVER write to `$state` that the effect reads
```typescript
// WRONG — reads openDirs, writes openDirs → infinite loop
$effect(() => {
openDirs = new Set(openDirs); // read + write same $state
});
// WRONG — calls function that writes to $state
$effect(() => {
loadTasks(); // internally does ++pollToken which is $state
});
// RIGHT — use onMount for initialization
onMount(() => {
loadTasks();
openDirs = new Set([cwd]);
});
```
### `$effect` — NEVER use for async initialization
```typescript
// WRONG — async writes to $state during effect evaluation
$effect(() => {
loadData(); // writes to channels, agents, etc.
});
// RIGHT — onMount for all async init + side effects
onMount(() => {
loadData();
const timer = setInterval(poll, 30000);
return () => clearInterval(timer);
});
```
### Props that change frequently — NEVER pass as component props
```typescript
// WRONG — blinkVisible changes every 500ms → re-renders ENTIRE ProjectCard tree
<ProjectCard {blinkVisible} />
// RIGHT — store-based, only the leaf component (StatusDot) reads it
// blink-store.svelte.ts owns the timer
// StatusDot imports getBlinkVisible() directly
```
## Quick Reference
| Pattern | Safe? | Why |
|---------|-------|-----|
| `$derived(count + 1)` | Yes | Primitive, stable |
| `$derived(obj.field)` | Yes | Stable reference |
| `$derived(arr.filter(...))` | **NO** | New array every eval |
| `$derived(x ?? [])` | **NO** | New `[]` when x is undefined |
| `$derived(items.map(...))` | **NO** | New array + new objects |
| `$effect(() => { state = ... })` | **NO** | Write triggers re-run |
| `$effect(() => { asyncFn() })` | **NO** | Async writes to state |
| `onMount(() => { state = ... })` | Yes | Runs once, no re-trigger |
| Plain function in template | Yes | Svelte tracks inner reads |

View file

@ -1,104 +0,0 @@
# State Tree Architecture (MANDATORY)
All application state lives in a single hierarchical state tree. Components are pure renderers — they read from the tree via getter functions and dispatch actions. No component-local `$state` except DOM refs (`bind:this`).
## Tree Structure
```
AppState (root) → app-state.svelte.ts
├── theme, locale, appReady
├── ui: settings, palette, search, wizard, notifications
├── workspace
│ ├── groups[], activeGroupId
│ └── projects[] (per project ↓)
│ ├── agent: session, messages, status, cost
│ ├── terminals: tabs[], collapsed, activeTabId
│ ├── files: tree, selectedPath, content, dirty
│ ├── comms: channels[], messages[], mode
│ ├── tasks: items[], dragTarget
│ └── tab: activeTab, activatedTabs
└── health: per-project trackers, attention queue
```
## Rules
### 1. State ownership follows the tree
Each node owns its children. A node NEVER reads siblings or ancestors directly — it receives what it needs via getter functions scoped to its level.
```typescript
// RIGHT — scoped access through parent
function getProjectAgent(projectId: string) {
return getProjectState(projectId).agent;
}
// WRONG — cross-branch reach
function getAgentForComms() {
return agentStore.getSession(commsStore.activeAgentId);
}
```
### 2. Actions bubble up, state flows down
Components dispatch actions to the store that owns the state. State changes propagate downward via Svelte's reactive reads.
```typescript
// Component dispatches action
onclick={() => projectActions.setActiveTab(projectId, 'files')}
// Store mutates owned state
function setActiveTab(projectId: string, tab: ProjectTab) {
getProjectState(projectId).tab.activeTab = tab;
}
```
### 3. One store file per tree level
| Level | File | Owns |
|-------|------|------|
| Root | `app-state.svelte.ts` | theme, locale, appReady |
| UI | `ui-store.svelte.ts` | drawers, overlays, modals |
| Workspace | `workspace-store.svelte.ts` | groups, projects list |
| Project | `project-state.svelte.ts` | per-project sub-states |
| Health | `health-store.svelte.ts` | trackers, attention |
### 4. Per-project state is a typed object, not scattered stores
```typescript
interface ProjectState {
agent: AgentState;
terminals: TerminalState;
files: FileState;
comms: CommsState;
tasks: TaskState;
tab: TabState;
}
// Accessed via:
function getProjectState(id: string): ProjectState
```
### 5. No `$state` in components
Components use ONLY:
- `$props()` for parent-passed values
- Getter functions from stores (e.g., `getProjectAgent(id)`)
- Action functions from stores (e.g., `projectActions.sendMessage(id, text)`)
- `bind:this` for DOM element references (the sole exception)
- Ephemeral interaction state (hover, drag coordinates) via plain `let` — NOT `$state`
### 6. No `$derived` with new objects (Rule 57)
All computed values are plain functions. See Rule 57 for details.
### 7. Initialization in `onMount`, never `$effect`
All async data loading, timer setup, and event listener registration goes in `onMount`. See Rule 57.
## Adding New State
1. Identify which tree node owns it
2. Add the field to that node's interface
3. Add getter + action functions in the store file
4. Components read via getter, mutate via action
5. NEVER add `$state` to a `.svelte` component file

View file

@ -1,10 +0,0 @@
{
"permissions": {
"allow": [
"mcp__agor-launcher__agor-status",
"mcp__agor-launcher__agor-kill-stale",
"mcp__agor-launcher__agor-stop",
"mcp__agor-launcher__agor-start"
]
}
}

View file

@ -1,53 +0,0 @@
#!/usr/bin/env bash
# Pre-push hook: prevent commercial code from leaking to the community remote.
#
# Git calls pre-push with the remote name and URL as arguments,
# and feeds (local_ref local_sha remote_ref remote_sha) lines on stdin.
remote="$1"
url="$2"
# Only guard pushes to the community origin (DexterFromLab)
if ! echo "$url" | grep -qi "DexterFromLab"; then
exit 0
fi
echo "[pre-push] Scanning commits for commercial code before push to community remote..."
COMMERCIAL_PATTERNS="agor-pro/|src/lib/commercial/"
while read -r local_ref local_sha remote_ref remote_sha; do
# Skip delete pushes
if [ "$local_sha" = "0000000000000000000000000000000000000000" ]; then
continue
fi
# For new branches, diff against remote HEAD; for updates, diff against remote_sha
if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
range="$local_sha"
else
range="$remote_sha..$local_sha"
fi
# Check file paths in the commits being pushed
leaked_files=$(git diff --name-only "$range" 2>/dev/null | grep -E "$COMMERCIAL_PATTERNS" || true)
if [ -n "$leaked_files" ]; then
echo ""
echo "=========================================="
echo " PUSH BLOCKED: Commercial code detected!"
echo "=========================================="
echo ""
echo "The following commercial files were found in commits being pushed:"
echo "$leaked_files" | sed 's/^/ - /'
echo ""
echo "You are pushing to the community remote ($url)."
echo "Commercial code must NOT be pushed to this remote."
echo ""
echo "To fix: remove commercial files from these commits or push to the commercial remote instead."
echo "=========================================="
exit 1
fi
done
exit 0

4
.github/cla.yml vendored
View file

@ -1,4 +0,0 @@
# CLA-assistant configuration
# See: https://github.com/cla-assistant/cla-assistant
signedClaUrl: "https://github.com/DexterFromLab/agent-orchestrator/blob/main/CLA.md"
allowOrganizationMembers: true

View file

@ -1,71 +0,0 @@
name: Commercial Build
on:
push:
branches:
- 'commercial/**'
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
commercial-build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libssl-dev \
build-essential \
pkg-config
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-pro-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-pro-
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install npm dependencies
run: npm ci
- name: Cargo check (pro features)
run: cargo check --features pro
- name: Cargo test (pro features)
run: cargo test --features pro
- name: Vitest (frontend)
run: npm run test
- name: Commercial tests
run: |
if [ -d "tests/commercial/" ] && ls tests/commercial/*.test.* 2>/dev/null; then
npx vitest run tests/commercial/
else
echo "No commercial tests found, skipping."
fi

View file

@ -1,91 +0,0 @@
name: Community Sync
on:
workflow_dispatch:
inputs:
dry_run:
description: "Dry run (show what would be removed, don't push)"
required: false
default: "false"
type: boolean
release:
types: [published]
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Strip commercial content
run: bash scripts/strip-commercial.sh
- name: Verify no commercial references remain
run: |
failed=0
if [ -d "agor-pro/" ]; then
echo "::error::agor-pro/ directory still exists after strip"
failed=1
fi
content=$(find src/lib/commercial/ -type f ! -name '.gitkeep' 2>/dev/null | wc -l)
if [ "$content" -gt 0 ]; then
echo "::error::Commercial files remain in src/lib/commercial/"
failed=1
fi
if grep -r "LicenseRef-Commercial" --include="*.ts" --include="*.svelte" \
--include="*.rs" --include="*.toml" --include="*.json" \
src/ src-tauri/src/ agor-core/ 2>/dev/null; then
echo "::error::LicenseRef-Commercial references remain in source"
failed=1
fi
if [ "$failed" -eq 1 ]; then
exit 1
fi
echo "Verification passed: no commercial content remains."
- name: Show diff summary
run: |
echo "=== Files removed or modified ==="
git status --short
echo ""
echo "=== Diff stats ==="
git diff --stat
- name: Push to community repo
if: ${{ github.event.inputs.dry_run != 'true' }}
env:
COMMUNITY_PAT: ${{ secrets.COMMUNITY_PAT }}
run: |
if [ -z "$COMMUNITY_PAT" ]; then
echo "::error::COMMUNITY_PAT secret is not set"
exit 1
fi
git config user.name "community-sync[bot]"
git config user.email "community-sync[bot]@users.noreply.github.com"
git add -A
git commit -m "chore: sync community edition from commercial repo
Stripped commercial content for community release.
Source: ${{ github.sha }}"
SYNC_BRANCH="community-sync/${{ github.sha }}"
git checkout -b "$SYNC_BRANCH"
git remote add community \
"https://x-access-token:${COMMUNITY_PAT}@github.com/DexterFromLab/agent-orchestrator.git"
git push community "$SYNC_BRANCH"
echo "Pushed branch $SYNC_BRANCH to community repo."
echo "Create a PR at: https://github.com/DexterFromLab/agent-orchestrator/compare/main...$SYNC_BRANCH"

View file

@ -6,7 +6,7 @@ on:
paths:
- 'v2/src/**'
- 'v2/src-tauri/**'
- 'v2/agor-core/**'
- 'v2/bterminal-core/**'
- 'v2/tests/e2e/**'
- '.github/workflows/e2e.yml'
pull_request:
@ -14,7 +14,7 @@ on:
paths:
- 'v2/src/**'
- 'v2/src-tauri/**'
- 'v2/agor-core/**'
- 'v2/bterminal-core/**'
- 'v2/tests/e2e/**'
workflow_dispatch:
@ -134,21 +134,19 @@ jobs:
- name: Run E2E tests (Phase A — deterministic)
working-directory: v2
env:
AGOR_TEST: '1'
BTERMINAL_TEST: '1'
SKIP_BUILD: '1'
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
npx wdio tests/e2e/wdio.conf.js \
--spec tests/e2e/specs/agor.test.ts \
--spec tests/e2e/specs/phase-a-structure.test.ts \
--spec tests/e2e/specs/phase-a-agent.test.ts \
--spec tests/e2e/specs/phase-a-navigation.test.ts
--spec tests/e2e/specs/bterminal.test.ts \
--spec tests/e2e/specs/agent-scenarios.test.ts
- name: Run E2E tests (Phase B — multi-project)
if: success()
working-directory: v2
env:
AGOR_TEST: '1'
BTERMINAL_TEST: '1'
SKIP_BUILD: '1'
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" \
@ -160,7 +158,7 @@ jobs:
if: success() && env.ANTHROPIC_API_KEY != ''
working-directory: v2
env:
AGOR_TEST: '1'
BTERMINAL_TEST: '1'
SKIP_BUILD: '1'
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |

View file

@ -1,108 +0,0 @@
name: Leak Check
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
leak-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check for commercial directories
run: |
failed=0
# Check agor-pro/ exists
if [ -d "agor-pro/" ]; then
echo "::error::Commercial directory 'agor-pro/' found in community repo"
failed=1
fi
# Check src/lib/commercial/ has actual content (beyond .gitkeep)
if [ -d "src/lib/commercial/" ]; then
content_count=$(find src/lib/commercial/ -type f ! -name '.gitkeep' | wc -l)
if [ "$content_count" -gt 0 ]; then
echo "::error::Commercial code found in 'src/lib/commercial/' ($content_count files beyond .gitkeep)"
find src/lib/commercial/ -type f ! -name '.gitkeep'
failed=1
fi
fi
# Check tests/commercial/ has actual content (beyond .gitkeep)
if [ -d "tests/commercial/" ]; then
content_count=$(find tests/commercial/ -type f ! -name '.gitkeep' | wc -l)
if [ "$content_count" -gt 0 ]; then
echo "::error::Commercial test code found in 'tests/commercial/' ($content_count files beyond .gitkeep)"
find tests/commercial/ -type f ! -name '.gitkeep'
failed=1
fi
fi
if [ "$failed" -eq 1 ]; then
exit 1
fi
echo "No commercial directories with content found."
- name: Check for commercial license file
run: |
if [ -f "LICENSE-COMMERCIAL" ]; then
echo "::error::LICENSE-COMMERCIAL found in community repo"
exit 1
fi
echo "No commercial license file found."
- name: Check for LicenseRef-Commercial SPDX headers
run: |
files=$(grep -rl "LicenseRef-Commercial" \
--include="*.ts" --include="*.svelte" --include="*.rs" \
--include="*.toml" --include="*.css" \
src/ src-tauri/src/ agor-core/ 2>/dev/null || true)
if [ -n "$files" ]; then
echo "::error::Files with LicenseRef-Commercial SPDX headers found:"
echo "$files"
exit 1
fi
echo "No LicenseRef-Commercial headers found."
- name: Grep for commercial references in source
run: |
failed=0
for pattern in "agor-pro" "agor_pro"; do
if grep -r --include="*.ts" --include="*.svelte" --include="*.rs" --include="*.toml" \
"$pattern" src/ src-tauri/src/ 2>/dev/null; then
echo "::error::Found '$pattern' reference in source code"
failed=1
fi
done
if [ "$failed" -eq 1 ]; then
echo "::error::Commercial references detected in community source. See above for details."
exit 1
fi
echo "No commercial references found in source."
- name: Check for commercial feature flags in package.json
run: |
failed=0
if grep -q '"commercial\|:pro"' package.json 2>/dev/null; then
echo "::error::Commercial feature flags found in package.json"
grep '"commercial\|:pro"' package.json
failed=1
fi
if grep -q 'agor-pro' package.json 2>/dev/null; then
echo "::error::agor-pro dependency found in package.json"
grep 'agor-pro' package.json
failed=1
fi
if [ "$failed" -eq 1 ]; then
exit 1
fi
echo "No commercial feature flags in package.json."

View file

@ -1,75 +0,0 @@
name: PAT Health Check
on:
schedule:
- cron: '0 9 * * 1' # Every Monday at 9am UTC
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
check-pat:
runs-on: ubuntu-latest
steps:
- name: Check COMMUNITY_PAT validity
env:
COMMUNITY_PAT: ${{ secrets.COMMUNITY_PAT }}
run: |
if [ -z "$COMMUNITY_PAT" ]; then
echo "::error::COMMUNITY_PAT secret is not set"
echo "pat_valid=false" >> "$GITHUB_ENV"
exit 0
fi
status=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Authorization: token $COMMUNITY_PAT" \
-H "Accept: application/vnd.github+json" \
https://api.github.com/user)
if [ "$status" -eq 200 ]; then
echo "COMMUNITY_PAT is valid (HTTP $status)"
echo "pat_valid=true" >> "$GITHUB_ENV"
else
echo "::error::COMMUNITY_PAT returned HTTP $status"
echo "pat_valid=false" >> "$GITHUB_ENV"
fi
- name: Create issue if PAT is invalid
if: env.pat_valid == 'false'
uses: actions/github-script@v7
with:
script: |
const title = 'COMMUNITY_PAT is invalid or missing';
const body = [
'## PAT Health Check Failed',
'',
'The weekly PAT health check detected that `COMMUNITY_PAT` is either missing or returning an error from the GitHub API.',
'',
'**Action required:** Rotate or re-create the PAT and update the repository secret.',
'',
`Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
].join('\n');
// Avoid duplicate issues
const existing = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'pat-health',
per_page: 1,
});
if (existing.data.length > 0) {
console.log('Open PAT health issue already exists, skipping creation.');
return;
}
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title,
body,
labels: ['pat-health'],
});

View file

@ -85,7 +85,7 @@ jobs:
"platforms": {
"linux-x86_64": {
"signature": "${SIG}",
"url": "https://github.com/agents-orchestrator/agents-orchestrator/releases/download/${GITHUB_REF_NAME}/${APPIMAGE_NAME}"
"url": "https://github.com/DexterFromLab/BTerminal/releases/download/${GITHUB_REF_NAME}/${APPIMAGE_NAME}"
}
}
}
@ -94,13 +94,13 @@ jobs:
- name: Upload .deb
uses: actions/upload-artifact@v4
with:
name: agor-deb
name: bterminal-deb
path: v2/src-tauri/target/release/bundle/deb/*.deb
- name: Upload AppImage
uses: actions/upload-artifact@v4
with:
name: agor-appimage
name: bterminal-appimage
path: v2/src-tauri/target/release/bundle/appimage/*.AppImage
- name: Upload latest.json
@ -118,13 +118,13 @@ jobs:
- name: Download .deb
uses: actions/download-artifact@v4
with:
name: agor-deb
name: bterminal-deb
path: artifacts/
- name: Download AppImage
uses: actions/download-artifact@v4
with:
name: agor-appimage
name: bterminal-appimage
path: artifacts/
- name: Download latest.json

19
.gitignore vendored
View file

@ -26,22 +26,3 @@ sidecar/node_modules
*.njsproj
*.sln
*.sw?
# Python
__pycache__/
*.pyc
*.pyo
# Project-specific
/CLAUDE.md
/plugins/
projects/
.playwright-mcp/
.audit/
.tribunal/
.local/
debug/
*.tar.gz
tests/test-results/
ui-electrobun/build/
ui-electrobun/artifacts/

View file

@ -1,8 +0,0 @@
{
"mcpServers": {
"agor-launcher": {
"command": "node",
"args": [".claude/mcp-servers/agor-launcher/index.mjs"]
}
}
}

4
.vscode/launch.json vendored
View file

@ -2,10 +2,10 @@
"version": "0.2.0",
"configurations": [
{
"name": "Launch Agents Orchestrator (v1)",
"name": "Launch BTerminal (v1)",
"type": "debugpy",
"request": "launch",
"program": "${workspaceFolder}/# v1 removed"
"program": "${workspaceFolder}/bterminal.py"
}
]
}

2
.vscode/tasks.json vendored
View file

@ -4,7 +4,7 @@
{
"label": "run",
"type": "shell",
"command": "python3 ${workspaceFolder}/# v1 removed",
"command": "python3 ${workspaceFolder}/bterminal.py",
"group": {
"kind": "build",
"isDefault": true

View file

@ -1,570 +0,0 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- **ThemeEditor** — 26 color pickers (14 Accents + 12 Neutrals), live preview, import/export JSON, custom theme persistence to SQLite
- **Plugin marketplace** — 13 plugins (8 free, 5 paid), catalog.json, SHA-256 checksum verification, HTTPS-only downloads, path traversal protection
- **6 commercial Rust modules** — Budget Governor, Smart Model Router, Persistent Agent Memory (FTS5), Codebase Symbol Graph, Git Context Injection, Branch Policy Enforcement
- **Pro Svelte components wired** — AnalyticsDashboard, SessionExporter, AccountSwitcher, PluginMarketplace, BudgetManager, ProjectMemory, CodeIntelligence integrated into ProjectBox Pro tab
- **SPKI pin persistence** — relay TLS pins saved to groups.json (TOFU model), survive app restarts
- **E2E test daemon** — standalone CLI (tests/e2e/daemon/) with ANSI terminal dashboard, smart test caching (3-pass skip), error toast catching, Agent SDK NDJSON bridge
- **E2E Phase D/E/F specs** — 54 new tests covering settings panel, error states, agent pane, providers, health indicators, metrics, search, LLM-judged quality
- **Error handling foundation**`extractErrorMessage(err: unknown)` normalizer, `handleError`/`handleInfraError` dual utilities, error-classifier extended with ipc/database/filesystem types (9 total), toast rate-limiting (max 3 per type per 30s)
- **AppError enum (Rust)** — 10 typed variants (Database, Auth, Filesystem, Ipc, NotFound, Validation, Sidecar, Config, Network, Internal) replacing Result<T, String> across 28 files
- **Global unhandled rejection handler** — catches unhandledrejection + error events, routes through handleInfraError
- **Settings redesign** — 6 modular category components (Appearance, Agents, Projects, Orchestration, Security, Advanced) replacing 2959-line SettingsTab monolith, SettingsPanel shell with horizontal tab bar
- **Docs reorganization** — 11 new subdirectory files (architecture, production, agents, sidecar, multi-machine, contributing), 6 new reference docs (quickstart, ref-settings, ref-btmsg, ref-bttask, ref-providers, guide-developing, dual-repo-workflow), bterminal references purged
### Fixed
- **5 critical security issues** — fake SHA-256 → real sha2 crate, tar path traversal protection (--no-same-owner + canonicalize), install path traversal (plugin_id validation), SSRF via curl (--proto =https), symbol scanner path traversal (depth + file count limits)
- **14 high security issues** — git flag injection guards, FTS5 query sanitization (double-quote wrapping), budget TOCTOU (atomic transaction), UTF-8 boundary panic (floor_char_boundary), positional→named column access (5 files)
- **Theme dropdown**`$derived.by()` instead of `$derived()` for themeGroups computation, `@html` replaced with proper Svelte elements
- **E2E port conflict** — dedicated port 9750 for tauri-driver, devUrl port 9710 conflict detection, app identity verification, stale process cleanup
- **E2E selectors** — 9 spec files updated for redesigned UI (settings panel, agent pane, terminal tabs, project header)
- **15 Svelte warnings** — a11y_click_events_have_key_events, a11y_consider_explicit_label, css_unused_selector, state_referenced_locally, node_invalid_placement_ssr
- **Infrastructure bridge error handling** — telemetry-bridge and notifications-bridge empty `.catch(() => {})` replaced with documented console.warn explaining recursion prevention
### Security
- **Marketplace hardening** — real SHA-256 (sha2 crate), empty checksum rejection, HTTPS-only URLs (--proto =https), 50MB download limit (--max-filesize), tar --no-same-owner, post-extraction path validation, plugin_id sanitization (rejects .., /, \)
- **FTS5 injection prevention** — user queries wrapped in double-quotes to prevent operator injection
- **Memory fragment limits** — per-project 1000 fragment cap, 10000 char content limit, transaction-wrapped multi-updates
- **Budget index** — added idx_budget_log_project for query performance
### Changed
- **Dual-repo commercial structure** — agents-orchestrator/agents-orchestrator private repo as commercial source of truth, DexterFromLab/agent-orchestrator as community mirror. Two git remotes (origin + orchestrator) configured locally
- **agor-pro plugin crate** — Tauri 2.x plugin for commercial features. Feature-gated via `--features pro`. Registered via `app.handle().plugin(agor_pro::init())` in setup()
- **Analytics Dashboard (Pro)**`pro_analytics_summary`, `pro_analytics_daily`, `pro_analytics_model_breakdown` commands. Period-based queries (7/14/30/90d) against session_metrics. AnalyticsSummary, DailyStats, ModelBreakdown structs
- **Session Export (Pro)**`pro_export_session`, `pro_export_project_summary` commands. Generates Markdown reports with metrics tables, conversation logs (500-char truncation), error sections
- **Multi-Account Profiles (Pro)**`pro_list_accounts`, `pro_get_active_account`, `pro_set_active_account` commands. Account definitions in ~/.config/agor/accounts.json, active-account file switching
- **Pro IPC bridge**`src/lib/commercial/pro-bridge.ts` with typed interfaces for all 9 plugin commands
- **Commercial Tauri config overlay**`tauri.conf.commercial.json` with separate bundle ID (`com.agentsorchestrator.pro`) and updater endpoints
- **Asymmetric test configuration (updated)** — Pro edition now runs 521 vitest (507 community + 14 commercial) + 168 cargo tests (160 + 8 agor-pro)
- **14 commercial vitest tests** — Bridge type verification, IPC command invocation tests for analytics, export, and profiles modules
- **Asymmetric test configuration**`AGOR_EDITION=pro` env var enables `tests/commercial/` in vitest. Pro: 509 tests, Community: 507 tests. Same framework, different coverage
- **CI leak prevention**`leak-check.yml` blocks commercial code on community pushes. `commercial-build.yml` validates pro edition. `pat-health.yml` weekly PAT monitoring
- **Pre-push hook**`.githooks/pre-push` detects and blocks commercial paths when pushing to origin
- **Makefile** — setup, build, build-pro, test, test-pro, sync, clean targets
- **Commercial docs** — CONTRIBUTING.md (dual-repo model, CLA, SPDX), MAINTENANCE.md (PAT rotation, sync workflow, release checklists), LICENSE-COMMERCIAL
### Changed
- **bterminal→agor rebrand** — Cargo crates: bterminal-core→agor-core, bterminal-relay→agor-relay. Env vars: BTERMINAL_*→AGOR_*. Config paths: ~/.config/agor, ~/.local/share/agor. CSS: --agor-pane-padding-inline. Plugin API: `agor` object. Package names: agents-orchestrator, agor-sidecar
- **Repo flattened** — all source code moved from `v2/` subdirectory to repo root. 351-commit history squashed by upstream rebuild. Branch `hib_changes_v2` created from new flat `main` with reconciled docs, CLI tools, and scaffolding
### Added
- **Plugin sandbox Web Worker migration** — replaced `new Function()` sandbox with dedicated Web Worker per plugin. True process-level isolation — no DOM, no Tauri IPC, no main-thread access. Permission-gated API proxied via postMessage with RPC pattern. 26 tests (MockWorker class in vitest)
- **seen_messages startup pruning**`pruneSeen()` called fire-and-forget in App.svelte onMount. Removes entries older than 7 days (emergency: 3 days at 200k rows)
- **Aider autonomous mode toggle** — per-project `autonomousMode` setting ('restricted'|'autonomous') gates shell command execution in Aider sidecar. Default restricted. SettingsTab toggle
- **SPKI certificate pinning (TOFU)**`remote_probe_spki` Tauri command + `probe_spki_hash()` extracts relay TLS certificate SPKI hash. `remote_add_pin`/`remote_remove_pin` commands. In-memory pin store in RemoteManager
- **Per-message btmsg acknowledgment**`seen_messages` table with session-scoped tracking replaces count-based polling. `btmsg_unseen_messages`, `btmsg_mark_seen`, `btmsg_prune_seen` commands. ON DELETE CASCADE cleanup
- **Aider parser test suite** — 72 vitest tests for extracted `aider-parser.ts` (pure parsing functions). 8 realistic Aider output fixtures. Covers prompt detection, suppression, turn parsing, cost extraction, shell execution, format-drift canaries
- **Dead code wiring** — 4 orphaned Rust functions wired as Tauri commands: `btmsg_get_agent_heartbeats`, `btmsg_queue_dead_letter`, `search_index_task`, `search_index_btmsg`
### Security
- **Plugin sandbox hardened**`new Function()` (same-realm, escapable via prototype walking) replaced with Web Worker (separate JS context, no escape vectors). Eliminates `arguments.callee.constructor` and `({}).constructor.constructor` attack paths
### Changed
- **SidecarManager actor refactor** — replaced `Arc<Mutex<HashMap>>` with dedicated actor thread via `std::sync::mpsc` channel. Eliminates TOCTOU race conditions on session lifecycle. All mutable state owned by single thread
- **Aider parser extraction** — pure functions (`looksLikePrompt`, `parseTurnOutput`, `extractSessionCost`, etc.) extracted from `aider-runner.ts` to `aider-parser.ts` for testability. Runner imports from parser module
### Fixed
- **groups.rs test failure**`test_groups_roundtrip` missing 9 Option fields added in P1-P10 (provider, model, use_worktrees, sandbox_enabled, anchor_budget_scale, stall_threshold_min, is_agent, agent_role, system_prompt)
- **remote_probe_spki tracing skip mismatch**`#[tracing::instrument(skip(state))]` referenced non-existent parameter name. Removed unused State parameter
### Added
- **Comprehensive documentation suite** — 4 new docs: `architecture.md` (end-to-end system architecture with component hierarchy, data flow, IPC patterns), `sidecar.md` (multi-provider runner lifecycle, env stripping, NDJSON protocol, build pipeline), `orchestration.md` (btmsg messaging, bttask kanban, agent roles, wake scheduler, session anchors, health monitoring), `production.md` (sidecar supervisor, Landlock sandbox, FTS5 search, plugin system, secrets management, notifications, audit logging, error classification, telemetry)
- **Sidecar crash recovery/supervision**`agor-core/src/supervisor.rs`: SidecarSupervisor wraps SidecarManager with auto-restart, exponential backoff (1s base, 30s cap, 5 retries), SidecarHealth enum (Healthy/Degraded/Failed), 5min stability window. 17 tests
- **Notification system** — OS desktop notifications via `notify-rust` + in-app NotificationCenter.svelte (bell icon, unread badge, history max 100, 6 notification types). Agent dispatcher emits on complete/error/crash. notifications-bridge.ts adapter
- **Secrets management**`keyring` crate with linux-native (libsecret). SecretsManager in secrets.rs: store/get/delete/list with `__agor_keys__` metadata tracking. SettingsTab Secrets section. secrets-bridge.ts adapter. No plaintext fallback
- **Keyboard-first UX** — Alt+1-5 project jump, Ctrl+H/L vi-nav, Ctrl+Shift+1-9 tab switch, Ctrl+J terminal toggle, Ctrl+Shift+K focus agent, Ctrl+Shift+F search overlay. `isEditing()` guard prevents conflicts. CommandPalette rewritten: 18+ commands, 6 categories, fuzzy filter, arrow nav, keyboard shortcuts overlay
- **Agent health monitoring** — heartbeats table + dead_letter_queue table in btmsg.db. 15s heartbeat polling in ProjectBox. Stale detection (5min threshold). ProjectHeader heart indicator (green/yellow/red). StatusBar health badge
- **FTS5 full-text search** — rusqlite upgraded to `bundled-full`. SearchDb with 3 FTS5 virtual tables (search_messages, search_tasks, search_btmsg). SearchOverlay.svelte: Spotlight-style Ctrl+Shift+F overlay, 300ms debounce, grouped results with FTS5 highlight snippets
- **Plugin system**`~/.config/agor/plugins/` with plugin.json manifest. plugins.rs: discovery, path-traversal-safe file reading, permission validation. plugin-host.ts: sandboxed `new Function()` execution, permission-gated API (palette, btmsg:read, bttask:read, events). plugins.svelte.ts store. SettingsTab plugins section. Example hello plugin
- **Landlock sandbox**`agor-core/src/sandbox.rs`: SandboxConfig with RW/RO paths, applied via `pre_exec()` in sidecar child process. Requires kernel 6.2+ (graceful fallback). Per-project toggle in SettingsTab
- **Error classification**`error-classifier.ts`: classifyApiError() with 6 types (rate_limit, auth, quota, overloaded, network, unknown), actionable messages, retry delays. 20 tests
- **Audit log** — audit_log table in btmsg.db. AuditLogTab.svelte: Manager-only tab, filter by type+agent, 5s auto-refresh. audit-bridge.ts adapter. Events: agent_start/stop/error, task changes, wake events, prompt injection
- **Usage meter** — UsageMeter.svelte: compact inline cost/token meter with color thresholds (50/75/90%), hover tooltip. Integrated in AgentPane cost bar
- **Team agent orchestration** — install_cli_tools() copies btmsg/bttask to ~/.local/bin on startup. register_agents_from_groups() with bidirectional contacts. ensure_review_channels_for_group() creates #review-queue/#review-log per group
- **Optimistic locking for bttask**`version` column in tasks table. `WHERE id=? AND version=?` in update_task_status(). Conflict detection in TaskBoardTab. Both Rust + Python CLI updated
- **Unified test runner**`v2/scripts/test-all.sh` runs vitest + cargo tests with optional E2E (`--e2e` flag). npm scripts: `test:all`, `test:all:e2e`, `test:cargo`. Summary output with color-coded pass/fail
- **Testing gate rule**`.claude/rules/20-testing-gate.md` requires running full test suite after every major change (new features, refactors touching 3+ files, store/adapter/bridge/backend changes)
- **E2E test mode infrastructure**`AGOR_TEST=1` env var disables file watchers (watcher.rs, fs_watcher.rs), wake scheduler, and allows data/config dir overrides via `AGOR_TEST_DATA_DIR`/`AGOR_TEST_CONFIG_DIR`. New `is_test_mode` Tauri command bridges test state to frontend
- **E2E data-testid attributes** — Stable test selectors on 7 key Svelte components: AgentPane (agent-pane, data-agent-status, agent-messages, agent-stop, agent-prompt, agent-submit), ProjectBox (project-box, data-project-id, project-tabs, terminal-toggle), StatusBar, AgentSession, GlobalTabBar, CommandPalette, TerminalTabs
- **E2E Phase A scenarios** — 7 human-authored test scenarios (22 tests) in `agent-scenarios.test.ts`: app structural integrity, settings panel, agent pane initial state, terminal tab management, command palette, project focus/tab switching, agent prompt submission (graceful Claude CLI skip)
- **E2E test fixtures**`tests/e2e/fixtures.ts`: creates isolated temp environments with data/config dirs, git repos, and groups.json. `createTestFixture()`, `createMultiProjectFixture()`, `destroyTestFixture()`
- **E2E results store**`tests/e2e/results-db.ts`: JSON-based test run/step tracking (pivoted from better-sqlite3 due to Node 25 native compile failure)
- **E2E Phase B scenarios** — 6 multi-project + LLM-judged test scenarios in `phase-b.test.ts`: multi-project grid rendering, independent tab switching, status bar fleet state, LLM-judged agent response quality, LLM-judged code generation, context tab verification
- **LLM judge helper**`tests/e2e/llm-judge.ts`: dual-mode judge (CLI first, API fallback). CLI backend spawns `claude` with `--output-format text` (unsets CLAUDECODE). API backend uses raw fetch to Anthropic. Backend selectable via `LLM_JUDGE_BACKEND` env var. Structured verdicts (pass/fail + reasoning + confidence), `assertWithJudge()` with configurable min confidence threshold
- **E2E testing documentation**`docs/e2e-testing.md`: comprehensive guide covering all 3 pillars (test fixtures, test mode, LLM judge), spec phases A-C, CI integration, WebKit2GTK pitfalls, troubleshooting
- **E2E CI workflow**`.github/workflows/e2e.yml`: 3 jobs (vitest, cargo, e2e), xvfb-run for headless WebKit2GTK, path-filtered triggers on v2 source changes, LLM-judged tests gated on `ANTHROPIC_API_KEY` secret availability
### Fixed
- **E2E fixture env propagation**`tauri:options.env` does not reliably set process-level env vars for Rust `std::env::var()`. Added `process.env` injection at module scope in wdio.conf.js so fixture groups.json is loaded instead of real user config
- **LLM judge CLI context pollution** — Claude CLI loaded project CLAUDE.md files causing model to refuse JSON output. Fixed by running judge from `cwd: /tmp` with `--setting-sources user` and `--system-prompt` flags
- **E2E mocha timeout** — Increased global mocha timeout from 60s to 180s. Agent-running tests (B4/B5) need 120s+ for Claude CLI round-trip
- **E2E test suite — 27 failures fixed** across 3 spec files: agor.test.ts (22 — stale v2 CSS selectors, v3 tab order/count, JS-dispatched KeyboardEvent for Ctrl+K, idempotent palette open/close, backdrop click close, scrollIntoView for below-fold settings, scoped theme dropdown selectors), agent-scenarios.test.ts (3 — JS click for settings button, programmatic focus check, graceful 40s agent timeout with skip), phase-b.test.ts (2 — waitUntil for project box render, conditional null handling for burn-rate/cost elements). 82 E2E passing, 0 failing, 4 skipped
- **AgentPane.svelte missing closing `>`** — div tag with data-testid attributes was missing closing angle bracket, causing template parse issues
### Changed
- **WebDriverIO config** — TCP readiness probe replaces blind 2s sleep for tauri-driver startup (200ms interval, 10s deadline). Added AGOR_TEST=1 passthrough in capabilities
### Security
- `claude_read_skill` path traversal: added `canonicalize()` + `starts_with()` validation to prevent reading arbitrary files via crafted skill paths (commands/claude.rs)
- **Sidecar env allowlist hardening** — added `ANTHROPIC_*` to Rust-level `strip_provider_env_var()` as defense-in-depth (Claude CLI uses credentials file, not env for auth). Dual-layer stripping documented: Rust layer (first checkpoint) + JS runner layer (per-provider)
- **Plugin sandbox hardening** — 13 shadowed globals in `new Function()` sandbox (window, document, fetch, globalThis, self, XMLHttpRequest, WebSocket, Function, importScripts, require, process, Deno, __TAURI__, __TAURI_INTERNALS__). `this` bound to undefined via `.call()`. 35 tests covering all shadows, permissions, and lifecycle. Known escape vectors documented in JSDoc
- **WAL checkpoint** — periodic `PRAGMA wal_checkpoint(TRUNCATE)` every 5 minutes on sessions.db + btmsg.db to prevent unbounded WAL growth under sustained multi-agent load. 2 tests
- **TLS support for agor-relay** — optional `--tls-cert` and `--tls-key` CLI args. Server wraps TCP streams with native-tls. Client already supports `wss://` URLs. Generic handler refactor avoids code duplication
- **Landlock fallback logging** — improved warning message with kernel version requirement (6.2+) and documented 3 enforcement states
### Fixed
- **btmsg.rs column index mismatch**`get_agents()` used `SELECT a.*` with positional index 7 for `status`, but column 7 is actually `system_prompt`. Converted all query functions in btmsg.rs and bttask.rs from positional to named column access (`row.get("column_name")`). Added SQL aliases for JOIN columns
- **btmsg-bridge.ts camelCase mismatch**`BtmsgAgent` and `BtmsgMessage` TypeScript interfaces used snake_case fields (`group_id`, `unread_count`, `from_agent`) but Rust `#[serde(rename_all = "camelCase")]` sends camelCase. Fixed interfaces + all consumers (CommsTab.svelte)
- **GroupAgentsPanel event propagation** — toggleAgent button click propagated to parent card click handler (`setActiveProject`). Added `e.stopPropagation()`
- **ArchitectureTab PlantUML encoding**`rawDeflate()` was a no-op, `encode64()` did hex encoding. Collapsed into single `plantumlEncode()` using PlantUML's `~h` hex encoding
- **TestingTab Tauri 2.x asset URL** — used `asset://localhost/` (Tauri 1.x). Fixed to `convertFileSrc()` from `@tauri-apps/api/core`
- **Reconnect loop race in RemoteManager** — orphaned reconnect tasks continued running after `remove_machine()` or `disconnect()`. Added `cancelled: Arc<AtomicBool>` flag to `RemoteMachine`; set on removal/disconnect, checked each reconnect iteration. `connect()` resets flag for new connections (remote.rs)
- **Subagent delegation not triggering** — Manager system prompt had no documentation of Agent tool / delegation capability. Added "Multi-Agent Delegation" section with usage examples and guidelines. Also inject `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` env var for Manager agents
- **Gitignore ignoring source code** — root `.gitignore` `plugins/` rule matched `v2/src/lib/plugins/` (source code). Narrowed to `/plugins/` and `/v2/plugins/` (runtime dirs only)
### Added
- **Reviewer agent role** — Tier 1 specialist with reviewer workflow in `agent-prompts.ts` (8-step process: inbox → review-queue → analyze → verdict → status update → review-log → report). Rust `bttask.rs` auto-posts to `#review-queue` btmsg channel on task→review transition via `notify_review_channel()` + `ensure_review_channels()` (idempotent). `reviewQueueDepth` in `attention-scorer.ts` (10pts/task, cap 50). `review_queue_count()` Rust function + Tauri command + `reviewQueueCount()` IPC bridge. ProjectBox: 'Tasks' tab for reviewer (reuses TaskBoardTab), 10s review queue polling → `setReviewQueueDepth()` in health store. 7 new vitest + 4 new cargo tests. 388 vitest + 76 cargo total
- **Auto-wake Manager scheduler**`wake-scheduler.svelte.ts` + `wake-scorer.ts` with 3 user-selectable strategies: persistent (Manager stays running, resume prompt with fleet context), on-demand (fresh session per wake), smart (threshold-gated on-demand, default). 6 wake signals from tribunal S-3 hybrid: AttentionSpike(1.0), ContextPressureCluster(0.9), BurnRateAnomaly(0.8), TaskQueuePressure(0.7), ReviewBacklog(0.6), PeriodicFloor(0.1). Settings UI: strategy segmented button + threshold slider in Manager agent cards. `GroupAgentConfig` extended with `wakeStrategy` + `wakeThreshold` fields. 24 tests in wake-scorer.test.ts. 381 vitest + 72 cargo total
- **Dashboard metrics panel**`MetricsPanel.svelte` new ProjectBox tab ('metrics', PERSISTED-LAZY, all projects). Live view: fleet aggregates (running/idle/stalled + burn rate), project health grid (status, burn rate, context %, idle, tokens, cost, turns, model, conflicts, attention), task board summary (5 kanban columns polled every 10s), cross-project attention queue. History view: 5 switchable SVG sparkline charts (cost/tokens/turns/tools/duration) with area fill, stats row (last/avg/max/min), recent sessions table. 25 tests in MetricsPanel.test.ts. 357 vitest + 72 cargo total
### Changed
- **Branded types for GroupId/AgentId (SOLID Phase 3b)** — Extended `types/ids.ts` with GroupId and AgentId branded types. Applied to ~40 sites: groups.ts interfaces (ProjectConfig.id, GroupConfig.id, GroupAgentConfig.id, GroupsFile.activeGroupId), btmsg-bridge.ts (5 interfaces, 15 function params), bttask-bridge.ts (Task/TaskComment, 6 params), groups-bridge.ts (AgentMessageRecord, ProjectAgentState, SessionMetric), 3 Svelte components (GroupAgentsPanel, TaskBoardTab, SettingsTab). agentToProject() uses `as unknown as ProjectId` cast for domain crossing. 12 tests in ids.test.ts. 332 vitest + 72 cargo total
- **Branded types for SessionId/ProjectId (SOLID Phase 3)**`types/ids.ts` with compile-time branded types (`string & { __brand }`) and factory functions. Applied to ~140 sites across 11 files: Map/Set keys in conflicts.svelte.ts (4 maps), health.svelte.ts (2 maps), session-persistence.ts (3 maps), function signatures across 6 files, boundary branding at sidecar entry in agent-dispatcher.ts, Svelte component call sites in AgentSession/ProjectBox/ProjectHeader. 293 vitest + 49 cargo total
- **agent-dispatcher.ts split (SOLID Phase 2)** — 496→260 lines. Extracted 4 modules: `utils/worktree-detection.ts` (pure function), `utils/session-persistence.ts` (session maps + persist), `utils/auto-anchoring.ts` (compaction anchor), `utils/subagent-router.ts` (spawn + route). Dispatcher is now a thin coordinator
- **session.rs split (SOLID Phase 2)** — 1008-line monolith split into 7 sub-modules under `session/` directory: mod.rs (struct + migrate), sessions.rs, layout.rs, settings.rs, ssh.rs, agents.rs, metrics.rs, anchors.rs. `pub(in crate::session)` conn visibility. 21 new cargo tests
- **lib.rs command module split** — 976-line monolith with 48 Tauri commands split into 11 domain modules under `src-tauri/src/commands/` (pty, agent, watcher, session, persistence, knowledge, claude, groups, files, remote, misc). lib.rs reduced to ~170 lines (AppState + setup + handler registration)
- **Attention scorer extraction**`scoreAttention()` pure function extracted from inline health store code to `utils/attention-scorer.ts` with 14 tests. Priority chain: stalled > error > context critical > file conflict > context high
- **Shared type guards** — deduplicated `str()`/`num()` runtime guards from claude-messages.ts, codex-messages.ts, ollama-messages.ts into shared `utils/type-guards.ts`
- **btmsg/bttask WAL mode** — added SQLite WAL journal mode + 5s busy_timeout to both `btmsg.rs` and `bttask.rs` `open_db()` for safe concurrent access from Python CLIs + Rust backend
### Added
- **Regression tests for btmsg/bttask bug fixes** — 49 new tests: btmsg.rs (8, in-memory SQLite with named column access regression for status vs system_prompt), bttask.rs (7, named column access + serde camelCase), sidecar strip_provider_env_var (8, all prefix combinations), btmsg-bridge.test.ts (17, camelCase fields + IPC commands), bttask-bridge.test.ts (10, camelCase + IPC), plantuml-encode.test.ts (7, hex encoding algorithm). Total: 327 vitest + 72 cargo
- **Configurable stall threshold** — per-project range slider (560 min, step 5) in SettingsTab. `stallThresholdMin` in `ProjectConfig` (groups.json), `setStallThreshold()` API in health store with `stallThresholds` Map and `DEFAULT_STALL_THRESHOLD_MS` fallback. ProjectBox `$effect` syncs config → store on mount/change
- **Memora adapter**`MemoraAdapter` (memora-bridge.ts) implements `MemoryAdapter` interface, bridging to Memora's SQLite database (`~/.local/share/memora/memories.db`) via read-only Rust backend (`memora.rs`). FTS5 text search, tag filtering via `json_each()`. 4 Tauri commands (memora_available, memora_list, memora_search, memora_get). Registered in App.svelte onMount. 16 vitest + 7 cargo tests. MemoriesTab now shows Memora memories on startup
- **Codex provider runner**`sidecar/codex-runner.ts` wraps `@openai/codex-sdk` (dynamic import, graceful failure if not installed). Maps Codex ThreadEvents (agent_message, reasoning, command_execution, file_change, mcp_tool_call, web_search) to common AgentMessage format via `codex-messages.ts` adapter. Sandbox/approval mode mapping from Agents Orchestrator permission modes. Session resume via thread ID. `providers/codex.ts` ProviderMeta (gpt-5.4 default, hasSandbox, supportsResume). 19 adapter tests
- **Ollama provider runner**`sidecar/ollama-runner.ts` uses direct HTTP to `localhost:11434/api/chat` with NDJSON streaming (zero external dependencies). Health check before session start. Configurable host/model/num_ctx/think via providerConfig. Supports Qwen3 extended thinking. `ollama-messages.ts` adapter maps streaming chunks to AgentMessage (text, thinking, cost with token counts). `providers/ollama.ts` ProviderMeta (qwen3:8b default, modelSelection only). 11 adapter tests
- All 3 providers registered in App.svelte onMount + message-adapters.ts. `build:sidecar` builds all 3 runners
- **S-1 Phase 3: Worktree isolation per project** — per-project `useWorktrees` toggle in SettingsTab. When enabled, agents run in git worktrees at `<repo>/.claude/worktrees/<sessionId>/` via SDK `extraArgs: { worktree: sessionId }`. CWD-based worktree detection in agent-dispatcher (`detectWorktreeFromCwd()`) matches `.claude/`, `.codex/`, `.cursor/` worktree patterns on init events. Dual detection: CWD-based (primary) + tool_call-based (subagent fallback). 8 files, +125 lines, 7 new tests. 226 vitest + 42 cargo tests
- **S-2 Session Anchors** — preserves important conversation turns through context compaction chains. Auto-anchors first 3 turns with observation masking (reasoning preserved in full per research). Manual pin button on AgentPane text messages. Three anchor types: auto (re-injectable), pinned (display-only), promoted (user-promoted, re-injectable). Re-injection via `system_prompt` field. ContextTab anchor section with budget meter bar, per-anchor promote/demote/remove actions. SQLite `session_anchors` table with 5 CRUD commands. 5 new files, 7 modified. 219 vitest + 42 cargo tests
- **Configurable anchor budget scale**`AnchorBudgetScale` type with 4 presets: Small (2K), Medium (6K, default), Large (12K), Full (20K). Per-project 4-stop range slider in SettingsTab. `ProjectConfig.anchorBudgetScale` persisted in groups.json. ContextTab budget meter derives from project setting. agent-dispatcher resolves scale on auto-anchor
- **Agent provider adapter pattern** — full implementation (3 phases complete): core abstraction layer (provider types/registry/capabilities, message adapter registry, 4 file renames), Settings UI (collapsible per-provider config panels, per-project provider dropdown, settings persistence), sidecar routing (provider-based runner selection, env var stripping for CLAUDE*/CODEX*/OLLAMA*). 5 new files, 4 renames, 20+ modified. 6 architecture decisions (PA-1PA-6). Docs at `docs/provider-adapter/`
- **PDF viewer** in Files tab: `PdfViewer.svelte` using pdfjs-dist (v5.5.207). Canvas-based multi-page rendering, zoom controls (0.5x3x, 25% steps), HiDPI-aware via devicePixelRatio. Reads PDF via `convertFileSrc()` — no new Rust commands needed
- **CSV table view** in Files tab: `CsvTable.svelte` with RFC 4180 CSV parser (no external dependency). Auto-detects delimiter (comma, semicolon, tab). Sortable columns (numeric-aware), sticky header, row numbers, text truncation at 20rem
- FilesTab routing update: Binary+pdf → PdfViewer, Text+csv → CsvTable. Updated file icons (📕 PDF, 📊 CSV)
- **S-1 Phase 2: Filesystem write detection** — inotify-based real-time file change detection via `ProjectFsWatcher` (fs_watcher.rs). Watches project CWDs recursively, filters .git/node_modules/target, debounces 100ms per-file (fs_watcher.rs, lib.rs)
- External write conflict detection: timing heuristic (2s grace window) distinguishes agent writes from external edits. `EXTERNAL_SESSION_ID` sentinel, `recordExternalWrite()`, `getExternalConflictCount()`, `FileConflict.isExternal` flag (conflicts.svelte.ts)
- Separate external write badge (orange ⚡) and agent conflict badge (red ⚠) in ProjectHeader (ProjectHeader.svelte)
- `externalConflictCount` in ProjectHealth interface with attention scoring integration (health.svelte.ts)
- Frontend bridge for filesystem watcher: `fsWatchProject()`, `fsUnwatchProject()`, `onFsWriteDetected()`, `fsWatcherStatus()` (fs-watcher-bridge.ts)
- Inotify watch limit sensing: `FsWatcherStatus` reads `/proc/sys/fs/inotify/max_user_watches`, counts watched directories per project, warns at >75% usage with shell command to increase limit (fs_watcher.rs, lib.rs, ProjectBox.svelte)
- Delayed scanning toast: "Scanning project directories…" info toast shown only when inotify status check takes >300ms, auto-dismissed on completion (ProjectBox.svelte)
- `notify()` returns toast ID (was void) to enable dismissing specific toasts via `dismissNotification(id)` (notifications.svelte.ts)
- ProjectBox `$effect` starts/stops fs watcher per project CWD on mount/unmount with toast on new external conflict + inotify capacity check (ProjectBox.svelte)
- Collapsible text messages in AgentPane: model responses wrapped in `<details open>` (open by default, user-collapsible with first-line preview) (AgentPane.svelte)
- Collapsible cost summary in AgentPane: `cost.result` wrapped in `<details>` (collapsed by default, expandable with 80-char preview) (AgentPane.svelte)
- Project max aspect ratio setting: `project_max_aspect` (float 0.33.0, default 1.0) limits project box width via CSS `max-width: calc(100vh * var(--project-max-aspect))` (SettingsTab.svelte, ProjectGrid.svelte, App.svelte)
- No-implicit-push rule: `.claude/rules/52-no-implicit-push.md` — never push unless user explicitly asks
- `StartupWMClass=agor` in install-v2.sh .desktop template for GNOME auto-move extension compatibility
- MarkdownPane link navigation: relative file links open in Files tab, external URLs open in system browser via `xdg-open`, anchor links scroll in-page (MarkdownPane.svelte, ProjectFiles.svelte, lib.rs)
- `open_url` Tauri command for opening http/https URLs in system browser (lib.rs)
- Tab system overhaul: renamed Claude→Model, Files→Docs, added 3 new tabs (Files, SSH, Memory) with PERSISTED-EAGER/LAZY mount strategies (ProjectBox.svelte)
- FilesTab: VSCode-style directory tree sidebar + tabbed content viewer with shiki syntax highlighting, word wrap, image display via convertFileSrc, 10MB file size gate, collapsible/resizable sidebar, preview vs pinned tabs (FilesTab.svelte)
- CodeEditor: CodeMirror 6 editor component with Catppuccin theme (reads --ctp-* CSS vars), 15 lazy-loaded language modes, auto-close brackets, bracket matching, code folding, line numbers, search, line wrapping, Ctrl+S save binding, blur event (CodeEditor.svelte)
- FilesTab editor mode: files are now editable with dirty dot indicator on tabs, (unsaved) label in path bar, Ctrl+S save, auto-save dirty tabs on close (FilesTab.svelte)
- Rust `write_file_content` command: writes content to existing files only — safety check prevents creating new files (lib.rs)
- Save-on-blur setting: `files_save_on_blur` toggle in Settings → Defaults → Editor, auto-saves files when editor loses focus (SettingsTab.svelte, FilesTab.svelte)
- SshTab: SSH connection CRUD panel with launch-to-terminal button, reuses existing ssh-bridge.ts model (SshTab.svelte)
- MemoriesTab: pluggable knowledge explorer with MemoryAdapter interface, adapter registry, search, tag display, expandable cards (MemoriesTab.svelte, memory-adapter.ts)
- Rust `list_directory_children` command: lazy tree expansion, hidden files skipped, dirs-first alphabetical sort (lib.rs)
- Rust `read_file_content` command: FileContent tagged union (Text/Binary/TooLarge), 30+ language mappings (lib.rs)
- Frontend `files-bridge.ts` adapter: DirEntry and FileContent TypeScript types + IPC wrappers
- ContextTab: LLM context window visualization with stats bar (tokens, cost, turns, duration), segmented token meter (color-coded by message type), file references tree (extracted from tool calls), and collapsible turn breakdown — replaces old ContextPane ctx database viewer (ContextTab.svelte)
- ContextTab AST view: per-turn SVG conversation trees showing hierarchical message flow (Turn → Thinking/Response/Tool Calls → File operations), with bezier edges, color-coded nodes, token counts, and detail tooltips (ContextTab.svelte)
- ContextTab Graph view: bipartite tool→file DAG with tools on left (color-coded by type) and files on right, curved SVG edges showing which tools touched which files, count badges on both sides (ContextTab.svelte)
- Compaction event detection: `compact_boundary` SDK messages adapted to `CompactionContent` type in sdk-messages.ts, ContextTab shows yellow compaction count pill in stats bar and red boundary nodes in AST view
- Project health store: per-project activity state (running/idle/stalled), burn rate ($/hr EMA), context pressure (% of model limit), attention scoring with urgency weights (health.svelte.ts)
- Mission Control status bar: running/idle/stalled agent counts, total $/hr burn rate, "needs attention" dropdown priority queue with click-to-focus cards (StatusBar.svelte)
- ProjectHeader health indicators: color-coded status dot (green=running, orange=stalled), context pressure badge, burn rate badge (ProjectHeader.svelte)
- Session metrics SQLite table: per-project historical metrics with 100-row retention, `session_metric_save` and `session_metrics_load` Tauri commands (session.rs, lib.rs)
- Session metric persistence on agent completion: records peak tokens, turn count, tool call count, cost, model, status (agent-dispatcher.ts)
- File overlap conflict detection store: per-project tracking of Write/Edit tool file paths across agent sessions, detects when 2+ sessions write same file, SCORE_FILE_CONFLICT=70 attention signal (conflicts.svelte.ts)
- Shared tool-files utility: extractFilePaths() and extractWritePaths() extracted from ContextTab to reusable module (tool-files.ts)
- File conflict indicators: red "⚠ N conflicts" badge in ProjectHeader, conflict count in StatusBar, toast notification on new conflict, conflict cards in attention queue (ProjectHeader.svelte, StatusBar.svelte)
- Health tick auto-stop/auto-start: tick timer self-stops when no running/starting sessions, auto-restarts on recordActivity() (health.svelte.ts)
- Bash write detection in tool-files.ts: BASH_WRITE_PATTERNS regex array covering >, >>, sed -i, tee, cp, mv, chmod/chown — conflict detection now catches shell-based file writes (tool-files.ts)
- Worktree-aware conflict suppression: sessions in different git worktrees don't trigger conflicts, sessionWorktrees tracking map, setSessionWorktree() API, extractWorktreePath() detects Agent/Task isolation:"worktree" and EnterWorktree tool calls (conflicts.svelte.ts, tool-files.ts, agent-dispatcher.ts)
- Acknowledge/dismiss conflicts: acknowledgeConflicts(projectId) suppresses badge until new session writes, acknowledgedFiles state map, auto-clear on new session write to acknowledged file (conflicts.svelte.ts)
- Clickable conflict badge in ProjectHeader: red button with ✕ calls acknowledgeConflicts() on click with stopPropagation, hover darkens background (ProjectHeader.svelte)
- `useWorktrees` optional boolean field on ProjectConfig for future per-project worktree spawning setting (groups.ts)
### Changed
- Anchor observation masking no longer truncates assistant reasoning text (was 500 chars) — reasoning is preserved in full per research consensus (JetBrains NeurIPS 2025, SWE-agent, OpenDev ACC); only tool outputs are compacted (anchor-serializer.ts)
- `getAnchorSettings()` now accepts optional `AnchorBudgetScale` parameter to resolve budget from per-project scale setting (anchors.svelte.ts)
- ContextTab now derives anchor budget from `anchorBudgetScale` prop via `ANCHOR_BUDGET_SCALE_MAP` instead of hardcoded `DEFAULT_ANCHOR_SETTINGS` (ContextTab.svelte)
- Renamed `sdk-messages.ts``claude-messages.ts`, `agent-runner.ts``claude-runner.ts`, `ClaudeSession.svelte``AgentSession.svelte` — provider-neutral naming for multi-provider support
- `agent-dispatcher.ts` now uses `adaptMessage(provider, event)` from message-adapters.ts registry instead of directly calling `adaptSDKMessage` — enables per-provider message parsing
- Rust `AgentQueryOptions` gained `provider` (String, defaults "claude") and `provider_config` (serde_json::Value) fields with serde defaults for backward compatibility
- Rust `SidecarManager.resolve_sidecar_for_provider(provider)` looks for `{provider}-runner.mjs` instead of hardcoded `claude-runner.mjs`
- Rust `strip_provider_env_var()` strips CLAUDE*/CODEX*/OLLAMA* env vars (whitelists CLAUDE_CODE_EXPERIMENTAL_*)
- SettingsTab: added Providers section with collapsible per-provider config panels (enabled toggle, default model, capabilities display) and per-project provider dropdown
- AgentPane: capability-driven rendering via ProviderCapabilities props (hasProfiles, hasSkills, supportsResume gates)
- AgentPane UI redesign: sans-serif root font (system-ui), tool calls paired with results in collapsible `<details>` groups, hook messages collapsed into compact labels, context window usage meter in status strip, cost bar made minimal (no background), session summary with translucent background, two-phase scroll anchoring, tool-aware output truncation (Bash 500/Read 50/Glob 20 lines), colors softened via `color-mix()`, responsive margins via container queries (AgentPane.svelte)
- MarkdownPane: added inner scroll wrapper with `container-type: inline-size`, responsive padding via shared `--agor-pane-padding-inline` variable (MarkdownPane.svelte)
- Added `--agor-pane-padding-inline: clamp(0.75rem, 3.5cqi, 2rem)` shared CSS variable for responsive pane padding (catppuccin.css)
### Fixed
- FilesTab invalid HTML nesting: file tab bar used `<button>` inside `<button>` which Svelte/browser rejects — changed outer element to `<div role="tab">` (FilesTab.svelte)
- FilesTab file content not rendering: after inserting a FileTab into the `$state` array, the local plain-object reference lost Svelte 5 proxy reactivity — content mutations were invisible. Fixed by looking up from the reactive array before setting content (FilesTab.svelte)
- ClaudeSession type errors: cast `last_session_id` to UUID template literal type, add missing `timestamp` field (from `created_at`) to restored AgentMessage records (ClaudeSession.svelte)
- Cost bar shows only last turn's cost instead of cumulative session total: `updateAgentCost()` changed from assignment to accumulation (`+=`) so continued sessions properly sum costs across all turns (agents.svelte.ts)
- ProjectBox tab switch destroys running agent sessions: changed `{#if activeTab}` conditional rendering to CSS `style:display` (flex/none) for all three content panes and terminal section — ClaudeSession now stays mounted across tab switches, preserving session ID, message history, and running agents (ProjectBox.svelte)
- Sidecar env var stripping now whitelists `CLAUDE_CODE_EXPERIMENTAL_*` vars (both Rust sidecar.rs and JS agent-runner.ts) — previously all `CLAUDE*` vars were stripped, blocking feature flags like agent teams from reaching the SDK (sidecar.rs, agent-runner.ts)
- E2E terminal tab tests: scoped selectors to `.tab-bar .tab-title` (was `.tab-title` which matched project tabs), used `browser.execute()` for DOM text reads to avoid stale element issues (agor.test.ts)
- E2E wdio.conf.js: added `wdio:enforceWebDriverClassic: true` to disable BiDi negotiation (wdio v9 injects `webSocketUrl:true` which tauri-driver rejects), removed unnecessary `browserName: 'wry'`, fixed binary path to Cargo workspace target dir (`v2/target/debug/` not `v2/src-tauri/target/debug/`)
- E2E consolidated to single spec file: Tauri creates one app session per spec file; multiple files caused "invalid session id" on 2nd+ file (wdio.conf.js, agor.test.ts)
- E2E WebDriver clicks on Svelte 5 components: `element.click()` doesn't reliably trigger onclick handlers inside complex components via WebKit2GTK/tauri-driver; replaced with `browser.execute()` JS-level clicks for .ptab, .dropdown-trigger, .panel-close (agor.test.ts)
- Removed `tauri-plugin-log` entirely — `telemetry::init()` already registers tracing-subscriber which bridges the `log` crate; adding plugin-log after panics with "attempted to set a logger after the logging system was already initialized" (lib.rs, Cargo.toml)
### Changed
- E2E tests expanded from 6 smoke tests to 48 tests across 8 describe blocks: Smoke (6), Workspace & Projects (8), Settings Panel (6), Keyboard Shortcuts (5), Command Palette (5), Terminal Tabs (7), Theme Switching (3), Settings Interaction (8) — all in single agor.test.ts file
- wdio.conf.js: added SKIP_BUILD env var to skip cargo tauri build when debug binary already exists
### Removed
- Ollama-specific warning toast from AgentPane when injecting anchors — replaced by generic configurable budget scale slider (AgentPane.svelte)
- Unused `notify` import from AgentPane (AgentPane.svelte)
- `tauri-plugin-log` dependency from Cargo.toml — redundant with telemetry::init() tracing-subscriber setup
- Individual E2E spec files (smoke.test.ts, keyboard.test.ts, settings.test.ts, workspace.test.ts) — consolidated into agor.test.ts
- Workspace teardown race: `switchGroup()` now awaits `waitForPendingPersistence()` before clearing agent state, preventing data loss when agents complete during group switch (agent-dispatcher.ts, workspace.svelte.ts)
- SettingsTab switchGroup click handler made async with await to properly handle the async switchGroup() flow (SettingsTab.svelte)
- Re-entrant sidecar exit handler race condition: added `restarting` guard flag preventing double-restart on rapid disconnect/reconnect (agent-dispatcher.ts)
- Memory leak: `toolUseToChildPane` and `sessionProjectMap` maps now cleared in `stopAgentDispatcher()` (agent-dispatcher.ts)
- Listener leak: 5 Tauri event listeners in machines store now tracked via `UnlistenFn[]` array with `destroyMachineListeners()` cleanup function (machines.svelte.ts)
- Fragile abort detection: replaced `errMsg.includes('aborted')` with `controller.signal.aborted` for authoritative abort state check (agent-runner.ts)
- Unhandled rejection: `handleMessage` made async with `.catch()` on `rl.on('line')` handler preventing sidecar crash on malformed input (agent-runner.ts)
- Remote machine `add_machine`/`list_machines`/`remove_machine` converted from `try_lock()` (silent failure on contention) to async `.lock().await` (remote.rs)
- `remove_machine` now aborts `WsConnection` tasks before removal, preventing resource leak (remote.rs)
- `save_agent_messages` wrapped in `unchecked_transaction()` for atomic DELETE+INSERT, preventing partial writes on crash (session.rs)
- Non-null assertion `msg.event!` replaced with safe check `if (msg.event)` in agent bridge event handler (agent-bridge.ts)
- Runtime type guards (`str()`, `num()`) replace bare `as` casts on untrusted SDK wire format in sdk-messages.ts
- ANTHROPIC_* environment variables now stripped alongside CLAUDE* in sidecar agent-runner.ts
- Frontend persistence timestamps use `Math.floor(Date.now() / 1000)` matching Rust seconds convention (agent-dispatcher.ts)
- Remote disconnect handler converted from `try_lock()` to async `.lock().await` (remote.rs)
- `save_layout` pane_ids serialization error now propagated instead of silent fallback (session.rs)
- ctx.rs Mutex::lock() returns Err instead of panicking on poisoned lock (5 occurrences)
- ctx CLI: `int()` limit argument validated with try/except (ctx)
- ctx CLI: FTS5 MATCH query wrapped in try/except for syntax errors (ctx)
- File watcher: explicit error for root-level path instead of silent fallback (watcher.rs)
- Agent bridge payload validated before cast to SidecarMessage (agent-bridge.ts)
- Profile.toml and resource_dir failures now log::warn instead of silent empty fallback (lib.rs)
### Changed
- All ~100 px layout values converted to rem across 10 components per rule 18: AgentPane, ToastContainer, CommandPalette, SettingsTab, TeamAgentsPanel, AgentCard, StatusBar, AgentTree, TerminalPane, AgentPreviewPane (1rem = 16px base, icon/dot dimensions kept as px)
### Added
- E2E testing infrastructure: WebdriverIO v9.24 + tauri-driver setup with `wdio.conf.js` (lifecycle hooks for tauri-driver spawn/kill, debug binary build), 6 smoke tests (`smoke.test.ts`), TypeScript config, `test:e2e` npm script, 4 new devDeps (@wdio/cli, @wdio/local-runner, @wdio/mocha-framework, @wdio/spec-reporter)
- `waitForPendingPersistence()` export in agent-dispatcher.ts: counter-based fence that resolves when all in-flight `persistSessionForProject()` calls complete
- OpenTelemetry instrumentation: `telemetry.rs` module with TelemetryGuard (Drop-based shutdown), tracing + optional OTLP/HTTP export to Tempo, controlled by `AGOR_OTLP_ENDPOINT` env var (absent = console-only fallback)
- `#[tracing::instrument]` on 10 key Tauri commands: pty_spawn, pty_kill, agent_query, agent_stop, agent_restart, remote_connect, remote_disconnect, remote_agent_query, remote_agent_stop, remote_pty_spawn
- `frontend_log` Tauri command: routes frontend telemetry events (level + message + context JSON) to Rust tracing layer with `source="frontend"` field
- `telemetry-bridge.ts` adapter: `tel.info/warn/error/debug/trace()` convenience wrappers for frontend → Rust tracing bridge via IPC
- Agent dispatcher telemetry: structured events for agent_started, agent_stopped, agent_error, sidecar_crashed, and agent_cost (with full metrics: costUsd, tokens, turns, duration)
- Docker Tempo + Grafana stack (`docker/tempo/`): Tempo (OTLP gRPC 4317, HTTP 4318, query 3200) + Grafana (port 9715) with auto-provisioned Tempo datasource
- 6 new Rust dependencies: tracing 0.1, tracing-subscriber 0.3, opentelemetry 0.28, opentelemetry_sdk 0.28, opentelemetry-otlp 0.28, tracing-opentelemetry 0.29
- `ctx_register_project` Tauri command and `ctxRegisterProject()` bridge function: registers a project in the ctx database via `INSERT OR IGNORE` into sessions table; opens DB read-write briefly then closes
- Agent preview terminal (`AgentPreviewPane.svelte`): read-only xterm.js terminal that subscribes to agent session messages in real-time; renders Bash commands as cyan ` command`, file operations as yellow `[Read/Write/Edit] path`, tool results (80-line truncation), text summaries, errors in red, session start/complete with cost; uses `disableStdin: true`, Canvas addon, theme hot-swap; spawned via 👁 button in TerminalTabs tab bar (appears when agent session is active); deduplicates — only one preview per session
- `TerminalTab.type` extended with `'agent-preview'` variant and `agentSessionId?: string` field in workspace store
- `ProjectBox` passes `mainSessionId` to `TerminalTabs` for agent preview tab creation
- SettingsTab project settings card redesign: each project rendered as a polished card with icon picker (Svelte state-driven emoji grid popup), inline-editable name input, CWD with left-ellipsis (`direction: rtl`), account/profile dropdown (via `listProfiles()` from claude-bridge.ts), custom toggle switch (green track/thumb), and subtle remove footer with trash icon
- Account/profile dropdown per project in SettingsTab: uses `listProfiles()` to fetch Claude profiles, displays display_name + email in dropdown, blue badge styling; falls back to static label when single profile
- ProjectHeader profile badge: account name styled as blue pill with translucent background (`color-mix(in srgb, var(--ctp-blue) 10%, transparent)`), font-weight 600, expanded max-width to 8rem
- Theme integration rule (`.claude/rules/51-theme-integration.md`): mandates all colors via `--ctp-*` CSS custom properties, never hardcode hex/rgb/hsl values
- AgentPane VSCode-style prompt: unified input always at bottom with auto-resizing textarea, send icon button (arrow SVG) inside rounded container, welcome state with chat icon when no session
- AgentPane session controls: New Session and Continue buttons shown after session completes, enabling explicit session management
- ClaudeSession `handleNewSession()`: resets sessionId for fresh agent sessions, wired via `onExit` prop to AgentPane
- ContextPane "Initialize Database" button: when ctx database doesn't exist, shows a prominent button to create `~/.claude-context/context.db` with full schema (sessions, contexts, shared, summaries + FTS5 + sync triggers) directly from the UI; replaces old "run ctx init" hint text; auto-loads data after successful init
- Project-level tab bar in ProjectBox: Claude | Files | Context tabs switch the content area between ClaudeSession, ProjectFiles, and ContextPane
- ProjectFiles.svelte: project-scoped markdown file viewer (file picker sidebar + MarkdownPane), accepts cwd/projectName props
- ProjectHeader info bar: CWD path (ellipsized from start via `direction: rtl`) + profile name displayed as read-only info alongside project icon/name
- Emoji icon picker in SettingsTab: 24 project-relevant emoji in 8-column grid popup, replaces plain text icon input
- Native directory picker for CWD fields: custom `pick_directory` Tauri command using `rfd` crate with `set_parent(&window)` for modal behavior on Linux; browse buttons added to Default CWD, existing project CWD, and Add Project path inputs in SettingsTab
- `rfd = { version = "0.16", default-features = false, features = ["gtk3"] }` direct dependency for modal file dialogs (zero extra compile — already built transitively via tauri-plugin-dialog)
- CSS relative units rule (`.claude/rules/18-relative-units.md`): enforces rem/em for layout CSS, px only for icons/borders/shadows
### Changed
- ContextPane redesigned as project-scoped: now receives `projectName` + `projectCwd` props from ProjectBox; auto-registers project in ctx database on mount (`INSERT OR IGNORE`); removed project selector list — directly shows context entries, shared context, and session summaries for the current project; empty state shows `ctx set <project> <key> <value>` usage hint; all CSS converted to rem; header shows project name in accent color
- Sidebar simplified to Settings-only: removed Sessions, Docs, Context icons from GlobalTabBar (project-specific tabs already in ProjectBox); removed DocsTab/ContextTab imports from App.svelte; removed Alt+1..4 keyboard shortcuts; drawer always renders SettingsTab
- MarkdownPane file switching: replaced onMount-only `watchFile()` with reactive `$effect` that unwatches previous file and watches new one when `filePath` prop changes; added `highlighterReady` gate to prevent premature watches
- MarkdownPane premium typography overhaul: font changed from `var(--ui-font-family)` (resolved to JetBrains Mono) to hardcoded `'Inter', system-ui, sans-serif` for proper prose rendering; added `text-rendering: optimizeLegibility`, `-webkit-font-smoothing: antialiased`, `font-feature-settings: 'cv01', 'cv02', 'cv03', 'cv04', 'ss01'` (Inter alternates); body color softened from `--ctp-text` to `--ctp-subtext1` for reduced dark-mode contrast; Tailwind-prose-inspired spacing (1.15-1.75em paragraph/heading margins); heading line-height tightened to 1.2-1.4 with negative letter-spacing on h1/h2; gradient HR (`linear-gradient` fading to transparent edges); link underlines use `text-decoration-color` transition (30% opacity → full on hover, VitePress pattern); blockquotes now italic with translucent bg; code blocks have inset `box-shadow` for depth; added h5 (uppercase small) and h6 styles; all colors via `--ctp-*` vars for 17-theme compatibility
- ProjectBox terminal area: only visible on Claude tab, now collapsible — collapsed shows a status bar with chevron toggle, "Terminal" label, and tab count badge; expanded shows full 16rem TerminalTabs area. Default: collapsed. Grid rows: `auto auto 1fr auto`
- SettingsTab project settings: flat row layout replaced with stacked card layout; icon picker rewritten from DOM `classList.toggle('visible')` to Svelte `$state` (iconPickerOpenFor); checkbox replaced with custom toggle switch component
- SettingsTab CSS: all remaining px values in project section converted to rem; add-project form uses dashed border container
- AgentPane prompt: replaced separate initial prompt + follow-up input with single unified prompt area; removed `followUpPrompt` state, `handleSubmit` function; follow-up handled via `isResume` detection in `handleUnifiedSubmit()`
- AgentPane CSS: migrated all legacy CSS vars (`--bg-primary`, `--bg-surface`, `--text-primary`, `--text-secondary`, `--text-muted`, `--border`, `--accent`, `--font-mono`, `--border-radius`) to `--ctp-*` theme vars + rem units
- ContextPane CSS: same legacy-to-theme var migration as AgentPane
- ProjectBox tab CSS: polished with `margin-bottom: -1px` active tab trick (merges with content), `scrollbar-width: none`, `focus-visible` outline, hover with `var(--ctp-surface0)` background
- ProjectBox layout: CSS grid with 4 rows (`auto auto 1fr auto`) — header | tab bar | content | terminal; content area switches by tab
- AgentPane: removed DIR/ACC toolbar entirely — CWD and profile now passed as props from parent (set in Settings, shown in ProjectHeader); clean chat window with prompt + send button only
- AgentPane prompt area: anchored to bottom (`justify-content: flex-end`) instead of vertical center, removed `max-width: 600px` constraint — uses full panel width
- ClaudeSession passes `project.profile` to AgentPane for automatic profile resolution
- ProjectGrid.svelte CSS converted from px to rem: gap 0.25rem, padding 0.25rem, min-width 30rem
- TerminalTabs.svelte CSS converted from px to rem: tab bar, tabs, close/add buttons, empty state
### Removed
- Dead ctx code: `ContextTab.svelte` wrapper component, `CtxProject` struct (Rust), `list_projects()` method, `ctx_list_projects` Tauri command, `ctxListProjects()` bridge function, `CtxProject` TypeScript interface — all unused after ContextPane project-scoped redesign
- Unused Python imports in `ctx` CLI: `os`, `datetime`/`timezone` modules
- AgentPane session toolbar (DIR/ACC inputs) — CWD and profile are now props, not interactive inputs
- Nerd Font codepoints for project icons — replaced with emoji (`📁` default) for cross-platform compatibility
- Nerd Font `font-family` declarations from ProjectHeader and TerminalTabs
- Stub `pick_directory` Tauri command (replaced by `tauri-plugin-dialog` frontend API)
### Fixed
- `ctx init` fails when `~/.claude-context/` directory doesn't exist: `get_db()` called `sqlite3.connect()` without creating the parent directory; added `DB_PATH.parent.mkdir(parents=True, exist_ok=True)` before connect
- Terminal tabs cannot be closed and all named "Shell 1": `$state<Map<string, TerminalTab[]>>` in workspace store didn't trigger reactive updates for `$derived` consumers when `Map.set()` was called; changed `projectTerminals` from `Map` to `Record<string, TerminalTab[]>` (plain object property access is Svelte 5's strongest reactivity path)
- SettingsTab icon picker not opening: replaced broken DOM `classList.toggle('visible')` approach with Svelte `$state` (`iconPickerOpenFor` keyed by project ID); icon picker now reliably opens/closes and dismisses on click-outside or Escape
- SettingsTab CWD path truncated from right: added `direction: rtl; text-align: left; unicode-bidi: plaintext` on CWD input so path shows the end (project directory) instead of the beginning when truncated
- Project icons showing "?" — Nerd Font codepoint `\uf120` not rendering without font installed; switched to emoji
- Native directory picker not opening: added missing `"dialog:default"` permission to `v2/src-tauri/capabilities/default.json` — Tauri's IPC security layer silently blocked `invoke()` calls without this capability
- Native directory picker not modal on Linux: replaced `@tauri-apps/plugin-dialog` `open()` with custom `pick_directory` Tauri command using `rfd::AsyncFileDialog::set_parent(&window)` — the plugin skips `set_parent` on Linux via `cfg(any(windows, target_os = "macos"))` gate
- Native directory picker not dark-themed: set `GTK_THEME=Adwaita:dark` via `std::env::set_var` at Tauri startup to force dark theme on native GTK dialogs
- Sidebar drawer not scaling to content width: removed leftover v2 grid layout on `#app` in `app.css` (`display: grid; grid-template-columns: var(--sidebar-width) 1fr` + media queries) that constrained `.app-shell` to 260px first column; v3 `.app-shell` manages its own flexbox layout internally
- ContextPane.svelte CSS converted from px to rem: font-size, padding, margin, gap; added `white-space: nowrap` on `.ctx-header`/`.ctx-error` for intrinsic width measurement
### Changed
- GlobalTabBar.svelte CSS converted from px to rem: rail width 2.75rem, button 2rem, gap 0.25rem, padding 0.5rem 0.375rem, border-radius 0.375rem; rail-btn color changed from --ctp-overlay1 to --ctp-subtext0 for better contrast
- App.svelte sidebar header CSS converted from px to rem: padding 0.5rem 0.75rem, close button 1.375rem, border-radius 0.25rem
- App.svelte sidebar drawer: JS `$effect` measures content width via `requestAnimationFrame` + `querySelectorAll` for nowrap elements, headings, inputs, and tab-specific selectors; `panelWidth` state drives inline `style:width` on `aside.sidebar-panel`
- Sidebar panel changed from fixed width (28em) to content-driven sizing with `min-width: 16em` and `max-width: 50%`; each tab component defines its own `min-width: 22em`
- Sidebar panel and panel-content overflow changed from `hidden` to `overflow-y: auto` to allow content to drive parent width
- SettingsTab.svelte padding converted from px to rem (0.75rem 1rem)
- DocsTab.svelte converted from px to rem: file-picker 14em, picker-title/file-btn/empty padding in rem
- ContextTab.svelte, DocsTab.svelte, SettingsTab.svelte all now set `min-width: 22em` for content-driven drawer sizing
- UI redesigned from top tab bar + right-side settings drawer to VSCode-style left sidebar: vertical icon rail (GlobalTabBar, 2.75rem, 4 SVG icons) + expandable drawer panel (content-driven width) + always-visible main workspace (ProjectGrid)
- GlobalTabBar rewritten from horizontal text tabs + gear icon to vertical icon rail with SVG icons for Sessions, Docs, Context, Settings; Props: `expanded`/`ontoggle` (was `settingsOpen`/`ontoggleSettings`)
- Settings is now a regular sidebar tab (not a special right-side drawer); `WorkspaceTab` type: `'sessions' | 'docs' | 'context' | 'settings'`
- App.svelte layout: `.main-row` flex container with icon rail + optional sidebar panel + workspace; state renamed `settingsOpen` -> `drawerOpen`
- Keyboard shortcuts: Alt+1..4 (switch tabs + open drawer), Ctrl+B (toggle sidebar), Ctrl+, (toggle settings), Escape (close drawer)
- SettingsTab CSS: `height: 100%` (was `flex: 1`) for sidebar panel context
### Added
- SettingsTab split font controls: separate UI font (sans-serif options: System Sans-Serif, Inter, Roboto, Open Sans, Lato, Noto Sans, Source Sans 3, IBM Plex Sans, Ubuntu) and Terminal font (monospace options: JetBrains Mono, Fira Code, Cascadia Code, Source Code Pro, IBM Plex Mono, Hack, Inconsolata, Ubuntu Mono, monospace), each with custom themed dropdown + size stepper (8-24px), font previews in own typeface
- `--term-font-family` and `--term-font-size` CSS custom properties in catppuccin.css (defaults: JetBrains Mono fallback chain, 13px)
- Deep Dark theme group: 6 new themes (Tokyo Night, Gruvbox Dark, Ayu Dark, Poimandres, Vesper, Midnight) — total 17 themes across 3 groups (Catppuccin, Editor, Deep Dark). Midnight is pure OLED black (#000000), Ayu Dark near-black (#0b0e14), Vesper warm dark (#101010)
- Multi-theme system: 7 new editor themes (VSCode Dark+, Atom One Dark, Monokai, Dracula, Nord, Solarized Dark, GitHub Dark) alongside 4 Catppuccin flavors
- `ThemeId` union type, `ThemePalette` (26-color interface), `ThemeMeta` (id/label/group/isDark), `THEME_LIST` registry with group metadata, `ALL_THEME_IDS` for validation
- Theme store `getCurrentTheme()`/`setTheme()` as primary API; deprecated `getCurrentFlavor()`/`setFlavor()` wrappers for backwards compat
- SettingsTab custom themed dropdown for theme selection: color swatches (base color per theme), 4 accent color dots (red/green/blue/yellow), grouped sections (Catppuccin/Editor/Deep Dark) with styled headers, click-outside and Escape to close
- SettingsTab global settings section: theme selector, UI font dropdown (sans-serif options), Terminal font dropdown (monospace options), each with size stepper (8-24px), default shell input, default CWD input — all custom themed dropdowns (no native `<select>`), all persisted via settings-bridge
- Typography CSS custom properties (`--ui-font-family`, `--ui-font-size`, `--term-font-family`, `--term-font-size`) in catppuccin.css with defaults; consumed by app.css body rule
- `initTheme()` now restores 4 saved font settings (ui_font_family, ui_font_size, term_font_family, term_font_size) from SQLite on startup alongside theme restoration
- v3 Mission Control (All Phases 1-10 complete): multi-project dashboard with project groups, per-project Claude sessions, team agents panel, terminal tabs, 3 workspace tabs (Sessions/Docs/Context) + settings drawer
- v3 session continuity (P6): `persistSessionForProject()` saves agent state + messages to SQLite on session complete; `registerSessionProject()` maps session to project; `ClaudeSession.restoreMessagesFromRecords()` restores cached messages on mount
- v3 workspace teardown (P7): `clearAllAgentSessions()` clears agent sessions on group switch; terminal tabs reset via `switchGroup()`
- v3 data model: `groups.rs` (Rust structs + load/save `~/.config/agor/groups.json`), `groups.ts` (TypeScript interfaces), `groups-bridge.ts` (IPC adapter), `--group` CLI argument
- v3 workspace store (`workspace.svelte.ts`): replaces `layout.svelte.ts`, manages groups/activeGroupId/activeTab/focusedProjectId with Svelte 5 runes
- v3 SQLite migrations: `agent_messages` table (per-project message persistence), `project_agent_state` table (sdkSessionId/cost/status per project), `project_id` column on sessions
- 12 new Workspace components: GlobalTabBar, ProjectGrid, ProjectBox, ProjectHeader, ClaudeSession, TeamAgentsPanel, AgentCard, TerminalTabs, CommandPalette, DocsTab, ContextTab, SettingsTab
- v3 App.svelte full rewrite: GlobalTabBar + tab content area + StatusBar (no sidebar, no TilingGrid)
- 24 new vitest tests for workspace store, 7 new cargo tests for groups (total: 138 vitest + 36 cargo)
- v3 adversarial architecture review: 3 agents (Architect, Devil's Advocate, UX+Performance Specialist), 12 issues identified and resolved
- v3 Mission Control redesign planning: architecture docs (`docs/architecture.md`, `docs/decisions.md`, `docs/findings.md`), codebase reuse analysis
- Claude profile/account switching: `claude_list_profiles()` reads `~/.config/switcher/profiles/` directories with `profile.toml` metadata (email, subscription_type, display_name); profile selector dropdown in AgentPane toolbar when multiple profiles available; selected profile's `config_dir` passed as `CLAUDE_CONFIG_DIR` env override to SDK
- Skill discovery and autocomplete: `claude_list_skills()` reads `~/.claude/skills/` (directories with `SKILL.md` or standalone `.md` files); type `/` in agent prompt textarea to trigger autocomplete menu with arrow key navigation, Tab/Enter selection, Escape dismiss; `expandSkillPrompt()` reads skill content and injects as prompt
- New frontend adapter `claude-bridge.ts`: `ClaudeProfile` and `ClaudeSkill` interfaces, `listProfiles()`, `listSkills()`, `readSkill()` IPC wrappers
- AgentPane session toolbar: editable working directory input, profile/account selector (shown when >1 profile), all rendered above prompt form
- Extended `AgentQueryOptions` with 5 new fields across full stack (Rust struct, sidecar JSON, SDK options): `setting_sources` (defaults to `['user', 'project']`), `system_prompt`, `model`, `claude_config_dir`, `additional_directories`
- 4 new Tauri commands: `claude_list_profiles`, `claude_list_skills`, `claude_read_skill`, `pick_directory`
- Claude CLI path auto-detection: `findClaudeCli()` in both sidecar runners checks common paths (~/.local/bin/claude, ~/.claude/local/claude, /usr/local/bin/claude, /usr/bin/claude) then falls back to `which`/`where`; resolved path passed to SDK via `pathToClaudeCodeExecutable` option
- Early error reporting when Claude CLI is not found — sidecar emits `agent_error` immediately instead of cryptic SDK failure
### Changed
- SettingsTab global settings restructured to single-column layout with labels above controls, split into "Appearance" (theme, UI font, terminal font) and "Defaults" (shell, CWD) subsections; all native `<select>` replaced with custom themed dropdowns
- Font setting keys changed from `font_family`/`font_size` to `ui_font_family`/`ui_font_size` + `term_font_family`/`term_font_size`; UI font fallback changed from monospace to sans-serif
- `app.css` body font-family and font-size now use CSS custom properties (`var(--ui-font-family)`, `var(--ui-font-size)`) instead of hardcoded values
- Theme system generalized from Catppuccin-only to multi-theme: all 17 themes map to same `--ctp-*` CSS custom properties (26 vars) — zero component-level changes needed
- `CatppuccinFlavor` type deprecated in favor of `ThemeId`; `CatppuccinPalette` deprecated in favor of `ThemePalette`; `FLAVOR_LABELS` and `ALL_FLAVORS` deprecated in favor of `THEME_LIST` and `ALL_THEME_IDS`
### Fixed
- SettingsTab theme dropdown sizing: set `min-width: 180px` on trigger container, `min-width: 280px` and `max-height: 400px` on dropdown menu, `white-space: nowrap` on option labels to prevent text truncation
- SettingsTab input overflow: added `min-width: 0` on `.setting-row` to prevent flex children from overflowing container
- SettingsTab a11y: project field labels changed from `<div><label>` to wrapping `<label><span class="field-label">` pattern for proper label/input association
- SettingsTab CSS: removed unused `.project-field label` selector, simplified input selector to `.project-field input:not([type="checkbox"])`
### Removed
- Dead `update_ssh_session()` method from session.rs and its unit test (method was unused after SSH CRUD refactoring)
- Stale TilingGrid reference in AgentPane.svelte comment (TilingGrid was deleted in v3 P10)
### Changed
- StatusBar rewritten for v3 workspace store: shows active group name, project count, agent count instead of pane counts; version label updated to "Agents Orchestrator v3"
- Agent dispatcher subagent routing: project-scoped sessions skip layout pane creation (subagents render in TeamAgentsPanel instead); detached mode still creates layout pane
- AgentPane `cwd` prop renamed to `initialCwd` — now editable via text input in session toolbar instead of fixed prop
### Removed
- Dead v2 components deleted in P10 (~1,836 lines): `TilingGrid.svelte` (328), `PaneContainer.svelte` (113), `PaneHeader.svelte` (44), `SessionList.svelte` (374), `SshSessionList.svelte` (263), `SshDialog.svelte` (281), `SettingsDialog.svelte` (433)
- Empty component directories removed: `Layout/`, `Sidebar/`, `Settings/`, `SSH/`
- Sidecar runners now pass `settingSources` (defaults to `['user', 'project']`), `systemPrompt`, `model`, and `additionalDirectories` to SDK `query()` options
- Sidecar runners inject `CLAUDE_CONFIG_DIR` into clean env when `claudeConfigDir` provided in query message (multi-account support)
### Fixed
- AgentPane Svelte 5 event modifier syntax: `on:click` changed to `onclick` (Svelte 5 requires lowercase event handler attributes, not colon syntax)
- CLAUDE* env var stripping now applied at Rust level in SidecarManager (agor-core/src/sidecar.rs): `env_clear()` + `envs(clean_env)` strips all CLAUDE-prefixed vars before spawning sidecar process, providing primary defense against nesting detection (JS-side stripping retained as defense-in-depth)
### Changed
- Sidecar resolution unified: single pre-built `agent-runner.mjs` bundle replaces separate `agent-runner-deno.ts` + `agent-runner.ts` lookup; same `.mjs` file runs under both Deno and Node.js
- `resolve_sidecar_command()` in sidecar.rs now checks deno/node availability upfront before searching paths, improved error message with runtime availability note
- Removed `agent-runner-deno.ts` from tauri.conf.json bundled resources (only `dist/agent-runner.mjs` shipped)
### Added
- `@anthropic-ai/claude-agent-sdk` ^0.2.70 npm dependency for sidecar agent session management
- `build:sidecar` npm script for esbuild bundling of agent-runner.ts (SDK bundled in, no external dependency at runtime)
- `permission_mode` field in AgentQueryOptions (Rust, TypeScript) — flows from controller through sidecar to SDK, defaults to 'bypassPermissions', supports 'default' mode
### Changed
- Sidecar agent runners migrated from raw `claude` CLI spawning (`child_process.spawn`/`Deno.Command`) to `@anthropic-ai/claude-agent-sdk` query() function — fixes silent hang when CLI spawned with piped stdio (known bug github.com/anthropics/claude-code/issues/6775)
- agent-runner.ts: sessions now use `{ query: Query, controller: AbortController }` map instead of `ChildProcess` map; stop uses `controller.abort()` instead of `child.kill()`
- agent-runner-deno.ts: sessions now use `AbortController` map; uses `npm:@anthropic-ai/claude-agent-sdk` import specifier
- Deno sidecar permissions expanded: added `--allow-write` and `--allow-net` flags in sidecar.rs (required by SDK)
- CLAUDE* env var stripping now passes clean env via SDK's `env` option in query() instead of filtering process.env before spawn
- SDK permissionMode and allowDangerouslySkipPermissions now dynamically set based on permission_mode option (was hardcoded to bypassPermissions)
- build:sidecar esbuild command no longer uses --external for SDK (SDK bundled into output)
### Fixed
- AgentPane onDestroy no longer kills running agent sessions on component remount — stopAgent() moved from AgentPane.svelte onDestroy to TilingGrid.svelte onClose handler, ensuring agents only stop on explicit user close action
### Previously Added
- Exponential backoff reconnection in RemoteManager: on disconnect, spawns async task with 1s/2s/4s/8s/16s/30s-cap backoff, uses attempt_tcp_probe() (TCP-only, no WS upgrade, 5s timeout, default port 9750), emits remote-machine-reconnecting and remote-machine-reconnect-ready events
- Frontend reconnection listeners: onRemoteMachineReconnecting and onRemoteMachineReconnectReady in remote-bridge.ts; machines store sets status to 'reconnecting' and auto-calls connectMachine() on ready
- Relay command response propagation: agor-relay now sends structured responses (pty_created, pong, error) back to client via shared event channel with commandId correlation
- send_error() helper in agor-relay for consistent error reporting across all command handlers
- PTY creation confirmation flow: pty_create command returns pty_created event with session ID and commandId; RemoteManager emits remote-pty-created Tauri event
- agor-core shared crate with EventSink trait: extracted PtyManager and SidecarManager into reusable crate at v2/agor-core/, EventSink trait abstracts event emission for both Tauri and WebSocket contexts
- agor-relay WebSocket server binary: standalone Rust binary at v2/agor-relay/ with token auth (--port, --token, --insecure CLI flags), rate limiting (10 attempts, 5min lockout), per-connection isolated PTY + sidecar managers
- RemoteManager for multi-machine WebSocket connections: v2/src-tauri/src/remote.rs manages WebSocket client connections to relay instances, 12 new Tauri commands for remote operations, heartbeat ping every 15s
- Remote machine management UI in settings: SettingsDialog "Remote Machines" section for add/remove/connect/disconnect
- Auto-grouping of remote panes in sidebar: remote panes auto-grouped by machine label in SessionList
- remote-bridge.ts adapter for remote machine IPC operations
- machines.svelte.ts store for remote machine state management (Svelte 5 runes)
- Pane.remoteMachineId field in layout store for local vs remote routing
- TauriEventSink (event_sink.rs) implementing EventSink trait for Tauri AppHandle
- Multi-machine support architecture design (`docs/multi-machine.md`): WebSocket NDJSON protocol, pre-shared token + TLS auth, autonomous relay model
- Subagent cost aggregation: getTotalCost() recursive helper in agents store aggregates cost across parent + all child sessions; total cost displayed in parent pane done-bar when children present
- 10 new subagent routing tests in agent-dispatcher.test.ts: spawn, dedup, child message routing, init/cost forwarding, fallbacks (28 total dispatcher tests, 114 vitest tests overall)
- TAURI_SIGNING_PRIVATE_KEY secret set in GitHub repo for auto-update signing
- Agent teams/subagent support (Phase 7): auto-detects subagent tool calls ('Agent', 'Task', 'dispatch_agent'), spawns child agent panes with parent/child navigation, routes messages via parentId field
- Agent store parent/child hierarchy: AgentSession extended with parentSessionId, parentToolUseId, childSessionIds; findChildByToolUseId() and getChildSessions() query functions
- AgentPane parent link bar: SUB badge with navigate-to-parent button for subagent panes
- AgentPane children bar: clickable chips per child subagent with status-colored indicators (running/done/error)
- SessionList subagent icon: subagent panes show '↳' instead of '*' in sidebar
- Session groups/folders: group_name column in sessions table, setPaneGroup in layout store, collapsible group headers in sidebar with arrow/count, right-click pane to set group
- Auto-update signing key: generated minisign keypair, pubkey configured in tauri.conf.json updater section
- Deno-first sidecar: SidecarCommand struct in sidecar.rs, resolve_sidecar_command() prefers Deno (runs TS directly) with Node.js fallback, both runners bundled via tauri.conf.json resources
- Vitest integration tests: layout.test.ts (30 tests), agent-bridge.test.ts (11 tests), agent-dispatcher.test.ts (28 tests) — total 114 vitest tests passing
- E2E test scaffold: v2/tests/e2e/README.md documenting WebDriver approach
- Terminal copy/paste: Ctrl+Shift+C copies selection, Ctrl+Shift+V pastes from clipboard to PTY (TerminalPane.svelte)
- Terminal theme hot-swap: onThemeChange() callback registry in theme.svelte.ts, open terminals update immediately when flavor changes
- Agent tree node click: clicking a tree node scrolls to the corresponding message in the agent pane (scrollIntoView smooth)
- Agent tree subtree cost: cumulative cost displayed in yellow below each tree node label (subtreeCost utility)
- Agent session resume: follow-up prompt input after session completes or errors, passes resume_session_id to SDK
- Pane drag-resize handles: splitter overlays in TilingGrid with mouse drag, supports 2-col/3-col/2-row layouts with 10-90% ratio clamping
- Auto-update CI workflow: release.yml generates latest.json with version, platform URL, and signature from .sig file; uploads as release artifact
- Deno sidecar proof-of-concept: agent-runner-deno.ts with same NDJSON protocol, compiles to single binary via deno compile
- Vitest test suite: sdk-messages.test.ts (SDK message adapter) and agent-tree.test.ts (tree builder/cost), vite.config.ts test config, npm run test script
- Cargo test suite: session.rs tests (SessionDb CRUD for sessions, SSH sessions, settings, layout) and ctx.rs tests (CtxDb error handling with missing database)
- tempfile dev dependency for Rust test isolation
### Fixed
- Sidecar env var leak: both agent-runner.ts and agent-runner-deno.ts now strip ALL `CLAUDE*` prefixed env vars before spawning the claude CLI, preventing silent hangs when Agents Orchestrator is launched from within a Claude Code terminal session (previously only CLAUDECODE was removed)
### Changed
- RemoteManager reconnection probe refactored from attempt_ws_connect() (full WS handshake + auth) to attempt_tcp_probe() (TCP-only connect, no resource allocation on relay)
- agor-relay command handlers refactored: all error paths now use send_error() helper instead of log::error!() only; pong response sent via event channel instead of no-op
- RemoteManager disconnect handler: scoped mutex release before event emission to prevent deadlocks; spawns reconnection task
- PtyManager and SidecarManager extracted from src-tauri to agor-core shared crate (src-tauri now has thin re-export wrappers)
- Cargo workspace structure at v2/ level: members = [src-tauri, agor-core, agor-relay], Cargo.lock moved from src-tauri/ to workspace root
- agent-bridge.ts and pty-bridge.ts extended with remote routing (check remoteMachineId, route to remote_* commands)
- Agent dispatcher refactored to split messages: parentId-bearing messages routed to child panes via toolUseToChildPane Map, main session messages stay in parent
- Agent store createAgentSession() now accepts optional parent parameter for registering bidirectional parent/child links
- Agent store removeAgentSession() cleans up parent's childSessionIds on removal
- Sidecar manager refactored from Node.js-only to Deno-first with Node.js fallback (SidecarCommand abstraction)
- Session struct: added group_name field with serde default
- SessionDb: added update_group method, list/save queries updated for group_name column
- SessionList sidebar: uses Svelte 5 snippets for grouped pane rendering with collapsible headers
- Agent tree NODE_H increased from 32 to 40 to accommodate subtree cost display
- release.yml build step now passes TAURI_SIGNING_PRIVATE_KEY and PASSWORD env vars from secrets
- release.yml uploads latest.json alongside .deb and .AppImage artifacts
- vitest ^4.0.18 added as npm dev dependency
### Previously Added
- SSH session management: SshSession CRUD in SQLite, SshDialog create/edit modal, SshSessionList grouped by folder with color dots, SSH pane type routing to TerminalPane with shell=/usr/bin/ssh (Phase 5)
- ctx context database integration: read-only CtxDb (Rust, SQLITE_OPEN_READ_ONLY), ContextPane with project selector, tabs for entries/summaries/search, ctx-bridge adapter (Phase 5)
- Catppuccin theme flavors: all 4 palettes (Latte/Frappe/Macchiato/Mocha) selectable via Settings dialog, theme.svelte.ts reactive store with SQLite persistence, TerminalPane theme-aware (Phase 5)
- Detached pane mode: pop-out terminal/agent panes into standalone windows via URL params (?detached=1), detach.ts utility, App.svelte conditional rendering (Phase 5)
- Shiki syntax highlighting: lazy singleton highlighter with catppuccin-mocha theme, 13 preloaded languages, integrated in MarkdownPane and AgentPane text messages (Phase 5)
- Tauri auto-updater plugin: tauri-plugin-updater (Rust + npm) + updater.ts frontend utility (Phase 6)
- Markdown rendering in agent text messages with Shiki code highlighting (Phase 5)
- Build-from-source installer `install-v2.sh` with 6-step dependency checking (Node.js 20+, Rust 1.77+, WebKit2GTK, GTK3, and 8 other system libraries), auto-install via apt, binary install to `~/.local/bin/agents-orchestrator` with desktop entry (Phase 6)
- Tauri bundle configuration for .deb and AppImage targets with category, descriptions, and deb dependencies (Phase 6)
- GitHub Actions release workflow (`.github/workflows/release.yml`): triggered on `v*` tags, builds on Ubuntu 22.04 with Rust/npm caching, uploads .deb + AppImage as GitHub Release artifacts (Phase 6)
- Regenerated application icons from `agor.svg` as RGBA PNGs (32x32, 128x128, 256x256, 512x512, .ico) (Phase 6)
- Agent tree visualization: SVG tree of tool calls with horizontal layout, bezier edges, status-colored nodes (AgentTree.svelte + agent-tree.ts) (Phase 5)
- Global status bar showing terminal/agent pane counts, active agents with pulse animation, total tokens and cost (StatusBar.svelte) (Phase 5)
- Toast notification system with auto-dismiss (4s), max 5 visible, color-coded by type (notifications.svelte.ts + ToastContainer.svelte) (Phase 5)
- Agent dispatcher toast integration: notifications on agent complete, error, and sidecar crash (Phase 5)
- Settings dialog with default shell, working directory, and max panes configuration (SettingsDialog.svelte) (Phase 5)
- Settings persistence: key-value settings table in SQLite, Tauri commands settings_get/set/list, settings-bridge.ts adapter (Phase 5)
- Keyboard shortcuts: Ctrl+W close focused pane, Ctrl+, open settings dialog (Phase 5)
- SQLite session persistence with rusqlite (bundled, WAL mode) — sessions table + layout_state singleton (Phase 4)
- Session CRUD: save, delete, update_title, touch with 7 Tauri commands (Phase 4)
- Layout restore on app startup — panes and preset restored from database (Phase 4)
- File watcher backend using notify crate v6 — watches files, emits Tauri events on change (Phase 4)
- MarkdownPane component with marked.js rendering, Catppuccin-themed styles, and live reload (Phase 4)
- Sidebar "M" button for opening markdown/text files via file picker (Phase 4)
- Session bridge adapter for Tauri IPC (session + layout persistence wrappers) (Phase 4)
- File bridge adapter for Tauri IPC (watch, unwatch, read, onChange wrappers) (Phase 4)
- Sidecar crash detection — dispatcher listens for process exit, marks running sessions as error (Phase 3 polish)
- Sidecar restart UI — "Restart Sidecar" button in AgentPane error bar (Phase 3 polish)
- Auto-scroll lock — disables auto-scroll when user scrolls up, shows "Scroll to bottom" button (Phase 3 polish)
- Agent restart Tauri command (agent_restart) (Phase 3 polish)
- Agent pane with prompt input, structured message rendering, stop button, and cost display (Phase 3)
### Fixed
- Svelte 5 rune stores (layout, agents, sessions) renamed from `.ts` to `.svelte.ts` — runes only work in `.svelte` and `.svelte.ts` files, plain `.ts` caused "rune_outside_svelte" runtime error (blank screen)
- Updated all import paths to use `.svelte` suffix for store modules
- Node.js sidecar manager (Rust) for spawning and communicating with agent-runner via stdio NDJSON (Phase 3)
- Agent-runner sidecar: spawns `claude` CLI with `--output-format stream-json` for structured agent output (Phase 3)
- SDK message adapter parsing stream-json into 9 typed message types: init, text, thinking, tool_call, tool_result, status, cost, error, unknown (Phase 3)
- Agent bridge adapter for Tauri IPC (invoke + event listeners) (Phase 3)
- Agent dispatcher routing sidecar events to agent session store (Phase 3)
- Agent session store with message history, cost tracking, and lifecycle management (Phase 3)
- Keyboard shortcut: Ctrl+Shift+N to open new agent pane (Phase 3)
- Sidebar button for creating new agent sessions (Phase 3)
- Rust PTY backend with portable-pty: spawn, write, resize, kill with Tauri event streaming (Phase 2)
- xterm.js terminal pane with Canvas addon, FitAddon, and Catppuccin Mocha theme (Phase 2)
- CSS Grid tiling layout with 5 presets: 1-col, 2-col, 3-col, 2x2, master-stack (Phase 2)
- Layout store with Svelte 5 $state runes and auto-preset selection (Phase 2)
- Sidebar with session list, layout preset selector, and new terminal button (Phase 2)
- Keyboard shortcuts: Ctrl+N new terminal, Ctrl+1-4 focus pane (Phase 2)
- PTY bridge adapter for Tauri IPC (invoke + event listeners) (Phase 2)
- PaneContainer component with header bar, status indicator, and close button (Phase 2)
- Terminal resize handling with ResizeObserver and 100ms debounce (Phase 2)
- v2 project scaffolding: Tauri 2.x + Svelte 5 in `v2/` directory (Phase 1)
- Rust backend stubs: main.rs, lib.rs, pty.rs, sidecar.rs, watcher.rs, session.rs (Phase 1)
- Svelte frontend with Catppuccin Mocha CSS variables and component structure (Phase 1)
- Node.js sidecar scaffold with NDJSON communication pattern (Phase 1)
- v2 architecture planning: Tauri 2.x + Svelte 5 + Claude Agent SDK via Node.js sidecar
- Research documentation covering Agent SDK, xterm.js performance, Tauri ecosystem, and ultrawide layout patterns
- Phased implementation plan (6 phases, MVP = Phases 1-4)
- Error handling and testing strategy for v2
- Documentation structure in `docs/` (task_plan, phases, findings, progress)
- 17 operational rules in `.claude/rules/`
- TODO.md for tracking active work
- `.claude/CLAUDE.md` behavioral guide for Claude sessions
- VS Code workspace configuration with Peacock color

84
CLA.md
View file

@ -1,84 +0,0 @@
# Individual Contributor License Agreement
Thank you for your interest in contributing to Agent Orchestrator ("the Project").
This Contributor License Agreement ("Agreement") documents the rights granted by
contributors to the Project maintainers. This is a legally binding document, so
please read it carefully before agreeing.
## 1. Definitions
"You" (or "Your") means the individual who submits a Contribution to the Project.
"Contribution" means any original work of authorship, including any modifications
or additions to existing work, that You intentionally submit to the Project for
inclusion. "Submit" means any form of electronic or written communication sent to
the Project, including but not limited to pull requests, patches, issues, and
comments on any of these.
"Project Maintainers" means the owners and administrators of the
`DexterFromLab/agent-orchestrator` and `agents-orchestrator/agents-orchestrator`
repositories.
## 2. Grant of Copyright License
Subject to the terms of this Agreement, You hereby grant to the Project Maintainers
and to recipients of software distributed by the Project Maintainers a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to
reproduce, prepare derivative works of, publicly display, publicly perform,
sublicense, and distribute Your Contributions and such derivative works.
## 3. Grant of Patent License
Subject to the terms of this Agreement, You hereby grant to the Project Maintainers
and to recipients of software distributed by the Project Maintainers a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to
make, have made, use, offer to sell, sell, import, and otherwise transfer the
Contribution, where such license applies only to those patent claims licensable by
You that are necessarily infringed by Your Contribution alone or by combination of
Your Contribution with the Project to which such Contribution was submitted.
## 4. Representations
You represent that:
(a) You are legally entitled to grant the above licenses. If your employer has
rights to intellectual property that you create, you represent that you have received
permission to make Contributions on behalf of that employer, or that your employer
has waived such rights for your Contributions.
(b) Each of Your Contributions is Your original creation. You represent that Your
Contribution submissions include complete details of any third-party license or
other restriction of which you are personally aware and which are associated with
any part of Your Contributions.
(c) Your Contribution does not violate any existing agreement or obligation you have
with any third party.
## 5. No Obligation
You understand that the decision to include Your Contribution in any product or
project is entirely at the discretion of the Project Maintainers and that this
Agreement does not guarantee that Your Contribution will be included.
## 6. Support
You are not expected to provide support for Your Contributions, except to the extent
You desire to provide support. You may provide support for free, for a fee, or not
at all.
## 7. Ownership
You retain ownership of the copyright in Your Contributions. This Agreement does
not transfer ownership; it only grants the licenses described above.
## 8. Dual-License Usage
You acknowledge that Your Contributions may be used in both the open-source
community edition (MIT License) and the commercial edition of the Project. The
licenses granted above permit this dual usage.
---
To sign this CLA, submit a pull request to the community repository
(`DexterFromLab/agent-orchestrator`). The CLA-assistant bot will guide you through
the signing process automatically.

257
CLAUDE.md
View file

@ -1,229 +1,38 @@
# Agents Orchestrator — Project Guide for Claude
## Project Overview
Terminal emulator with SSH and Claude Code session management. v1 (GTK3+VTE Python) is production-stable. v2 redesign (Tauri 2.x + Svelte 5 + Claude Agent SDK) Phases 1-7 + multi-machine (A-D) + profiles/skills complete. Packaging: .deb + AppImage via GitHub Actions CI. v3 Mission Control (All Phases 1-10 Complete + Production Readiness): multi-project dashboard with project groups, per-project Claude sessions with session continuity, team agents panel, terminal tabs, VSCode-style left sidebar, multi-agent orchestration (Tier 1 management agents: Manager/Architect/Tester/Reviewer with role-specific tabs, btmsg inter-agent messaging, bttask kanban task board with optimistic locking). Production features: sidecar crash recovery/supervision, FTS5 full-text search, plugin system (Web Worker sandbox, 26 tests), Landlock sandboxing, secrets management (system keyring), OS + in-app notifications, keyboard-first UX (18+ palette commands), agent health monitoring + dead letter queue, audit logging, error classification. Hardening: TLS relay support, SPKI certificate pinning (TOFU), WAL checkpoint (5min), subagent delegation fix, SidecarManager actor pattern (mpsc), per-message btmsg acknowledgment (seen_messages), Aider autonomous mode toggle.
- **Repository:** github.com/DexterFromLab/Agents Orchestrator
- **License:** MIT
- **Primary target:** Linux x86_64
## Documentation (SOURCE OF TRUTH)
**All project documentation lives in [`docs/`](docs/README.md). This is the single source of truth for this project.** Before making changes, consult the docs. After making changes, update the docs. No exceptions.
## Key Paths
| Path | Description |
|------|-------------|
| `agor.py` | v1 main application (2092 lines, GTK3+VTE) |
| `ctx` | Context manager CLI tool (SQLite-based) |
| `install.sh` | v1 system installer |
| `install-v2.sh` | v2 build-from-source installer (Node.js 20+, Rust 1.77+, system libs) |
| `.github/workflows/release.yml` | CI: builds .deb + AppImage on v* tags, uploads to GitHub Releases |
| `docs/architecture.md` | End-to-end system architecture, data model, layout system |
| `docs/decisions.md` | Architecture decisions log with rationale and dates |
| `docs/phases.md` | v2 implementation phases (1-7 + multi-machine A-D) |
| `docs/findings.md` | All research findings (v2 + v3 combined) |
| `docs/progress/` | Session progress logs (v2, v3, archive) |
| `docs/multi-machine.md` | Multi-machine architecture (implemented, Phases A-D) |
| `docs/release-notes.md` | v3.0 release notes |
| `docs/e2e-testing.md` | E2E testing facility: fixtures, test mode, LLM judge, spec phases, CI |
| `Cargo.toml` | Cargo workspace root (members: src-tauri, agor-core, agor-relay) |
| `agor-core/` | Shared crate: EventSink trait, PtyManager, SidecarManager |
| `agor-relay/` | Standalone relay binary (WebSocket server, token auth, CLI) |
| `src-tauri/src/pty.rs` | PTY backend (thin re-export from agor-core) |
| `src-tauri/src/groups.rs` | Groups config (load/save ~/.config/agor/groups.json) |
| `src-tauri/src/fs_watcher.rs` | ProjectFsWatcher (inotify per-project recursive file change detection, S-1 Phase 2) |
| `src-tauri/src/lib.rs` | AppState + setup + handler registration (~170 lines) |
| `src-tauri/src/commands/` | 16 domain command modules (pty, agent, watcher, session, persistence, knowledge, claude, groups, files, remote, misc, bttask, notifications, plugins, search, secrets) |
| `src-tauri/src/btmsg.rs` | Agent messaging backend (agents, DMs, channels, contacts ACL, heartbeats, dead_letter_queue, audit_log; SQLite WAL mode, named column access) |
| `src-tauri/src/bttask.rs` | Task board backend (list, create, update status with optimistic locking, delete, comments, review_queue_count; shared btmsg.db) |
| `src-tauri/src/search.rs` | FTS5 full-text search (SearchDb, 3 virtual tables: search_messages/tasks/btmsg, index/search/rebuild) |
| `src-tauri/src/secrets.rs` | SecretsManager (keyring crate, linux-native/libsecret, store/get/delete/list with metadata tracking) |
| `src-tauri/src/plugins.rs` | Plugin discovery (scan config dir for plugin.json, path-traversal-safe file reading, permission validation) |
| `src-tauri/src/notifications.rs` | Desktop notifications (notify-rust, graceful fallback if daemon unavailable) |
| `agor-core/src/supervisor.rs` | SidecarSupervisor (auto-restart, exponential backoff 1s-30s, 5 retries, SidecarHealth enum, 17 tests) |
| `agor-core/src/sandbox.rs` | Landlock sandbox (SandboxConfig RW/RO paths, pre_exec() integration, kernel 6.2+ graceful fallback) |
| `src-tauri/src/sidecar.rs` | SidecarManager (thin re-export from agor-core) |
| `src-tauri/src/event_sink.rs` | TauriEventSink (implements EventSink for AppHandle) |
| `src-tauri/src/remote.rs` | RemoteManager (WebSocket client connections to relays) |
| `src-tauri/src/session/` | SessionDb module: mod.rs (struct + migrate), sessions.rs, layout.rs, settings.rs, ssh.rs, agents.rs, metrics.rs, anchors.rs |
| `src-tauri/src/watcher.rs` | FileWatcherManager (notify crate, file change events) |
| `src-tauri/src/ctx.rs` | CtxDb (read-only access to ~/.claude-context/context.db) |
| `src-tauri/src/memora.rs` | MemoraDb (read-only access to ~/.local/share/memora/memories.db, FTS5 search) |
| `src-tauri/src/telemetry.rs` | OTEL telemetry (TelemetryGuard, tracing + OTLP export, AGOR_OTLP_ENDPOINT) |
| `src/lib/stores/workspace.svelte.ts` | v3 workspace store (project groups, tabs, focus, replaces layout store) |
| `src/lib/stores/layout.svelte.ts` | v2 layout store (panes, presets, groups, persistence, Svelte 5 runes) |
| `src/lib/stores/agents.svelte.ts` | Agent session store (messages, cost, parent/child hierarchy) |
| `src/lib/components/Terminal/TerminalPane.svelte` | xterm.js terminal pane |
| `src/lib/components/Terminal/AgentPreviewPane.svelte` | Read-only xterm.js showing agent activity (Bash commands, tool results, errors) |
| `src/lib/components/Agent/AgentPane.svelte` | Agent session pane (sans-serif font, tool call/result pairing, hook collapsing, context meter, prompt, cost, profile selector, skill autocomplete) |
| `src/lib/adapters/pty-bridge.ts` | PTY IPC wrapper (Tauri invoke/listen) |
| `src/lib/adapters/agent-bridge.ts` | Agent IPC wrapper (Tauri invoke/listen) |
| `src/lib/adapters/claude-messages.ts` | Claude message adapter (stream-json parser, renamed from sdk-messages.ts) |
| `src/lib/adapters/message-adapters.ts` | Provider message adapter registry (per-provider routing to common AgentMessage) |
| `src/lib/adapters/provider-bridge.ts` | Generic provider bridge (delegates to provider-specific bridges) |
| `src/lib/providers/types.ts` | Provider abstraction types (ProviderId, ProviderCapabilities, ProviderMeta, ProviderSettings) |
| `src/lib/providers/registry.svelte.ts` | Svelte 5 rune-based provider registry (registerProvider, getProviders) |
| `src/lib/providers/claude.ts` | Claude provider metadata constant (CLAUDE_PROVIDER) |
| `src/lib/providers/codex.ts` | Codex provider metadata constant (CODEX_PROVIDER, gpt-5.4 default) |
| `src/lib/providers/ollama.ts` | Ollama provider metadata constant (OLLAMA_PROVIDER, qwen3:8b default) |
| `src/lib/adapters/codex-messages.ts` | Codex message adapter (ThreadEvent parser) |
| `src/lib/adapters/ollama-messages.ts` | Ollama message adapter (streaming chunk parser) |
| `src/lib/agent-dispatcher.ts` | Thin coordinator: routes sidecar events to agent store, delegates to extracted modules |
| `src/lib/utils/session-persistence.ts` | Session-project maps + persistSessionForProject + waitForPendingPersistence |
| `src/lib/utils/auto-anchoring.ts` | triggerAutoAnchor on first compaction event |
| `src/lib/utils/subagent-router.ts` | Subagent pane creation + toolUseToChildPane routing |
| `src/lib/utils/worktree-detection.ts` | detectWorktreeFromCwd pure function (3 provider patterns) |
| `src/lib/adapters/file-bridge.ts` | File watcher IPC wrapper |
| `src/lib/adapters/settings-bridge.ts` | Settings IPC wrapper (get/set/list) |
| `src/lib/adapters/ctx-bridge.ts` | ctx database IPC wrapper |
| `src/lib/adapters/ssh-bridge.ts` | SSH session IPC wrapper |
| `src/lib/adapters/claude-bridge.ts` | Claude profiles + skills IPC wrapper |
| `src/lib/adapters/groups-bridge.ts` | Groups config IPC wrapper (load/save) |
| `src/lib/adapters/remote-bridge.ts` | Remote machine management IPC wrapper |
| `src/lib/adapters/files-bridge.ts` | File browser IPC wrapper (list_directory_children, read_file_content) |
| `src/lib/adapters/memory-adapter.ts` | Pluggable memory adapter interface (MemoryAdapter, registry) |
| `src/lib/adapters/memora-bridge.ts` | Memora IPC bridge + MemoraAdapter (read-only SQLite via Tauri commands) |
| `src/lib/adapters/fs-watcher-bridge.ts` | Filesystem watcher IPC wrapper (project CWD write detection) |
| `src/lib/adapters/anchors-bridge.ts` | Session anchors IPC wrapper (save, load, delete, clear, updateType) |
| `src/lib/adapters/bttask-bridge.ts` | Task board IPC adapter (listTasks, createTask, updateTaskStatus, deleteTask, comments) |
| `src/lib/adapters/telemetry-bridge.ts` | Frontend telemetry bridge (routes events to Rust tracing via IPC) |
| `src/lib/utils/agent-prompts.ts` | Agent prompt generator (generateAgentPrompt: identity, env, team, btmsg/bttask docs, workflow) |
| `docker/tempo/` | Docker compose: Tempo + Grafana for trace visualization (port 9715) |
| `scripts/test-all.sh` | Unified test runner: vitest + cargo + optional E2E (--e2e flag) |
| `tests/e2e/wdio.conf.js` | WebDriverIO config (tauri-driver lifecycle, TCP probe, test env vars) |
| `tests/e2e/fixtures.ts` | E2E test fixture generator (isolated temp dirs, git repos, groups.json) |
| `tests/e2e/results-db.ts` | JSON test results store (run/step tracking, no native deps) |
| `tests/e2e/specs/agor.test.ts` | E2E smoke tests (CSS class selectors, 50+ tests) |
| `tests/e2e/specs/phase-a-structure.test.ts` | Phase A E2E: structural integrity + settings (Scenarios 1-2, 12 tests) |
| `tests/e2e/specs/phase-a-agent.test.ts` | Phase A E2E: agent pane + prompt submission (Scenarios 3+7, 15 tests) |
| `tests/e2e/specs/phase-a-navigation.test.ts` | Phase A E2E: terminal tabs + palette + focus (Scenarios 4-6, 15 tests) |
| `tests/e2e/specs/phase-b.test.ts` | Phase B E2E scenarios (multi-project, LLM-judged assertions, 6 scenarios) |
| `tests/e2e/llm-judge.ts` | LLM judge helper (Claude API assertions, confidence thresholds) |
| `.github/workflows/e2e.yml` | CI: unit + cargo + E2E tests (xvfb-run, path-filtered, LLM tests gated on secret) |
| `src/lib/stores/machines.svelte.ts` | Remote machine state store (Svelte 5 runes) |
| `src/lib/utils/attention-scorer.ts` | Pure attention scoring function (extracted from health store, 14 tests) |
| `src/lib/utils/wake-scorer.ts` | Pure wake signal evaluation (6 signals, 24 tests) |
| `src/lib/types/wake.ts` | WakeStrategy, WakeSignal, WakeEvaluation, WakeContext types |
| `src/lib/stores/wake-scheduler.svelte.ts` | Manager auto-wake scheduler (3 strategies, per-manager timers) |
| `src/lib/utils/type-guards.ts` | Shared runtime guards: str(), num() for untyped wire format parsing |
| `src/lib/utils/agent-tree.ts` | Agent tree builder (hierarchy from messages) |
| `src/lib/utils/highlight.ts` | Shiki syntax highlighter (lazy singleton, 13 languages) |
| `src/lib/utils/detach.ts` | Detached pane mode (pop-out windows via URL params) |
| `src/lib/utils/updater.ts` | Tauri auto-updater utility |
| `src/lib/stores/notifications.svelte.ts` | Notification store (toast + history, 6 NotificationTypes, unread badge, max 100 history) |
| `src/lib/stores/plugins.svelte.ts` | Plugin store (command registry, event bus, loadAllPlugins/unloadAllPlugins) |
| `src/lib/adapters/audit-bridge.ts` | Audit log IPC adapter (logAuditEvent, getAuditLog, AuditEntry, AuditEventType) |
| `src/lib/adapters/notifications-bridge.ts` | Desktop notification IPC wrapper (sendDesktopNotification) |
| `src/lib/adapters/plugins-bridge.ts` | Plugin discovery IPC wrapper (discoverPlugins, readPluginFile) |
| `src/lib/adapters/search-bridge.ts` | FTS5 search IPC wrapper (initSearch, searchAll, rebuildIndex, indexMessage) |
| `src/lib/adapters/secrets-bridge.ts` | Secrets IPC wrapper (storeSecret, getSecret, deleteSecret, listSecrets, hasKeyring) |
| `src/lib/utils/error-classifier.ts` | API error classification (6 types: rate_limit/auth/quota/overloaded/network/unknown, retry logic, 20 tests) |
| `src/lib/plugins/plugin-host.ts` | Sandboxed plugin runtime (Web Worker isolation, permission-gated API via postMessage, load/unload lifecycle) |
| `src/lib/components/Agent/UsageMeter.svelte` | Compact inline usage meter (color thresholds 50/75/90%, hover tooltip) |
| `src/lib/components/Notifications/NotificationCenter.svelte` | Bell icon + dropdown notification panel (unread badge, history, mark read/clear) |
| `src/lib/components/Workspace/AuditLogTab.svelte` | Manager audit log tab (filter by type+agent, 5s auto-refresh, max 200 entries) |
| `src/lib/components/Workspace/SearchOverlay.svelte` | FTS5 search overlay (Ctrl+Shift+F, Spotlight-style, 300ms debounce, grouped results) |
| `src/lib/stores/theme.svelte.ts` | Theme store (17 themes: 4 Catppuccin + 7 Editor + 6 Deep Dark, UI + terminal font restoration on startup) |
| `src/lib/styles/themes.ts` | Theme palette definitions (17 themes), ThemeId/ThemePalette/ThemeMeta types, THEME_LIST |
| `src/lib/styles/catppuccin.css` | CSS custom properties: 26 --ctp-* color vars + --ui-font-* + --term-font-* |
| `src/lib/components/Agent/AgentTree.svelte` | SVG agent tree visualization |
| `src/lib/components/Context/ContextPane.svelte` | ctx database viewer (projects, entries, search) — replaced by ContextTab in ProjectBox |
| `src/lib/components/Workspace/ContextTab.svelte` | LLM context window visualization (stats, token meter, file refs, turn breakdown) |
| `src/lib/components/Workspace/CodeEditor.svelte` | CodeMirror 6 wrapper (15 languages, Catppuccin theme, save/blur callbacks) |
| `src/lib/components/Workspace/PdfViewer.svelte` | PDF viewer (pdfjs-dist, canvas multi-page, zoom 0.5x3x, HiDPI) |
| `src/lib/components/Workspace/CsvTable.svelte` | CSV table viewer (RFC 4180 parser, delimiter auto-detect, sortable columns) |
| `src/lib/components/Workspace/MetricsPanel.svelte` | Dashboard metrics panel (live health + task counts + history sparklines, 25 tests) |
| `src/lib/stores/health.svelte.ts` | Project health store (activity state, burn rate, context pressure, file conflicts, attention scoring) |
| `src/lib/stores/conflicts.svelte.ts` | File overlap + external write conflict detection (per-project, session-scoped, worktree-aware, dismissible, inotify-backed) |
| `src/lib/stores/anchors.svelte.ts` | Session anchor store (per-project anchors, auto-anchor tracking, re-injection support) |
| `src/lib/types/anchors.ts` | Anchor types (AnchorType, SessionAnchor, AnchorSettings, AnchorBudgetScale, SessionAnchorRecord) |
| `src/lib/utils/anchor-serializer.ts` | Anchor serialization (turn grouping, observation masking, token estimation) |
| `src/lib/utils/tool-files.ts` | Shared file path extraction from tool_call inputs (extractFilePaths, extractWritePaths, extractWorktreePath) |
| `src/lib/components/StatusBar/StatusBar.svelte` | Mission Control bar (agent states, $/hr burn rate, attention queue, cost) |
| `src/lib/components/Notifications/ToastContainer.svelte` | Toast notification display |
| `src/lib/components/Workspace/` | v3 components: GlobalTabBar, ProjectGrid, ProjectBox, ProjectHeader, AgentSession, TeamAgentsPanel, AgentCard, TerminalTabs, ProjectFiles, FilesTab, SshTab, MemoriesTab, CommandPalette, DocsTab, SettingsTab, TaskBoardTab, ArchitectureTab, TestingTab |
| `src/lib/types/groups.ts` | TypeScript interfaces (ProjectConfig, GroupConfig, GroupsFile) |
| `src/lib/adapters/session-bridge.ts` | Session/layout/group persistence IPC wrapper |
| `src/lib/components/Markdown/MarkdownPane.svelte` | Markdown file viewer (marked.js + shiki, live reload) |
| `sidecar/claude-runner.ts` | Claude sidecar source (compiled to .mjs by esbuild, includes findClaudeCli()) |
| `sidecar/codex-runner.ts` | Codex sidecar source (@openai/codex-sdk dynamic import, sandbox/approval mapping) |
| `sidecar/ollama-runner.ts` | Ollama sidecar source (direct HTTP to localhost:11434, zero external deps) |
| `sidecar/aider-parser.ts` | Aider output parser (pure functions: looksLikePrompt, parseTurnOutput, extractSessionCost, execShell) |
| `sidecar/aider-parser.test.ts` | Vitest tests for Aider parser (72 tests: prompt detection, turn parsing, cost extraction, format-drift canaries) |
| `sidecar/agent-runner-deno.ts` | Standalone Deno sidecar runner (not used by SidecarManager, alternative) |
| `sidecar/dist/claude-runner.mjs` | Bundled Claude sidecar (runs on both Deno and Node.js) |
| `src/lib/adapters/claude-messages.test.ts` | Vitest tests for Claude message adapter (25 tests) |
| `src/lib/adapters/codex-messages.test.ts` | Vitest tests for Codex message adapter (19 tests) |
| `src/lib/adapters/ollama-messages.test.ts` | Vitest tests for Ollama message adapter (11 tests) |
| `src/lib/adapters/memora-bridge.test.ts` | Vitest tests for Memora bridge + adapter (16 tests) |
| `src/lib/adapters/btmsg-bridge.test.ts` | Vitest tests for btmsg bridge (17 tests: camelCase, IPC commands) |
| `src/lib/adapters/bttask-bridge.test.ts` | Vitest tests for bttask bridge (10 tests: camelCase, IPC commands) |
| `src/lib/adapters/agent-bridge.test.ts` | Vitest tests for agent IPC bridge (11 tests) |
| `src/lib/agent-dispatcher.test.ts` | Vitest tests for agent dispatcher (29 tests) |
| `src/lib/stores/conflicts.test.ts` | Vitest tests for conflict detection (28 tests) |
| `src/lib/utils/tool-files.test.ts` | Vitest tests for tool file extraction (27 tests) |
| `src/lib/stores/layout.test.ts` | Vitest tests for layout store (30 tests) |
| `src/lib/utils/agent-tree.test.ts` | Vitest tests for agent tree builder (20 tests) |
| `src/lib/stores/workspace.test.ts` | Vitest tests for workspace store (24 tests) |
## v1 Stack
- Python 3, GTK3 (PyGObject), VTE 2.91
- Config: `~/.config/agor/` (sessions.json, claude_sessions.json)
- Context DB: `~/.claude-context/context.db`
- Theme: Catppuccin Mocha
## v2/v3 Stack (v2 complete, v3 All Phases 1-10 complete, branch: v2-mission-control)
- Tauri 2.x (Rust backend) + Svelte 5 (frontend)
- Cargo workspace: agor-core (shared), agor-relay (remote binary), src-tauri (Tauri app)
- xterm.js with Canvas addon (no WebGL on WebKit2GTK)
- Agent sessions via `@anthropic-ai/claude-agent-sdk` query() function (migrated from raw CLI spawning)
- Sidecar uses SDK internally (single .mjs bundle, Deno-first + Node.js fallback, stdio NDJSON to Rust, auto-detects Claude CLI path via findClaudeCli(), supports CLAUDE_CONFIG_DIR override for multi-account)
- portable-pty for terminal management (in agor-core)
- Multi-machine: agor-relay WebSocket server + RemoteManager WebSocket client
- SQLite session persistence (rusqlite, WAL mode) + layout restore on startup
- File watcher (notify crate) for live markdown viewer
- OpenTelemetry: tracing + tracing-subscriber + opentelemetry 0.28 + tracing-opentelemetry 0.29, OTLP/HTTP to Tempo, AGOR_OTLP_ENDPOINT env var
- Rust deps (src-tauri): tauri, agor-core (path), rusqlite (bundled-full, FTS5), dirs, notify, serde, tokio, tokio-tungstenite, futures-util, tracing, tracing-subscriber, opentelemetry, opentelemetry_sdk, opentelemetry-otlp, tracing-opentelemetry, tauri-plugin-updater, tauri-plugin-dialog, notify-rust, keyring (linux-native)
- Rust deps (agor-core): portable-pty, uuid, serde, serde_json, log, landlock
- Rust deps (agor-relay): agor-core, tokio, tokio-tungstenite, clap, env_logger, futures-util
- npm deps: @anthropic-ai/claude-agent-sdk, @xterm/xterm, @xterm/addon-canvas, @xterm/addon-fit, @tauri-apps/api, @tauri-apps/plugin-updater, @tauri-apps/plugin-dialog, marked, shiki, pdfjs-dist, vitest (dev)
- Source: `` directory
## Build / Run
# agent_orchestrator
On session start, load context:
```bash
# v1 (current production)
./install.sh # Install system-wide
agor # Run
# v1 Dependencies (Debian/Ubuntu)
sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-vte-2.91
# Development
npm install && npm run tauri dev # Dev mode
npm run tauri build # Release build
# Tests
npm run test:all # All tests (vitest + cargo)
npm run test:all:e2e # All tests + E2E (needs built binary)
npm run test # Vitest only (frontend)
npm run test:cargo # Cargo only (backend)
npm run test:e2e # E2E only (WebDriverIO)
# Telemetry stack (Tempo + Grafana)
cd docker/tempo && docker compose up -d # Grafana at http://localhost:9715
AGOR_OTLP_ENDPOINT=http://localhost:4318 npm run tauri dev # Enable OTLP export
ctx get agent_orchestrator
```
## Conventions
Context manager: `ctx --help`
- 17 themes in 3 groups: 4 Catppuccin (Mocha default) + 7 Editor + 6 Deep Dark (Tokyo Night, Gruvbox Dark, Ayu Dark, Poimandres, Vesper, Midnight)
- CSS uses rem/em for layout; px only for icons/borders (see `.claude/rules/18-relative-units.md`)
- Session configs stored as JSON
- Single-file Python app (v1) — will change to multi-file Rust+Svelte (v2)
- Polish language in some code comments (v1 legacy)
During work:
- Save important discoveries: `ctx set agent_orchestrator <key> <value>`
- Append to existing: `ctx append agent_orchestrator <key> <value>`
- Before ending session: `ctx summary agent_orchestrator "<what was done>"`
## External AI consultation (OpenRouter)
Consult other models (GPT, Gemini, DeepSeek, etc.) for code review, cross-checks, or analysis:
```bash
consult "question" # ask default model
consult -m model_id "question" # ask specific model
consult -f file.py "review this code" # include file
consult # show available models
```
## Task management (CLI tool)
IMPORTANT: Use the `tasks` CLI tool via Bash — NOT the built-in TaskCreate/TaskUpdate/TaskList tools.
The built-in task tools are a different system. Always use `tasks` in Bash.
```bash
tasks list agent_orchestrator # show all tasks
tasks context agent_orchestrator # show tasks + next task instructions
tasks add agent_orchestrator "description" # add a task
tasks done agent_orchestrator <task_id> # mark task as done
tasks --help # full help
```
Do NOT pick up tasks on your own. Only execute tasks when the auto-trigger system sends you a command.

View file

@ -1,168 +0,0 @@
# Contributing to Agent Orchestrator
## Dual-Repository Model
This project uses a dual-repo structure:
| Repository | License | Purpose |
|------------|---------|---------|
| `DexterFromLab/agent-orchestrator` | MIT | Community edition (open source) |
| `agents-orchestrator/agents-orchestrator` | MIT + Commercial | Commercial edition (this repo) |
Community contributions target the community repo. Commercial development happens
exclusively in this repo. The two share a common `main` branch that is synced
periodically.
## Community Contributions
All community contributions go to **DexterFromLab/agent-orchestrator**. Do not open
PRs against this repo for community features.
### How to Contribute
1. Fork the community repo at `DexterFromLab/agent-orchestrator`
2. Create a feature branch from `main`
3. Make your changes and commit using conventional commits
4. Open a pull request against `DexterFromLab/agent-orchestrator` `main`
5. Sign the CLA when prompted by the bot on your first PR
6. Address review feedback
7. Once approved, a maintainer will merge your PR
Do **not** fork or open PRs against `agents-orchestrator/agents-orchestrator` for
community contributions. That repository contains commercial code and access is
restricted.
### Contributor License Agreement (CLA)
Every community contributor must sign the CLA before their first PR is merged.
CLA signing is automated via [CLA-assistant.io](https://cla-assistant.io/) on
the community repository. The bot will prompt you on your first PR.
The CLA grants the project maintainers a perpetual, irrevocable license to use
your contribution in both the community and commercial editions. You retain full
ownership of your code. See [CLA.md](CLA.md) for the full agreement text.
## Commercial Development
Commercial features are developed only in this repository. Access is restricted
to authorized team members.
### What Content Is Commercial-Only
The following paths and markers identify commercial-only content:
| Marker | Description |
|--------|-------------|
| `agor-pro/` | Commercial feature modules |
| `src/lib/commercial/` | Commercial frontend components |
| `tests/commercial/` | Commercial test suites |
| `LICENSE-COMMERCIAL` | Commercial license file |
| `LicenseRef-Commercial` SPDX header | Any file with this header |
| `test:all:commercial` script | Commercial test runner |
This content is automatically stripped during community sync and never appears in
the community repository.
### SPDX License Headers
All commercial source files must include the following header as the first line:
```
// SPDX-License-Identifier: LicenseRef-Commercial
```
For CSS/HTML files:
```
/* SPDX-License-Identifier: LicenseRef-Commercial */
```
For Rust files:
```rust
// SPDX-License-Identifier: LicenseRef-Commercial
```
Community-shared code uses the MIT identifier:
```
// SPDX-License-Identifier: MIT
```
## Community Sync Workflow
The community repo is kept in sync with this repo via an automated workflow:
1. **Trigger**: Manual dispatch or on release tag publication
2. **Strip**: `scripts/strip-commercial.sh` removes all commercial content
3. **Verify**: Automated checks ensure no commercial references remain
4. **Push**: A sync branch is pushed to `DexterFromLab/agent-orchestrator`
5. **Merge**: A maintainer reviews and merges the sync PR
To preview what would be stripped locally:
```bash
# Dry run — shows what files would be removed (modifies working tree)
bash scripts/strip-commercial.sh
# Reset after preview
git checkout .
```
The leak-check CI workflow runs on every push and PR to `main`, verifying that no
commercial content has been accidentally committed to community-bound code.
## Branch Model
| Branch | Purpose |
|--------|---------|
| `main` | Shared with community edition. Never commit commercial code here. |
| `commercial/*` | Commercial-only features. Merged into the commercial release branch. |
| `feature/*`, `fix/*` | Standard development branches. |
### Sync Flow
The community repo's `main` is merged into this repo's `main` periodically:
```
community/main --> origin/main --> commercial branches
```
Use `make sync` to pull community changes. Never force-push `main`.
## Commit Conventions
Use [Conventional Commits](https://www.conventionalcommits.org/):
```
type(scope): description
feat(dashboard): add team analytics panel
fix(sidecar): handle timeout on agent restart
docs(api): update webhook payload reference
chore(deps): bump tauri to 2.3.1
```
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`.
Breaking changes use `type!:` prefix or include `BREAKING CHANGE:` in the footer.
## Testing
Both editions must pass their respective test suites before merge:
```bash
# Community tests (must always pass on main)
npm run test:all
# Commercial tests (includes commercial-specific tests)
npm run test:all:commercial
```
Do not merge a PR if either suite is red. If a community sync introduces
failures in commercial tests, fix them before merging.
## Code Review
- All PRs require at least one approval.
- Commercial PRs must be reviewed by a team member with commercial repo access.
- Verify no commercial code leaks into community-bound branches before approving.

80
Cargo.lock generated
View file

@ -12,8 +12,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
name = "agent-orchestrator"
version = "0.1.0"
dependencies = [
"agor-core",
"agor-pro",
"bterminal-core",
"dirs 5.0.1",
"futures-util",
"hex",
@ -44,52 +43,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "agor-core"
version = "0.1.0"
dependencies = [
"dirs 5.0.1",
"landlock",
"log",
"portable-pty",
"serde",
"serde_json",
"uuid",
]
[[package]]
name = "agor-pro"
version = "0.1.0"
dependencies = [
"agor-core",
"dirs 5.0.1",
"log",
"rusqlite",
"serde",
"serde_json",
"sha2",
"tauri",
"tokio",
]
[[package]]
name = "agor-relay"
version = "0.1.0"
dependencies = [
"agor-core",
"clap",
"env_logger",
"futures-util",
"log",
"native-tls",
"serde",
"serde_json",
"tokio",
"tokio-native-tls",
"tokio-tungstenite",
"uuid",
]
[[package]]
name = "ahash"
version = "0.8.12"
@ -445,6 +398,37 @@ dependencies = [
"alloc-stdlib",
]
[[package]]
name = "bterminal-core"
version = "0.1.0"
dependencies = [
"dirs 5.0.1",
"landlock",
"log",
"portable-pty",
"serde",
"serde_json",
"uuid",
]
[[package]]
name = "bterminal-relay"
version = "0.1.0"
dependencies = [
"bterminal-core",
"clap",
"env_logger",
"futures-util",
"log",
"native-tls",
"serde",
"serde_json",
"tokio",
"tokio-native-tls",
"tokio-tungstenite",
"uuid",
]
[[package]]
name = "bumpalo"
version = "3.20.2"

View file

@ -1,3 +1,3 @@
[workspace]
members = ["src-tauri", "agor-core", "agor-relay", "agor-pro"]
members = ["src-tauri", "bterminal-core", "bterminal-relay"]
resolver = "2"

View file

@ -1,50 +0,0 @@
Commercial License
Copyright (c) 2025-2026 Agents Orchestrator. All rights reserved.
TERMS AND CONDITIONS
1. GRANT OF LICENSE
This software and associated documentation files (the "Software") located
under the directories `agor-pro/` and `src/lib/commercial/`, and any other
files bearing the SPDX header `LicenseRef-Commercial`, are licensed to
authorized users under the terms of a separate commercial agreement.
2. RESTRICTIONS
Unless you have a valid commercial license agreement, you may not:
a. Copy, modify, merge, publish, distribute, sublicense, or sell copies
of the Software.
b. Reverse engineer, decompile, or disassemble the Software.
c. Remove or alter any proprietary notices, labels, or marks on the
Software.
d. Use the Software to provide a competing product or service.
3. OWNERSHIP
The Software is the intellectual property of Agents Orchestrator and is
protected by copyright law. This license does not transfer ownership.
4. NO WARRANTY
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING
FROM, OUT OF, OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
5. TERMINATION
This license terminates automatically if you breach any of its terms. Upon
termination, you must destroy all copies of the Software in your possession.
6. GOVERNING LAW
This license shall be governed by and construed in accordance with the laws
of the jurisdiction in which Agents Orchestrator is incorporated.
For licensing inquiries, contact the Agents Orchestrator organization.

View file

@ -1,128 +0,0 @@
# Maintenance Guide
Operational procedures for the commercial edition of Agent Orchestrator.
## PAT Rotation
The `COMMUNITY_PAT` personal access token is used by CI to sync with the
community repository. Rotate it every 90 days.
### Rotation Procedure
1. Generate a new fine-grained PAT on GitHub with scope:
- Repository: `DexterFromLab/agent-orchestrator`
- Permissions: `Contents: Read-only`
2. Update the secret in this repo's GitHub Settings > Secrets > Actions:
- Name: `COMMUNITY_PAT`
- Value: the new token
3. Run the sync workflow manually to verify: Actions > Community Sync > Run workflow.
4. Record the rotation date. Next rotation due in 90 days.
### Token Audit
Check token expiry dates monthly. Set a calendar reminder.
## Community Sync
### Automated
```bash
make sync
```
This fetches `community/main`, merges it into `origin/main`, and runs the test
suite. Conflicts must be resolved manually.
### Manual
```bash
git remote add community https://github.com/DexterFromLab/agent-orchestrator.git 2>/dev/null
git fetch community main
git checkout main
git merge community/main --no-edit
npm run test:all
```
If tests fail after sync, fix before pushing.
## Pre-Release Checklist: Community Edition
Before publishing a community release from `main`:
- [ ] `git diff main..commercial/main -- agor-pro/ src/lib/commercial/` shows no commercial code on `main`
- [ ] Run `grep -r "LicenseRef-Commercial" --include="*.ts" --include="*.rs" --include="*.svelte" src/ src-tauri/` on `main` returns nothing
- [ ] Run `npm run test:all` passes
- [ ] Run `cargo test --workspace` passes
- [ ] CHANGELOG.md updated with release notes
- [ ] Tag follows semver: `v{major}.{minor}.{patch}`
- [ ] No secrets, API keys, or internal URLs in the diff since last release
## Pre-Release Checklist: Commercial Edition
Before publishing a commercial release:
- [ ] All commercial branches merged into the release branch
- [ ] `npm run test:all:commercial` passes
- [ ] `cargo test --workspace` passes
- [ ] License headers present on all commercial files (`grep -rL "SPDX-License-Identifier" agor-pro/ src/lib/commercial/` returns nothing)
- [ ] No hardcoded credentials or internal endpoints
- [ ] Database migrations tested against fresh install and upgrade from previous version
- [ ] Release notes written for commercial changelog
## Database Migration Notes
The commercial edition uses a separate data directory to avoid conflicts:
| Edition | Data Directory |
|---------|---------------|
| Community | `~/.local/share/bterminal/` |
| Commercial | `~/.local/share/agor/` |
### Migration Rules
- Schema migrations run automatically on startup (WAL mode SQLite).
- Never modify existing migration SQL. Add new migrations with incrementing version numbers.
- Test migrations against: (a) fresh install, (b) upgrade from N-1, (c) upgrade from N-2.
- Back up `~/.local/share/agor/` before testing destructive migrations locally.
### Edition Switching in Development
When switching between community and commercial editions locally:
```bash
make clean
```
This clears build artifacts and resets configuration to avoid cross-contamination.
The two editions use separate data directories, so user data is not affected.
Rebuild after switching:
```bash
npm install && npm run tauri dev
```
## Quarterly Maintenance
Perform these tasks every quarter:
### Security
- [ ] Rotate `COMMUNITY_PAT` (if due within the quarter)
- [ ] Run `npm audit` and `cargo audit` on both editions
- [ ] Review GitHub Dependabot alerts
- [ ] Verify no secrets in git history: `git log --all --diff-filter=A -- '*.env' '*.pem' '*.key'`
### Dependencies
- [ ] Update Rust toolchain (`rustup update`)
- [ ] Update Node.js to latest LTS if applicable
- [ ] Review and update pinned dependency versions
- [ ] Run full test suite after updates
### Repository Health
- [ ] Prune stale branches (`git branch --merged main | grep -v main`)
- [ ] Verify CI workflows are green on main
- [ ] Review and close stale issues/PRs
- [ ] Sync community changes if not done recently
- [ ] Verify backup procedures for commercial data

View file

@ -1,215 +0,0 @@
# Migration Clusters: Reactive Dependency Graph Analysis
Phase 2 binding analysis — reactive state dependencies across stores, bridge adapters, and components.
## Store Dependency Matrix
### agents.svelte.ts
- **Bridge imports:** claude-messages (type only)
- **Store imports:** none
- **Reactive state:** `$state<AgentSession[]>` (sessions)
- **Exported functions:** getAgentSessions, getAgentSession, createAgentSession, updateAgentStatus, setAgentSdkSessionId, setAgentModel, appendAgentMessage(s), updateAgentCost, findChildByToolUseId, getChildSessions, getTotalCost, clearAllAgentSessions, removeAgentSession
- **Consumed by stores:** workspace, health, wake-scheduler
- **Consumed by components:** AgentPane, AgentPreviewPane, AgentTree, AgentCard, AgentSession, ContextTab, MetricsPanel, StatusBar, TeamAgentsPanel
- **Consumed by utils:** session-persistence, subagent-router, agent-dispatcher
### workspace.svelte.ts
- **Bridge imports:** groups-bridge, btmsg-bridge
- **Store imports:** agents, health, conflicts, wake-scheduler
- **Reactive state:** `$state<GroupsFile | null>`, `$state<string>` (activeGroupId), `$state<WorkspaceTab>`, `$state<string | null>` (activeProjectId), `$state<Record<string, TerminalTab[]>>`, `$state<string | null>` (focusFlash)
- **Exported functions:** get*/set* for all state, switchGroup, load/saveWorkspace, add/remove/update Group/Project/Agent, terminal tab mgmt, event callbacks
- **Consumed by stores:** wake-scheduler
- **Consumed by components:** GlobalTabBar, ProjectGrid, ProjectBox, GroupAgentsPanel, CommandPalette, SettingsTab, DocsTab, SearchOverlay, TerminalTabs, SshTab, AgentSession, StatusBar, CommsTab, ProjectSettings
- **Consumed by utils:** auto-anchoring, agent-dispatcher (via waitForPendingPersistence)
### health.svelte.ts
- **Bridge imports:** none
- **Store imports:** agents, conflicts
- **Reactive state:** `$state<Map>` (trackers, stallThresholds), `$state<number>` (tickTs)
- **Exported functions:** trackProject, untrackProject, setStallThreshold, updateProjectSession, recordActivity, recordToolDone, recordTokenSnapshot, start/stopHealthTick, setReviewQueueDepth, clearHealthTracking, getProjectHealth, getAllProjectHealth, getAttentionQueue, getHealthAggregates
- **Consumed by stores:** workspace (import clearHealthTracking), wake-scheduler
- **Consumed by components:** ProjectBox, ProjectHeader, MetricsPanel, StatusBar, AgentSession
- **Consumed by utils:** agent-dispatcher, attention-scorer (type only)
### conflicts.svelte.ts
- **Bridge imports:** none (types/ids only)
- **Store imports:** none
- **Reactive state:** `$state<Map>` (projectFileWrites, acknowledgedFiles, sessionWorktrees, agentWriteTimestamps)
- **Exported functions:** setSessionWorktree, recordFileWrite, recordExternalWrite, getExternalConflictCount, getProjectConflicts, hasConflicts, getTotalConflictCount, acknowledgeConflicts, clearSessionWrites, clearProjectConflicts, clearAllConflicts
- **Consumed by stores:** workspace (import clearAllConflicts), health
- **Consumed by components:** ProjectBox, ProjectHeader, StatusBar
- **Consumed by utils:** agent-dispatcher
### anchors.svelte.ts
- **Bridge imports:** anchors-bridge
- **Store imports:** none
- **Reactive state:** `$state<Map>` (projectAnchors), `$state<Set>` (autoAnchoredProjects)
- **Exported functions:** get/add/remove/change anchor(s), loadAnchorsForProject, hasAutoAnchored, markAutoAnchored, getAnchorSettings
- **Consumed by components:** AgentPane, ContextTab, AgentSession
- **Consumed by utils:** auto-anchoring, agent-dispatcher
### theme.svelte.ts
- **Bridge imports:** settings-bridge
- **Store imports:** none
- **Reactive state:** `$state<ThemeId>`, `$state<ThemePalette | null>`
- **Exported functions:** getCurrentTheme, getXtermTheme, setTheme, initTheme, previewPalette, clearPreview, setCustomTheme, onThemeChange
- **Consumed by components:** TerminalPane, AgentPreviewPane, SettingsTab, AppearanceSettings, ThemeEditor
- **Consumed by:** App.svelte (init)
### plugins.svelte.ts
- **Bridge imports:** plugins-bridge, settings-bridge
- **Store imports:** none
- **Reactive state:** `$state<PluginCommand[]>`, `$state<PluginEntry[]>`
- **Exported functions:** get/add/removePluginCommands, pluginEventBus, getPluginEntries, setPluginEnabled, loadAllPlugins, reloadAllPlugins, destroyAllPlugins
- **Consumed by components:** CommandPalette, SettingsTab, AdvancedSettings
- **Consumed by:** plugin-host.ts
### machines.svelte.ts
- **Bridge imports:** remote-bridge
- **Store imports:** notifications
- **Reactive state:** `$state<Machine[]>`
- **Exported functions:** getMachines, getMachine, loadMachines, addMachine, removeMachine, connectMachine, disconnectMachine, init/destroyMachineListeners
- **Consumed by components:** (used by SettingsTab multi-machine section)
### wake-scheduler.svelte.ts
- **Bridge imports:** bttask-bridge, audit-bridge
- **Store imports:** health, workspace, agents
- **Reactive state:** `$state<Map>` (registrations, pendingWakes)
- **Exported functions:** disableWakeScheduler, register/unregisterManager, updateManager*, getWakeEvent, consumeWakeEvent, forceWake, clearWakeScheduler
- **Consumed by stores:** workspace (import clearWakeScheduler)
- **Consumed by components:** ProjectBox, AgentSession, StatusBar
### notifications.svelte.ts
- **Bridge imports:** notifications-bridge
- **Store imports:** none
- **Reactive state:** `$state<Toast[]>`, `$state<HistoryNotification[]>`
- **Exported functions:** getNotifications, notify, dismissNotification, addNotification, getNotificationHistory, getUnreadCount, markRead, markAllRead, clearHistory
- **Consumed by stores:** machines
- **Consumed by components:** ToastContainer, NotificationCenter, ProjectBox, AgentPane
- **Consumed by utils:** handle-error, global-error-handler, auto-anchoring, agent-dispatcher
### layout.svelte.ts
- **Bridge imports:** session-bridge
- **Store imports:** none
- **Reactive state:** `$state<Pane[]>`, `$state<LayoutPreset>`, `$state<string | null>` (focusedPaneId)
- **Exported functions:** getPanes, getActivePreset, getFocusedPaneId, add/removePane, focusPane, setPreset, renamePaneTitle, setPaneGroup, restoreFromDb, getGridTemplate, getPaneGridArea
- **Consumed by components:** AgentPane (focusPane)
- **Consumed by utils:** detach (type only), subagent-router
### settings-scope.svelte.ts
- **Bridge imports:** settings-bridge
- **Store imports:** none
- **Reactive state:** `$state<string | null>` (activeProjectId), `$state<Map>` (overrideCache)
- **Exported functions:** setActiveProject, getActiveProjectForSettings, scopedGet/Set, removeOverride, getOverrideChain, hasProjectOverride, invalidateSettingsCache
- **Consumed by components:** ProjectSettings
### sessions.svelte.ts
- **Bridge imports:** none
- **Store imports:** none
- **Reactive state:** `$state<Session[]>`
- **Exported functions:** getSessions, addSession, removeSession
- **Consumed by:** (v2 legacy, minimal usage)
---
## Migration Clusters
Clusters are groups of files that share reactive state and must migrate atomically.
### Cluster 1: Pure State (No Bridge Dependencies)
**Files:** `agents.svelte.ts`, `conflicts.svelte.ts`, `sessions.svelte.ts`
**Dependency:** None on bridges. Pure in-memory reactive state.
**Risk:** LOW. These are self-contained state containers with zero platform coupling.
**Migration:** Move to `@agor/stores` immediately. No BackendAdapter wiring needed.
### Cluster 2: Settings Domain
**Files:** `settings-bridge.ts`, `settings-scope.svelte.ts`, `theme.svelte.ts`, `plugins.svelte.ts`, `custom-themes.ts`
**Dependency:** All import `settings-bridge.ts` (Tauri `invoke`).
**Component consumers:** SettingsTab, FilesTab, AppearanceSettings, AgentSettings, OrchestrationSettings, SecuritySettings, AdvancedSettings, ProjectSettings, App.svelte
**Risk:** MEDIUM. `settings-bridge` is the most-imported bridge (13 consumers). Theme init runs at app startup. Plugin lifecycle depends on settings for enabled state.
**Migration:** Replace `settings-bridge` imports with `getBackend().getSetting()` / `getBackend().setSetting()`. Migrate `settings-scope.svelte.ts` and `theme.svelte.ts` first, then `plugins.svelte.ts`.
### Cluster 3: Workspace + Groups
**Files:** `workspace.svelte.ts`, `groups-bridge.ts`, `btmsg-bridge.ts`
**Dependency:** Imports groups-bridge (Tauri invoke) and btmsg-bridge (agent registration).
**Cross-store deps:** Imports agents, health, conflicts, wake-scheduler (clear functions).
**Risk:** HIGH. Central orchestration hub. 15+ component consumers. switchGroup() cascades clears across 4 stores. loadWorkspace() is app bootstrap path.
**Migration:** Replace groups-bridge calls with BackendAdapter.loadGroups()/saveGroups(). btmsg registration needs a new BackendAdapter method or stays as a separate bridge.
### Cluster 4: Notification + Error Handling
**Files:** `notifications.svelte.ts`, `notifications-bridge.ts`, `handle-error.ts`, `global-error-handler.ts`
**Dependency:** notifications-bridge (Tauri invoke for desktop notifications).
**Risk:** LOW-MEDIUM. notifications-bridge is a single function (sendDesktopNotification). Capability-gated by `supportsDesktopNotifications`. handle-error is consumed everywhere.
**Migration:** Replace sendDesktopNotification with capability-checked BackendAdapter call. handle-error stays as-is (it calls notify() which is pure state).
### Cluster 5: Machine Management
**Files:** `machines.svelte.ts`, `remote-bridge.ts`
**Dependency:** remote-bridge (Tauri invoke + listen for WebSocket events).
**Cross-store deps:** imports notifications
**Risk:** MEDIUM. Event listener lifecycle (listen/unlisten). 12 IPC commands. Not in BackendAdapter yet.
**Migration:** Defer. Needs BackendAdapter extension for remote machine operations. Low priority (multi-machine is advanced feature).
### Cluster 6: Layout + Session Persistence
**Files:** `layout.svelte.ts`, `session-bridge.ts`
**Dependency:** session-bridge (Tauri invoke for SQLite session CRUD).
**Risk:** MEDIUM. V2 legacy layout — v3 uses workspace store. Still consumed by AgentPane.focusPane and subagent-router.
**Migration:** Replace session-bridge calls with BackendAdapter (needs session methods). Can coexist during transition.
### Cluster 7: Anchors
**Files:** `anchors.svelte.ts`, `anchors-bridge.ts`
**Dependency:** anchors-bridge (Tauri invoke for SQLite anchor CRUD).
**Risk:** LOW. Self-contained, small API surface (save, load, delete, updateType).
**Migration:** Replace anchors-bridge with BackendAdapter (needs anchor methods) or standalone bridge kept temporarily.
### Cluster 8: Wake Scheduler
**Files:** `wake-scheduler.svelte.ts`, `bttask-bridge.ts`, `audit-bridge.ts`
**Dependency:** bttask-bridge (Tauri invoke for task listing), audit-bridge (Tauri invoke for audit logging).
**Cross-store deps:** Reads from health, workspace, agents.
**Risk:** MEDIUM-HIGH. Complex evaluation logic + timer management. Multiple bridge dependencies. Reads across 3 stores.
**Migration:** Last cluster. Depends on clusters 1, 3, 8 completing first.
---
## Cluster Dependency Graph
```
Cluster 1 (Pure State)
|
+---> Cluster 3 (Workspace) depends on Cluster 1
| |
| +---> Cluster 8 (Wake Scheduler) depends on Clusters 1, 3, 4
|
+---> Cluster 4 (Notifications) standalone
|
+---> Cluster 6 (Layout) standalone
|
+---> Cluster 7 (Anchors) standalone
Cluster 2 (Settings) standalone
Cluster 5 (Machines) depends on Cluster 4
```
## Recommended Migration Order
1. **Cluster 1: Pure State** (agents, conflicts, sessions) -- zero risk, no bridges
2. **Cluster 2: Settings Domain** -- most consumers, establishes BackendAdapter pattern
3. **Cluster 4: Notifications** -- small bridge surface, enables error handling migration
4. **Cluster 7: Anchors** -- self-contained, small
5. **Cluster 6: Layout** -- v2 legacy, low priority
6. **Cluster 3: Workspace** -- high risk, many cross-deps, central hub
7. **Cluster 5: Machines** -- needs BackendAdapter extension
8. **Cluster 8: Wake Scheduler** -- depends on everything else
## Risk Assessment Summary
| Cluster | Risk | Reason |
|---------|------|--------|
| 1. Pure State | LOW | No platform deps, pure memory |
| 2. Settings | MEDIUM | 13 consumers, startup path, theme init |
| 3. Workspace | HIGH | Central hub, cascading clears, 15+ components |
| 4. Notifications | LOW-MEDIUM | Single bridge function, capability-gated |
| 5. Machines | MEDIUM | Event listeners, not in BackendAdapter yet |
| 6. Layout | MEDIUM | V2 legacy, session persistence |
| 7. Anchors | LOW | Small, self-contained |
| 8. Wake Scheduler | MEDIUM-HIGH | Multi-bridge, cross-store reads, timers |

View file

@ -1,36 +0,0 @@
.PHONY: setup build build-pro test test-pro sync clean
# --- Community ---
setup:
git config core.hooksPath .githooks
npm install
@echo "Git hooks configured and dependencies installed."
build:
cargo build
npm run build
test:
npm run test:all
# --- Commercial ---
build-pro:
cargo build --features pro
npm run build
@echo "For Tauri release: cargo tauri build -- --features pro --config src-tauri/tauri.conf.commercial.json"
test-pro:
cargo test --features pro
AGOR_EDITION=pro npx vitest run
# --- Maintenance ---
sync:
git fetch origin
git merge origin/main
clean:
cargo clean
rm -rf node_modules/.vite

View file

@ -1,267 +0,0 @@
# Phase 1 Store Audit: Platform Dependency Analysis
Categorization of each store for migration readiness to `@agor/stores`.
## Category Definitions
- **CLEAN:** No platform dependencies. Can move to `@agor/stores` as-is.
- **BRIDGE-DEPENDENT:** Uses bridge adapters (Tauri invoke/listen). Needs BackendAdapter migration first.
- **PLATFORM-SPECIFIC:** Contains platform-specific logic (paths, APIs). Stays in app-specific layer.
---
## Store Analysis
### 1. agents.svelte.ts
**Category: CLEAN**
| Check | Result |
|-------|--------|
| Platform-specific paths | None |
| Persistence paths | None (pure in-memory) |
| Capability-conditioned defaults | None |
| Tauri/Bun imports | None |
**Notes:** Only import is `type AgentMessage` from `claude-messages` (type-only, already in `@agor/types` as `AgentMessage`). Pure reactive state with no I/O. Ready to move immediately.
---
### 2. workspace.svelte.ts
**Category: BRIDGE-DEPENDENT**
| Check | Result |
|-------|--------|
| Platform-specific paths | None directly (groups.json path resolved in Rust backend) |
| Persistence paths | `groups-bridge.ts` → Tauri `invoke('groups_load')` / `invoke('groups_save')` |
| Capability-conditioned defaults | None |
| Tauri/Bun imports | `groups-bridge` (Tauri invoke), `btmsg-bridge` (Tauri invoke) |
**Bridge dependencies:**
- `loadGroups()` / `saveGroups()` → already in BackendAdapter
- `getCliGroup()` → Tauri-specific CLI arg parsing, not in BackendAdapter
- `registerAgents()` → btmsg-bridge, not in BackendAdapter
**Notes:** `loadGroups`/`saveGroups` can migrate to `getBackend()`. `getCliGroup` and `registerAgents` need new BackendAdapter methods or remain as separate bridges. Cross-store imports (agents, health, conflicts, wake-scheduler) are all clean function calls.
---
### 3. layout.svelte.ts
**Category: BRIDGE-DEPENDENT**
| Check | Result |
|-------|--------|
| Platform-specific paths | None (SQLite path resolved in Rust) |
| Persistence paths | `session-bridge.ts` → 8 Tauri invoke commands |
| Capability-conditioned defaults | None |
| Tauri/Bun imports | `session-bridge` (Tauri invoke) |
**Bridge dependencies:**
- `listSessions`, `saveSession`, `deleteSession`, `updateSessionTitle`, `touchSession` → session CRUD
- `saveLayout`, `loadLayout` → layout persistence
- `updateSessionGroup` → group assignment
**Notes:** V2 legacy store. V3 uses workspace.svelte.ts. Session persistence commands not yet in BackendAdapter. Could be deferred or added as BackendAdapter extension.
---
### 4. health.svelte.ts
**Category: CLEAN**
| Check | Result |
|-------|--------|
| Platform-specific paths | None |
| Persistence paths | None (pure in-memory with timer) |
| Capability-conditioned defaults | None |
| Tauri/Bun imports | None |
**Notes:** Imports from stores only (agents, conflicts) and utils (attention-scorer). Pure computation + timers. Ready to move.
---
### 5. conflicts.svelte.ts
**Category: CLEAN**
| Check | Result |
|-------|--------|
| Platform-specific paths | None |
| Persistence paths | None (session-scoped, no persistence) |
| Capability-conditioned defaults | None |
| Tauri/Bun imports | None |
**Notes:** Only imports `types/ids`. Pure in-memory conflict tracking. Ready to move.
---
### 6. anchors.svelte.ts
**Category: BRIDGE-DEPENDENT**
| Check | Result |
|-------|--------|
| Platform-specific paths | None (SQLite path resolved in Rust) |
| Persistence paths | `anchors-bridge.ts` → 4 Tauri invoke commands |
| Capability-conditioned defaults | None |
| Tauri/Bun imports | `anchors-bridge` (Tauri invoke) |
**Bridge dependencies:**
- `saveSessionAnchors`, `loadSessionAnchors`, `deleteSessionAnchor`, `updateAnchorType`
**Notes:** Small, well-defined bridge surface. Could add anchor CRUD to BackendAdapter or keep as separate bridge temporarily.
---
### 7. theme.svelte.ts
**Category: BRIDGE-DEPENDENT**
| Check | Result |
|-------|--------|
| Platform-specific paths | None |
| Persistence paths | `settings-bridge.ts``getSetting`/`setSetting` (Tauri invoke) |
| Capability-conditioned defaults | None |
| Tauri/Bun imports | `settings-bridge` (Tauri invoke) |
**Bridge dependencies:**
- `getSetting('theme')`, `setSetting('theme', ...)` — theme persistence
- `getSetting('ui_font_family')` etc. — 4 font settings on init
**Platform-specific behavior:**
- `document.documentElement.style.setProperty()` — browser API, works on both Tauri and Electrobun webviews. Not platform-specific.
**Notes:** Simple migration — replace `getSetting`/`setSetting` with `getBackend().getSetting()`/`getBackend().setSetting()`.
---
### 8. plugins.svelte.ts
**Category: BRIDGE-DEPENDENT**
| Check | Result |
|-------|--------|
| Platform-specific paths | None (plugin config dir resolved in Rust) |
| Persistence paths | `settings-bridge.ts` → plugin enabled state; `plugins-bridge.ts` → plugin discovery |
| Capability-conditioned defaults | `supportsPluginSandbox` could gate plugin loading |
| Tauri/Bun imports | `plugins-bridge` (Tauri invoke), `settings-bridge` (Tauri invoke) |
**Bridge dependencies:**
- `discoverPlugins()` → plugins-bridge (not in BackendAdapter)
- `getSetting`/`setSetting` → plugin enabled state
**Notes:** Plugin discovery is not in BackendAdapter. Settings part can migrate. Plugin host (`plugin-host.ts`) is pure Web Worker logic — platform-independent.
---
### 9. machines.svelte.ts
**Category: BRIDGE-DEPENDENT**
| Check | Result |
|-------|--------|
| Platform-specific paths | None |
| Persistence paths | `remote-bridge.ts` → 5 invoke commands + 5 listen events |
| Capability-conditioned defaults | None |
| Tauri/Bun imports | `remote-bridge` (Tauri invoke + listen) |
**Bridge dependencies:**
- `listRemoteMachines`, `addRemoteMachine`, `removeRemoteMachine`, `connectRemoteMachine`, `disconnectRemoteMachine`
- 5 event listeners: `onRemoteMachineReady`, `onRemoteMachineDisconnected`, `onRemoteError`, `onRemoteMachineReconnecting`, `onRemoteMachineReconnectReady`
**Notes:** Heavy bridge dependency with event subscription lifecycle. Not in BackendAdapter. Defer migration.
---
### 10. wake-scheduler.svelte.ts
**Category: BRIDGE-DEPENDENT**
| Check | Result |
|-------|--------|
| Platform-specific paths | None |
| Persistence paths | `bttask-bridge.ts``listTasks`; `audit-bridge.ts``logAuditEvent` |
| Capability-conditioned defaults | None |
| Tauri/Bun imports | `bttask-bridge` (Tauri invoke), `audit-bridge` (Tauri invoke) |
**Bridge dependencies:**
- `listTasks(groupId)` → task listing for wake signal evaluation
- `logAuditEvent(...)` → audit logging
**Notes:** Neither bttask nor audit operations are in BackendAdapter. Cross-store reads (health, workspace, agents). Complex timer logic is pure JS — platform independent. Bridges are the only blockers.
---
### 11. notifications.svelte.ts
**Category: BRIDGE-DEPENDENT**
| Check | Result |
|-------|--------|
| Platform-specific paths | None |
| Persistence paths | None (ephemeral state) |
| Capability-conditioned defaults | `supportsDesktopNotifications` should gate `sendDesktopNotification` |
| Tauri/Bun imports | `notifications-bridge` (Tauri invoke) |
**Bridge dependencies:**
- `sendDesktopNotification(title, body, urgency)` — single function, fire-and-forget
**Notes:** Toast system is pure reactive state. Only the OS notification part needs the bridge. Easy to capability-gate: `if (getBackend().capabilities.supportsDesktopNotifications) sendDesktopNotification(...)`.
---
### BONUS: settings-scope.svelte.ts
**Category: BRIDGE-DEPENDENT**
| Check | Result |
|-------|--------|
| Platform-specific paths | None |
| Persistence paths | `settings-bridge.ts``getSetting`/`setSetting` |
| Capability-conditioned defaults | None |
| Tauri/Bun imports | `settings-bridge` (Tauri invoke) |
**Notes:** Thin scoping layer over settings-bridge. Migrates with Cluster 2 (Settings Domain).
---
### BONUS: sessions.svelte.ts
**Category: CLEAN**
| Check | Result |
|-------|--------|
| Platform-specific paths | None |
| Persistence paths | None |
| Capability-conditioned defaults | None |
| Tauri/Bun imports | None |
**Notes:** V2 legacy, minimal. Pure reactive state.
---
## Summary Table
| Store | Category | Bridge Deps | Migration Blocker |
|-------|----------|-------------|-------------------|
| agents | CLEAN | none | -- |
| conflicts | CLEAN | none | -- |
| sessions | CLEAN | none | -- |
| health | CLEAN | none | -- |
| theme | BRIDGE-DEPENDENT | settings-bridge | BackendAdapter.getSetting/setSetting (ready) |
| settings-scope | BRIDGE-DEPENDENT | settings-bridge | BackendAdapter.getSetting/setSetting (ready) |
| notifications | BRIDGE-DEPENDENT | notifications-bridge | Capability-gate sendDesktopNotification |
| anchors | BRIDGE-DEPENDENT | anchors-bridge | Need BackendAdapter anchor methods |
| plugins | BRIDGE-DEPENDENT | plugins-bridge, settings-bridge | Need BackendAdapter plugin discovery |
| layout | BRIDGE-DEPENDENT | session-bridge | Need BackendAdapter session methods |
| workspace | BRIDGE-DEPENDENT | groups-bridge, btmsg-bridge | BackendAdapter groups (ready), btmsg TBD |
| machines | BRIDGE-DEPENDENT | remote-bridge | Need BackendAdapter remote machine methods |
| wake-scheduler | BRIDGE-DEPENDENT | bttask-bridge, audit-bridge | Need BackendAdapter bttask/audit methods |
### Ready to Migrate Now (4 stores)
- `agents.svelte.ts` — CLEAN
- `conflicts.svelte.ts` — CLEAN
- `health.svelte.ts` — CLEAN
- `sessions.svelte.ts` — CLEAN
### Ready After Settings-Bridge Replacement (3 stores)
- `theme.svelte.ts` — settings-bridge only
- `settings-scope.svelte.ts` — settings-bridge only
- `plugins.svelte.ts` — settings-bridge + plugins-bridge
### Requires BackendAdapter Extension (6 stores)
- `notifications.svelte.ts` — 1 method
- `anchors.svelte.ts` — 4 methods
- `layout.svelte.ts` — 8 methods
- `workspace.svelte.ts` — groups ready, btmsg TBD
- `machines.svelte.ts` — 10 methods + events
- `wake-scheduler.svelte.ts` — bttask + audit

160
README.md
View file

@ -1,141 +1,47 @@
# Agent Orchestrator
# Svelte + TS + Vite
Multi-project agent dashboard for orchestrating Claude AI teams across multiple codebases simultaneously. Built with Tauri 2.x (Rust) + Svelte 5 + Claude Agent SDK.
This template should help get you started developing with Svelte and TypeScript in Vite.
![Agent Orchestrator](screenshot.png)
## Recommended IDE Setup
## What it does
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
Agent Orchestrator lets you run multiple Claude Code agents in parallel, organized into project groups. Each agent gets its own terminal, file browser, and Claude session. Agents communicate with each other via built-in messaging (btmsg) and coordinate work through a shared task board (bttask).
## Need an official Svelte framework?
## Key Features
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
### Multi-Agent Orchestration
- **Project groups** — up to 5 projects side-by-side, adaptive layout (5 @ultrawide, 3 @1920px)
- **Tier 1 agents** — Manager, Architect, Tester, Reviewer with role-specific tabs and auto-generated system prompts
- **Tier 2 agents** — per-project Claude sessions with custom context
- **btmsg** — inter-agent messaging CLI + UI (Activity Feed, DMs, Channels)
- **bttask** — Kanban task board with role-based visibility (Manager CRUD, others read-only)
- **Auto-wake scheduler** — 3 strategies (persistent, on-demand, smart) with configurable wake signals
## Technical considerations
### Per-Project Workspace
- **Claude sessions** with session continuity, anchors, and structured output
- **Terminal tabs** — shell, SSH, agent preview per project
- **File browser** — CodeMirror 6 editor (15 languages), PDF viewer, CSV table
- **Docs viewer** — live Markdown with Shiki syntax highlighting
- **Context tab** — LLM context window visualization (token meter, turn breakdown)
- **Metrics panel** — live health, SVG sparkline history, session stats
**Why use this over SvelteKit?**
### Multi-Provider Support
- **Claude** (primary) — via Agent SDK sidecar
- **Codex** — OpenAI Codex SDK adapter
- **Ollama** — local models via native fetch
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
### Production Hardening
- **Sidecar supervisor** — crash recovery with exponential backoff
- **Landlock sandbox** — Linux kernel process isolation for sidecar
- **FTS5 search** — full-text search with Spotlight-style overlay (Ctrl+F)
- **Plugin system** — sandboxed runtime with permission gates
- **Secrets management** — system keyring integration
- **Notifications** — OS + in-app notification center
- **Agent health monitoring** — heartbeats, dead letter queue, audit log
- **Optimistic locking** — bttask concurrent access protection
- **Error classification** — 6 error types with auto-retry logic
- **TLS relay** — encrypted WebSocket for remote machines
- **WAL checkpoint** — periodic SQLite maintenance (5min interval)
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
### Developer Experience
- **17 themes** — Catppuccin (4), Editor (7), Deep Dark (6)
- **Keyboard-first UX** — Command Palette (Ctrl+K), 18+ commands, vi-style navigation
- **Claude profiles** — per-project account switching
- **Skill discovery** — type `/` in agent prompt for autocomplete
- **ctx integration** — SQLite context database for cross-session memory
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
### Testing
- **516 vitest** + **159 cargo** + **109 E2E** tests
- **E2E engine** — WebDriverIO + tauri-driver, Phase A/B/C scenarios
- **LLM judge** — dual-mode CLI/API for semantic assertion (claude-haiku)
- **CI** — GitHub Actions with xvfb + LLM-judged test gating
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
## Architecture
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `allowJs` in the TS template?**
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```ts
// store.ts
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
```
Agent Orchestrator (Tauri 2.x)
├── Rust backend (src-tauri/)
│ ├── Commands: groups, sessions, btmsg, bttask, search, secrets, plugins, notifications
│ ├── agor-core: PtyManager, SidecarManager, EventSink trait
│ └── agor-relay: WebSocket server for remote machines (+ TLS)
├── Svelte 5 frontend (src/)
│ ├── Workspace: ProjectGrid, ProjectBox (per-project tabs), StatusBar
│ ├── Stores: workspace, agents, health, conflicts, wake-scheduler, plugins
│ ├── Adapters: claude-bridge, btmsg-bridge, bttask-bridge, groups-bridge
│ └── Agent dispatcher: sidecar event routing, session persistence, auto-anchoring
└── Node.js sidecar (sidecar/)
├── claude-runner.mjs (Agent SDK)
├── codex-runner.mjs (OpenAI Codex)
└── ollama-runner.mjs (local models)
```
## Installation
Requires Node.js 20+, Rust 1.77+, WebKit2GTK 4.1, GTK3.
```bash
git clone https://github.com/DexterFromLab/agent-orchestrator.git
cd agent-orchestrator/v2
npm install
npm run build:sidecar
npm run tauri:dev
```
### Build for distribution
```bash
npm run tauri:build
# Output: .deb + AppImage in target/release/bundle/
```
## Configuration
Config: `~/.config/agor/groups.json` — project groups, agents, prompts (human-editable JSON).
Database: `~/.local/share/agor/` — sessions.db (sessions, metrics, anchors), btmsg.db (messages, tasks, agents).
## Multi-Machine Support
```
Agent Orchestrator --WebSocket/TLS--> agor-relay (Remote Machine)
├── PtyManager (remote terminals)
└── SidecarManager (remote agents)
```
```bash
cd v2 && cargo build --release -p agor-relay
./target/release/agor-relay --port 9750 --token <secret> --tls-cert cert.pem --tls-key key.pem
```
## Keyboard Shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl+K` | Command Palette |
| `Ctrl+M` | Messages (CommsTab) |
| `Ctrl+B` | Toggle sidebar |
| `Ctrl+,` | Settings |
| `Ctrl+F` | Full-text search |
| `Ctrl+1-5` | Focus project by index |
| `Escape` | Close overlay/sidebar |
## Documentation
| Document | Description |
|----------|-------------|
| [docs/decisions.md](docs/decisions.md) | Architecture decisions log |
| [docs/progress/](docs/progress/) | Session progress logs (v2, v3, archive) |
| [docs/release-notes.md](docs/release-notes.md) | v3.0 release notes |
| [docs/e2e-testing.md](docs/e2e-testing.md) | E2E testing facility documentation |
| [docs/multi-machine.md](docs/multi-machine.md) | Multi-machine relay architecture |
## License
MIT

64
TODO.md
View file

@ -1,64 +0,0 @@
# Agents Orchestrator — TODO
## Architecture Decisions
- [ ] **Tauri vs WGPU alternative** — Evaluate staying with Tauri 2.x (WebKit2GTK) vs migrating to a Bun-based stack with WGPU rendering. Key factors: WebGL limitations in WebKit2GTK, xterm.js Canvas addon constraint (max 4 instances), native GPU acceleration, Bun's single-binary advantage. Research: Dioxus, Slint, Zed's GPUI. Decision needed before v4.
- [ ] **Frontend-backend tight binding** — Reduce IPC overhead between Svelte frontend and Rust backend. Options: shared memory via WebAssembly, direct Rust→DOM rendering for perf-critical paths, compile Svelte components to WASM, or move more logic to Rust (terminal rendering, syntax highlighting). Profile current IPC bottlenecks first.
## Features (v3.2)
- [ ] **Profile export/import** — Define a portable profile format (JSON/TOML/YAML) for groups, projects, agents, themes, keybindings, secrets (encrypted). Must handle: version migration, partial import (merge vs overwrite), sensitive data encryption (age/libsodium), cross-machine portability. Evaluate TOML (human-readable) vs JSON (tooling) vs custom binary (compact + signed).
- [ ] **Keyboard shortcuts settings** — Configurable keybindings UI in SettingsTab. Levels: global (app-wide), context (terminal, agent pane, palette), compose sequences (Ctrl+K → Ctrl+S). Conflict detection. Import/export. Default keymap file at ~/.config/agor/keybindings.json. Reference: VSCode keybindings model.
- [ ] **Per-project settings** — Deeper per-project configuration beyond current fields. Per-project theme override, per-project keybindings, per-project plugin enable/disable, per-project environment variables, per-project shell, per-project model preferences. Cascade: global → group → project (most specific wins).
- [ ] **Custom editors (AI-augmented)** — Specialized editor panes for non-code content: image editor (crop, annotate, AI inpaint/upscale via stable diffusion API), video editor (trim, subtitle, AI transcription), audio editor (waveform, AI transcription/TTS), 3D viewer/editor (glTF/OBJ, AI mesh generation). Each as a ProjectBox tab, triggered by file extension. Evaluate: WebGL for 3D (blocked by WebKit2GTK — ties into Tauri vs WGPU decision), Canvas for 2D, Web Audio API for audio.
## Electrobun Hardening (from Codex Audit #3)
- [ ] **Durable event sequencing** — Monotonic message indexes per session, idempotent replay on reconnect, conflict-safe persistence. Prevents message loss during concurrent agent output. Useful for session replay/debugging.
- [ ] **File-save conflict detection** — Track `mtime` + content hash before write. Atomic temp-file rename on save. Show conflict dialog if file changed externally between read and write. Prevents silent overwrites.
- [ ] **Remote credential vault** — Secure storage for relay tokens (encrypted at rest). Auto-reconnect uses stored token without re-prompting. Integrates with system keyring when available, falls back to encrypted SQLite blob.
- [ ] **Push-based task/relay updates** — Replace 5-second polling in TaskBoardTab and CommsTab with WebSocket push from btmsg/bttask backends. Request tokens or revision numbers for stale-response detection. Reduces CPU + network overhead.
- [ ] **Sidecar backpressure guard** — Max NDJSON line size (10MB), max pending stdout buffer, max terminal paste chunk (64KB). Prevents memory exhaustion from buggy/malicious sidecar runners.
- [ ] **Per-project retention controls** — Configurable session history retention (last N sessions, or N days). `untrackProject()` cleans up health store, agent store, search index. Prevents unbounded memory/disk growth.
- [ ] **Channel membership/ACL enforcement** — btmsg group_id validation (sender + recipient same group), channel membership checks before send, auto-add creator on channel create. Prevents cross-tenant message leakage.
- [ ] **Transport diagnostics panel** — Real-time view of PTY/relay/session persistence health. Dropped event counters, reconnection history, RPC latency histogram, buffer fill levels. Useful for debugging multi-machine setups.
- [ ] **Plugin sandbox policy layer** — Per-plugin network egress control (allow/deny), CPU time quotas (terminate after N seconds), memory limits, filesystem access scope. Prevents malicious plugins from exfiltrating data or DoS.
- [ ] **Multi-tool health tracking** — Replace `toolInFlight: boolean` with `toolsInFlight: number` counter. Accurate state machine for concurrent tool execution. Prevents false idle/stalled transitions during parallel tool use.
## Dual-Repo & Commercial
- [ ] **CLA setup** — Configure CLA-assistant.io on community repo (DexterFromLab/agent-orchestrator) before accepting external PRs.
- [ ] **Community export workflow** — Define and document the process for stripping commercial content and pushing to DexterFromLab origin.
- [ ] **Dual CI validation** — Verify both leak-check.yml and commercial-build.yml workflows work in GitHub Actions.
## Multi-Machine (v3.1)
- [ ] **Real-world relay testing** — TLS added, code complete in bridges/stores. Needs 2-machine test to verify relay + RemoteManager end-to-end.
## Multi-Agent (v3.1)
- [ ] **Agent Teams real-world testing** — Subagent delegation prompt + env injection done. Needs real multi-agent session to verify Manager spawns child agents.
## Reliability
- [ ] **Soak test** — Run 4-hour soak with 6+ agents across 3+ projects. Monitor: memory, WAL size, xterm count, supervisor restarts.
- [ ] **WebKit2GTK Worker verification** — Verify Web Worker Blob URL approach in Tauri's WebKit2GTK webview.
## E2E Testing
- [ ] **More realistic fixtures** — Add 3-5 dummy projects to test fixtures with varied configurations: different providers (claude, codex, ollama), agent roles (manager, architect, tester), worktree isolation enabled/disabled, multiple groups, SSH configs. Makes tests more reliable and covers multi-project interactions.
- [ ] **Test daemon CI integration** — Wire daemon CLI (tests/e2e/daemon/) into CI workflow. Verify --agent flag works with Agent SDK.
## Completed
- [x] E2E full suite passing — 19/19 specs, 306s, daemon with smart caching | Done: 2026-03-18
- [x] E2E test daemon CLI — ANSI dashboard, smart caching (3-pass skip), error toast catching, Agent SDK bridge | Done: 2026-03-18
- [x] SPKI pin persistence — pins saved to groups.json, survive app restarts | Done: 2026-03-18
- [x] E2E spec expansion — 19 files, ~200 tests, Phase D/E/F added, all specs split <300 lines | Done: 2026-03-18
- [x] E2E port isolation — dedicated port 9750, app identity verification, devUrl conflict detection | Done: 2026-03-18
- [x] Pro Svelte components wired — AnalyticsDashboard, SessionExporter, AccountSwitcher in ProjectBox Pro tab | Done: 2026-03-18
- [x] ThemeEditor — 26 color pickers, live preview, import/export, custom theme persistence | Done: 2026-03-18
- [x] Comprehensive error handling — AppError enum (Rust), handleError/handleInfraError (frontend), global handler | Done: 2026-03-18
- [x] Plugin marketplace — 13 plugins (8 free, 5 paid), catalog, security (SHA-256, HTTPS, path traversal) | Done: 2026-03-17
- [x] Security audit fixes — 5 critical + 14 high issues found and fixed across agor-pro + Svelte | Done: 2026-03-17
- [x] Settings redesign — 6 modular components replacing 2959-line monolith | Done: 2026-03-18

View file

@ -1,17 +0,0 @@
[package]
name = "agor-pro"
version = "0.1.0"
edition = "2021"
description = "Commercial plugin for Agents Orchestrator"
license = "LicenseRef-Commercial"
[dependencies]
agor-core = { path = "../agor-core" }
tauri = { version = "2.10.3", features = [] }
rusqlite = { version = "0.31", features = ["bundled-full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
dirs = "5"
tokio = { version = "1", features = ["process"] }
sha2 = "0.10"

View file

@ -1,181 +0,0 @@
// SPDX-License-Identifier: LicenseRef-Commercial
// Analytics Dashboard — historical cost tracking, model usage, token trends.
use serde::Serialize;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AnalyticsSummary {
pub total_sessions: i64,
pub total_cost_usd: f64,
pub total_tokens: i64,
pub total_turns: i64,
pub total_tool_calls: i64,
pub avg_cost_per_session: f64,
pub avg_tokens_per_session: f64,
pub period_days: i64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DailyStats {
pub date: String,
pub session_count: i64,
pub cost_usd: f64,
pub tokens: i64,
pub turns: i64,
pub tool_calls: i64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelBreakdown {
pub model: String,
pub session_count: i64,
pub total_cost_usd: f64,
pub total_tokens: i64,
pub avg_cost_per_session: f64,
}
#[tauri::command]
pub fn pro_analytics_summary(project_id: String, days: Option<i64>) -> Result<AnalyticsSummary, String> {
let conn = super::open_sessions_db()?;
let period = days.unwrap_or(30);
let cutoff = now_epoch() - (period * 86400);
let mut stmt = conn.prepare(
"SELECT COUNT(*) AS cnt, COALESCE(SUM(cost_usd), 0) AS total_cost,
COALESCE(SUM(peak_tokens), 0) AS total_tokens,
COALESCE(SUM(turn_count), 0) AS total_turns,
COALESCE(SUM(tool_call_count), 0) AS total_tools
FROM session_metrics WHERE project_id = ?1 AND end_time >= ?2"
).map_err(|e| format!("Query failed: {e}"))?;
let result = stmt.query_row(rusqlite::params![project_id, cutoff], |row| {
let count: i64 = row.get("cnt")?;
let cost: f64 = row.get("total_cost")?;
let tokens: i64 = row.get("total_tokens")?;
let turns: i64 = row.get("total_turns")?;
let tools: i64 = row.get("total_tools")?;
Ok(AnalyticsSummary {
total_sessions: count,
total_cost_usd: cost,
total_tokens: tokens,
total_turns: turns,
total_tool_calls: tools,
avg_cost_per_session: if count > 0 { cost / count as f64 } else { 0.0 },
avg_tokens_per_session: if count > 0 { tokens as f64 / count as f64 } else { 0.0 },
period_days: period,
})
}).map_err(|e| format!("Query failed: {e}"))?;
Ok(result)
}
#[tauri::command]
pub fn pro_analytics_daily(project_id: String, days: Option<i64>) -> Result<Vec<DailyStats>, String> {
let conn = super::open_sessions_db()?;
let period = days.unwrap_or(30);
let cutoff = now_epoch() - (period * 86400);
let mut stmt = conn.prepare(
"SELECT date(end_time, 'unixepoch') AS day,
COUNT(*) AS cnt, COALESCE(SUM(cost_usd), 0) AS total_cost,
COALESCE(SUM(peak_tokens), 0) AS total_tokens,
COALESCE(SUM(turn_count), 0) AS total_turns,
COALESCE(SUM(tool_call_count), 0) AS total_tools
FROM session_metrics
WHERE project_id = ?1 AND end_time >= ?2
GROUP BY day ORDER BY day ASC"
).map_err(|e| format!("Query failed: {e}"))?;
let rows = stmt.query_map(rusqlite::params![project_id, cutoff], |row| {
Ok(DailyStats {
date: row.get("day")?,
session_count: row.get("cnt")?,
cost_usd: row.get("total_cost")?,
tokens: row.get("total_tokens")?,
turns: row.get("total_turns")?,
tool_calls: row.get("total_tools")?,
})
}).map_err(|e| format!("Query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
Ok(rows)
}
#[tauri::command]
pub fn pro_analytics_model_breakdown(project_id: String, days: Option<i64>) -> Result<Vec<ModelBreakdown>, String> {
let conn = super::open_sessions_db()?;
let period = days.unwrap_or(30);
let cutoff = now_epoch() - (period * 86400);
let mut stmt = conn.prepare(
"SELECT COALESCE(model, 'unknown') AS model_name, COUNT(*) AS cnt,
COALESCE(SUM(cost_usd), 0) AS total_cost,
COALESCE(SUM(peak_tokens), 0) AS total_tokens
FROM session_metrics
WHERE project_id = ?1 AND end_time >= ?2
GROUP BY model ORDER BY SUM(cost_usd) DESC"
).map_err(|e| format!("Query failed: {e}"))?;
let rows = stmt.query_map(rusqlite::params![project_id, cutoff], |row| {
let count: i64 = row.get("cnt")?;
let cost: f64 = row.get("total_cost")?;
Ok(ModelBreakdown {
model: row.get("model_name")?,
session_count: count,
total_cost_usd: cost,
total_tokens: row.get("total_tokens")?,
avg_cost_per_session: if count > 0 { cost / count as f64 } else { 0.0 },
})
}).map_err(|e| format!("Query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
Ok(rows)
}
pub(crate) fn now_epoch() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_analytics_summary_struct() {
let s = AnalyticsSummary {
total_sessions: 5,
total_cost_usd: 1.25,
total_tokens: 50000,
total_turns: 30,
total_tool_calls: 100,
avg_cost_per_session: 0.25,
avg_tokens_per_session: 10000.0,
period_days: 30,
};
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("totalSessions"));
assert!(json.contains("avgCostPerSession"));
}
#[test]
fn test_model_breakdown_serializes_camel_case() {
let m = ModelBreakdown {
model: "opus".into(),
session_count: 3,
total_cost_usd: 0.75,
total_tokens: 30000,
avg_cost_per_session: 0.25,
};
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("sessionCount"));
assert!(json.contains("totalCostUsd"));
}
}

View file

@ -1,214 +0,0 @@
// SPDX-License-Identifier: LicenseRef-Commercial
// Branch Policy Enforcement — block agent sessions on protected branches.
use rusqlite::params;
use serde::Serialize;
use std::process::Command;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BranchPolicy {
pub id: i64,
pub pattern: String,
pub action: String,
pub reason: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PolicyDecision {
pub allowed: bool,
pub branch: String,
pub matched_policy: Option<BranchPolicy>,
pub reason: String,
}
fn ensure_tables(conn: &rusqlite::Connection) -> Result<(), String> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS pro_branch_policies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pattern TEXT NOT NULL,
action TEXT NOT NULL DEFAULT 'block',
reason TEXT NOT NULL DEFAULT ''
);"
).map_err(|e| format!("Failed to create branch_policies table: {e}"))?;
// Seed default policies if table is empty
let count: i64 = conn.query_row(
"SELECT COUNT(*) AS cnt FROM pro_branch_policies", [], |row| row.get("cnt")
).unwrap_or(0);
if count == 0 {
conn.execute_batch(
"INSERT INTO pro_branch_policies (pattern, action, reason) VALUES
('main', 'block', 'Protected branch: direct work on main is not allowed'),
('master', 'block', 'Protected branch: direct work on master is not allowed'),
('release/*', 'block', 'Protected branch: release branches require PRs');"
).map_err(|e| format!("Failed to seed default policies: {e}"))?;
}
Ok(())
}
/// Simple glob matching: supports `*` at the end of a pattern (e.g., `release/*`).
fn glob_match(pattern: &str, value: &str) -> bool {
if pattern == value {
return true;
}
if let Some(prefix) = pattern.strip_suffix('*') {
return value.starts_with(prefix);
}
false
}
fn get_current_branch(project_path: &str) -> Result<String, String> {
if project_path.starts_with('-') {
return Err("Invalid project path: cannot start with '-'".into());
}
let output = Command::new("git")
.args(["-C", project_path, "branch", "--show-current"])
.output()
.map_err(|e| format!("Failed to run git: {e}"))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
Err("Not a git repository or git not available".into())
}
}
#[tauri::command]
pub fn pro_branch_check(project_path: String) -> Result<PolicyDecision, String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
let branch = get_current_branch(&project_path)?;
let mut stmt = conn.prepare(
"SELECT id, pattern, action, reason FROM pro_branch_policies"
).map_err(|e| format!("Query failed: {e}"))?;
let policies: Vec<BranchPolicy> = stmt.query_map([], |row| {
Ok(BranchPolicy {
id: row.get("id")?,
pattern: row.get("pattern")?,
action: row.get("action")?,
reason: row.get("reason")?,
})
}).map_err(|e| format!("Query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
for policy in &policies {
if glob_match(&policy.pattern, &branch) {
let allowed = policy.action != "block";
return Ok(PolicyDecision {
allowed,
branch: branch.clone(),
matched_policy: Some(policy.clone()),
reason: policy.reason.clone(),
});
}
}
Ok(PolicyDecision {
allowed: true,
branch,
matched_policy: None,
reason: "No matching policy".into(),
})
}
#[tauri::command]
pub fn pro_branch_policy_list() -> Result<Vec<BranchPolicy>, String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
let mut stmt = conn.prepare(
"SELECT id, pattern, action, reason FROM pro_branch_policies ORDER BY id"
).map_err(|e| format!("Query failed: {e}"))?;
let rows = stmt.query_map([], |row| {
Ok(BranchPolicy {
id: row.get("id")?,
pattern: row.get("pattern")?,
action: row.get("action")?,
reason: row.get("reason")?,
})
}).map_err(|e| format!("Query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
Ok(rows)
}
#[tauri::command]
pub fn pro_branch_policy_add(pattern: String, action: Option<String>, reason: Option<String>) -> Result<i64, String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
let act = action.unwrap_or_else(|| "block".into());
if !["block", "warn"].contains(&act.as_str()) {
return Err(format!("Invalid action '{}': must be 'block' or 'warn'", act));
}
let rsn = reason.unwrap_or_default();
conn.execute(
"INSERT INTO pro_branch_policies (pattern, action, reason) VALUES (?1, ?2, ?3)",
params![pattern, act, rsn],
).map_err(|e| format!("Failed to add policy: {e}"))?;
Ok(conn.last_insert_rowid())
}
#[tauri::command]
pub fn pro_branch_policy_remove(id: i64) -> Result<(), String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
conn.execute("DELETE FROM pro_branch_policies WHERE id = ?1", params![id])
.map_err(|e| format!("Failed to remove policy: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_policy_decision_serializes_camel_case() {
let d = PolicyDecision {
allowed: false,
branch: "main".into(),
matched_policy: Some(BranchPolicy {
id: 1,
pattern: "main".into(),
action: "block".into(),
reason: "Protected".into(),
}),
reason: "Protected".into(),
};
let json = serde_json::to_string(&d).unwrap();
assert!(json.contains("matchedPolicy"));
assert!(json.contains("\"allowed\":false"));
}
#[test]
fn test_branch_policy_serializes_camel_case() {
let p = BranchPolicy {
id: 1,
pattern: "release/*".into(),
action: "block".into(),
reason: "No direct commits".into(),
};
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("\"pattern\":\"release/*\""));
assert!(json.contains("\"action\":\"block\""));
}
#[test]
fn test_glob_match() {
assert!(glob_match("main", "main"));
assert!(!glob_match("main", "main2"));
assert!(glob_match("release/*", "release/v1.0"));
assert!(glob_match("release/*", "release/hotfix"));
assert!(!glob_match("release/*", "feature/test"));
assert!(!glob_match("master", "main"));
}
}

View file

@ -1,287 +0,0 @@
// SPDX-License-Identifier: LicenseRef-Commercial
// Budget Governor — per-project monthly token budgets with soft/hard limits.
use rusqlite::params;
use serde::Serialize;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BudgetStatus {
pub project_id: String,
pub limit: i64,
pub used: i64,
pub remaining: i64,
pub percent: f64,
pub reset_date: i64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BudgetDecision {
pub allowed: bool,
pub reason: String,
pub remaining: i64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BudgetEntry {
pub project_id: String,
pub monthly_limit_tokens: i64,
pub used_tokens: i64,
pub reset_date: i64,
}
fn ensure_tables(conn: &rusqlite::Connection) -> Result<(), String> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS pro_budgets (
project_id TEXT PRIMARY KEY,
monthly_limit_tokens INTEGER NOT NULL,
used_tokens INTEGER NOT NULL DEFAULT 0,
reset_date INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS pro_budget_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL,
session_id TEXT NOT NULL,
tokens_used INTEGER NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_budget_log_project ON pro_budget_log(project_id, timestamp);"
).map_err(|e| format!("Failed to create budget tables: {e}"))
}
fn now_epoch() -> i64 {
super::analytics::now_epoch()
}
/// Calculate reset date: first day of next calendar month as epoch.
fn next_month_epoch() -> i64 {
let now = now_epoch();
let days_since_epoch = now / 86400;
let mut year = 1970i64;
let mut remaining_days = days_since_epoch;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if remaining_days < days_in_year { break; }
remaining_days -= days_in_year;
year += 1;
}
let month_days: [i64; 12] = if is_leap_year(year) {
[31,29,31,30,31,30,31,31,30,31,30,31]
} else {
[31,28,31,30,31,30,31,31,30,31,30,31]
};
let mut month = 0usize;
for (i, &d) in month_days.iter().enumerate() {
if remaining_days < d { month = i; break; }
remaining_days -= d;
}
// Advance to first of next month
let (next_year, next_month) = if month >= 11 {
(year + 1, 0usize)
} else {
(year, month + 1)
};
// Calculate epoch for first of next_month in next_year
let mut epoch: i64 = 0;
for y in 1970..next_year {
epoch += if is_leap_year(y) { 366 } else { 365 };
}
let nm_days: [i64; 12] = if is_leap_year(next_year) {
[31,29,31,30,31,30,31,31,30,31,30,31]
} else {
[31,28,31,30,31,30,31,31,30,31,30,31]
};
for i in 0..next_month {
epoch += nm_days[i];
}
epoch * 86400
}
fn is_leap_year(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
#[tauri::command]
pub fn pro_budget_set(project_id: String, monthly_limit_tokens: i64) -> Result<(), String> {
if monthly_limit_tokens <= 0 {
return Err("monthly_limit_tokens must be positive".into());
}
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
let reset = next_month_epoch();
conn.execute(
"INSERT INTO pro_budgets (project_id, monthly_limit_tokens, used_tokens, reset_date)
VALUES (?1, ?2, 0, ?3)
ON CONFLICT(project_id) DO UPDATE SET monthly_limit_tokens = ?2",
params![project_id, monthly_limit_tokens, reset],
).map_err(|e| format!("Failed to set budget: {e}"))?;
Ok(())
}
#[tauri::command]
pub fn pro_budget_get(project_id: String) -> Result<BudgetStatus, String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
auto_reset_if_expired(&conn, &project_id)?;
let mut stmt = conn.prepare(
"SELECT monthly_limit_tokens, used_tokens, reset_date FROM pro_budgets WHERE project_id = ?1"
).map_err(|e| format!("Query failed: {e}"))?;
stmt.query_row(params![project_id], |row| {
let limit: i64 = row.get("monthly_limit_tokens")?;
let used: i64 = row.get("used_tokens")?;
let reset_date: i64 = row.get("reset_date")?;
let remaining = (limit - used).max(0);
let percent = if limit > 0 { (used as f64 / limit as f64) * 100.0 } else { 0.0 };
Ok(BudgetStatus { project_id: project_id.clone(), limit, used, remaining, percent, reset_date })
}).map_err(|e| format!("Budget not found for project '{}': {e}", project_id))
}
#[tauri::command]
pub fn pro_budget_check(project_id: String, estimated_tokens: i64) -> Result<BudgetDecision, String> {
if estimated_tokens < 0 {
return Err("estimated_tokens must be non-negative".into());
}
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
auto_reset_if_expired(&conn, &project_id)?;
let result = conn.prepare(
"SELECT monthly_limit_tokens, used_tokens FROM pro_budgets WHERE project_id = ?1"
).map_err(|e| format!("Query failed: {e}"))?
.query_row(params![project_id], |row| {
Ok((row.get::<_, i64>("monthly_limit_tokens")?, row.get::<_, i64>("used_tokens")?))
});
match result {
Ok((limit, used)) => {
let remaining = (limit - used).max(0);
if used + estimated_tokens > limit {
Ok(BudgetDecision {
allowed: false,
reason: format!("Would exceed budget: {} remaining, {} requested", remaining, estimated_tokens),
remaining,
})
} else {
Ok(BudgetDecision { allowed: true, reason: "Within budget".into(), remaining })
}
}
Err(_) => {
// No budget set — allow by default
Ok(BudgetDecision { allowed: true, reason: "No budget configured".into(), remaining: i64::MAX })
}
}
}
#[tauri::command]
pub fn pro_budget_log_usage(project_id: String, session_id: String, tokens_used: i64) -> Result<(), String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
let ts = now_epoch();
let tx = conn.unchecked_transaction()
.map_err(|e| format!("Transaction failed: {e}"))?;
tx.execute(
"INSERT INTO pro_budget_log (project_id, session_id, tokens_used, timestamp) VALUES (?1, ?2, ?3, ?4)",
params![project_id, session_id, tokens_used, ts],
).map_err(|e| format!("Failed to log usage: {e}"))?;
tx.execute(
"UPDATE pro_budgets SET used_tokens = used_tokens + ?2 WHERE project_id = ?1",
params![project_id, tokens_used],
).map_err(|e| format!("Failed to update used tokens: {e}"))?;
tx.commit().map_err(|e| format!("Commit failed: {e}"))?;
Ok(())
}
#[tauri::command]
pub fn pro_budget_reset(project_id: String) -> Result<(), String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
let reset = next_month_epoch();
conn.execute(
"UPDATE pro_budgets SET used_tokens = 0, reset_date = ?2 WHERE project_id = ?1",
params![project_id, reset],
).map_err(|e| format!("Failed to reset budget: {e}"))?;
Ok(())
}
#[tauri::command]
pub fn pro_budget_list() -> Result<Vec<BudgetEntry>, String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
let mut stmt = conn.prepare(
"SELECT project_id, monthly_limit_tokens, used_tokens, reset_date FROM pro_budgets ORDER BY project_id"
).map_err(|e| format!("Query failed: {e}"))?;
let rows = stmt.query_map([], |row| {
Ok(BudgetEntry {
project_id: row.get("project_id")?,
monthly_limit_tokens: row.get("monthly_limit_tokens")?,
used_tokens: row.get("used_tokens")?,
reset_date: row.get("reset_date")?,
})
}).map_err(|e| format!("Query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
Ok(rows)
}
fn auto_reset_if_expired(conn: &rusqlite::Connection, project_id: &str) -> Result<(), String> {
let now = now_epoch();
conn.execute(
"UPDATE pro_budgets SET used_tokens = 0, reset_date = ?3
WHERE project_id = ?1 AND reset_date < ?2",
params![project_id, now, now + 30 * 86400],
).map_err(|e| format!("Auto-reset failed: {e}"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_budget_status_serializes_camel_case() {
let s = BudgetStatus {
project_id: "proj1".into(),
limit: 100_000,
used: 25_000,
remaining: 75_000,
percent: 25.0,
reset_date: 1710000000,
};
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("projectId"));
assert!(json.contains("resetDate"));
assert!(json.contains("\"remaining\":75000"));
}
#[test]
fn test_budget_decision_serializes_camel_case() {
let d = BudgetDecision {
allowed: true,
reason: "Within budget".into(),
remaining: 50_000,
};
let json = serde_json::to_string(&d).unwrap();
assert!(json.contains("\"allowed\":true"));
assert!(json.contains("\"remaining\":50000"));
}
#[test]
fn test_budget_entry_serializes_camel_case() {
let e = BudgetEntry {
project_id: "p".into(),
monthly_limit_tokens: 200_000,
used_tokens: 10_000,
reset_date: 1710000000,
};
let json = serde_json::to_string(&e).unwrap();
assert!(json.contains("monthlyLimitTokens"));
assert!(json.contains("usedTokens"));
}
}

View file

@ -1,241 +0,0 @@
// SPDX-License-Identifier: LicenseRef-Commercial
// Session Export & Reporting — generate Markdown reports from agent sessions.
use serde::Serialize;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SessionReport {
pub project_id: String,
pub session_id: String,
pub markdown: String,
pub cost_usd: f64,
pub turn_count: i64,
pub tool_call_count: i64,
pub duration_minutes: f64,
pub model: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProjectSummaryReport {
pub project_id: String,
pub markdown: String,
pub total_sessions: i64,
pub total_cost_usd: f64,
pub period_days: i64,
}
#[tauri::command]
pub fn pro_export_session(project_id: String, session_id: String) -> Result<SessionReport, String> {
let conn = super::open_sessions_db()?;
// Get metric for this session
let mut stmt = conn.prepare(
"SELECT start_time, end_time, peak_tokens, turn_count, tool_call_count, cost_usd, model, status, error_message
FROM session_metrics WHERE project_id = ?1 AND session_id = ?2"
).map_err(|e| format!("Query failed: {e}"))?;
let (start, end, tokens, turns, tools, cost, model, status, error): (i64, i64, i64, i64, i64, f64, Option<String>, String, Option<String>) =
stmt.query_row(rusqlite::params![project_id, session_id], |row| {
Ok((row.get("start_time")?, row.get("end_time")?, row.get("peak_tokens")?, row.get("turn_count")?,
row.get("tool_call_count")?, row.get("cost_usd")?, row.get("model")?, row.get("status")?, row.get("error_message")?))
}).map_err(|e| format!("Session not found: {e}"))?;
let model_name = model.clone().unwrap_or_else(|| "unknown".into());
let duration_min = (end - start) as f64 / 60.0;
let start_str = epoch_to_iso(start);
let end_str = epoch_to_iso(end);
// Get messages for this session
let mut msg_stmt = conn.prepare(
"SELECT message_type, content, created_at
FROM agent_messages WHERE session_id = ?1 ORDER BY created_at ASC"
).map_err(|e| format!("Messages query failed: {e}"))?;
let messages: Vec<(String, String, i64)> = msg_stmt
.query_map(rusqlite::params![session_id], |row| {
Ok((row.get("message_type")?, row.get("content")?, row.get("created_at")?))
})
.map_err(|e| format!("Messages query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
let mut md = String::new();
md.push_str(&format!("# Session Report: {session_id}\n\n"));
md.push_str(&format!("**Project:** {project_id} \n"));
md.push_str(&format!("**Model:** {model_name} \n"));
md.push_str(&format!("**Status:** {status} \n"));
md.push_str(&format!("**Duration:** {duration_min:.1} min \n"));
md.push_str(&format!("**Start:** {start_str} \n"));
md.push_str(&format!("**End:** {end_str} \n\n"));
md.push_str("## Metrics\n\n");
md.push_str(&format!("| Metric | Value |\n|--------|-------|\n"));
md.push_str(&format!("| Cost | ${cost:.4} |\n"));
md.push_str(&format!("| Peak Tokens | {tokens} |\n"));
md.push_str(&format!("| Turns | {turns} |\n"));
md.push_str(&format!("| Tool Calls | {tools} |\n\n"));
if let Some(err) = &error {
md.push_str(&format!("## Error\n\n```\n{err}\n```\n\n"));
}
if !messages.is_empty() {
md.push_str("## Conversation\n\n");
for (msg_type, content, ts) in &messages {
let time = epoch_to_time(*ts);
let prefix = match msg_type.as_str() {
"user" | "human" => "**User**",
"assistant" => "**Assistant**",
"tool_call" => "**Tool Call**",
"tool_result" => "**Tool Result**",
_ => msg_type.as_str(),
};
// Truncate long content (safe UTF-8 boundary)
let display = if content.len() > 500 {
let truncated: String = content.chars().take(500).collect();
format!("{}... *(truncated, {} chars)*", truncated, content.len())
} else {
content.clone()
};
md.push_str(&format!("### [{time}] {prefix}\n\n{display}\n\n"));
}
}
Ok(SessionReport {
project_id,
session_id,
markdown: md,
cost_usd: cost,
turn_count: turns,
tool_call_count: tools,
duration_minutes: duration_min,
model: model_name,
})
}
#[tauri::command]
pub fn pro_export_project_summary(project_id: String, days: Option<i64>) -> Result<ProjectSummaryReport, String> {
let conn = super::open_sessions_db()?;
let period = days.unwrap_or(30);
let cutoff = super::analytics::now_epoch() - (period * 86400);
let mut stmt = conn.prepare(
"SELECT session_id, start_time, end_time, cost_usd, turn_count, tool_call_count, model, status
FROM session_metrics WHERE project_id = ?1 AND end_time >= ?2 ORDER BY end_time DESC"
).map_err(|e| format!("Query failed: {e}"))?;
let sessions: Vec<(String, i64, i64, f64, i64, i64, Option<String>, String)> = stmt
.query_map(rusqlite::params![project_id, cutoff], |row| {
Ok((row.get("session_id")?, row.get("start_time")?, row.get("end_time")?, row.get("cost_usd")?,
row.get("turn_count")?, row.get("tool_call_count")?, row.get("model")?, row.get("status")?))
})
.map_err(|e| format!("Query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
let total_cost: f64 = sessions.iter().map(|s| s.3).sum();
let total_sessions = sessions.len() as i64;
let mut md = String::new();
md.push_str(&format!("# Project Summary: {project_id}\n\n"));
md.push_str(&format!("**Period:** last {period} days \n"));
md.push_str(&format!("**Total Sessions:** {total_sessions} \n"));
md.push_str(&format!("**Total Cost:** ${total_cost:.4} \n\n"));
if !sessions.is_empty() {
md.push_str("## Sessions\n\n");
md.push_str("| Date | Session | Model | Duration | Cost | Turns | Tools | Status |\n");
md.push_str("|------|---------|-------|----------|------|-------|-------|--------|\n");
for (sid, start, end, cost, turns, tools, model, status) in &sessions {
let date = epoch_to_date(*start);
let dur = (*end - *start) as f64 / 60.0;
let m = model.as_deref().unwrap_or("?");
let short_sid = if sid.len() > 8 { &sid[..8] } else { sid };
md.push_str(&format!("| {date} | {short_sid}.. | {m} | {dur:.0}m | ${cost:.4} | {turns} | {tools} | {status} |\n"));
}
}
Ok(ProjectSummaryReport {
project_id,
markdown: md,
total_sessions,
total_cost_usd: total_cost,
period_days: period,
})
}
fn epoch_to_iso(epoch: i64) -> String {
let secs = epoch;
let time = secs % 86400;
let h = time / 3600;
let m = (time % 3600) / 60;
// Simple date from epoch (2000-01-01 = day 10957 from 1970)
format!("{}T{:02}:{:02}Z", epoch_to_date(secs), h, m)
}
fn epoch_to_time(epoch: i64) -> String {
let time = epoch % 86400;
format!("{:02}:{:02}", time / 3600, (time % 3600) / 60)
}
pub(crate) fn epoch_to_date(epoch: i64) -> String {
// Days since 1970-01-01
let mut days = epoch / 86400;
let mut year = 1970i64;
loop {
let days_in_year = if is_leap(year) { 366 } else { 365 };
if days < days_in_year { break; }
days -= days_in_year;
year += 1;
}
let months = if is_leap(year) {
[31,29,31,30,31,30,31,31,30,31,30,31]
} else {
[31,28,31,30,31,30,31,31,30,31,30,31]
};
let mut month = 0usize;
for (i, &d) in months.iter().enumerate() {
if days < d { month = i; break; }
days -= d;
}
format!("{year:04}-{:02}-{:02}", month + 1, days + 1)
}
fn is_leap(y: i64) -> bool {
(y % 4 == 0 && y % 100 != 0) || y % 400 == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_epoch_to_date() {
assert_eq!(epoch_to_date(0), "1970-01-01");
assert_eq!(epoch_to_date(1710000000), "2024-03-09");
}
#[test]
fn test_epoch_to_iso() {
let iso = epoch_to_iso(1710000000);
assert!(iso.starts_with("2024-"));
assert!(iso.ends_with('Z'));
}
#[test]
fn test_session_report_struct() {
let r = SessionReport {
project_id: "test".into(),
session_id: "abc".into(),
markdown: "# Report".into(),
cost_usd: 0.5,
turn_count: 10,
tool_call_count: 5,
duration_minutes: 15.0,
model: "opus".into(),
};
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("durationMinutes"));
}
}

View file

@ -1,212 +0,0 @@
// SPDX-License-Identifier: LicenseRef-Commercial
// Git Context Injection — lightweight git CLI wrapper for agent session context.
// Full git2/libgit2 implementation deferred until git2 dep is added.
use serde::Serialize;
use std::process::Command;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GitContext {
pub branch: String,
pub last_commits: Vec<CommitSummary>,
pub modified_files: Vec<String>,
pub has_unstaged: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CommitSummary {
pub hash: String,
pub message: String,
pub author: String,
pub timestamp: i64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BranchInfo {
pub name: String,
pub is_protected: bool,
pub upstream: Option<String>,
pub ahead: i64,
pub behind: i64,
}
fn git_cmd(project_path: &str, args: &[&str]) -> Result<String, String> {
if project_path.starts_with('-') {
return Err("Invalid project path: cannot start with '-'".into());
}
let output = Command::new("git")
.args(["-C", project_path])
.args(args)
.output()
.map_err(|e| format!("Failed to run git: {e}"))?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(format!("git error: {stderr}"))
}
}
fn parse_log_line(line: &str) -> Option<CommitSummary> {
// Format: hash|author|timestamp|message
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() < 4 { return None; }
Some(CommitSummary {
hash: parts[0].to_string(),
author: parts[1].to_string(),
timestamp: parts[2].parse().unwrap_or(0),
message: parts[3].to_string(),
})
}
#[tauri::command]
pub fn pro_git_context(project_path: String) -> Result<GitContext, String> {
let branch = git_cmd(&project_path, &["branch", "--show-current"])
.unwrap_or_else(|_| "unknown".into());
let log_output = git_cmd(
&project_path,
&["log", "--format=%H|%an|%at|%s", "-10"],
).unwrap_or_default();
let last_commits: Vec<CommitSummary> = log_output
.lines()
.filter_map(parse_log_line)
.collect();
let status_output = git_cmd(&project_path, &["status", "--porcelain"])
.unwrap_or_default();
let modified_files: Vec<String> = status_output
.lines()
.filter(|l| !l.is_empty())
.map(|l| {
// Format: XY filename (first 3 chars are status + space)
if l.len() > 3 { l[3..].to_string() } else { l.to_string() }
})
.collect();
let has_unstaged = status_output.lines().any(|l| {
l.len() >= 2 && !l[1..2].eq(" ") && !l[1..2].eq("?")
});
Ok(GitContext { branch, last_commits, modified_files, has_unstaged })
}
#[tauri::command]
pub fn pro_git_inject(project_path: String, max_tokens: Option<i64>) -> Result<String, String> {
let ctx = pro_git_context(project_path)?;
let max_chars = (max_tokens.unwrap_or(1000) * 3) as usize;
let mut md = String::new();
md.push_str(&format!("## Git Context\n\n**Branch:** {}\n\n", ctx.branch));
if !ctx.last_commits.is_empty() {
md.push_str("**Recent commits:**\n");
for c in &ctx.last_commits {
let short_hash = if c.hash.len() >= 7 { &c.hash[..7] } else { &c.hash };
let line = format!("- {} {}\n", short_hash, c.message);
if md.len() + line.len() > max_chars { break; }
md.push_str(&line);
}
md.push('\n');
}
if !ctx.modified_files.is_empty() {
md.push_str("**Modified files:**\n");
for f in &ctx.modified_files {
let line = format!("- {f}\n");
if md.len() + line.len() > max_chars { break; }
md.push_str(&line);
}
}
Ok(md)
}
#[tauri::command]
pub fn pro_git_branch_info(project_path: String) -> Result<BranchInfo, String> {
let name = git_cmd(&project_path, &["branch", "--show-current"])
.unwrap_or_else(|_| "unknown".into());
let upstream = git_cmd(
&project_path,
&["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
).ok();
let (ahead, behind) = if upstream.is_some() {
let counts = git_cmd(
&project_path,
&["rev-list", "--left-right", "--count", "HEAD...@{u}"],
).unwrap_or_else(|_| "0\t0".into());
let parts: Vec<&str> = counts.split('\t').collect();
let a = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
let b = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
(a, b)
} else {
(0, 0)
};
let is_protected = matches!(name.as_str(), "main" | "master")
|| name.starts_with("release/");
Ok(BranchInfo { name, is_protected, upstream, ahead, behind })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_git_context_serializes_camel_case() {
let ctx = GitContext {
branch: "main".into(),
last_commits: vec![],
modified_files: vec!["src/lib.rs".into()],
has_unstaged: true,
};
let json = serde_json::to_string(&ctx).unwrap();
assert!(json.contains("lastCommits"));
assert!(json.contains("modifiedFiles"));
assert!(json.contains("hasUnstaged"));
}
#[test]
fn test_commit_summary_serializes_camel_case() {
let c = CommitSummary {
hash: "abc1234".into(),
message: "feat: add router".into(),
author: "dev".into(),
timestamp: 1710000000,
};
let json = serde_json::to_string(&c).unwrap();
assert!(json.contains("\"hash\":\"abc1234\""));
assert!(json.contains("\"timestamp\":1710000000"));
}
#[test]
fn test_branch_info_serializes_camel_case() {
let b = BranchInfo {
name: "feature/test".into(),
is_protected: false,
upstream: Some("origin/feature/test".into()),
ahead: 2,
behind: 0,
};
let json = serde_json::to_string(&b).unwrap();
assert!(json.contains("isProtected"));
}
#[test]
fn test_parse_log_line() {
let line = "abc123|Author Name|1710000000|feat: test commit";
let c = parse_log_line(line).unwrap();
assert_eq!(c.hash, "abc123");
assert_eq!(c.author, "Author Name");
assert_eq!(c.message, "feat: test commit");
}
}

View file

@ -1,94 +0,0 @@
// SPDX-License-Identifier: LicenseRef-Commercial
//
// agor-pro — Commercial plugin for Agents Orchestrator.
// This crate is NOT open-source. It is distributed only via the
// agents-orchestrator/agents-orchestrator private repository.
mod analytics;
mod branch_policy;
mod budget;
mod export;
mod git_context;
mod marketplace;
mod memory;
mod profiles;
mod router;
mod symbols;
use tauri::{
plugin::{Builder, TauriPlugin},
Runtime,
};
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("agor-pro")
.invoke_handler(tauri::generate_handler![
pro_status,
analytics::pro_analytics_summary,
analytics::pro_analytics_daily,
analytics::pro_analytics_model_breakdown,
export::pro_export_session,
export::pro_export_project_summary,
profiles::pro_list_accounts,
profiles::pro_get_active_account,
profiles::pro_set_active_account,
marketplace::pro_marketplace_fetch_catalog,
marketplace::pro_marketplace_installed,
marketplace::pro_marketplace_install,
marketplace::pro_marketplace_uninstall,
marketplace::pro_marketplace_check_updates,
marketplace::pro_marketplace_update,
budget::pro_budget_set,
budget::pro_budget_get,
budget::pro_budget_check,
budget::pro_budget_log_usage,
budget::pro_budget_reset,
budget::pro_budget_list,
router::pro_router_recommend,
router::pro_router_set_profile,
router::pro_router_get_profile,
router::pro_router_list_profiles,
memory::pro_memory_add,
memory::pro_memory_list,
memory::pro_memory_search,
memory::pro_memory_update,
memory::pro_memory_delete,
memory::pro_memory_inject,
memory::pro_memory_extract_from_session,
symbols::pro_symbols_scan,
symbols::pro_symbols_search,
symbols::pro_symbols_find_callers,
symbols::pro_symbols_status,
git_context::pro_git_context,
git_context::pro_git_inject,
git_context::pro_git_branch_info,
branch_policy::pro_branch_check,
branch_policy::pro_branch_policy_list,
branch_policy::pro_branch_policy_add,
branch_policy::pro_branch_policy_remove,
])
.build()
}
#[tauri::command]
fn pro_status() -> String {
"active".to_string()
}
/// Open the sessions.db for the current data directory.
fn open_sessions_db() -> Result<rusqlite::Connection, String> {
let config = agor_core::config::AppConfig::from_env();
let db_path = config.data_dir.join("sessions.db");
rusqlite::Connection::open(&db_path)
.map_err(|e| format!("Failed to open sessions.db: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_pro_status() {
assert_eq!(pro_status(), "active");
}
}

View file

@ -1,370 +0,0 @@
// SPDX-License-Identifier: LicenseRef-Commercial
// Plugin Marketplace — browse, install, update, and manage plugins from the catalog.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
const CATALOG_URL: &str = "https://raw.githubusercontent.com/agents-orchestrator/agor-plugins/main/catalog.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogPlugin {
pub id: String,
pub name: String,
pub version: String,
pub author: String,
pub description: String,
pub license: Option<String>,
pub homepage: Option<String>,
pub repository: Option<String>,
pub download_url: String,
pub checksum_sha256: String,
pub size_bytes: Option<i64>,
pub permissions: Vec<String>,
pub tags: Option<Vec<String>>,
pub min_agor_version: Option<String>,
pub downloads: Option<i64>,
pub rating: Option<f64>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InstalledPlugin {
pub id: String,
pub name: String,
pub version: String,
pub author: String,
pub description: String,
pub permissions: Vec<String>,
pub install_path: String,
pub has_update: bool,
pub latest_version: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Catalog {
#[allow(dead_code)]
version: i64,
plugins: Vec<CatalogPlugin>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(not(test), allow(dead_code))]
pub struct MarketplaceState {
pub catalog: Vec<CatalogPlugin>,
pub installed: Vec<InstalledPlugin>,
pub last_fetched: Option<String>,
}
/// Fetch the plugin catalog from GitHub.
#[tauri::command]
pub async fn pro_marketplace_fetch_catalog() -> Result<Vec<CatalogPlugin>, String> {
let response = reqwest_get(CATALOG_URL).await?;
let catalog: Catalog = serde_json::from_str(&response)
.map_err(|e| format!("Failed to parse catalog: {e}"))?;
Ok(catalog.plugins)
}
/// List installed plugins with update availability.
#[tauri::command]
pub fn pro_marketplace_installed() -> Result<Vec<InstalledPlugin>, String> {
let plugins_dir = plugins_dir()?;
let mut installed = Vec::new();
if !plugins_dir.exists() {
return Ok(installed);
}
let entries = std::fs::read_dir(&plugins_dir)
.map_err(|e| format!("Failed to read plugins dir: {e}"))?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() { continue; }
let manifest_path = path.join("plugin.json");
if !manifest_path.exists() { continue; }
let content = std::fs::read_to_string(&manifest_path)
.map_err(|e| format!("Failed to read {}: {e}", manifest_path.display()))?;
#[derive(Deserialize)]
struct PluginManifest {
id: String,
name: String,
version: String,
#[serde(default)]
author: String,
#[serde(default)]
description: String,
#[serde(default)]
permissions: Vec<String>,
}
let manifest: PluginManifest = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse plugin.json: {e}"))?;
installed.push(InstalledPlugin {
id: manifest.id,
name: manifest.name,
version: manifest.version,
author: manifest.author,
description: manifest.description,
permissions: manifest.permissions,
install_path: path.to_string_lossy().to_string(),
has_update: false,
latest_version: None,
});
}
Ok(installed)
}
/// Install a plugin from the catalog by ID.
#[tauri::command]
pub async fn pro_marketplace_install(plugin_id: String) -> Result<InstalledPlugin, String> {
// Fetch catalog to get download URL
let catalog = pro_marketplace_fetch_catalog().await?;
let plugin = catalog.iter()
.find(|p| p.id == plugin_id)
.ok_or_else(|| format!("Plugin '{}' not found in catalog", plugin_id))?;
// Path traversal protection
if plugin_id.contains("..") || plugin_id.contains('/') || plugin_id.contains('\\') {
return Err("Invalid plugin ID: contains path traversal characters".into());
}
let plugins_dir = plugins_dir()?;
let install_dir = plugins_dir.join(&plugin_id);
// Don't reinstall if already present
if install_dir.exists() {
return Err(format!("Plugin '{}' is already installed. Uninstall first or use update.", plugin_id));
}
// Validate download URL is HTTPS
if !plugin.download_url.starts_with("https://") {
return Err(format!("Insecure download URL (must be HTTPS): {}", plugin.download_url));
}
// Download the archive
let archive_bytes = reqwest_get_bytes(&plugin.download_url).await?;
// Reject plugins without a checksum
if plugin.checksum_sha256.is_empty() {
return Err("Plugin has no checksum — refusing to install unsigned plugin".into());
}
// Verify checksum
let hash = sha256_hex(&archive_bytes);
if hash != plugin.checksum_sha256 {
return Err(format!(
"Checksum mismatch: expected {}, got {}. Download may be corrupted.",
plugin.checksum_sha256, hash
));
}
// Create install directory and extract
std::fs::create_dir_all(&install_dir)
.map_err(|e| format!("Failed to create plugin dir: {e}"))?;
extract_tar_gz(&archive_bytes, &install_dir)?;
// Verify plugin.json exists after extraction
if !install_dir.join("plugin.json").exists() {
// Clean up failed install
let _ = std::fs::remove_dir_all(&install_dir);
return Err("Invalid plugin archive: missing plugin.json".into());
}
Ok(InstalledPlugin {
id: plugin.id.clone(),
name: plugin.name.clone(),
version: plugin.version.clone(),
author: plugin.author.clone(),
description: plugin.description.clone(),
permissions: plugin.permissions.clone(),
install_path: install_dir.to_string_lossy().to_string(),
has_update: false,
latest_version: None,
})
}
/// Uninstall a plugin by ID.
#[tauri::command]
pub fn pro_marketplace_uninstall(plugin_id: String) -> Result<(), String> {
let plugins_dir = plugins_dir()?;
let install_dir = plugins_dir.join(&plugin_id);
// Path traversal protection
let canonical = install_dir.canonicalize()
.map_err(|_| format!("Plugin '{}' is not installed", plugin_id))?;
let canonical_plugins = plugins_dir.canonicalize()
.map_err(|e| format!("Plugins dir error: {e}"))?;
if !canonical.starts_with(&canonical_plugins) {
return Err("Access denied: path traversal detected".into());
}
std::fs::remove_dir_all(&canonical)
.map_err(|e| format!("Failed to remove plugin: {e}"))?;
Ok(())
}
/// Check for updates across all installed plugins.
#[tauri::command]
pub async fn pro_marketplace_check_updates() -> Result<Vec<InstalledPlugin>, String> {
let catalog = pro_marketplace_fetch_catalog().await?;
let mut installed = pro_marketplace_installed()?;
for plugin in &mut installed {
if let Some(catalog_entry) = catalog.iter().find(|c| c.id == plugin.id) {
if catalog_entry.version != plugin.version {
plugin.has_update = true;
plugin.latest_version = Some(catalog_entry.version.clone());
}
}
}
Ok(installed)
}
/// Update a plugin to the latest version.
#[tauri::command]
pub async fn pro_marketplace_update(plugin_id: String) -> Result<InstalledPlugin, String> {
pro_marketplace_uninstall(plugin_id.clone())?;
pro_marketplace_install(plugin_id).await
}
// --- Helpers ---
fn plugins_dir() -> Result<PathBuf, String> {
let config = agor_core::config::AppConfig::from_env();
Ok(config.config_dir.join("plugins"))
}
fn sha256_hex(data: &[u8]) -> String {
use sha2::{Sha256, Digest};
use std::fmt::Write;
let hash = Sha256::digest(data);
let mut hex = String::with_capacity(64);
for byte in hash {
write!(hex, "{:02x}", byte).unwrap();
}
hex
}
async fn reqwest_get(url: &str) -> Result<String, String> {
// Use std::process::Command to call curl since we don't have reqwest
let output = tokio::process::Command::new("curl")
.args(["-sfL", "--max-time", "30", "--proto", "=https", "--max-filesize", "52428800", url])
.output()
.await
.map_err(|e| format!("HTTP request failed: {e}"))?;
if !output.status.success() {
return Err(format!("HTTP request failed: status {}", output.status));
}
String::from_utf8(output.stdout)
.map_err(|e| format!("Invalid UTF-8 in response: {e}"))
}
async fn reqwest_get_bytes(url: &str) -> Result<Vec<u8>, String> {
let output = tokio::process::Command::new("curl")
.args(["-sfL", "--max-time", "120", "--proto", "=https", "--max-filesize", "52428800", url])
.output()
.await
.map_err(|e| format!("Download failed: {e}"))?;
if !output.status.success() {
return Err(format!("Download failed: status {}", output.status));
}
Ok(output.stdout)
}
fn extract_tar_gz(data: &[u8], dest: &std::path::Path) -> Result<(), String> {
// Write to temp file, extract with tar
let temp_path = dest.join(".download.tar.gz");
std::fs::write(&temp_path, data)
.map_err(|e| format!("Failed to write temp archive: {e}"))?;
let output = std::process::Command::new("tar")
.args(["xzf", &temp_path.to_string_lossy(), "-C", &dest.to_string_lossy(),
"--strip-components=1", "--no-same-owner", "--no-same-permissions"])
.output()
.map_err(|e| format!("tar extraction failed: {e}"))?;
let _ = std::fs::remove_file(&temp_path);
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("tar extraction failed: {stderr}"));
}
// After tar extraction, verify no files escaped dest
for entry in std::fs::read_dir(dest).into_iter().flatten() {
if let Ok(e) = entry {
let canonical = e.path().canonicalize().unwrap_or_default();
let canonical_dest = dest.canonicalize().unwrap_or_default();
if !canonical.starts_with(&canonical_dest) {
let _ = std::fs::remove_dir_all(dest);
return Err("Path traversal detected in archive — installation aborted".into());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_catalog_plugin_serializes() {
let p = CatalogPlugin {
id: "test".into(), name: "Test".into(), version: "1.0.0".into(),
author: "Author".into(), description: "Desc".into(), license: Some("MIT".into()),
homepage: None, repository: None,
download_url: "https://example.com/test.tar.gz".into(),
checksum_sha256: "abc123".into(), size_bytes: Some(1024),
permissions: vec!["palette".into()], tags: Some(vec!["test".into()]),
min_agor_version: Some("0.1.0".into()), downloads: Some(42),
rating: Some(4.5), created_at: None, updated_at: None,
};
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("downloadUrl"));
assert!(json.contains("checksumSha256"));
assert!(json.contains("minAgorVersion"));
}
#[test]
fn test_installed_plugin_serializes() {
let p = InstalledPlugin {
id: "test".into(), name: "Test".into(), version: "1.0.0".into(),
author: "Author".into(), description: "Desc".into(),
permissions: vec!["palette".into()],
install_path: "/home/user/.config/agor/plugins/test".into(),
has_update: true, latest_version: Some("2.0.0".into()),
};
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("hasUpdate"));
assert!(json.contains("latestVersion"));
assert!(json.contains("installPath"));
}
#[test]
fn test_marketplace_state_serializes() {
let s = MarketplaceState {
catalog: vec![], installed: vec![],
last_fetched: Some("2026-03-17T00:00:00Z".into()),
};
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("lastFetched"));
}
}

View file

@ -1,355 +0,0 @@
// SPDX-License-Identifier: LicenseRef-Commercial
// Persistent Agent Memory — project-scoped structured fragments that survive sessions.
use rusqlite::params;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MemoryFragment {
pub id: i64,
pub project_id: String,
pub content: String,
pub source: String,
pub trust: String,
pub confidence: f64,
pub created_at: i64,
pub ttl_days: i64,
pub tags: String,
}
fn ensure_tables(conn: &rusqlite::Connection) -> Result<(), String> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS pro_memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL,
content TEXT NOT NULL,
source TEXT NOT NULL DEFAULT '',
trust TEXT NOT NULL DEFAULT 'agent',
confidence REAL NOT NULL DEFAULT 1.0,
created_at INTEGER NOT NULL,
ttl_days INTEGER NOT NULL DEFAULT 90,
tags TEXT NOT NULL DEFAULT ''
);
CREATE VIRTUAL TABLE IF NOT EXISTS pro_memories_fts USING fts5(
content, tags, content=pro_memories, content_rowid=id
);
CREATE TRIGGER IF NOT EXISTS pro_memories_ai AFTER INSERT ON pro_memories BEGIN
INSERT INTO pro_memories_fts(rowid, content, tags) VALUES (new.id, new.content, new.tags);
END;
CREATE TRIGGER IF NOT EXISTS pro_memories_ad AFTER DELETE ON pro_memories BEGIN
INSERT INTO pro_memories_fts(pro_memories_fts, rowid, content, tags)
VALUES ('delete', old.id, old.content, old.tags);
END;
CREATE TRIGGER IF NOT EXISTS pro_memories_au AFTER UPDATE ON pro_memories BEGIN
INSERT INTO pro_memories_fts(pro_memories_fts, rowid, content, tags)
VALUES ('delete', old.id, old.content, old.tags);
INSERT INTO pro_memories_fts(rowid, content, tags) VALUES (new.id, new.content, new.tags);
END;"
).map_err(|e| format!("Failed to create memory tables: {e}"))
}
fn now_epoch() -> i64 {
super::analytics::now_epoch()
}
fn prune_expired(conn: &rusqlite::Connection) -> Result<(), String> {
let now = now_epoch();
conn.execute(
"DELETE FROM pro_memories WHERE created_at + (ttl_days * 86400) < ?1",
params![now],
).map_err(|e| format!("Prune failed: {e}"))?;
Ok(())
}
fn row_to_fragment(row: &rusqlite::Row) -> rusqlite::Result<MemoryFragment> {
Ok(MemoryFragment {
id: row.get("id")?,
project_id: row.get("project_id")?,
content: row.get("content")?,
source: row.get("source")?,
trust: row.get("trust")?,
confidence: row.get("confidence")?,
created_at: row.get("created_at")?,
ttl_days: row.get("ttl_days")?,
tags: row.get("tags")?,
})
}
#[tauri::command]
pub fn pro_memory_add(
project_id: String,
content: String,
source: Option<String>,
tags: Option<String>,
) -> Result<i64, String> {
if content.len() > 10000 {
return Err("Memory content too long (max 10000 chars)".into());
}
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
// Per-project memory cap
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM pro_memories WHERE project_id = ?1",
params![project_id], |row| row.get(0)
).unwrap_or(0);
if count >= 1000 {
return Err("Memory limit reached for this project (max 1000 fragments)".into());
}
let ts = now_epoch();
let src = source.unwrap_or_default();
let tgs = tags.unwrap_or_default();
conn.execute(
"INSERT INTO pro_memories (project_id, content, source, created_at, tags) VALUES (?1, ?2, ?3, ?4, ?5)",
params![project_id, content, src, ts, tgs],
).map_err(|e| format!("Failed to add memory: {e}"))?;
Ok(conn.last_insert_rowid())
}
#[tauri::command]
pub fn pro_memory_list(project_id: String, limit: Option<i64>) -> Result<Vec<MemoryFragment>, String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
prune_expired(&conn)?;
let lim = limit.unwrap_or(50);
let mut stmt = conn.prepare(
"SELECT id, project_id, content, source, trust, confidence, created_at, ttl_days, tags
FROM pro_memories WHERE project_id = ?1 ORDER BY created_at DESC LIMIT ?2"
).map_err(|e| format!("Query failed: {e}"))?;
let rows = stmt.query_map(params![project_id, lim], row_to_fragment)
.map_err(|e| format!("Query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
Ok(rows)
}
#[tauri::command]
pub fn pro_memory_search(project_id: String, query: String) -> Result<Vec<MemoryFragment>, String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
prune_expired(&conn)?;
// Sanitize query to prevent FTS5 operator injection
let safe_query = format!("\"{}\"", query.replace('"', "\"\""));
let mut stmt = conn.prepare(
"SELECT m.id, m.project_id, m.content, m.source, m.trust, m.confidence, m.created_at, m.ttl_days, m.tags
FROM pro_memories m
JOIN pro_memories_fts f ON m.id = f.rowid
WHERE f.pro_memories_fts MATCH ?1 AND m.project_id = ?2
ORDER BY rank LIMIT 20"
).map_err(|e| format!("Search query failed: {e}"))?;
let rows = stmt.query_map(params![safe_query, project_id], row_to_fragment)
.map_err(|e| format!("Search failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
Ok(rows)
}
#[tauri::command]
pub fn pro_memory_update(
id: i64,
content: Option<String>,
trust: Option<String>,
confidence: Option<f64>,
) -> Result<(), String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
let tx = conn.unchecked_transaction()
.map_err(|e| format!("Transaction failed: {e}"))?;
if let Some(c) = content {
tx.execute("UPDATE pro_memories SET content = ?2 WHERE id = ?1", params![id, c])
.map_err(|e| format!("Update content failed: {e}"))?;
}
if let Some(t) = trust {
tx.execute("UPDATE pro_memories SET trust = ?2 WHERE id = ?1", params![id, t])
.map_err(|e| format!("Update trust failed: {e}"))?;
}
if let Some(c) = confidence {
tx.execute("UPDATE pro_memories SET confidence = ?2 WHERE id = ?1", params![id, c])
.map_err(|e| format!("Update confidence failed: {e}"))?;
}
tx.commit().map_err(|e| format!("Commit failed: {e}"))?;
Ok(())
}
#[tauri::command]
pub fn pro_memory_delete(id: i64) -> Result<(), String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
conn.execute("DELETE FROM pro_memories WHERE id = ?1", params![id])
.map_err(|e| format!("Delete failed: {e}"))?;
Ok(())
}
#[tauri::command]
pub fn pro_memory_inject(project_id: String, max_tokens: Option<i64>) -> Result<String, String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
prune_expired(&conn)?;
let max_chars = (max_tokens.unwrap_or(2000) * 3) as usize; // ~3 chars per token heuristic
let mut stmt = conn.prepare(
"SELECT content, trust, confidence FROM pro_memories
WHERE project_id = ?1 ORDER BY confidence DESC, created_at DESC"
).map_err(|e| format!("Query failed: {e}"))?;
let entries: Vec<(String, String, f64)> = stmt
.query_map(params![project_id], |row| Ok((row.get("content")?, row.get("trust")?, row.get("confidence")?)))
.map_err(|e| format!("Query failed: {e}"))?
.collect::<Result<Vec<_>, _>>()
.map_err(|e| format!("Row read failed: {e}"))?;
let mut md = String::from("## Project Memory\n\n");
let mut chars = md.len();
for (content, trust, confidence) in &entries {
let line = format!("- [{}|{:.1}] {}\n", trust, confidence, content);
if chars + line.len() > max_chars {
break;
}
md.push_str(&line);
chars += line.len();
}
Ok(md)
}
#[tauri::command]
pub fn pro_memory_extract_from_session(
project_id: String,
session_messages_json: String,
) -> Result<Vec<MemoryFragment>, String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
let messages: Vec<serde_json::Value> = serde_json::from_str(&session_messages_json)
.map_err(|e| format!("Invalid JSON: {e}"))?;
let ts = now_epoch();
let mut extracted = Vec::new();
// Patterns to extract: decisions, file references, errors
let decision_patterns = ["decision:", "chose ", "decided to ", "instead of "];
let error_patterns = ["error:", "failed:", "Error:", "panic", "FAILED"];
for msg in &messages {
let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
// Extract decisions
for pattern in &decision_patterns {
if content.contains(pattern) {
let fragment_content = extract_surrounding(content, pattern, 200);
conn.execute(
"INSERT INTO pro_memories (project_id, content, source, trust, confidence, created_at, tags)
VALUES (?1, ?2, 'auto-extract', 'auto', 0.7, ?3, 'decision')",
params![project_id, fragment_content, ts],
).map_err(|e| format!("Insert failed: {e}"))?;
let id = conn.last_insert_rowid();
extracted.push(MemoryFragment {
id,
project_id: project_id.clone(),
content: fragment_content,
source: "auto-extract".into(),
trust: "auto".into(),
confidence: 0.7,
created_at: ts,
ttl_days: 90,
tags: "decision".into(),
});
break; // One extraction per message
}
}
// Extract errors
for pattern in &error_patterns {
if content.contains(pattern) {
let fragment_content = extract_surrounding(content, pattern, 300);
conn.execute(
"INSERT INTO pro_memories (project_id, content, source, trust, confidence, created_at, tags)
VALUES (?1, ?2, 'auto-extract', 'auto', 0.6, ?3, 'error')",
params![project_id, fragment_content, ts],
).map_err(|e| format!("Insert failed: {e}"))?;
let id = conn.last_insert_rowid();
extracted.push(MemoryFragment {
id,
project_id: project_id.clone(),
content: fragment_content,
source: "auto-extract".into(),
trust: "auto".into(),
confidence: 0.6,
created_at: ts,
ttl_days: 90,
tags: "error".into(),
});
break;
}
}
}
Ok(extracted)
}
/// Extract surrounding text around a pattern match, up to max_chars.
fn extract_surrounding(text: &str, pattern: &str, max_chars: usize) -> String {
if let Some(pos) = text.find(pattern) {
let start = pos.saturating_sub(50);
let end = (pos + max_chars).min(text.len());
// Ensure valid UTF-8 boundaries
let start = text.floor_char_boundary(start);
let end = text.ceil_char_boundary(end);
text[start..end].to_string()
} else {
text.chars().take(max_chars).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_memory_fragment_serializes_camel_case() {
let f = MemoryFragment {
id: 1,
project_id: "proj1".into(),
content: "We decided to use SQLite".into(),
source: "session-abc".into(),
trust: "agent".into(),
confidence: 0.9,
created_at: 1710000000,
ttl_days: 90,
tags: "decision,architecture".into(),
};
let json = serde_json::to_string(&f).unwrap();
assert!(json.contains("projectId"));
assert!(json.contains("createdAt"));
assert!(json.contains("ttlDays"));
}
#[test]
fn test_memory_fragment_deserializes() {
let json = r#"{"id":1,"projectId":"p","content":"test","source":"s","trust":"human","confidence":1.0,"createdAt":0,"ttlDays":30,"tags":"t"}"#;
let f: MemoryFragment = serde_json::from_str(json).unwrap();
assert_eq!(f.project_id, "p");
assert_eq!(f.trust, "human");
}
#[test]
fn test_extract_surrounding() {
let text = "We chose SQLite instead of PostgreSQL for simplicity";
let result = extract_surrounding(text, "chose ", 100);
assert!(result.contains("chose SQLite"));
}
}

View file

@ -1,143 +0,0 @@
// SPDX-License-Identifier: LicenseRef-Commercial
// Multi-Account Profile Switching — manage multiple Claude/provider accounts.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountProfile {
pub id: String,
pub display_name: String,
pub email: Option<String>,
pub provider: String,
pub config_dir: String,
pub is_active: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ActiveAccount {
pub profile_id: String,
pub provider: String,
pub config_dir: String,
}
/// List all configured account profiles across providers.
/// Scans ~/.config/agor/accounts.json for profile definitions.
#[tauri::command]
pub fn pro_list_accounts() -> Result<Vec<AccountProfile>, String> {
let accounts_path = accounts_file_path()?;
let active = load_active_id();
if !accounts_path.exists() {
// Return default account derived from Claude profiles
let home = dirs::home_dir().unwrap_or_default();
let default_dir = home.join(".claude").to_string_lossy().to_string();
return Ok(vec![AccountProfile {
id: "default".into(),
display_name: "Default".into(),
email: None,
provider: "claude".into(),
config_dir: default_dir,
is_active: true,
}]);
}
let content = std::fs::read_to_string(&accounts_path)
.map_err(|e| format!("Failed to read accounts.json: {e}"))?;
let mut profiles: Vec<AccountProfile> = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse accounts.json: {e}"))?;
for p in &mut profiles {
p.is_active = p.id == active;
}
Ok(profiles)
}
/// Get the currently active account.
#[tauri::command]
pub fn pro_get_active_account() -> Result<ActiveAccount, String> {
let profiles = pro_list_accounts()?;
let active = profiles.iter()
.find(|p| p.is_active)
.or_else(|| profiles.first())
.ok_or("No accounts configured")?;
Ok(ActiveAccount {
profile_id: active.id.clone(),
provider: active.provider.clone(),
config_dir: active.config_dir.clone(),
})
}
/// Switch the active account. Updates ~/.config/agor/active-account.
#[tauri::command]
pub fn pro_set_active_account(profile_id: String) -> Result<ActiveAccount, String> {
let profiles = pro_list_accounts()?;
let target = profiles.iter()
.find(|p| p.id == profile_id)
.ok_or_else(|| format!("Account '{}' not found", profile_id))?;
let active_path = active_account_path()?;
std::fs::write(&active_path, &profile_id)
.map_err(|e| format!("Failed to write active account: {e}"))?;
Ok(ActiveAccount {
profile_id: target.id.clone(),
provider: target.provider.clone(),
config_dir: target.config_dir.clone(),
})
}
fn accounts_file_path() -> Result<PathBuf, String> {
let config = agor_core::config::AppConfig::from_env();
Ok(config.config_dir.join("accounts.json"))
}
fn active_account_path() -> Result<PathBuf, String> {
let config = agor_core::config::AppConfig::from_env();
Ok(config.config_dir.join("active-account"))
}
fn load_active_id() -> String {
active_account_path()
.ok()
.and_then(|p| std::fs::read_to_string(p).ok())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "default".into())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_account_profile_serializes_camel_case() {
let p = AccountProfile {
id: "work".into(),
display_name: "Work Account".into(),
email: Some("work@example.com".into()),
provider: "claude".into(),
config_dir: "/home/user/.claude-work".into(),
is_active: true,
};
let json = serde_json::to_string(&p).unwrap();
assert!(json.contains("displayName"));
assert!(json.contains("isActive"));
assert!(json.contains("configDir"));
}
#[test]
fn test_active_account_struct() {
let a = ActiveAccount {
profile_id: "test".into(),
provider: "claude".into(),
config_dir: "/tmp/test".into(),
};
let json = serde_json::to_string(&a).unwrap();
assert!(json.contains("profileId"));
}
}

View file

@ -1,194 +0,0 @@
// SPDX-License-Identifier: LicenseRef-Commercial
// Smart Model Router — select optimal model based on task type and project config.
use rusqlite::params;
use serde::Serialize;
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelRecommendation {
pub model: String,
pub reason: String,
pub estimated_cost_factor: f64,
pub profile: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RoutingProfile {
pub name: String,
pub description: String,
pub rules: Vec<String>,
}
fn ensure_tables(conn: &rusqlite::Connection) -> Result<(), String> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS pro_router_profiles (
project_id TEXT PRIMARY KEY,
profile TEXT NOT NULL DEFAULT 'balanced'
);"
).map_err(|e| format!("Failed to create router tables: {e}"))
}
fn get_profiles() -> Vec<RoutingProfile> {
vec![
RoutingProfile {
name: "cost_saver".into(),
description: "Minimize cost — use cheapest viable model".into(),
rules: vec![
"All roles use cheapest model".into(),
"Only upgrade for prompts > 10000 chars".into(),
],
},
RoutingProfile {
name: "quality_first".into(),
description: "Maximize quality — always use premium model".into(),
rules: vec![
"All roles use premium model".into(),
"No downgrade regardless of prompt size".into(),
],
},
RoutingProfile {
name: "balanced".into(),
description: "Match model to task — role and prompt size heuristic".into(),
rules: vec![
"Manager/Architect → premium model".into(),
"Tester/Reviewer → mid-tier model".into(),
"Short prompts (<2000 chars) → cheap model".into(),
"Long prompts (>8000 chars) → premium model".into(),
],
},
]
}
fn select_model(profile: &str, role: &str, prompt_length: i64, provider: &str) -> (String, String, f64) {
let (cheap, mid, premium) = match provider {
"codex" => ("gpt-4.1-mini", "gpt-4.1", "gpt-5"),
"ollama" => ("qwen3:8b", "qwen3:8b", "qwen3:32b"),
_ => ("claude-haiku-4-5", "claude-sonnet-4-5", "claude-opus-4"),
};
match profile {
"cost_saver" => {
if prompt_length > 10_000 {
(mid.into(), "Long prompt upgrade in cost_saver profile".into(), 0.5)
} else {
(cheap.into(), "Cost saver: cheapest model".into(), 0.1)
}
}
"quality_first" => {
(premium.into(), "Quality first: premium model".into(), 1.0)
}
_ => {
// Balanced: role + prompt heuristic
match role {
"manager" | "architect" => {
(premium.into(), format!("Balanced: premium for {role} role"), 1.0)
}
"tester" | "reviewer" => {
(mid.into(), format!("Balanced: mid-tier for {role} role"), 0.5)
}
_ => {
if prompt_length < 2_000 {
(cheap.into(), "Balanced: cheap for short prompt".into(), 0.1)
} else if prompt_length > 8_000 {
(premium.into(), "Balanced: premium for long prompt".into(), 1.0)
} else {
(mid.into(), "Balanced: mid-tier default".into(), 0.5)
}
}
}
}
}
}
#[tauri::command]
pub fn pro_router_recommend(
project_id: String,
role: String,
prompt_length: i64,
provider: Option<String>,
) -> Result<ModelRecommendation, String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
let profile = conn.prepare("SELECT profile FROM pro_router_profiles WHERE project_id = ?1")
.map_err(|e| format!("Query failed: {e}"))?
.query_row(params![project_id], |row| row.get::<_, String>(0))
.unwrap_or_else(|_| "balanced".into());
let prov = provider.as_deref().unwrap_or("claude");
let (model, reason, cost_factor) = select_model(&profile, &role, prompt_length, prov);
Ok(ModelRecommendation { model, reason, estimated_cost_factor: cost_factor, profile })
}
#[tauri::command]
pub fn pro_router_set_profile(project_id: String, profile: String) -> Result<(), String> {
let valid = ["cost_saver", "quality_first", "balanced"];
if !valid.contains(&profile.as_str()) {
return Err(format!("Invalid profile '{}'. Valid: {:?}", profile, valid));
}
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
conn.execute(
"INSERT INTO pro_router_profiles (project_id, profile) VALUES (?1, ?2)
ON CONFLICT(project_id) DO UPDATE SET profile = ?2",
params![project_id, profile],
).map_err(|e| format!("Failed to set profile: {e}"))?;
Ok(())
}
#[tauri::command]
pub fn pro_router_get_profile(project_id: String) -> Result<String, String> {
let conn = super::open_sessions_db()?;
ensure_tables(&conn)?;
let profile = conn.prepare("SELECT profile FROM pro_router_profiles WHERE project_id = ?1")
.map_err(|e| format!("Query failed: {e}"))?
.query_row(params![project_id], |row| row.get::<_, String>(0))
.unwrap_or_else(|_| "balanced".into());
Ok(profile)
}
#[tauri::command]
pub fn pro_router_list_profiles() -> Vec<RoutingProfile> {
get_profiles()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_recommendation_serializes_camel_case() {
let r = ModelRecommendation {
model: "claude-sonnet-4-5".into(),
reason: "test".into(),
estimated_cost_factor: 0.5,
profile: "balanced".into(),
};
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("estimatedCostFactor"));
assert!(json.contains("\"profile\":\"balanced\""));
}
#[test]
fn test_select_model_balanced_manager() {
let (model, _, cost) = select_model("balanced", "manager", 5000, "claude");
assert_eq!(model, "claude-opus-4");
assert_eq!(cost, 1.0);
}
#[test]
fn test_select_model_cost_saver() {
let (model, _, cost) = select_model("cost_saver", "worker", 1000, "claude");
assert_eq!(model, "claude-haiku-4-5");
assert!(cost < 0.2);
}
#[test]
fn test_select_model_codex_provider() {
let (model, _, _) = select_model("quality_first", "manager", 5000, "codex");
assert_eq!(model, "gpt-5");
}
}

View file

@ -1,319 +0,0 @@
// SPDX-License-Identifier: LicenseRef-Commercial
// Codebase Symbol Graph — stub implementation using regex parsing.
// Full tree-sitter implementation deferred until tree-sitter dep is added.
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
static SYMBOL_CACHE: std::sync::LazyLock<Mutex<HashMap<String, Vec<Symbol>>>> =
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Symbol {
pub name: String,
pub kind: String,
pub file_path: String,
pub line_number: usize,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CallerRef {
pub file_path: String,
pub line_number: usize,
pub context: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ScanResult {
pub files_scanned: usize,
pub symbols_found: usize,
pub duration_ms: u64,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct IndexStatus {
pub indexed: bool,
pub symbols_count: usize,
pub last_scan: Option<String>,
}
/// Common directories to skip during scan.
const SKIP_DIRS: &[&str] = &[
".git", "node_modules", "target", "dist", "build", ".next",
"__pycache__", ".venv", "venv", ".tox",
];
/// Supported extensions for symbol extraction.
const SUPPORTED_EXT: &[&str] = &["ts", "rs", "py", "js", "tsx", "jsx"];
fn should_skip(name: &str) -> bool {
SKIP_DIRS.contains(&name)
}
const MAX_FILES: usize = 50_000;
const MAX_DEPTH: usize = 20;
fn walk_files(dir: &Path, files: &mut Vec<PathBuf>) {
walk_files_bounded(dir, files, 0);
}
fn walk_files_bounded(dir: &Path, files: &mut Vec<PathBuf>, depth: usize) {
if depth >= MAX_DEPTH || files.len() >= MAX_FILES {
return;
}
let Ok(entries) = std::fs::read_dir(dir) else { return };
for entry in entries.flatten() {
if files.len() >= MAX_FILES {
return;
}
let ft = entry.file_type();
// Skip symlinks
if ft.as_ref().map_or(false, |ft| ft.is_symlink()) {
continue;
}
let path = entry.path();
if ft.as_ref().map_or(false, |ft| ft.is_dir()) {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if !should_skip(name) {
walk_files_bounded(&path, files, depth + 1);
}
}
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
if SUPPORTED_EXT.contains(&ext) {
files.push(path);
}
}
}
}
fn extract_symbols_from_file(path: &Path) -> Vec<Symbol> {
let Ok(content) = std::fs::read_to_string(path) else { return vec![] };
let file_str = path.to_string_lossy().to_string();
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let mut symbols = Vec::new();
for (line_idx, line) in content.lines().enumerate() {
let trimmed = line.trim();
match ext {
"rs" => {
if let Some(name) = extract_after(trimmed, "fn ") {
symbols.push(Symbol { name, kind: "function".into(), file_path: file_str.clone(), line_number: line_idx + 1 });
} else if let Some(name) = extract_after(trimmed, "struct ") {
symbols.push(Symbol { name, kind: "struct".into(), file_path: file_str.clone(), line_number: line_idx + 1 });
} else if let Some(name) = extract_after(trimmed, "enum ") {
symbols.push(Symbol { name, kind: "type".into(), file_path: file_str.clone(), line_number: line_idx + 1 });
} else if let Some(name) = extract_after(trimmed, "const ") {
symbols.push(Symbol { name, kind: "const".into(), file_path: file_str.clone(), line_number: line_idx + 1 });
} else if let Some(name) = extract_after(trimmed, "trait ") {
symbols.push(Symbol { name, kind: "type".into(), file_path: file_str.clone(), line_number: line_idx + 1 });
}
}
"ts" | "tsx" | "js" | "jsx" => {
if let Some(name) = extract_after(trimmed, "function ") {
symbols.push(Symbol { name, kind: "function".into(), file_path: file_str.clone(), line_number: line_idx + 1 });
} else if let Some(name) = extract_after(trimmed, "class ") {
symbols.push(Symbol { name, kind: "class".into(), file_path: file_str.clone(), line_number: line_idx + 1 });
} else if let Some(name) = extract_ts_const_fn(trimmed) {
symbols.push(Symbol { name, kind: "function".into(), file_path: file_str.clone(), line_number: line_idx + 1 });
} else if let Some(name) = extract_after(trimmed, "interface ") {
symbols.push(Symbol { name, kind: "type".into(), file_path: file_str.clone(), line_number: line_idx + 1 });
} else if let Some(name) = extract_after(trimmed, "type ") {
symbols.push(Symbol { name, kind: "type".into(), file_path: file_str.clone(), line_number: line_idx + 1 });
}
}
"py" => {
if let Some(name) = extract_after(trimmed, "def ") {
symbols.push(Symbol { name, kind: "function".into(), file_path: file_str.clone(), line_number: line_idx + 1 });
} else if let Some(name) = extract_after(trimmed, "class ") {
symbols.push(Symbol { name, kind: "class".into(), file_path: file_str.clone(), line_number: line_idx + 1 });
}
}
_ => {}
}
}
symbols
}
/// Extract identifier after a keyword (e.g., "fn " -> function name).
fn extract_after(line: &str, prefix: &str) -> Option<String> {
if !line.starts_with(prefix) && !line.starts_with(&format!("pub {prefix}"))
&& !line.starts_with(&format!("export {prefix}"))
&& !line.starts_with(&format!("pub(crate) {prefix}"))
&& !line.starts_with(&format!("async {prefix}"))
&& !line.starts_with(&format!("pub async {prefix}"))
&& !line.starts_with(&format!("export async {prefix}"))
&& !line.starts_with(&format!("export default {prefix}"))
{
return None;
}
let after = line.find(prefix)? + prefix.len();
let rest = &line[after..];
let name: String = rest.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if name.is_empty() { None } else { Some(name) }
}
/// Extract arrow function / const fn pattern: `const foo = (` or `export const foo = (`
fn extract_ts_const_fn(line: &str) -> Option<String> {
let stripped = line.strip_prefix("export ")
.or(Some(line))?;
let rest = stripped.strip_prefix("const ")?;
let name: String = rest.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if name.is_empty() { return None; }
// Check if it looks like a function assignment
if rest.contains("= (") || rest.contains("= async (") || rest.contains("=> ") {
Some(name)
} else {
None
}
}
#[tauri::command]
pub fn pro_symbols_scan(project_path: String) -> Result<ScanResult, String> {
let start = std::time::Instant::now();
let root = PathBuf::from(&project_path);
if !root.is_absolute() || root.components().count() < 3 {
return Err("Invalid project path: must be an absolute path at least 3 levels deep".into());
}
if !root.is_dir() {
return Err(format!("Not a directory: {project_path}"));
}
let mut files = Vec::new();
walk_files(&root, &mut files);
let mut all_symbols = Vec::new();
for file in &files {
all_symbols.extend(extract_symbols_from_file(file));
}
let result = ScanResult {
files_scanned: files.len(),
symbols_found: all_symbols.len(),
duration_ms: start.elapsed().as_millis() as u64,
};
let mut cache = SYMBOL_CACHE.lock().map_err(|e| format!("Lock failed: {e}"))?;
cache.insert(project_path, all_symbols);
Ok(result)
}
#[tauri::command]
pub fn pro_symbols_search(project_path: String, query: String) -> Result<Vec<Symbol>, String> {
let cache = SYMBOL_CACHE.lock().map_err(|e| format!("Lock failed: {e}"))?;
let symbols = cache.get(&project_path).cloned().unwrap_or_default();
let query_lower = query.to_lowercase();
let results: Vec<Symbol> = symbols.into_iter()
.filter(|s| s.name.to_lowercase().contains(&query_lower))
.take(50)
.collect();
Ok(results)
}
#[tauri::command]
pub fn pro_symbols_find_callers(project_path: String, symbol_name: String) -> Result<Vec<CallerRef>, String> {
let root = PathBuf::from(&project_path);
if !root.is_absolute() || root.components().count() < 3 {
return Err("Invalid project path: must be an absolute path at least 3 levels deep".into());
}
if !root.is_dir() {
return Err(format!("Not a directory: {project_path}"));
}
let mut files = Vec::new();
walk_files(&root, &mut files);
let mut callers = Vec::new();
for file in &files {
let Ok(content) = std::fs::read_to_string(file) else { continue };
for (idx, line) in content.lines().enumerate() {
if line.contains(&symbol_name) {
callers.push(CallerRef {
file_path: file.to_string_lossy().to_string(),
line_number: idx + 1,
context: line.trim().to_string(),
});
}
}
}
// Cap results
callers.truncate(100);
Ok(callers)
}
#[tauri::command]
pub fn pro_symbols_status(project_path: String) -> Result<IndexStatus, String> {
let cache = SYMBOL_CACHE.lock().map_err(|e| format!("Lock failed: {e}"))?;
match cache.get(&project_path) {
Some(symbols) => Ok(IndexStatus {
indexed: true,
symbols_count: symbols.len(),
last_scan: None, // In-memory only, no timestamp tracking
}),
None => Ok(IndexStatus {
indexed: false,
symbols_count: 0,
last_scan: None,
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_symbol_serializes_camel_case() {
let s = Symbol {
name: "processEvent".into(),
kind: "function".into(),
file_path: "/src/lib.rs".into(),
line_number: 42,
};
let json = serde_json::to_string(&s).unwrap();
assert!(json.contains("filePath"));
assert!(json.contains("lineNumber"));
}
#[test]
fn test_scan_result_serializes_camel_case() {
let r = ScanResult {
files_scanned: 10,
symbols_found: 50,
duration_ms: 123,
};
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("filesScanned"));
assert!(json.contains("symbolsFound"));
assert!(json.contains("durationMs"));
}
#[test]
fn test_extract_after_rust_fn() {
assert_eq!(extract_after("fn hello()", "fn "), Some("hello".into()));
assert_eq!(extract_after("pub fn world()", "fn "), Some("world".into()));
assert_eq!(extract_after("pub async fn go()", "fn "), Some("go".into()));
assert_eq!(extract_after("let x = 5;", "fn "), None);
}
#[test]
fn test_extract_ts_const_fn() {
assert_eq!(extract_ts_const_fn("const foo = (x: number) => x"), Some("foo".into()));
assert_eq!(extract_ts_const_fn("export const bar = async ("), Some("bar".into()));
assert_eq!(extract_ts_const_fn("const DATA = 42"), None);
}
}

1131
agor-pty/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,34 +0,0 @@
[package]
name = "agor-pty"
version = "0.1.0"
edition = "2021"
description = "Standalone PTY multiplexer daemon — manages terminal sessions via Unix socket IPC"
license = "MIT"
# Standalone — NOT part of the workspace Cargo.toml (same pattern as ui-gpui)
[workspace]
# Binary: the daemon process
[[bin]]
name = "agor-ptyd"
path = "src/main.rs"
# Library: shared types for IPC clients (Tauri, Electrobun, tests)
[lib]
name = "agor_pty"
path = "src/lib.rs"
[dependencies]
portable-pty = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
log = "0.4"
env_logger = "0.11"
nix = { version = "0.29", features = ["process", "signal", "ioctl", "term"] }
rand = "0.8"
hex = "0.4"
[dev-dependencies]
tokio-test = "0.4"

View file

@ -1,90 +0,0 @@
# agor-pty — PTY Multiplexer Daemon
Standalone Rust daemon that manages terminal sessions via Unix socket IPC.
Portable across Tauri and Electrobun frontends.
## Architecture
```
Frontend (Electrobun/Tauri)
↕ Unix Socket IPC (JSON-framed)
agor-ptyd (Rust daemon)
↕ PTY master FDs
Shell processes (/bin/bash, etc.)
```
## Features
- **PTY multiplexing**: single daemon manages all terminal sessions
- **Session persistence**: sessions survive frontend disconnects/restarts
- **Multi-client**: multiple frontends can connect and subscribe to session output
- **Auth**: 256-bit token authentication per connection
- **Output fanout**: PTY output fanned to all subscribed clients (non-blocking)
- **Graceful shutdown**: SIGTERM/SIGINT cleanup
## Usage
```bash
# Build
cd agor-pty && cargo build --release
# Run daemon
./target/release/agor-ptyd --verbose
# Custom socket directory
./target/release/agor-ptyd --socket-dir /tmp/agor-test
# Custom default shell
./target/release/agor-ptyd --shell /bin/zsh
```
## IPC Protocol
JSON messages over Unix socket, newline-delimited.
### Client → Daemon
- `Auth { token }` — authenticate (must be first message)
- `CreateSession { id, shell, cwd, env, cols, rows }` — spawn shell
- `WriteInput { session_id, data }` — send input to PTY
- `Resize { session_id, cols, rows }` — resize terminal
- `Subscribe { session_id }` — receive output from session
- `Unsubscribe { session_id }` — stop receiving output
- `CloseSession { session_id }` — kill session
- `ListSessions` — get all active sessions
- `Ping` — keepalive
### Daemon → Client
- `AuthResult { ok }` — auth response
- `SessionCreated { session_id, pid }` — session spawned
- `SessionOutput { session_id, data }` — PTY output (base64)
- `SessionClosed { session_id, exit_code }` — session ended
- `SessionList { sessions }` — list response
- `Pong` — keepalive response
- `Error { message }` — error
## Integration
### As library (for Tauri)
```rust
use agor_pty::protocol::{ClientMessage, DaemonMessage};
```
### TypeScript client (for Electrobun)
See `clients/ts/` for the IPC client module.
## Directory Structure
```
agor-pty/
├── Cargo.toml
├── README.md
├── src/
│ ├── main.rs — daemon entry, CLI, signals
│ ├── lib.rs — library re-exports
│ ├── protocol.rs — IPC message types
│ ├── session.rs — PTY session management
│ ├── daemon.rs — Unix socket server
│ └── auth.rs — token authentication
└── clients/
└── ts/ — TypeScript IPC client (for Electrobun/Bun)
```

View file

@ -1,248 +0,0 @@
/**
* agor-pty TypeScript IPC client.
* Connects to the agor-ptyd daemon via Unix socket.
* Works with both Bun (Electrobun) and Node.js (Tauri sidecar).
*/
import { connect, type Socket } from "net";
import { readFileSync } from "fs";
import { join } from "path";
import { EventEmitter } from "events";
// ── IPC Protocol Types ──────────────────────────────────────────
export interface SessionInfo {
id: string;
pid: number;
shell: string;
cwd: string;
cols: number;
rows: number;
created_at: number;
alive: boolean;
}
export type DaemonEvent =
| { type: "auth_result"; ok: boolean }
| { type: "session_created"; session_id: string; pid: number }
/** data is base64-encoded bytes from the PTY. */
| { type: "session_output"; session_id: string; data: string }
| { type: "session_closed"; session_id: string; exit_code: number | null }
| { type: "session_list"; sessions: SessionInfo[] }
| { type: "pong" }
| { type: "error"; message: string };
// ── Client ──────────────────────────────────────────────────────
export class PtyClient extends EventEmitter {
private socket: Socket | null = null;
private buffer = "";
private authenticated = false;
private socketPath: string;
private tokenPath: string;
constructor(socketDir?: string) {
super();
const dir =
socketDir ??
(process.env.XDG_RUNTIME_DIR
? join(process.env.XDG_RUNTIME_DIR, "agor")
: join(
process.env.HOME ?? "/tmp",
".local",
"share",
"agor",
"run"
));
this.socketPath = join(dir, "ptyd.sock");
this.tokenPath = join(dir, "ptyd.token");
}
/** Connect to daemon and authenticate. */
async connect(): Promise<void> {
return new Promise((resolve, reject) => {
let token: string;
try {
token = readFileSync(this.tokenPath, "utf-8").trim();
} catch {
reject(new Error(`Cannot read token at ${this.tokenPath}. Is agor-ptyd running?`));
return;
}
this.socket = connect(this.socketPath);
this.socket.on("connect", () => {
this.send({ type: "auth", token });
});
this.socket.on("data", (chunk: Buffer) => {
this.buffer += chunk.toString("utf-8");
let newlineIdx: number;
while ((newlineIdx = this.buffer.indexOf("\n")) !== -1) {
const line = this.buffer.slice(0, newlineIdx);
this.buffer = this.buffer.slice(newlineIdx + 1);
try {
const msg = JSON.parse(line) as DaemonEvent;
if (!this.authenticated && msg.type === "auth_result") {
if (msg.ok) {
this.authenticated = true;
resolve();
} else {
reject(new Error("Authentication failed"));
}
} else {
this.emit("message", msg);
this.emit(msg.type, msg);
}
} catch {
// Ignore malformed lines
}
}
});
this.socket.on("error", (err) => {
if (!this.authenticated) reject(err);
this.emit("error", err);
});
this.socket.on("close", () => {
this.authenticated = false;
this.emit("close");
});
});
}
/** Create a new PTY session. */
createSession(opts: {
id: string;
shell?: string;
cwd?: string;
env?: Record<string, string>;
cols?: number;
rows?: number;
}): void {
this.send({
type: "create_session",
id: opts.id,
shell: opts.shell ?? null,
cwd: opts.cwd ?? null,
env: opts.env ?? null,
cols: opts.cols ?? 80,
rows: opts.rows ?? 24,
});
}
/** Write input to a session's PTY. data is encoded as base64 for transport. */
writeInput(sessionId: string, data: string | Uint8Array): void {
const bytes =
typeof data === "string"
? new TextEncoder().encode(data)
: data;
// Daemon expects base64 string per protocol.rs WriteInput { data: String }
const b64 = btoa(String.fromCharCode(...bytes));
this.send({ type: "write_input", session_id: sessionId, data: b64 });
}
/** Resize a session's terminal. */
resize(sessionId: string, cols: number, rows: number): void {
this.send({ type: "resize", session_id: sessionId, cols, rows });
}
/** Subscribe to a session's output. */
subscribe(sessionId: string): void {
this.send({ type: "subscribe", session_id: sessionId });
}
/** Unsubscribe from a session's output. */
unsubscribe(sessionId: string): void {
this.send({ type: "unsubscribe", session_id: sessionId });
}
/** Close/kill a session. */
closeSession(sessionId: string): void {
this.send({ type: "close_session", session_id: sessionId });
}
/** List all active sessions. */
listSessions(): void {
this.send({ type: "list_sessions" });
}
/** Ping the daemon. */
ping(): void {
this.send({ type: "ping" });
}
/** Disconnect from daemon (sessions stay alive). */
disconnect(): void {
this.socket?.end();
this.socket = null;
this.authenticated = false;
}
/** Check if connected and authenticated. */
get isConnected(): boolean {
return this.authenticated && this.socket !== null && !this.socket.destroyed;
}
private send(msg: Record<string, unknown>): void {
if (!this.socket || this.socket.destroyed) {
throw new Error("Not connected to daemon");
}
this.socket.write(JSON.stringify(msg) + "\n");
}
}
// ── Convenience: auto-connect + session helpers ─────────────────
/**
* Connect to daemon, create a session, and return output as an async iterator.
* Usage:
* for await (const chunk of ptySession("my-shell", { cols: 120, rows: 40 })) {
* terminal.write(new Uint8Array(chunk));
* }
*/
/**
* Connect to daemon, create a session, and return output as an async iterator.
* Each yielded chunk is a Uint8Array of raw PTY bytes (decoded from base64).
* Usage:
* for await (const chunk of ptySession("my-shell", { cols: 120, rows: 40 })) {
* terminal.write(chunk);
* }
*/
export async function* ptySession(
sessionId: string,
opts?: { shell?: string; cwd?: string; cols?: number; rows?: number; socketDir?: string }
): AsyncGenerator<Uint8Array, void, void> {
const client = new PtyClient(opts?.socketDir);
await client.connect();
client.createSession({
id: sessionId,
shell: opts?.shell,
cwd: opts?.cwd,
cols: opts?.cols ?? 80,
rows: opts?.rows ?? 24,
});
client.subscribe(sessionId);
try {
while (client.isConnected) {
const msg: DaemonEvent = await new Promise((resolve) => {
client.once("message", resolve);
});
if (msg.type === "session_output" && msg.session_id === sessionId) {
// Decode base64 → raw bytes
const binary = atob(msg.data);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
yield bytes;
} else if (msg.type === "session_closed" && msg.session_id === sessionId) {
break;
}
}
} finally {
client.disconnect();
}
}

View file

@ -1,254 +0,0 @@
/**
* libagor-resize.so Native GTK resize for undecorated WebKitGTK windows.
*
* KEY INSIGHT: Signals MUST be connected on the WebKitWebView widget,
* NOT the GtkWindow. WebKit's GdkWindow receives all pointer events;
* the parent GtkWindow never sees them.
*
* This is the Wails pattern: connect button-press-event on the WebView,
* do hit-test in C, call gtk_window_begin_resize_drag with real event data.
*
* Build: gcc -shared -fPIC -o libagor-resize.so agor_resize.c \
* $(pkg-config --cflags --libs gtk+-3.0)
*/
#include <gtk/gtk.h>
#include <stdio.h>
static GtkWindow *stored_window = NULL;
static GtkWidget *stored_webview = NULL;
static int border_width = 8;
static gboolean resize_active = FALSE;
/**
* Hit-test: determine which edge the pointer is on.
* Returns GdkWindowEdge (0-7) or -1 if inside content area.
* x, y are window-local coordinates (same as webview coords when it fills the window).
*/
static GdkWindowEdge edge_for_position(int win_w, int win_h, double x, double y)
{
int B = border_width;
int left = x < B;
int right = x > win_w - B;
int top = y < B;
int bottom = y > win_h - B;
if (top && left) return GDK_WINDOW_EDGE_NORTH_WEST;
if (top && right) return GDK_WINDOW_EDGE_NORTH_EAST;
if (bottom && left) return GDK_WINDOW_EDGE_SOUTH_WEST;
if (bottom && right) return GDK_WINDOW_EDGE_SOUTH_EAST;
if (top) return GDK_WINDOW_EDGE_NORTH;
if (bottom) return GDK_WINDOW_EDGE_SOUTH;
if (left) return GDK_WINDOW_EDGE_WEST;
if (right) return GDK_WINDOW_EDGE_EAST;
return (GdkWindowEdge)-1;
}
/**
* Button-press handler on the WebKitWebView.
* If click is in the 8px border zone: start resize drag and consume the event.
* If click is in the interior: return FALSE to let WebKit handle it.
*/
/**
* Clear min-size on the entire widget tree so shrinking is allowed.
* set_size_request(1,1) forces a 1x1 minimum, overriding preferred size.
*/
static void clear_min_size(GtkWidget *widget, int depth)
{
if (!widget || depth > 10) return;
gtk_widget_set_size_request(widget, 1, 1);
if (GTK_IS_BIN(widget)) {
GtkWidget *child = gtk_bin_get_child(GTK_BIN(widget));
if (child) clear_min_size(child, depth + 1);
}
if (GTK_IS_CONTAINER(widget)) {
GList *children = gtk_container_get_children(GTK_CONTAINER(widget));
for (GList *l = children; l; l = l->next)
clear_min_size(GTK_WIDGET(l->data), depth + 1);
g_list_free(children);
}
}
static gboolean on_webview_button_press(GtkWidget *widget,
GdkEventButton *event,
gpointer user_data)
{
if (!stored_window || !event) return FALSE;
if (event->button != 1) return FALSE; /* LMB only */
int w, h;
gtk_window_get_size(stored_window, &w, &h);
GdkWindowEdge edge = edge_for_position(w, h, event->x, event->y);
if ((int)edge == -1) return FALSE; /* Interior — let WebKit handle */
fprintf(stderr, "[agor-resize] RESIZE edge=%d btn=%d xy=(%.0f,%.0f) root=(%.0f,%.0f) t=%u size=%dx%d\n",
edge, event->button, event->x, event->y, event->x_root, event->y_root, event->time, w, h);
/* Clear min-size on entire tree BEFORE resize drag — allows shrinking */
clear_min_size(GTK_WIDGET(stored_window), 0);
/* Set geometry hints with small minimum */
GdkGeometry geom = { .min_width = 400, .min_height = 300, .max_width = 32767, .max_height = 32767 };
gtk_window_set_geometry_hints(stored_window, NULL, &geom, GDK_HINT_MIN_SIZE | GDK_HINT_MAX_SIZE);
/* Disable WebView input to prevent it from stealing the WM's pointer grab.
* The WebView's GdkWindow won't interfere with events during the resize. */
if (stored_webview) {
gtk_widget_set_sensitive(stored_webview, FALSE);
resize_active = TRUE;
fprintf(stderr, "[agor-resize] WebView DISABLED for resize\n");
}
gtk_window_begin_resize_drag(
stored_window,
edge,
event->button,
(gint)event->x_root,
(gint)event->y_root,
event->time
);
return TRUE;
}
/**
* Motion handler on the WebKitWebView changes cursor on edge hover.
*/
static gboolean on_webview_motion(GtkWidget *widget,
GdkEventMotion *event,
gpointer user_data)
{
if (!stored_window || !event) return FALSE;
int w, h;
gtk_window_get_size(stored_window, &w, &h);
GdkWindowEdge edge = edge_for_position(w, h, event->x, event->y);
GdkWindow *gdk_win = gtk_widget_get_window(GTK_WIDGET(stored_window));
if (!gdk_win) return FALSE;
const char *cursor_name = NULL;
switch ((int)edge) {
case GDK_WINDOW_EDGE_NORTH: cursor_name = "n-resize"; break;
case GDK_WINDOW_EDGE_SOUTH: cursor_name = "s-resize"; break;
case GDK_WINDOW_EDGE_WEST: cursor_name = "w-resize"; break;
case GDK_WINDOW_EDGE_EAST: cursor_name = "e-resize"; break;
case GDK_WINDOW_EDGE_NORTH_WEST: cursor_name = "nw-resize"; break;
case GDK_WINDOW_EDGE_NORTH_EAST: cursor_name = "ne-resize"; break;
case GDK_WINDOW_EDGE_SOUTH_WEST: cursor_name = "sw-resize"; break;
case GDK_WINDOW_EDGE_SOUTH_EAST: cursor_name = "se-resize"; break;
default: break;
}
if (cursor_name) {
GdkCursor *cursor = gdk_cursor_new_from_name(gdk_display_get_default(), cursor_name);
gdk_window_set_cursor(gdk_win, cursor);
if (cursor) g_object_unref(cursor);
} else {
gdk_window_set_cursor(gdk_win, NULL); /* default cursor */
}
return FALSE; /* Always let WebKit see motion events too */
}
static guint reenable_timer_id = 0;
/**
* Timer callback: re-enable WebView after resize drag ends.
* Runs 500ms after the last configure event.
*/
static gboolean reenable_webview(gpointer data)
{
if (stored_webview && resize_active) {
gtk_widget_set_sensitive(stored_webview, TRUE);
resize_active = FALSE;
fprintf(stderr, "[agor-resize] WebView RE-ENABLED (resize ended)\n");
}
reenable_timer_id = 0;
return G_SOURCE_REMOVE;
}
/**
* Configure-event handler logs window size changes.
* Resets the re-enable timer on each configure (resize still in progress).
*/
static gboolean on_configure(GtkWidget *widget, GdkEventConfigure *event, gpointer data)
{
if (resize_active) {
/* CRITICAL: During active resize, re-clear min-size on EVERY configure.
* GTK's layout cycle re-asserts the WebView's preferred size after each
* WM resize step, sending a conflicting ConfigureRequest that cancels the drag. */
clear_min_size(GTK_WIDGET(stored_window), 0);
GdkGeometry geom = { .min_width = 400, .min_height = 300, .max_width = 32767, .max_height = 32767 };
gtk_window_set_geometry_hints(stored_window, NULL, &geom, GDK_HINT_MIN_SIZE | GDK_HINT_MAX_SIZE);
if (reenable_timer_id) g_source_remove(reenable_timer_id);
reenable_timer_id = g_timeout_add(500, reenable_webview, NULL);
}
return FALSE;
}
/**
* Initialize: find the deepest WebView child and connect signals there.
* Call ONCE after window creation.
*/
void agor_resize_init(void *window_ptr, int border)
{
if (!GTK_IS_WINDOW(window_ptr)) {
fprintf(stderr, "[agor-resize] ERROR: not a GtkWindow: %p\n", window_ptr);
return;
}
stored_window = GTK_WINDOW(window_ptr);
border_width = border > 0 ? border : 8;
/* Walk down the widget tree to find the deepest child (the WebKitWebView) */
GtkWidget *webview = GTK_WIDGET(stored_window);
while (GTK_IS_BIN(webview)) {
GtkWidget *child = gtk_bin_get_child(GTK_BIN(webview));
if (!child) break;
webview = child;
}
/* Set GTK window background to black (prevents white flash before WebView loads) */
{
GdkRGBA black = { 0.0, 0.0, 0.0, 1.0 };
gtk_widget_override_background_color(GTK_WIDGET(stored_window), GTK_STATE_FLAG_NORMAL, &black);
fprintf(stderr, "[agor-resize] Window background set to black\n");
}
stored_webview = webview;
if (webview == GTK_WIDGET(stored_window)) {
fprintf(stderr, "[agor-resize] WARNING: no child widget found, connecting on window\n");
} else {
fprintf(stderr, "[agor-resize] Found WebView widget: %p (type: %s)\n",
(void *)webview, G_OBJECT_TYPE_NAME(webview));
}
/* CRITICAL: add event masks on the WebView — without this, GTK won't deliver events */
gtk_widget_add_events(webview,
GDK_BUTTON_PRESS_MASK |
GDK_POINTER_MOTION_MASK);
/* Connect signals on the WebView (NOT the GtkWindow!) */
g_signal_connect(webview, "button-press-event",
G_CALLBACK(on_webview_button_press), NULL);
g_signal_connect(webview, "motion-notify-event",
G_CALLBACK(on_webview_motion), NULL);
/* Log every window size change */
g_signal_connect(GTK_WIDGET(stored_window), "configure-event",
G_CALLBACK(on_configure), NULL);
fprintf(stderr, "[agor-resize] Initialized on %s (border=%dpx)\n",
G_OBJECT_TYPE_NAME(webview), border_width);
}
/**
* Start resize from stored state (for RPC fallback not needed if C handler works).
*/
int agor_resize_start(int edge)
{
fprintf(stderr, "[agor-resize] agor_resize_start called with edge=%d (C handler should do this automatically)\n", edge);
return 0;
}

Binary file not shown.

View file

@ -1,59 +0,0 @@
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use rand::RngCore;
/// Holds the 256-bit authentication token for this daemon process.
#[derive(Clone)]
pub struct AuthToken {
hex: String,
}
impl AuthToken {
/// Generate a fresh random token and write it to `<socket_dir>/ptyd.token`
/// with 0600 permissions so only the owning user can read it.
pub fn generate_and_persist(socket_dir: &Path) -> Result<Self, String> {
let mut raw = [0u8; 32];
rand::thread_rng().fill_bytes(&mut raw);
let hex = hex::encode(raw);
let token_path = socket_dir.join("ptyd.token");
fs::write(&token_path, &hex)
.map_err(|e| format!("failed to write token file {token_path:?}: {e}"))?;
fs::set_permissions(&token_path, fs::Permissions::from_mode(0o600))
.map_err(|e| format!("failed to chmod token file: {e}"))?;
log::info!("token written to {:?}", token_path);
Ok(Self { hex })
}
/// Return true if the provided string matches the stored token.
/// Comparison is done in constant time via `hex::decode` + byte-level loop
/// to avoid short-circuit timing leaks.
pub fn verify(&self, presented: &str) -> bool {
// Decode both sides; if either fails the token is wrong.
let expected = match hex::decode(&self.hex) {
Ok(b) => b,
Err(_) => return false,
};
let provided = match hex::decode(presented) {
Ok(b) => b,
Err(_) => return false,
};
if expected.len() != provided.len() {
return false;
}
// Constant-time compare.
let mut diff = 0u8;
for (a, b) in expected.iter().zip(provided.iter()) {
diff |= a ^ b;
}
diff == 0
}
/// Expose the hex string for logging (first 8 chars only, for safety).
pub fn redacted(&self) -> String {
format!("{}...", &self.hex[..8])
}
}

View file

@ -1,443 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{broadcast, mpsc, Mutex};
use crate::auth::AuthToken;
use crate::protocol::{decode_input, encode_output, ClientMessage, DaemonMessage};
use crate::session::SessionManager;
/// High-water mark for the per-client send queue (in messages, not bytes).
/// We limit to ~256 KB worth of medium-sized chunks before dropping the client.
const CLIENT_QUEUE_CAP: usize = 64;
/// Shared mutable state accessible from all tasks.
struct State {
sessions: SessionManager,
/// session_id → set of client_ids currently subscribed.
subscriptions: HashMap<String, HashSet<u64>>,
/// client_id → channel to push messages back to that client's write task.
client_txs: HashMap<u64, mpsc::Sender<DaemonMessage>>,
next_client_id: u64,
}
impl State {
fn new(default_shell: String) -> Self {
Self {
sessions: SessionManager::new(default_shell),
subscriptions: HashMap::new(),
client_txs: HashMap::new(),
next_client_id: 1,
}
}
fn alloc_client_id(&mut self) -> u64 {
let id = self.next_client_id;
self.next_client_id += 1;
id
}
/// Remove a client from all subscription sets and from the client map.
fn remove_client(&mut self, cid: u64) {
self.client_txs.remove(&cid);
for subs in self.subscriptions.values_mut() {
subs.remove(&cid);
}
}
/// Fan-out a message to all subscribers of `session_id`.
fn fanout(&self, session_id: &str, msg: DaemonMessage) {
if let Some(subs) = self.subscriptions.get(session_id) {
for cid in subs {
if let Some(tx) = self.client_txs.get(cid) {
// Non-blocking: drop slow clients silently.
let _ = tx.try_send(msg.clone());
}
}
}
}
}
pub struct Daemon {
socket_path: PathBuf,
token: AuthToken,
default_shell: String,
}
impl Daemon {
pub fn new(socket_path: PathBuf, token: AuthToken, default_shell: String) -> Self {
Self {
socket_path,
token,
default_shell,
}
}
/// Run until `shutdown_rx` fires.
pub async fn run(self, mut shutdown_rx: broadcast::Receiver<()>) -> Result<(), String> {
// Remove stale socket from a previous run.
let _ = std::fs::remove_file(&self.socket_path);
let listener = UnixListener::bind(&self.socket_path)
.map_err(|e| format!("bind {:?}: {e}", self.socket_path))?;
log::info!("agor-ptyd v0.1.0 listening on {:?}", self.socket_path);
let state = Arc::new(Mutex::new(State::new(self.default_shell.clone())));
let token = Arc::new(self.token);
loop {
tokio::select! {
accept = listener.accept() => {
match accept {
Ok((stream, _addr)) => {
let state = state.clone();
let token = token.clone();
tokio::spawn(handle_client(stream, state, token));
}
Err(e) => log::warn!("accept error: {e}"),
}
}
_ = shutdown_rx.recv() => {
log::info!("shutdown signal received — stopping daemon");
break;
}
}
}
// Cleanup socket file.
let _ = std::fs::remove_file(&self.socket_path);
Ok(())
}
}
/// Handle a single client connection from handshake to disconnect.
async fn handle_client(
stream: UnixStream,
state: Arc<Mutex<State>>,
token: Arc<AuthToken>,
) {
let (read_half, write_half) = stream.into_split();
let mut reader = BufReader::new(read_half);
// First message must be Auth.
let mut line = String::new();
if reader.read_line(&mut line).await.unwrap_or(0) == 0 {
log::warn!("client disconnected before auth");
return;
}
let auth_msg: ClientMessage = match serde_json::from_str(line.trim()) {
Ok(m) => m,
Err(e) => {
log::warn!("invalid auth message: {e}");
return;
}
};
let presented_token = match auth_msg {
ClientMessage::Auth { token: t } => t,
_ => {
log::warn!("first message was not Auth — dropping client");
return;
}
};
if !token.verify(&presented_token) {
log::warn!("auth failed (token={} redacted)", token.redacted());
// Send failure then drop the connection.
let _ = send_line(
write_half,
&DaemonMessage::AuthResult { ok: false },
)
.await;
return;
}
// Register client.
let (out_tx, out_rx) = mpsc::channel::<DaemonMessage>(CLIENT_QUEUE_CAP);
let cid = {
let mut st = state.lock().await;
let cid = st.alloc_client_id();
st.client_txs.insert(cid, out_tx.clone());
cid
};
log::info!("client {cid} authenticated");
// Send auth success.
if let Err(e) = out_tx.try_send(DaemonMessage::AuthResult { ok: true }) {
log::warn!("client {cid}: failed to queue AuthResult: {e}");
state.lock().await.remove_client(cid);
return;
}
// Spawn a dedicated write task so the reader loop is never blocked by
// slow writes to the socket.
let write_task = tokio::spawn(write_loop(write_half, out_rx));
// Read loop.
loop {
let mut line = String::new();
match reader.read_line(&mut line).await {
Ok(0) => break, // EOF
Ok(_) => {}
Err(e) => {
log::debug!("client {cid} read error: {e}");
break;
}
}
let msg: ClientMessage = match serde_json::from_str(line.trim()) {
Ok(m) => m,
Err(e) => {
log::warn!("client {cid} bad message: {e}");
let _ = out_tx
.try_send(DaemonMessage::Error {
message: format!("parse error: {e}"),
});
continue;
}
};
handle_message(cid, msg, &state, &out_tx).await;
}
// Cleanup on disconnect.
log::info!("client {cid} disconnected");
state.lock().await.remove_client(cid);
write_task.abort();
}
/// Dispatch a single client message to the appropriate handler.
async fn handle_message(
cid: u64,
msg: ClientMessage,
state: &Arc<Mutex<State>>,
out_tx: &mpsc::Sender<DaemonMessage>,
) {
match msg {
ClientMessage::Auth { .. } => {
// Already authenticated — ignore duplicate.
}
ClientMessage::Ping => {
let _ = out_tx.try_send(DaemonMessage::Pong);
}
ClientMessage::ListSessions => {
let list = state.lock().await.sessions.list();
let _ = out_tx.try_send(DaemonMessage::SessionList { sessions: list });
}
ClientMessage::CreateSession { id, shell, cwd, env, cols, rows } => {
let state_clone = state.clone();
let out_tx_clone = out_tx.clone();
let id_clone = id.clone();
let result = {
let mut st = state.lock().await;
st.sessions.create_session(
id.clone(),
shell,
cwd,
env,
cols,
rows,
move |sid, code| {
// Invoked from the blocking reader task when child exits.
let state_clone = state_clone.clone();
let _ = &out_tx_clone; // captured for lifetime, not used
tokio::spawn(async move {
let st = state_clone.lock().await;
st.fanout(
&sid,
DaemonMessage::SessionClosed {
session_id: sid.clone(),
exit_code: code,
},
);
drop(st);
});
},
)
};
match result {
Ok((pid, output_rx)) => {
let _ = out_tx.try_send(DaemonMessage::SessionCreated {
session_id: id_clone.clone(),
pid,
});
// Immediately subscribe the creating client.
{
let mut st = state.lock().await;
st.subscriptions
.entry(id_clone.clone())
.or_default()
.insert(cid);
}
// Start a fanout task for this session's output.
let state_clone = state.clone();
tokio::spawn(output_fanout_task(id_clone, output_rx, state_clone));
}
Err(e) => {
let _ = out_tx.try_send(DaemonMessage::Error { message: e });
}
}
}
ClientMessage::WriteInput { session_id, data } => {
let bytes = match decode_input(&data) {
Ok(b) => b,
Err(e) => {
let _ = out_tx.try_send(DaemonMessage::Error {
message: format!("bad input encoding: {e}"),
});
return;
}
};
let st = state.lock().await;
match st.sessions.get(&session_id) {
Some(sess) => {
if let Err(e) = sess.write_input(&bytes).await {
let _ = out_tx.try_send(DaemonMessage::Error { message: e });
}
}
None => {
let _ = out_tx.try_send(DaemonMessage::Error {
message: format!("session {session_id} not found"),
});
}
}
}
ClientMessage::Resize { session_id, cols, rows } => {
let mut st = state.lock().await;
match st.sessions.get_mut(&session_id) {
Some(sess) => {
if let Err(e) = sess.resize(cols, rows).await {
log::warn!("resize {session_id}: {e}");
}
}
None => {
let _ = out_tx.try_send(DaemonMessage::Error {
message: format!("session {session_id} not found"),
});
}
}
}
ClientMessage::Subscribe { session_id } => {
let (exists, rx) = {
let st = state.lock().await;
let exists = st.sessions.get(&session_id).is_some();
let rx = st
.sessions
.get(&session_id)
.map(|s| s.subscribe());
(exists, rx)
};
if !exists {
let _ = out_tx.try_send(DaemonMessage::Error {
message: format!("session {session_id} not found"),
});
return;
}
{
let mut st = state.lock().await;
st.subscriptions
.entry(session_id.clone())
.or_default()
.insert(cid);
}
// If a new rx came back, start a fanout task (handles reconnect case
// where the original fanout task has gone away after all receivers
// dropped). We always start one; duplicates are harmless since the
// broadcast channel keeps all messages.
if let Some(rx) = rx {
let state_clone = state.clone();
tokio::spawn(output_fanout_task(session_id, rx, state_clone));
}
}
ClientMessage::Unsubscribe { session_id } => {
let mut st = state.lock().await;
if let Some(subs) = st.subscriptions.get_mut(&session_id) {
subs.remove(&cid);
}
}
ClientMessage::CloseSession { session_id } => {
let mut st = state.lock().await;
if let Err(e) = st.sessions.close_session(&session_id) {
let _ = out_tx.try_send(DaemonMessage::Error { message: e });
} else {
st.subscriptions.remove(&session_id);
}
}
}
}
/// Reads from a session's broadcast channel and fans output to all subscribed
/// clients via their individual mpsc queues.
async fn output_fanout_task(
session_id: String,
mut rx: broadcast::Receiver<Vec<u8>>,
state: Arc<Mutex<State>>,
) {
loop {
match rx.recv().await {
Ok(chunk) => {
let encoded = encode_output(&chunk);
let msg = DaemonMessage::SessionOutput {
session_id: session_id.clone(),
data: encoded,
};
state.lock().await.fanout(&session_id, msg);
}
Err(broadcast::error::RecvError::Lagged(n)) => {
log::warn!("session {session_id} fanout lagged, dropped {n} messages");
}
Err(broadcast::error::RecvError::Closed) => {
log::debug!("session {session_id} output channel closed");
break;
}
}
}
}
/// Drains the per-client mpsc queue and writes newline-delimited JSON to the
/// socket.
async fn write_loop(
mut writer: tokio::net::unix::OwnedWriteHalf,
mut rx: mpsc::Receiver<DaemonMessage>,
) {
while let Some(msg) = rx.recv().await {
match serde_json::to_string(&msg) {
Ok(mut json) => {
json.push('\n');
if let Err(e) = writer.write_all(json.as_bytes()).await {
log::debug!("write error: {e}");
break;
}
}
Err(e) => {
log::warn!("serialize error: {e}");
}
}
}
}
/// One-shot write for pre-auth messages (write_half not yet consumed by the
/// write_loop task).
async fn send_line(
mut writer: tokio::net::unix::OwnedWriteHalf,
msg: &DaemonMessage,
) -> Result<(), String> {
let mut json = serde_json::to_string(msg)
.map_err(|e| format!("serialize: {e}"))?;
json.push('\n');
writer
.write_all(json.as_bytes())
.await
.map_err(|e| format!("write: {e}"))
}

View file

@ -1,5 +0,0 @@
/// Public library surface for IPC clients (Tauri, Electrobun, integration tests).
///
/// Only protocol types are exposed — the daemon internals (session manager,
/// auth, socket server) are not part of the public API.
pub mod protocol;

View file

@ -1,207 +0,0 @@
mod auth;
mod daemon;
mod protocol;
mod session;
use std::path::PathBuf;
use tokio::signal::unix::{signal, SignalKind};
use tokio::sync::broadcast;
use auth::AuthToken;
use daemon::Daemon;
const VERSION: &str = "0.1.0";
// ---------------------------------------------------------------------------
// CLI argument parsing — no clap needed for 3 flags.
// ---------------------------------------------------------------------------
struct Cli {
socket_dir: Option<PathBuf>,
default_shell: Option<String>,
verbose: bool,
}
impl Cli {
fn parse() -> Result<Self, String> {
let mut args = std::env::args().skip(1).peekable();
let mut socket_dir = None;
let mut default_shell = None;
let mut verbose = false;
while let Some(arg) = args.next() {
match arg.as_str() {
"--socket-dir" => {
socket_dir = Some(PathBuf::from(
args.next().ok_or("--socket-dir requires a value")?,
));
}
"--shell" => {
default_shell = Some(
args.next().ok_or("--shell requires a value")?,
);
}
"--verbose" | "-v" => {
verbose = true;
}
"--help" | "-h" => {
print_usage();
std::process::exit(0);
}
other => {
return Err(format!("unknown argument: {other}"));
}
}
}
Ok(Self {
socket_dir,
default_shell,
verbose,
})
}
}
fn print_usage() {
eprintln!(
"USAGE: agor-ptyd [OPTIONS]\n\
\n\
OPTIONS:\n\
--socket-dir <PATH> Socket directory\n\
(default: /run/user/$UID/agor or ~/.local/share/agor/run)\n\
--shell <PATH> Default shell (default: $SHELL or /bin/bash)\n\
--verbose Enable debug logging\n\
--help Show this message"
);
}
// ---------------------------------------------------------------------------
// Socket directory resolution
// ---------------------------------------------------------------------------
fn resolve_socket_dir(override_path: Option<PathBuf>) -> Result<PathBuf, String> {
if let Some(p) = override_path {
return Ok(p);
}
// Prefer XDG runtime dir.
if let Ok(uid_str) = std::env::var("UID").or_else(|_| {
// UID is not always exported; fall back to getuid().
Ok::<_, std::env::VarError>(unsafe { libc_getuid() }.to_string())
}) {
let xdg = PathBuf::from(format!("/run/user/{uid_str}/agor"));
if xdg.parent().map(|p| p.exists()).unwrap_or(false) {
return Ok(xdg);
}
}
// Fallback: ~/.local/share/agor/run
let home = std::env::var("HOME").map_err(|_| "HOME not set".to_string())?;
Ok(PathBuf::from(home).join(".local/share/agor/run"))
}
#[cfg(target_os = "linux")]
unsafe fn libc_getuid() -> u32 {
// Safety: getuid() is always safe.
extern "C" {
fn getuid() -> u32;
}
getuid()
}
#[cfg(not(target_os = "linux"))]
unsafe fn libc_getuid() -> u32 {
0
}
// ---------------------------------------------------------------------------
// Default shell resolution
// ---------------------------------------------------------------------------
fn resolve_shell(override_shell: Option<String>) -> String {
override_shell
.or_else(|| std::env::var("SHELL").ok())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "/bin/bash".into())
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
#[tokio::main]
async fn main() {
let cli = match Cli::parse() {
Ok(c) => c,
Err(e) => {
eprintln!("error: {e}");
print_usage();
std::process::exit(1);
}
};
// Initialise logging.
let log_level = if cli.verbose { "debug" } else { "info" };
env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or(log_level),
)
.init();
log::info!("agor-ptyd v{VERSION} starting");
let socket_dir = match resolve_socket_dir(cli.socket_dir) {
Ok(d) => d,
Err(e) => {
log::error!("cannot resolve socket directory: {e}");
std::process::exit(1);
}
};
// Ensure the directory exists.
if let Err(e) = std::fs::create_dir_all(&socket_dir) {
log::error!("cannot create socket directory {socket_dir:?}: {e}");
std::process::exit(1);
}
let socket_path = socket_dir.join("ptyd.sock");
let shell = resolve_shell(cli.default_shell);
// Generate and persist auth token.
let token = match AuthToken::generate_and_persist(&socket_dir) {
Ok(t) => t,
Err(e) => {
log::error!("token generation failed: {e}");
std::process::exit(1);
}
};
// Shutdown broadcast channel — one sender, N receivers.
let (shutdown_tx, shutdown_rx) = broadcast::channel::<()>(1);
// Signal handlers for SIGTERM and SIGINT.
let shutdown_tx_sigterm = shutdown_tx.clone();
let shutdown_tx_sigint = shutdown_tx.clone();
tokio::spawn(async move {
let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM handler");
sigterm.recv().await;
log::info!("SIGTERM received");
let _ = shutdown_tx_sigterm.send(());
});
tokio::spawn(async move {
let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT handler");
sigint.recv().await;
log::info!("SIGINT received");
let _ = shutdown_tx_sigint.send(());
});
let daemon = Daemon::new(socket_path, token, shell);
if let Err(e) = daemon.run(shutdown_rx).await {
log::error!("daemon exited with error: {e}");
std::process::exit(1);
}
log::info!("agor-ptyd shut down cleanly");
}

View file

@ -1,166 +0,0 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// Messages sent from client → daemon.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
Auth {
token: String,
},
CreateSession {
id: String,
shell: Option<String>,
cwd: Option<String>,
env: Option<HashMap<String, String>>,
cols: u16,
rows: u16,
},
WriteInput {
session_id: String,
/// Raw bytes encoded as a base64 string.
data: String,
},
Resize {
session_id: String,
cols: u16,
rows: u16,
},
Subscribe {
session_id: String,
},
Unsubscribe {
session_id: String,
},
CloseSession {
session_id: String,
},
ListSessions,
Ping,
}
/// Messages sent from daemon → client.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DaemonMessage {
AuthResult {
ok: bool,
},
SessionCreated {
session_id: String,
pid: u32,
},
/// PTY output bytes base64-encoded so they survive JSON transport.
SessionOutput {
session_id: String,
data: String,
},
SessionClosed {
session_id: String,
exit_code: Option<i32>,
},
SessionList {
sessions: Vec<SessionInfo>,
},
Pong,
Error {
message: String,
},
}
/// Snapshot of a running or recently-exited session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInfo {
pub id: String,
pub pid: u32,
pub shell: String,
pub cwd: String,
pub cols: u16,
pub rows: u16,
/// Unix timestamp of session creation.
pub created_at: u64,
pub alive: bool,
}
/// Encode raw bytes as base64 for embedding in JSON.
pub fn encode_output(bytes: &[u8]) -> String {
use std::fmt::Write as FmtWrite;
// Manual base64 — avoids adding a new crate; the hex crate IS available but
// base64 better compresses binary PTY data (33% overhead vs 100% for hex).
// We use a simple lookup table implementation that stays within 300 lines.
const TABLE: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity((bytes.len() + 2) / 3 * 4);
let mut i = 0;
while i + 2 < bytes.len() {
let b0 = bytes[i] as usize;
let b1 = bytes[i + 1] as usize;
let b2 = bytes[i + 2] as usize;
let _ = write!(
out,
"{}{}{}{}",
TABLE[b0 >> 2] as char,
TABLE[((b0 & 3) << 4) | (b1 >> 4)] as char,
TABLE[((b1 & 0xf) << 2) | (b2 >> 6)] as char,
TABLE[b2 & 0x3f] as char
);
i += 3;
}
let rem = bytes.len() - i;
if rem == 1 {
let b0 = bytes[i] as usize;
let _ = write!(
out,
"{}{}==",
TABLE[b0 >> 2] as char,
TABLE[(b0 & 3) << 4] as char
);
} else if rem == 2 {
let b0 = bytes[i] as usize;
let b1 = bytes[i + 1] as usize;
let _ = write!(
out,
"{}{}{}=",
TABLE[b0 >> 2] as char,
TABLE[((b0 & 3) << 4) | (b1 >> 4)] as char,
TABLE[(b1 & 0xf) << 2] as char
);
}
out
}
/// Decode base64 string back to bytes. Returns error string on invalid input.
pub fn decode_input(s: &str) -> Result<Vec<u8>, String> {
fn val(c: u8) -> Result<u8, String> {
match c {
b'A'..=b'Z' => Ok(c - b'A'),
b'a'..=b'z' => Ok(c - b'a' + 26),
b'0'..=b'9' => Ok(c - b'0' + 52),
b'+' => Ok(62),
b'/' => Ok(63),
b'=' => Ok(0),
_ => Err(format!("invalid base64 char: {c}")),
}
}
let bytes = s.as_bytes();
if bytes.len() % 4 != 0 {
return Err("base64 length not a multiple of 4".into());
}
let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
let mut i = 0;
while i < bytes.len() {
let v0 = val(bytes[i])?;
let v1 = val(bytes[i + 1])?;
let v2 = val(bytes[i + 2])?;
let v3 = val(bytes[i + 3])?;
out.push((v0 << 2) | (v1 >> 4));
if bytes[i + 2] != b'=' {
out.push((v1 << 4) | (v2 >> 2));
}
if bytes[i + 3] != b'=' {
out.push((v2 << 6) | v3);
}
i += 4;
}
Ok(out)
}

View file

@ -1,281 +0,0 @@
use std::collections::HashMap;
use std::io::{Read, Write as IoWrite};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
use tokio::sync::{broadcast, Mutex};
use crate::protocol::SessionInfo;
const OUTPUT_CHANNEL_CAP: usize = 256;
/// A live (or recently exited) PTY session.
pub struct Session {
pub id: String,
pub pid: u32,
pub shell: String,
pub cwd: String,
pub cols: u16,
pub rows: u16,
pub created_at: u64,
writer: Arc<Mutex<Box<dyn IoWrite + Send>>>,
/// Master PTY handle — needed for resize (TIOCSWINSZ).
master: Arc<Mutex<Box<dyn MasterPty + Send>>>,
pub tx: broadcast::Sender<Vec<u8>>,
pub alive: Arc<AtomicBool>,
#[allow(dead_code)]
pub exit_code: Arc<Mutex<Option<i32>>>,
}
impl Session {
/// Snapshot metadata for ListSessions responses.
pub fn snapshot(&self) -> SessionInfo {
SessionInfo {
id: self.id.clone(),
pid: self.pid,
shell: self.shell.clone(),
cwd: self.cwd.clone(),
cols: self.cols,
rows: self.rows,
created_at: self.created_at,
alive: self.alive.load(Ordering::Relaxed),
}
}
/// Write raw bytes into the PTY master (keyboard input, paste, etc.).
pub async fn write_input(&self, data: &[u8]) -> Result<(), String> {
let mut w = self.writer.lock().await;
w.write_all(data)
.map_err(|e| format!("PTY write for {}: {e}", self.id))
}
/// Resize the PTY (issues TIOCSWINSZ) and update cached dimensions.
pub async fn resize(&mut self, cols: u16, rows: u16) -> Result<(), String> {
let master = self.master.lock().await;
master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("PTY resize for {}: {e}", self.id))?;
self.cols = cols;
self.rows = rows;
Ok(())
}
/// Return a new receiver subscribed to this session's broadcast output.
pub fn subscribe(&self) -> broadcast::Receiver<Vec<u8>> {
self.tx.subscribe()
}
}
// ---------------------------------------------------------------------------
// Session manager
// ---------------------------------------------------------------------------
/// Owns the full set of PTY sessions.
pub struct SessionManager {
sessions: HashMap<String, Session>,
default_shell: String,
}
impl SessionManager {
pub fn new(default_shell: String) -> Self {
Self {
sessions: HashMap::new(),
default_shell,
}
}
/// Spawn a new PTY session.
///
/// Returns `(pid, output_rx)` on success. `on_exit` is called from the
/// blocking reader task once the child process exits.
pub fn create_session(
&mut self,
id: String,
shell: Option<String>,
cwd: Option<String>,
env: Option<HashMap<String, String>>,
cols: u16,
rows: u16,
on_exit: impl FnOnce(String, Option<i32>) + Send + 'static,
) -> Result<(u32, broadcast::Receiver<Vec<u8>>), String> {
if self.sessions.contains_key(&id) {
return Err(format!("session {id} already exists"));
}
let shell_path = shell.unwrap_or_else(|| self.default_shell.clone());
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
rows,
cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("openpty: {e}"))?;
let mut cmd = CommandBuilder::new(&shell_path);
if let Some(ref dir) = cwd {
cmd.cwd(dir);
}
if let Some(ref vars) = env {
for (k, v) in vars {
cmd.env(k, v);
}
}
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("spawn: {e}"))?;
let pid = child.process_id().unwrap_or(0);
let cwd_str = cwd.unwrap_or_else(|| {
std::env::current_dir()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| "/".into())
});
// Take the writer before moving `pair.master` into the reader task.
let writer = pair
.master
.take_writer()
.map_err(|e| format!("take_writer: {e}"))?;
// Clone a reader; the master handle itself moves into the blocking task
// so the PTY stays open until the reader is done.
let reader = pair
.master
.try_clone_reader()
.map_err(|e| format!("clone_reader: {e}"))?;
let (tx, rx) = broadcast::channel(OUTPUT_CHANNEL_CAP);
let alive = Arc::new(AtomicBool::new(true));
let exit_code = Arc::new(Mutex::new(None::<i32>));
// Keep a reference to the master for resize operations.
// The reader task gets the master handle to keep the PTY fd alive.
let master_for_session: Box<dyn MasterPty + Send> = pair.master;
// Spawn the blocking reader task.
let tx_clone = tx.clone();
let alive_clone = alive.clone();
let exit_code_clone = exit_code.clone();
let id_clone = id.clone();
tokio::task::spawn_blocking(move || {
read_pty_output(
reader,
tx_clone,
alive_clone,
exit_code_clone,
id_clone,
on_exit,
child,
);
});
let session = Session {
id: id.clone(),
pid,
shell: shell_path,
cwd: cwd_str,
cols,
rows,
created_at: unix_now(),
writer: Arc::new(Mutex::new(writer)),
master: Arc::new(Mutex::new(master_for_session)),
tx,
alive,
exit_code,
};
log::info!("created session {id} pid={pid}");
self.sessions.insert(id, session);
Ok((pid, rx))
}
pub fn get(&self, id: &str) -> Option<&Session> {
self.sessions.get(id)
}
pub fn get_mut(&mut self, id: &str) -> Option<&mut Session> {
self.sessions.get_mut(id)
}
pub fn list(&self) -> Vec<SessionInfo> {
self.sessions.values().map(|s| s.snapshot()).collect()
}
/// Remove a session entry. The reader task will notice the PTY is closed
/// and stop on its own.
pub fn close_session(&mut self, id: &str) -> Result<(), String> {
if self.sessions.remove(id).is_some() {
log::info!("closed session {id}");
Ok(())
} else {
Err(format!("session {id} not found"))
}
}
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Blocking PTY reader — lives inside `tokio::task::spawn_blocking`.
///
/// `_master` is held here so the PTY file descriptor is not closed until this
/// task finishes.
#[allow(clippy::too_many_arguments)]
fn read_pty_output(
mut reader: Box<dyn Read + Send>,
tx: broadcast::Sender<Vec<u8>>,
alive: Arc<AtomicBool>,
exit_code_cell: Arc<Mutex<Option<i32>>>,
id: String,
on_exit: impl FnOnce(String, Option<i32>),
mut child: Box<dyn portable_pty::Child + Send>,
) {
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let _ = tx.send(buf[..n].to_vec());
}
Err(e) => {
log::debug!("session {id} reader error: {e}");
break;
}
}
}
alive.store(false, Ordering::Relaxed);
// `exit_code()` on portable-pty returns u32 directly (not Option).
let code = child
.wait()
.ok()
.map(|status| status.exit_code() as i32);
// Write exit code using try_lock spin — the lock is never held for long.
loop {
if let Ok(mut guard) = exit_code_cell.try_lock() {
*guard = code;
break;
}
std::thread::sleep(std::time::Duration::from_millis(1));
}
log::info!("session {id} exited with code {code:?}");
on_exit(id, code);
// `_master` drops here — PTY closed.
}

View file

@ -1,21 +0,0 @@
[Desktop Entry]
Type=Application
Name=Agents Orchestrator
Comment=Multi-project AI agent dashboard with terminals, SSH, and multi-agent orchestration
Exec=/home/hibryda/code/ai/agent-orchestrator/scripts/launch.sh start
Icon=agents-orchestrator
Terminal=false
Categories=Development;IDE;Utility;
Keywords=AI;Agent;Claude;Codex;Ollama;Terminal;SSH;
StartupWMClass=dev.agor.orchestrator
StartupNotify=true
MimeType=
Actions=stop;clean-start;
[Desktop Action stop]
Name=Stop All Instances
Exec=/home/hibryda/code/ai/agent-orchestrator/scripts/launch.sh stop
[Desktop Action clean-start]
Name=Clean Start (rebuild)
Exec=/home/hibryda/code/ai/agent-orchestrator/scripts/launch.sh start --clean

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M0 0h1024v1024H0z" style="fill:#0e0f13;fill-opacity:1;stroke-width:267.344"/><path d="M1404 357v216l188 79V437l-85-25 1-11-1-3-19-10-3 1-3 11z" style="fill:#cd9c34;fill-opacity:1;stroke-width:164" transform="translate(-2793 -448)scale(2.07)"/><path d="m1596 276-191 78 74 36 9-4 22 11-6 3 92 34 193-79zm193 79-27 14v109l-68 27-1 79-94 38V435l-6 2v215h3l192-77zm-115-44 25 10-187 75-22-11zm87 197 1 48-41 17v-49z" style="fill:#ffe14b;fill-opacity:1;stroke-width:164" transform="translate(-2793 -448)scale(2.07)"/><path d="m281 427 48 25 130 391-74-29-90-313-62 104 78 37 20 68-182-77z" style="fill:#000;stroke-width:164;fill-opacity:1"/></svg>

Before

Width:  |  Height:  |  Size: 715 B

View file

@ -1,57 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="1024"
height="1024"
viewBox="0 0 1024 1024"
sodipodi:docname="agor-icon-no-bkg.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="0.67718428"
inkscape:cx="779.69914"
inkscape:cy="335.94991"
inkscape:window-width="5120"
inkscape:window-height="1350"
inkscape:window-x="0"
inkscape:window-y="53"
inkscape:window-maximized="1"
inkscape:current-layer="g1" />
<g
inkscape:groupmode="layer"
inkscape:label="Image"
id="g1">
<g
id="g2">
<path
style="fill:#000000;fill-opacity:1;stroke-width:164"
d="m 511.25004,122.25982 399.8105,164.6543 -3.623,456.2363 -396.8829,158.5898 -7,-0.2187 -390.6152,-163.5664 1.4668,-447.3418 1.0117,-6.502 z"
id="path2" />
<path
id="path8"
style="fill:#cd9c34;fill-opacity:1;stroke-width:339.54"
d="M 114.40625 290.61523 L 112.93945 737.95508 L 503.55469 901.52148 L 503.55469 457.24609 L 327.4082 404.65039 L 328.1875 382.13086 L 326.80859 375.13086 L 286.69141 354.24219 L 281.49609 357.08398 L 273.9375 379.42188 L 114.40625 290.61523 z M 280.86719 426.71484 L 329.29297 451.78125 L 459.1875 843.17383 L 384.55469 814.11719 L 295.30273 501.27148 L 232.52734 604.51953 L 310.875 641.55078 L 331.00195 709.86133 L 149.16797 633.38281 L 280.86719 426.71484 z " />
<path
id="rect1"
style="fill:#ffe14b;fill-opacity:1;stroke-width:339.54"
d="m 511.25009,122.25976 -395.83217,161.85156 152.77352,74.28321 19.42189,-7.86133 45.39836,23.32031 -12.66795,6 191.15624,70.00585 399.56048,-162.9453 z m 399.81037,164.6543 -56.34024,28.38922 -0.59523,225.96235 -140.78321,56.89843 -1.13663,163.28516 -194.29491,78.69727 -0.63104,-386.8711 -11.79094,3.93945 v 444.5254 h 5.06639 L 907.43752,743.1504 Z M 672.56448,194.73827 723.9199,215.34181 337.83978,371.77538 291.59775,348.91993 Z m 180.42181,408.32813 2.0333,100.41211 -85.28125,33.83008 -0.4942,-101.18553 z"
sodipodi:nodetypes="ccccccccccccccccccccccccccccccc" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

View file

@ -1,66 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
version="1.1"
id="svg1"
width="1024"
height="1024"
viewBox="0 0 1024 1024"
sodipodi:docname="agor-icon.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:zoom="0.41639164"
inkscape:cx="951.02774"
inkscape:cy="704.86526"
inkscape:window-width="5120"
inkscape:window-height="1350"
inkscape:window-x="0"
inkscape:window-y="53"
inkscape:window-maximized="1"
inkscape:current-layer="g1" />
<g
inkscape:groupmode="layer"
inkscape:label="Image"
id="g1">
<rect
style="fill:#0e0f13;fill-opacity:1;stroke-width:267.344"
id="rect9"
width="1024"
height="1024"
x="0"
y="0" />
<g
id="g9"
transform="matrix(2.0703656,0,0,2.0703656,-2793.3973,-448.26751)">
<path
style="fill:#cd9c34;fill-opacity:1;stroke-width:164"
d="m 1404.4883,356.88477 -0.709,216.06835 188.6699,79.00391 V 437.36914 l -85.0801,-25.4043 0.377,-10.87695 -0.666,-3.38086 -19.377,-10.08984 -2.5097,1.37304 -3.6504,10.78907 z"
id="path8" />
<path
id="rect1"
style="fill:#ffe14b;fill-opacity:1;stroke-width:164"
d="m 1596.1661,275.56837 -191.1895,78.17535 73.7906,35.87927 9.3809,-3.79707 21.9277,11.26386 -6.1187,2.89804 92.3297,33.81328 192.9903,-78.70364 z m 193.111,79.52909 -27.2127,13.71218 -0.2875,109.14128 -67.9992,27.48231 -0.549,78.86779 -93.8457,38.01129 -0.3048,-186.86125 -5.6951,1.90278 v 214.70865 h 2.4471 l 191.697,-76.59992 z m -115.1951,-44.5215 24.805,9.95164 -186.4792,75.55843 -22.3352,-11.03933 z m 87.1449,197.22513 0.9821,48.4997 -41.1914,16.34015 -0.2387,-48.87327 z"
sodipodi:nodetypes="ccccccccccccccccccccccccccccccc" />
</g>
<path
style="fill:#000000;stroke-width:164;fill-opacity:1"
d="m 280.86786,426.71406 48.42549,25.06731 129.89427,391.39193 -74.63223,-29.0553 -89.25355,-312.84693 -62.77545,103.24854 78.34886,37.03126 20.12724,68.30989 -181.83362,-76.47704 z"
id="path1"
sodipodi:nodetypes="cccccccccc" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -1,8 +1,8 @@
[package]
name = "agor-core"
name = "bterminal-core"
version = "0.1.0"
edition = "2021"
description = "Shared PTY and sidecar management for Agents Orchestrator"
description = "Shared PTY and sidecar management for BTerminal"
license = "MIT"
[dependencies]

View file

@ -1,17 +1,17 @@
// AppConfig — centralized path resolution for all Agents Orchestrator subsystems.
// AppConfig — centralized path resolution for all BTerminal subsystems.
// In production, paths resolve via dirs:: crate defaults.
// In test mode (AGOR_TEST=1), paths resolve from env var overrides:
// AGOR_TEST_DATA_DIR → replaces dirs::data_dir()/agor
// AGOR_TEST_CONFIG_DIR → replaces dirs::config_dir()/agor
// AGOR_TEST_CTX_DIR → replaces ~/.claude-context
// In test mode (BTERMINAL_TEST=1), paths resolve from env var overrides:
// BTERMINAL_TEST_DATA_DIR → replaces dirs::data_dir()/bterminal
// BTERMINAL_TEST_CONFIG_DIR → replaces dirs::config_dir()/bterminal
// BTERMINAL_TEST_CTX_DIR → replaces ~/.claude-context
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct AppConfig {
/// Data directory for btmsg.db, sessions.db (default: ~/.local/share/agor)
/// Data directory for btmsg.db, sessions.db (default: ~/.local/share/bterminal)
pub data_dir: PathBuf,
/// Config directory for groups.json (default: ~/.config/agor)
/// Config directory for groups.json (default: ~/.config/bterminal)
pub config_dir: PathBuf,
/// ctx database path (default: ~/.claude-context/context.db)
pub ctx_db_path: PathBuf,
@ -22,31 +22,31 @@ pub struct AppConfig {
}
impl AppConfig {
/// Build config from environment. In test mode, uses AGOR_TEST_*_DIR env vars.
/// Build config from environment. In test mode, uses BTERMINAL_TEST_*_DIR env vars.
pub fn from_env() -> Self {
let test_mode = std::env::var("AGOR_TEST").map_or(false, |v| v == "1");
let test_mode = std::env::var("BTERMINAL_TEST").map_or(false, |v| v == "1");
let data_dir = std::env::var("AGOR_TEST_DATA_DIR")
let data_dir = std::env::var("BTERMINAL_TEST_DATA_DIR")
.ok()
.filter(|_| test_mode)
.map(PathBuf::from)
.unwrap_or_else(|| {
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("agor")
.join("bterminal")
});
let config_dir = std::env::var("AGOR_TEST_CONFIG_DIR")
let config_dir = std::env::var("BTERMINAL_TEST_CONFIG_DIR")
.ok()
.filter(|_| test_mode)
.map(PathBuf::from)
.unwrap_or_else(|| {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("agor")
.join("bterminal")
});
let ctx_db_path = std::env::var("AGOR_TEST_CTX_DIR")
let ctx_db_path = std::env::var("BTERMINAL_TEST_CTX_DIR")
.ok()
.filter(|_| test_mode)
.map(|d| PathBuf::from(d).join("context.db"))
@ -100,7 +100,7 @@ impl AppConfig {
self.config_dir.join("plugins")
}
/// Whether running in test mode (AGOR_TEST=1)
/// Whether running in test mode (BTERMINAL_TEST=1)
pub fn is_test_mode(&self) -> bool {
self.test_mode
}
@ -118,17 +118,17 @@ mod tests {
#[test]
fn test_production_paths_use_dirs() {
let _lock = ENV_LOCK.lock().unwrap();
// Without AGOR_TEST=1, paths should use dirs:: defaults
std::env::remove_var("AGOR_TEST");
std::env::remove_var("AGOR_TEST_DATA_DIR");
std::env::remove_var("AGOR_TEST_CONFIG_DIR");
std::env::remove_var("AGOR_TEST_CTX_DIR");
// Without BTERMINAL_TEST=1, paths should use dirs:: defaults
std::env::remove_var("BTERMINAL_TEST");
std::env::remove_var("BTERMINAL_TEST_DATA_DIR");
std::env::remove_var("BTERMINAL_TEST_CONFIG_DIR");
std::env::remove_var("BTERMINAL_TEST_CTX_DIR");
let config = AppConfig::from_env();
assert!(!config.is_test_mode());
// Should end with "agor" for data and config
assert!(config.data_dir.ends_with("agor"));
assert!(config.config_dir.ends_with("agor"));
// Should end with "bterminal" for data and config
assert!(config.data_dir.ends_with("bterminal"));
assert!(config.config_dir.ends_with("bterminal"));
assert!(config.ctx_db_path.ends_with("context.db"));
assert!(config.memora_db_path.ends_with("memories.db"));
}
@ -136,17 +136,17 @@ mod tests {
#[test]
fn test_btmsg_db_path() {
let _lock = ENV_LOCK.lock().unwrap();
std::env::remove_var("AGOR_TEST");
std::env::remove_var("BTERMINAL_TEST");
let config = AppConfig::from_env();
let path = config.btmsg_db_path();
assert!(path.ends_with("btmsg.db"));
assert!(path.parent().unwrap().ends_with("agor"));
assert!(path.parent().unwrap().ends_with("bterminal"));
}
#[test]
fn test_groups_json_path() {
let _lock = ENV_LOCK.lock().unwrap();
std::env::remove_var("AGOR_TEST");
std::env::remove_var("BTERMINAL_TEST");
let config = AppConfig::from_env();
let path = config.groups_json_path();
assert!(path.ends_with("groups.json"));
@ -155,55 +155,55 @@ mod tests {
#[test]
fn test_test_mode_uses_overrides() {
let _lock = ENV_LOCK.lock().unwrap();
std::env::set_var("AGOR_TEST", "1");
std::env::set_var("AGOR_TEST_DATA_DIR", "/tmp/agor-test-data");
std::env::set_var("AGOR_TEST_CONFIG_DIR", "/tmp/agor-test-config");
std::env::set_var("AGOR_TEST_CTX_DIR", "/tmp/agor-test-ctx");
std::env::set_var("BTERMINAL_TEST", "1");
std::env::set_var("BTERMINAL_TEST_DATA_DIR", "/tmp/bt-test-data");
std::env::set_var("BTERMINAL_TEST_CONFIG_DIR", "/tmp/bt-test-config");
std::env::set_var("BTERMINAL_TEST_CTX_DIR", "/tmp/bt-test-ctx");
let config = AppConfig::from_env();
assert!(config.is_test_mode());
assert_eq!(config.data_dir, PathBuf::from("/tmp/agor-test-data"));
assert_eq!(config.config_dir, PathBuf::from("/tmp/agor-test-config"));
assert_eq!(config.ctx_db_path, PathBuf::from("/tmp/agor-test-ctx/context.db"));
assert_eq!(config.btmsg_db_path(), PathBuf::from("/tmp/agor-test-data/btmsg.db"));
assert_eq!(config.groups_json_path(), PathBuf::from("/tmp/agor-test-config/groups.json"));
assert_eq!(config.data_dir, PathBuf::from("/tmp/bt-test-data"));
assert_eq!(config.config_dir, PathBuf::from("/tmp/bt-test-config"));
assert_eq!(config.ctx_db_path, PathBuf::from("/tmp/bt-test-ctx/context.db"));
assert_eq!(config.btmsg_db_path(), PathBuf::from("/tmp/bt-test-data/btmsg.db"));
assert_eq!(config.groups_json_path(), PathBuf::from("/tmp/bt-test-config/groups.json"));
// Cleanup
std::env::remove_var("AGOR_TEST");
std::env::remove_var("AGOR_TEST_DATA_DIR");
std::env::remove_var("AGOR_TEST_CONFIG_DIR");
std::env::remove_var("AGOR_TEST_CTX_DIR");
std::env::remove_var("BTERMINAL_TEST");
std::env::remove_var("BTERMINAL_TEST_DATA_DIR");
std::env::remove_var("BTERMINAL_TEST_CONFIG_DIR");
std::env::remove_var("BTERMINAL_TEST_CTX_DIR");
}
#[test]
fn test_test_mode_without_overrides_uses_defaults() {
let _lock = ENV_LOCK.lock().unwrap();
std::env::set_var("AGOR_TEST", "1");
std::env::remove_var("AGOR_TEST_DATA_DIR");
std::env::remove_var("AGOR_TEST_CONFIG_DIR");
std::env::remove_var("AGOR_TEST_CTX_DIR");
std::env::set_var("BTERMINAL_TEST", "1");
std::env::remove_var("BTERMINAL_TEST_DATA_DIR");
std::env::remove_var("BTERMINAL_TEST_CONFIG_DIR");
std::env::remove_var("BTERMINAL_TEST_CTX_DIR");
let config = AppConfig::from_env();
assert!(config.is_test_mode());
// Without override vars, falls back to dirs:: defaults
assert!(config.data_dir.ends_with("agor"));
assert!(config.data_dir.ends_with("bterminal"));
std::env::remove_var("AGOR_TEST");
std::env::remove_var("BTERMINAL_TEST");
}
#[test]
fn test_test_mode_memora_in_data_dir() {
let _lock = ENV_LOCK.lock().unwrap();
std::env::set_var("AGOR_TEST", "1");
std::env::set_var("AGOR_TEST_DATA_DIR", "/tmp/agor-test-data");
std::env::set_var("BTERMINAL_TEST", "1");
std::env::set_var("BTERMINAL_TEST_DATA_DIR", "/tmp/bt-test-data");
let config = AppConfig::from_env();
assert_eq!(
config.memora_db_path,
PathBuf::from("/tmp/agor-test-data/memora/memories.db")
PathBuf::from("/tmp/bt-test-data/memora/memories.db")
);
std::env::remove_var("AGOR_TEST");
std::env::remove_var("AGOR_TEST_DATA_DIR");
std::env::remove_var("BTERMINAL_TEST");
std::env::remove_var("BTERMINAL_TEST_DATA_DIR");
}
}

View file

@ -74,7 +74,7 @@ impl SandboxConfig {
home.join(".local"), // ~/.local/bin (claude CLI, user-installed tools)
home.join(".deno"), // Deno runtime cache
home.join(".nvm"), // Node.js version manager
home.join(".config"), // XDG config (claude profiles, agor config)
home.join(".config"), // XDG config (claude profiles, bterminal config)
home.join(".claude"), // Claude CLI data (worktrees, skills, settings)
];

View file

@ -55,7 +55,7 @@ fn default_provider() -> String {
#[derive(Debug, Clone)]
pub struct SidecarConfig {
pub search_paths: Vec<PathBuf>,
/// Extra env vars forwarded to sidecar processes (e.g. AGOR_TEST=1 for test isolation)
/// Extra env vars forwarded to sidecar processes (e.g. BTERMINAL_TEST=1 for test isolation)
pub env_overrides: std::collections::HashMap<String, String>,
/// Landlock filesystem sandbox configuration (Linux 5.13+, applied via pre_exec)
pub sandbox: SandboxConfig,
@ -268,7 +268,7 @@ fn start_provider_impl(
);
// Build a clean environment stripping provider-specific vars to prevent
// SDKs from detecting nesting when Agents Orchestrator is launched from a provider terminal.
// SDKs from detecting nesting when BTerminal is launched from a provider terminal.
let clean_env: Vec<(String, String)> = std::env::vars()
.filter(|(k, _)| strip_provider_env_var(k))
.collect();

View file

@ -1,16 +1,16 @@
[package]
name = "agor-relay"
name = "bterminal-relay"
version = "0.1.0"
edition = "2021"
description = "Remote relay server for Agents Orchestrator multi-machine support"
description = "Remote relay server for BTerminal multi-machine support"
license = "MIT"
[[bin]]
name = "agor-relay"
name = "bterminal-relay"
path = "src/main.rs"
[dependencies]
agor-core = { path = "../agor-core" }
bterminal-core = { path = "../bterminal-core" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"

View file

@ -1,8 +1,8 @@
// agor-relay — WebSocket relay server for remote PTY and agent management
// bterminal-relay — WebSocket relay server for remote PTY and agent management
use agor_core::event::EventSink;
use agor_core::pty::{PtyManager, PtyOptions};
use agor_core::sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager};
use bterminal_core::event::EventSink;
use bterminal_core::pty::{PtyManager, PtyOptions};
use bterminal_core::sidecar::{AgentQueryOptions, SidecarConfig, SidecarManager};
use clap::Parser;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
@ -14,7 +14,7 @@ use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::http;
#[derive(Parser)]
#[command(name = "agor-relay", about = "Agents Orchestrator remote relay server")]
#[command(name = "bterminal-relay", about = "BTerminal remote relay server")]
struct Cli {
/// Port to listen on
#[arg(short, long, default_value = "9750")]
@ -128,7 +128,7 @@ async fn main() {
let addr = SocketAddr::from(([0, 0, 0, 0], cli.port));
let listener = TcpListener::bind(&addr).await.expect("Failed to bind");
let protocol = if tls_acceptor.is_some() { "wss" } else { "ws" };
log::info!("agor-relay listening on {protocol}://{addr}");
log::info!("bterminal-relay listening on {protocol}://{addr}");
// Build sidecar config
let mut search_paths: Vec<std::path::PathBuf> = cli

1268
consult

File diff suppressed because it is too large Load diff

472
ctx
View file

@ -1,472 +0,0 @@
#!/usr/bin/env python3
"""
ctx — Cross-session context manager for Claude Code.
Stores project contexts and shared data in SQLite for instant access.
Usage: ctx <command> [args]
"""
import sqlite3
import sys
import json
from pathlib import Path
DB_PATH = Path.home() / ".claude-context" / "context.db"
def get_db():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(str(DB_PATH))
db.row_factory = sqlite3.Row
db.execute("PRAGMA journal_mode=WAL")
return db
def init_db():
db = get_db()
db.executescript("""
CREATE TABLE IF NOT EXISTS sessions (
name TEXT PRIMARY KEY,
description TEXT,
work_dir TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS contexts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now')),
UNIQUE(project, key)
);
CREATE TABLE IF NOT EXISTS shared (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project TEXT NOT NULL,
summary TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE VIRTUAL TABLE IF NOT EXISTS contexts_fts USING fts5(
project, key, value, content=contexts, content_rowid=id
);
CREATE VIRTUAL TABLE IF NOT EXISTS shared_fts USING fts5(
key, value, content=shared
);
-- Triggers to keep FTS in sync
CREATE TRIGGER IF NOT EXISTS contexts_ai AFTER INSERT ON contexts BEGIN
INSERT INTO contexts_fts(rowid, project, key, value)
VALUES (new.id, new.project, new.key, new.value);
END;
CREATE TRIGGER IF NOT EXISTS contexts_ad AFTER DELETE ON contexts BEGIN
INSERT INTO contexts_fts(contexts_fts, rowid, project, key, value)
VALUES ('delete', old.id, old.project, old.key, old.value);
END;
CREATE TRIGGER IF NOT EXISTS contexts_au AFTER UPDATE ON contexts BEGIN
INSERT INTO contexts_fts(contexts_fts, rowid, project, key, value)
VALUES ('delete', old.id, old.project, old.key, old.value);
INSERT INTO contexts_fts(rowid, project, key, value)
VALUES (new.id, new.project, new.key, new.value);
END;
""")
db.close()
# ─── Commands ───────────────────────────────────────────────────────────
def cmd_init(args):
"""Register a new project. Usage: ctx init <project> <description> [work_dir]"""
if len(args) < 2:
print("Usage: ctx init <project> <description> [work_dir]")
sys.exit(1)
name, desc = args[0], args[1]
work_dir = args[2] if len(args) > 2 else None
db = get_db()
db.execute(
"INSERT OR REPLACE INTO sessions (name, description, work_dir) VALUES (?, ?, ?)",
(name, desc, work_dir),
)
db.commit()
db.close()
print(f"Project '{name}' registered.")
def cmd_get(args):
"""Get full context for a project (shared + project-specific + recent summaries)."""
if len(args) < 1:
print("Usage: ctx get <project>")
sys.exit(1)
project = args[0]
db = get_db()
# Session info
session = db.execute("SELECT * FROM sessions WHERE name = ?", (project,)).fetchone()
# Shared context
shared = db.execute("SELECT key, value FROM shared ORDER BY key").fetchall()
# Project context
contexts = db.execute(
"SELECT key, value FROM contexts WHERE project = ? ORDER BY key", (project,)
).fetchall()
# Recent summaries (last 5)
summaries = db.execute(
"SELECT summary, created_at FROM summaries WHERE project = ? ORDER BY created_at DESC LIMIT 5",
(project,),
).fetchall()
# Output
print("=" * 60)
if session:
print(f"PROJECT: {session['name']} — {session['description']}")
if session["work_dir"]:
print(f"DIR: {session['work_dir']}")
else:
print(f"PROJECT: {project} (not registered, use: ctx init)")
print("=" * 60)
if shared:
print("\n--- Shared Context ---")
for row in shared:
print(f"\n[{row['key']}]")
print(row["value"])
if contexts:
print(f"\n--- {project} Context ---")
for row in contexts:
print(f"\n[{row['key']}]")
print(row["value"])
if summaries:
print("\n--- Recent Sessions ---")
for row in reversed(summaries):
print(f"\n[{row['created_at']}]")
print(row["summary"])
if not shared and not contexts and not summaries:
print("\nNo context stored yet. Use 'ctx set' or 'ctx shared set' to add.")
db.close()
def cmd_set(args):
"""Set a project context entry. Usage: ctx set <project> <key> <value>"""
if len(args) < 3:
print("Usage: ctx set <project> <key> <value>")
sys.exit(1)
project, key, value = args[0], args[1], " ".join(args[2:])
db = get_db()
db.execute(
"""INSERT INTO contexts (project, key, value, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(project, key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at""",
(project, key, value),
)
db.commit()
db.close()
print(f"[{project}] {key} = saved.")
def cmd_append(args):
"""Append to an existing context entry. Usage: ctx append <project> <key> <value>"""
if len(args) < 3:
print("Usage: ctx append <project> <key> <value>")
sys.exit(1)
project, key, new_value = args[0], args[1], " ".join(args[2:])
db = get_db()
existing = db.execute(
"SELECT value FROM contexts WHERE project = ? AND key = ?", (project, key)
).fetchone()
if existing:
value = existing["value"] + "\n" + new_value
else:
value = new_value
db.execute(
"""INSERT INTO contexts (project, key, value, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(project, key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at""",
(project, key, value),
)
db.commit()
db.close()
print(f"[{project}] {key} += appended.")
def cmd_shared(args):
"""Manage shared context. Usage: ctx shared get | ctx shared set <key> <value>"""
if len(args) < 1:
print("Usage: ctx shared get | ctx shared set <key> <value>")
sys.exit(1)
if args[0] == "get":
db = get_db()
rows = db.execute("SELECT key, value FROM shared ORDER BY key").fetchall()
db.close()
if rows:
for row in rows:
print(f"\n[{row['key']}]")
print(row["value"])
else:
print("No shared context yet.")
elif args[0] == "set":
if len(args) < 3:
print("Usage: ctx shared set <key> <value>")
sys.exit(1)
key, value = args[1], " ".join(args[2:])
db = get_db()
db.execute(
"""INSERT INTO shared (key, value, updated_at)
VALUES (?, ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at""",
(key, value),
)
db.commit()
db.close()
print(f"[shared] {key} = saved.")
elif args[0] == "delete":
if len(args) < 2:
print("Usage: ctx shared delete <key>")
sys.exit(1)
db = get_db()
db.execute("DELETE FROM shared WHERE key = ?", (args[1],))
db.commit()
db.close()
print(f"[shared] {args[1]} deleted.")
else:
print("Usage: ctx shared get | ctx shared set <key> <value> | ctx shared delete <key>")
def cmd_summary(args):
"""Save a session summary. Usage: ctx summary <project> <text>"""
if len(args) < 2:
print("Usage: ctx summary <project> <summary text>")
sys.exit(1)
project, summary = args[0], " ".join(args[1:])
db = get_db()
db.execute(
"INSERT INTO summaries (project, summary) VALUES (?, ?)", (project, summary)
)
# Keep last 20 summaries per project
db.execute(
"""DELETE FROM summaries WHERE project = ? AND id NOT IN (
SELECT id FROM summaries WHERE project = ? ORDER BY created_at DESC LIMIT 20
)""",
(project, project),
)
db.commit()
db.close()
print(f"[{project}] Summary saved.")
def cmd_history(args):
"""Show session history. Usage: ctx history <project> [limit]"""
if len(args) < 1:
print("Usage: ctx history <project> [limit]")
sys.exit(1)
project = args[0]
try:
limit = int(args[1]) if len(args) > 1 else 10
except ValueError:
print(f"Error: limit must be an integer, got '{args[1]}'")
sys.exit(1)
db = get_db()
rows = db.execute(
"SELECT summary, created_at FROM summaries WHERE project = ? ORDER BY created_at DESC LIMIT ?",
(project, limit),
).fetchall()
db.close()
if rows:
for row in reversed(rows):
print(f"\n[{row['created_at']}]")
print(row["summary"])
else:
print(f"No history for '{project}'.")
def cmd_search(args):
"""Full-text search across all contexts. Usage: ctx search <query>"""
if len(args) < 1:
print("Usage: ctx search <query>")
sys.exit(1)
query = " ".join(args)
db = get_db()
# Search project contexts (FTS5 MATCH can fail on malformed query syntax)
try:
results_ctx = db.execute(
"SELECT project, key, value FROM contexts_fts WHERE contexts_fts MATCH ?",
(query,),
).fetchall()
except sqlite3.OperationalError:
print(f"Invalid search query: '{query}' (FTS5 syntax error)")
db.close()
sys.exit(1)
# Search shared contexts
try:
results_shared = db.execute(
"SELECT key, value FROM shared_fts WHERE shared_fts MATCH ?", (query,)
).fetchall()
except sqlite3.OperationalError:
results_shared = []
# Search summaries (simple LIKE since no FTS on summaries)
results_sum = db.execute(
"SELECT project, summary, created_at FROM summaries WHERE summary LIKE ?",
(f"%{query}%",),
).fetchall()
db.close()
total = len(results_ctx) + len(results_shared) + len(results_sum)
print(f"Found {total} result(s) for '{query}':\n")
if results_shared:
print("--- Shared ---")
for row in results_shared:
print(f" [{row['key']}] {row['value'][:100]}")
if results_ctx:
print("--- Project Contexts ---")
for row in results_ctx:
print(f" [{row['project']}:{row['key']}] {row['value'][:100]}")
if results_sum:
print("--- Summaries ---")
for row in results_sum:
print(f" [{row['project']} @ {row['created_at']}] {row['summary'][:100]}")
def cmd_list(args):
"""List all registered projects."""
db = get_db()
sessions = db.execute("SELECT * FROM sessions ORDER BY name").fetchall()
# Also find projects with context but no session registration
orphans = db.execute(
"""SELECT DISTINCT project FROM contexts
WHERE project NOT IN (SELECT name FROM sessions)
ORDER BY project"""
).fetchall()
db.close()
if sessions:
print("Registered projects:")
for s in sessions:
ctx_count = _count_contexts(s["name"])
print(f" {s['name']:25s} — {s['description']} ({ctx_count} entries)")
if orphans:
print("\nUnregistered (have context but no init):")
for o in orphans:
print(f" {o['project']}")
if not sessions and not orphans:
print("No projects yet. Use 'ctx init <name> <description>' to start.")
def cmd_delete(args):
"""Delete a project or specific key. Usage: ctx delete <project> [key]"""
if len(args) < 1:
print("Usage: ctx delete <project> [key]")
sys.exit(1)
project = args[0]
db = get_db()
if len(args) >= 2:
key = args[1]
db.execute(
"DELETE FROM contexts WHERE project = ? AND key = ?", (project, key)
)
db.commit()
print(f"[{project}] {key} deleted.")
else:
db.execute("DELETE FROM contexts WHERE project = ?", (project,))
db.execute("DELETE FROM summaries WHERE project = ?", (project,))
db.execute("DELETE FROM sessions WHERE name = ?", (project,))
db.commit()
print(f"Project '{project}' and all its data deleted.")
db.close()
def cmd_export(args):
"""Export all data as JSON. Usage: ctx export"""
db = get_db()
data = {
"sessions": [dict(r) for r in db.execute("SELECT * FROM sessions").fetchall()],
"shared": [dict(r) for r in db.execute("SELECT * FROM shared").fetchall()],
"contexts": [dict(r) for r in db.execute("SELECT * FROM contexts").fetchall()],
"summaries": [dict(r) for r in db.execute("SELECT * FROM summaries").fetchall()],
}
db.close()
print(json.dumps(data, indent=2, ensure_ascii=False))
def _count_contexts(project):
db = get_db()
row = db.execute(
"SELECT COUNT(*) as c FROM contexts WHERE project = ?", (project,)
).fetchone()
db.close()
return row["c"]
# ─── Main ───────────────────────────────────────────────────────────────
COMMANDS = {
"init": cmd_init,
"get": cmd_get,
"set": cmd_set,
"append": cmd_append,
"shared": cmd_shared,
"summary": cmd_summary,
"history": cmd_history,
"search": cmd_search,
"list": cmd_list,
"delete": cmd_delete,
"export": cmd_export,
}
def print_help():
print("ctx — Cross-session context manager for Claude Code\n")
print("Commands:")
print(" init <project> <desc> [dir] Register a new project")
print(" get <project> Load full context (shared + project)")
print(" set <project> <key> <value> Set project context entry")
print(" append <project> <key> <val> Append to existing entry")
print(" shared get|set|delete Manage shared context")
print(" summary <project> <text> Save session work summary")
print(" history <project> [limit] Show session history")
print(" search <query> Full-text search across everything")
print(" list List all projects")
print(" delete <project> [key] Delete project or entry")
print(" export Export all data as JSON")
if __name__ == "__main__":
init_db()
if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help", "help"):
print_help()
sys.exit(0)
cmd = sys.argv[1]
if cmd not in COMMANDS:
print(f"Unknown command: {cmd}")
print_help()
sys.exit(1)
COMMANDS[cmd](sys.argv[2:])

View file

@ -1,27 +0,0 @@
services:
tempo:
image: grafana/tempo:latest
command: ["-config.file=/etc/tempo.yaml"]
volumes:
- ./tempo.yaml:/etc/tempo.yaml:ro
- tempo-data:/var/tempo
ports:
- "4317:4317" # OTLP gRPC
- "4318:4318" # OTLP HTTP
- "3200:3200" # Tempo query API
grafana:
image: grafana/grafana:latest
environment:
- GF_AUTH_ANONYMOUS_ENABLED=true
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
- GF_AUTH_DISABLE_LOGIN_FORM=true
volumes:
- ./grafana-datasources.yaml:/etc/grafana/provisioning/datasources/datasources.yaml:ro
ports:
- "9715:3000" # Grafana UI (project port convention)
depends_on:
- tempo
volumes:
tempo-data:

View file

@ -1,11 +0,0 @@
apiVersion: 1
datasources:
- name: Tempo
type: tempo
access: proxy
url: http://tempo:3200
isDefault: true
jsonData:
tracesToLogsV2:
datasourceUid: ''

View file

@ -1,19 +0,0 @@
server:
http_listen_port: 3200
distributor:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
storage:
trace:
backend: local
local:
path: /var/tempo/traces
wal:
path: /var/tempo/wal

View file

@ -1,112 +0,0 @@
# Agents Orchestrator — Documentation
Multi-project AI agent dashboard with terminal, SSH, and multi-provider session management.
Built with Tauri 2.x (Rust) + Svelte 5 + Claude Agent SDK.
> **Source of truth.** Before making changes, consult these docs. After making changes, update them.
## Quick Navigation
| Audience | Start Here |
|----------|-----------|
| New users | [Getting Started](getting-started/quickstart.md) |
| Contributors | [Dual-Repo Workflow](contributing/dual-repo-workflow.md), [Testing](contributing/testing.md) |
| Pro customers | [Pro Edition](pro/README.md), [Marketplace](pro/marketplace/README.md) |
| Plugin developers | [Plugin Development Guide](plugins/guide-developing.md) |
## Documentation Map
### Getting Started
- [Quickstart](getting-started/quickstart.md) — install, build, first agent session
### Architecture
- [System Overview](architecture/overview.md) — components, data flow, IPC patterns
- [Data Model](architecture/data-model.md) — SQLite schemas, layout, keyboard shortcuts
- [Decisions](architecture/decisions.md) — architecture decision log with rationale
- [Phases](architecture/phases.md) — v2 implementation phases (P1-7 + multi-machine A-D)
- [Research Findings](architecture/findings.md) — SDK, performance, coupling analysis
### Agents & Orchestration
- [Orchestration](agents/orchestration.md) — btmsg, bttask, 4 management roles, wake scheduler, session anchors
- [btmsg Reference](agents/ref-btmsg.md) — inter-agent messaging CLI and database schema
- [bttask Reference](agents/ref-bttask.md) — kanban task board CLI and operations
### Providers
- [Provider Reference](providers/ref-providers.md) — Claude, Codex, Ollama, Aider: models, capabilities, routing
### Sidecar
- [Sidecar Architecture](sidecar/architecture.md) — runners, NDJSON protocol, crash recovery, env stripping
### Multi-Machine
- [Relay Architecture](multi-machine/relay.md) — WebSocket server, TLS, SPKI pinning, reconnection
### Production Hardening
- [Hardening](production/hardening.md) — sidecar supervisor, Landlock sandbox, WAL checkpoint, TLS relay
- [Features](production/features.md) — FTS5 search, plugin sandbox, secrets, notifications, audit, error classification
### Configuration
- [Settings Reference](config/ref-settings.md) — env vars, config files, databases, themes, per-project settings
### Plugins
- [Plugin Development Guide](plugins/guide-developing.md) — Web Worker sandbox API, manifest, publishing, examples
### Contributing
- [Dual-Repo Workflow](contributing/dual-repo-workflow.md) — community vs commercial repos, sync, leak prevention
- [Testing](contributing/testing.md) — E2E fixtures, test mode, LLM judge, CI integration
### Pro Edition (Commercial)
- [Pro Overview](pro/README.md) — feature list, plugin architecture, IPC pattern
- [Analytics Dashboard](pro/features/analytics.md) — cost/token/model tracking over time
- [Cost Intelligence](pro/features/cost-intelligence.md) — budget governor, smart model router
- [Knowledge Base](pro/features/knowledge-base.md) — persistent memory, codebase symbol graph
- [Git Integration](pro/features/git-integration.md) — context injection, branch policy
- [Marketplace](pro/marketplace/README.md) — 13 plugins (8 free + 5 paid), catalog, install/update
### Release History
- [Release Notes](release-notes.md) — v3.0 features, breaking changes, requirements
### Progress Logs
- [v3 Progress](progress/v3.md) — session-by-session development log
- [v2 Progress](progress/v2.md)
- [v2 Archive](progress/v2-archive.md)
---
## Key Directories
| Path | Purpose |
|------|---------|
| `src-tauri/src/` | Rust backend: commands, SQLite, btmsg, bttask, search, secrets, plugins |
| `agor-core/` | Shared Rust crate: PtyManager, SidecarManager, EventSink, Landlock sandbox |
| `agor-relay/` | Standalone relay binary for remote machine support |
| `agor-pro/` | Commercial plugin crate (Pro edition features) |
| `src/lib/` | Svelte 5 frontend: components, stores, adapters, utils, providers |
| `src/lib/commercial/` | Pro edition Svelte components and IPC bridge |
| `sidecar/` | Agent sidecar runners (Claude, Codex, Ollama, Aider) — ESM bundles |
| `tests/e2e/` | WebDriverIO E2E tests, fixtures, LLM judge |
| `tests/commercial/` | Pro edition tests (excluded from community builds) |
| `scripts/` | Build scripts, plugin scaffolding, test runner |
| `.githooks/` | Pre-push leak prevention hook |
## Reading Order for New Contributors
1. [Getting Started](getting-started/quickstart.md) — build and run
2. [System Overview](architecture/overview.md) — how the pieces fit
3. [Decisions](architecture/decisions.md) — why things are built this way
4. [Sidecar Architecture](sidecar/architecture.md) — how agent sessions run
5. [Orchestration](agents/orchestration.md) — multi-agent coordination
6. [Testing](contributing/testing.md) — how to test changes
7. [Dual-Repo Workflow](contributing/dual-repo-workflow.md) — how to contribute

View file

@ -1,91 +0,0 @@
# Switching from Tauri to Electrobun
This guide covers migrating your data when switching from the Tauri v2/v3 build to the Electrobun build of AGOR.
## Overview
Both stacks use SQLite for persistence but store databases in different locations with slightly different schemas. The `migrate-db` tool copies data from a Tauri source database into an Electrobun target database using the canonical schema as the contract.
**This is a one-way migration.** The source database is opened read-only and never modified. The target database receives copies of all compatible data. Running the tool multiple times is safe (uses `INSERT OR IGNORE`).
## Prerequisites
- **Bun** >= 1.0 (ships with bun:sqlite)
- A working Tauri installation with existing data in `~/.local/share/agor/sessions.db`
- The Electrobun build installed (creates `~/.config/agor/settings.db` on first run)
## Database Locations
| Database | Tauri path | Electrobun path |
|----------|-----------|-----------------|
| Settings + sessions | `~/.local/share/agor/sessions.db` | `~/.config/agor/settings.db` |
| btmsg + bttask | `~/.local/share/agor/btmsg.db` | `~/.local/share/agor/btmsg.db` (shared) |
| FTS5 search | `~/.local/share/agor/search.db` | `~/.local/share/agor/search.db` (shared) |
The btmsg and search databases are already shared between stacks -- no migration needed for those.
## Steps
### 1. Stop both applications
Close the Tauri app and the Electrobun app if either is running. SQLite WAL mode handles concurrent reads, but stopping both avoids partial writes.
### 2. Back up your data
```bash
cp ~/.local/share/agor/sessions.db ~/.local/share/agor/sessions.db.bak
cp ~/.config/agor/settings.db ~/.config/agor/settings.db.bak 2>/dev/null
```
### 3. Run the migration
```bash
# Migrate settings/sessions from Tauri to Electrobun
bun tools/migrate-db.ts \
--from ~/.local/share/agor/sessions.db \
--to ~/.config/agor/settings.db
```
The tool will:
- Open the source database read-only
- Apply the canonical schema to the target if needed
- Copy all matching tables using `INSERT OR IGNORE` (existing rows are preserved)
- Report per-table row counts
- Write a version fence to `schema_version`
### 4. Verify
Launch the Electrobun app and confirm your projects, settings, and session history appear correctly.
### 5. (Optional) Validate the schema
```bash
bun tools/validate-schema.ts | jq '.tableCount'
# Should output the number of tables defined in canonical.sql
```
## Version Fence
After migration, the target database's `schema_version` table contains:
| Column | Value |
|--------|-------|
| `version` | `1` |
| `migration_source` | `migrate-db` |
| `migration_timestamp` | ISO-8601 timestamp of the migration |
This fence prevents accidental re-application of older schemas and provides an audit trail.
## Troubleshooting
**"Source database not found"** -- Verify the Tauri data directory path. On some systems, `XDG_DATA_HOME` may override `~/.local/share`.
**"Schema application failed"** -- The canonical.sql file may be out of sync with a newer database. Pull the latest version and retry.
**"Migration failed on table X"** -- The migration runs inside a single transaction. If any table fails, all changes are rolled back. Check the error message for column mismatches, which typically mean the canonical schema needs updating.
## What is NOT Migrated
- **FTS5 search indexes** -- These are virtual tables that cannot be copied via `SELECT *`. Rebuild the index from the Electrobun app (Ctrl+Shift+F, then rebuild).
- **Layout state** -- The Electrobun UI uses a different layout system. Your layout preferences will reset.
- **SSH key files** -- Only the SSH connection metadata (host, port, username) is migrated. Private key files remain on disk at their original paths.

View file

@ -1,197 +0,0 @@
# Multi-Agent Orchestration
Agor supports running multiple AI agents that communicate with each other, coordinate work through a shared task board, and are managed by a hierarchy of specialized roles. This document covers the inter-agent messaging system (btmsg), the task board (bttask), agent roles and system prompts, and the auto-wake scheduler.
---
## Agent Roles (Tier 1 and Tier 2)
### Tier 1 — Management Agents
Defined in `groups.json` under a group's `agents[]` array. Each management agent gets a full ProjectBox in the UI (converted via `agentToProject()`). They have role-specific capabilities, tabs, and system prompts.
| Role | Tabs | btmsg Permissions | bttask Permissions | Purpose |
|------|------|-------------------|-------------------|---------|
| **Manager** | Model, Tasks | Full (send, receive, create channels) | Full CRUD | Coordinates work, creates/assigns tasks |
| **Architect** | Model, Architecture | Send, receive | Read-only + comments | Designs solutions, creates PlantUML diagrams |
| **Tester** | Model, Selenium, Tests | Send, receive | Read-only + comments | Runs tests, monitors screenshots |
| **Reviewer** | Model, Tasks | Send, receive | Read + status + comments | Reviews code, manages review queue |
### Tier 2 — Project Agents
Regular `ProjectConfig` entries in `groups.json`. Each project gets its own Claude session with optional custom context via `project.systemPrompt`. Standard tabs (Model, Docs, Context, Files, SSH, Memory) but no role-specific tabs.
### System Prompt Generation
Tier 1 agents receive auto-generated system prompts built by `generateAgentPrompt()` in `utils/agent-prompts.ts` with 7 sections: Identity, Environment, Team, btmsg docs, bttask docs, Custom context, Workflow.
Tier 2 agents receive only the custom context section (if `project.systemPrompt` is set).
### BTMSG_AGENT_ID
Tier 1 agents receive the `BTMSG_AGENT_ID` environment variable, injected via `extra_env` in AgentQueryOptions. This flows through 5 layers: TypeScript -> Rust -> NDJSON -> JS runner -> SDK env. The CLI tools (`btmsg`, `bttask`) read this variable to identify which agent is sending messages.
### Periodic Re-injection
AgentSession runs a 1-hour timer that re-sends the system prompt when the agent is idle, countering LLM context degradation over long sessions.
---
## btmsg — Inter-Agent Messaging
btmsg lets agents communicate with each other via a Rust backend (SQLite), a Python CLI tool, and a Svelte frontend (CommsTab).
### Database Schema
The btmsg database (`btmsg.db`, `~/.local/share/agor/btmsg.db`) stores all messaging data:
| Table | Purpose | Key Columns |
|-------|---------|-------------|
| `agents` | Agent registry | id, name, role, project_id, status, created_at |
| `messages` | All messages | id, sender_id, recipient_id, channel_id, content, read, created_at |
| `channels` | Named channels | id, name, created_by, created_at |
| `contacts` | ACL | agent_id, contact_id (bidirectional) |
| `heartbeats` | Liveness | agent_id, last_heartbeat, status |
| `dead_letter_queue` | Failed delivery | message_id, reason, created_at |
| `audit_log` | All operations | id, event_type, agent_id, details, created_at |
### CLI Usage (for agents)
```bash
btmsg send architect "Please review the auth module design"
btmsg read
btmsg channel create #architecture-decisions
btmsg channel post #review-queue "PR #42 ready for review"
btmsg heartbeat
btmsg agents
```
### Dead Letter Queue
Messages sent to non-existent or offline agents are moved to the dead letter queue instead of being silently dropped.
---
## bttask — Task Board
bttask is a kanban-style task board sharing the same SQLite database as btmsg (`btmsg.db`).
### Task Lifecycle
```
Backlog -> In Progress -> Review -> Done / Rejected
```
When a task moves to "Review", the system auto-posts to the `#review-queue` btmsg channel.
### Optimistic Locking
To prevent concurrent updates from corrupting task state, bttask uses a `version` column:
1. Client reads task with current version (e.g., version=3)
2. Client sends update with expected version=3
3. Server's UPDATE includes `WHERE version = 3`
4. If another client updated first (version=4), WHERE matches 0 rows -> conflict error
### Role-Based Permissions
| Role | List | Create | Update Status | Delete | Comments |
|------|------|--------|---------------|--------|----------|
| Manager | Yes | Yes | Yes | Yes | Yes |
| Reviewer | Yes | No | Yes (review decisions) | No | Yes |
| Architect | Yes | No | No | No | Yes |
| Tester | Yes | No | No | No | Yes |
| Project (Tier 2) | Yes | No | No | No | Yes |
### Review Queue Integration
The Reviewer agent gets special treatment in attention scoring: `reviewQueueDepth` adds 10 points per review task (capped at 50). ProjectBox polls `review_queue_count` every 10 seconds for reviewer agents.
---
## Wake Scheduler
The wake scheduler automatically re-activates idle Manager agents when attention-worthy events occur (`wake-scheduler.svelte.ts`).
### Strategies
| Strategy | Behavior | Use Case |
|----------|----------|----------|
| **Persistent** | Resume prompt to existing session | Long-running managers |
| **On-demand** | Fresh session | Burst-work managers |
| **Smart** | On-demand when score exceeds threshold | Avoids waking for minor events |
### Wake Signals
| Signal | Weight | Trigger |
|--------|--------|---------|
| AttentionSpike | 1.0 | Project attention score exceeds threshold |
| ContextPressureCluster | 0.9 | Multiple projects >75% context usage |
| BurnRateAnomaly | 0.8 | Cost rate deviates from baseline |
| TaskQueuePressure | 0.7 | Task backlog grows beyond threshold |
| ReviewBacklog | 0.6 | Review queue has pending items |
| PeriodicFloor | 0.1 | Minimum periodic check |
Pure scoring function in `wake-scorer.ts` (24 tests). Types in `types/wake.ts`.
---
## Health Monitoring & Attention Scoring
The health store (`health.svelte.ts`) tracks per-project health with a 5-second tick timer.
### Activity States
| State | Meaning | Visual |
|-------|---------|--------|
| Inactive | No agent running | Dim dot |
| Running | Agent actively processing | Green pulse |
| Idle | Agent finished, waiting for input | Gray dot |
| Stalled | No output for >N minutes | Orange pulse |
Stall threshold is configurable per-project (default 15 min, range 5-60, step 5).
### Attention Scoring
| Condition | Score |
|-----------|-------|
| Stalled agent | 100 |
| Error state | 90 |
| Context >90% | 80 |
| File conflict | 70 |
| Review queue depth | 10/task, cap 50 |
| Context >75% | 40 |
Pure scoring function in `utils/attention-scorer.ts` (14 tests).
### File Conflict Detection
Two types detected by `conflicts.svelte.ts`:
1. **Agent overlap** — Two agents in the same worktree write the same file
2. **External writes** — File modified externally (detected via inotify, 2s timing heuristic)
---
## Session Anchors
Session anchors preserve important conversation turns through Claude's context compaction process.
### Anchor Types
| Type | Created By | Behavior |
|------|-----------|----------|
| **Auto** | System (first compaction) | Captures first 3 turns, observation-masked |
| **Pinned** | User (pin button) | Marks specific turns as important |
| **Promoted** | User (from pinned) | Re-injectable via system prompt |
### Anchor Budget
| Scale | Token Budget | Use Case |
|-------|-------------|----------|
| Small | 2,000 | Quick sessions |
| Medium | 6,000 | Default |
| Large | 12,000 | Complex debugging |
| Full | 20,000 | Maximum preservation |
Re-injection flow: `anchors.svelte.ts` -> `anchor-serializer.ts` -> `AgentPane.startQuery()` -> `system_prompt` -> sidecar -> SDK.

View file

@ -1,186 +0,0 @@
# btmsg Reference
btmsg is the inter-agent messaging system for Agents Orchestrator. It enables
management agents (Tier 1) and project agents (Tier 2) to communicate via direct
messages and broadcast channels.
## Overview
btmsg uses a shared SQLite database at `~/.local/share/agor/btmsg.db`. Both the
Python CLI tools (used by agents) and the Rust backend (Tauri app) access the
same database concurrently. WAL mode and a 5-second busy timeout handle
contention.
Agents must register before sending or receiving messages. Registration creates
an entry in the `agents` table with the agent's ID, name, role, group, and tier.
## Database schema
### agents
| Column | Type | Description |
|--------|------|-------------|
| `id` | TEXT PK | Unique agent identifier |
| `name` | TEXT | Display name |
| `role` | TEXT | Agent role (manager, architect, tester, reviewer, or project) |
| `group_id` | TEXT | Group this agent belongs to |
| `tier` | INTEGER | 1 = management, 2 = project |
| `model` | TEXT | Model identifier (nullable) |
| `status` | TEXT | Current status (active, sleeping, stopped) |
### messages
| Column | Type | Description |
|--------|------|-------------|
| `id` | TEXT PK | UUID |
| `from_agent` | TEXT FK | Sender agent ID |
| `to_agent` | TEXT FK | Recipient agent ID |
| `content` | TEXT | Message body |
| `read` | INTEGER | 0 = unread, 1 = read |
| `reply_to` | TEXT | Parent message ID (nullable, for threading) |
| `sender_group_id` | TEXT | Sender's group ID (nullable, added by migration) |
| `created_at` | TEXT | ISO timestamp |
### channels
| Column | Type | Description |
|--------|------|-------------|
| `id` | TEXT PK | Channel identifier |
| `name` | TEXT | Channel name (e.g. `#review-queue`) |
| `group_id` | TEXT | Group this channel belongs to |
| `created_by` | TEXT FK | Agent that created the channel |
| `created_at` | TEXT | ISO timestamp |
### contacts
ACL table controlling which agents can see and message each other.
### heartbeats
Liveness tracking. Agents send periodic heartbeats; the health store uses these
to detect stalled agents.
### dead_letter_queue
Messages that could not be delivered (recipient not found, agent stopped).
Surfaced in the agent health monitoring UI.
### audit_log
Records agent actions for compliance and debugging. Entries include agent ID,
action type, target, and timestamp.
### seen_messages
Per-message read tracking with session-level granularity.
| Column | Type | Description |
|--------|------|-------------|
| `session_id` | TEXT | Reading session identifier |
| `message_id` | TEXT FK | Message that was seen |
| `seen_at` | INTEGER | Unix timestamp |
Primary key: `(session_id, message_id)`. This enables per-message acknowledgment
rather than bulk "mark all read" operations.
## CLI usage
The `btmsg` CLI is a Python script installed at `~/.local/bin/btmsg`. Agents
invoke it via shell commands in their sessions. The `BTMSG_AGENT_ID` environment
variable identifies the calling agent.
### Register an agent
```bash
btmsg register --id mgr-1 --name "Manager" --role manager \
--group my-team --tier 1
```
### Send a direct message
```bash
btmsg send --to architect-1 --content "Review the auth module architecture"
```
### Send to a channel
```bash
btmsg channel-post --channel review-queue \
--content "Task T-42 moved to review"
```
### Read unread messages
```bash
btmsg read
```
Returns all unread messages for the agent identified by `BTMSG_AGENT_ID`.
### List channels
```bash
btmsg channels
```
### Send a heartbeat
```bash
btmsg heartbeat
```
Updates the agent's liveness timestamp. The health store marks agents as stalled
if no heartbeat arrives within the configured `stallThresholdMin`.
### Mark messages as seen
```bash
btmsg ack --message-id <uuid>
```
Records an entry in `seen_messages` for per-message tracking.
## Role permissions
Agent capabilities depend on their role:
| Capability | Manager | Architect | Tester | Reviewer | Project |
|------------|---------|-----------|--------|----------|---------|
| Send DMs | Yes | Yes | Yes | Yes | Yes |
| Read own DMs | Yes | Yes | Yes | Yes | Yes |
| Post to channels | Yes | Yes | Yes | Yes | Yes |
| Create channels | Yes | No | No | No | No |
| Delete messages | Yes | No | No | No | No |
| List all agents | Yes | Yes | Yes | Yes | Read-only |
| Update agent status | Yes | No | No | No | No |
The Manager role has full CRUD on all btmsg resources. Other roles can read
messages addressed to them and post to channels.
## Rust backend integration
The Tauri backend reads btmsg data via `src-tauri/src/btmsg.rs`. Key functions:
- `get_agents(group_id)` -- List agents with unread counts
- `unread_count(agent_id)` -- Unread message count
- `unread_messages(agent_id)` -- Full unread message list with sender metadata
- `get_feed(group_id)` -- Recent messages across the group
- `get_channels(group_id)` -- List channels with member counts
- `get_channel_messages(channel_id)` -- Messages in a channel
All queries use named column access (`row.get("column_name")`) and return
`#[serde(rename_all = "camelCase")]` structs for direct JSON serialization to the
frontend.
## Frontend
The frontend accesses btmsg through `src/lib/adapters/btmsg-bridge.ts`, which
wraps Tauri IPC commands. The CommsTab component in ProjectBox displays messages
and channels for management agents.
## Review queue integration
When a task transitions to `review` status via bttask, the system auto-posts a
notification to the `#review-queue` channel. The `ensure_review_channels`
function creates `#review-queue` and `#review-log` idempotently. See
[bttask reference](ref-bttask.md) for details.

View file

@ -1,161 +0,0 @@
# bttask Reference
bttask is the kanban task board for Agents Orchestrator. It provides structured
task tracking for multi-agent workflows, with optimistic locking to prevent
concurrent update conflicts.
## Overview
Tasks are stored in the shared `btmsg.db` SQLite database at
`~/.local/share/agor/btmsg.db`. The `bttask` CLI (Python, installed at
`~/.local/bin/bttask`) gives agents direct access. The Rust backend
(`src-tauri/src/bttask.rs`) provides read/write access for the Tauri frontend.
## Task schema
| Column | Type | Description |
|--------|------|-------------|
| `id` | TEXT PK | UUID |
| `title` | TEXT | Short task title |
| `description` | TEXT | Detailed description |
| `status` | TEXT | Current column (see below) |
| `priority` | TEXT | `low`, `medium`, or `high` |
| `assigned_to` | TEXT | Agent ID (nullable) |
| `created_by` | TEXT | Agent ID that created the task |
| `group_id` | TEXT | Group this task belongs to |
| `parent_task_id` | TEXT | Parent task for subtasks (nullable) |
| `sort_order` | INTEGER | Display order within column |
| `version` | INTEGER | Optimistic locking version (default 1) |
| `created_at` | TEXT | ISO timestamp |
| `updated_at` | TEXT | ISO timestamp |
### Task comments
| Column | Type | Description |
|--------|------|-------------|
| `id` | TEXT PK | UUID |
| `task_id` | TEXT FK | Parent task |
| `agent_id` | TEXT | Commenting agent |
| `content` | TEXT | Comment body |
| `created_at` | TEXT | ISO timestamp |
## Kanban columns
Tasks move through 5 statuses:
```
backlog -> todo -> in_progress -> review -> done
```
The frontend (`TaskBoardTab.svelte`) renders these as columns in a kanban board,
polling every 5 seconds for updates.
## Operations
### list_tasks
Returns all tasks for a group, ordered by `sort_order` then `created_at DESC`.
### create_task
Creates a new task with the given title, description, priority, and optional
assignment. Returns the created task with its generated ID.
### update_task_status
Moves a task to a new status. Uses optimistic locking: the caller must provide
the current `version` value. If the version in the database does not match, the
update is rejected with a conflict error. On success, the version is
incremented.
```
update_task_status(task_id, new_status, expected_version)
```
### delete_task
Removes a task by ID.
### add_comment
Adds a comment to a task. Any agent can comment regardless of role.
### task_comments
Returns all comments for a task, ordered by `created_at ASC`.
### review_queue_count
Returns the number of tasks currently in `review` status for a group. Used by
the health store to calculate reviewer attention scoring (10 points per review
task, capped at 50).
## CLI usage
Agents interact with bttask through shell commands. The `BTMSG_AGENT_ID`
environment variable identifies the calling agent.
```bash
# List all tasks
bttask list
# Create a task
bttask create --title "Fix auth bug" --priority high \
--description "Token refresh fails after 24h"
# Move a task to in_progress
bttask status --id T-42 --status in_progress --version 1
# Add a comment
bttask comment --id T-42 --content "Root cause identified: expired refresh token"
# Delete a task
bttask delete --id T-42
```
## Role permissions
| Capability | Manager | Reviewer | Others |
|------------|---------|----------|--------|
| List tasks | Yes | Yes | Yes |
| Create tasks | Yes | No | No |
| Update status | Yes | Yes | Read-only |
| Delete tasks | Yes | No | No |
| Add comments | Yes | Yes | Yes |
The Manager role has full CRUD. The Reviewer can change task status (to move
items through the review pipeline) and add comments. Other roles have read-only
access to tasks but can add comments.
## Review queue integration
When a task moves to `review` status, the system auto-posts a notification to
the `#review-queue` btmsg channel. This triggers the Reviewer agent's attention
scoring. The `ensure_review_channels` function creates `#review-queue` and
`#review-log` channels idempotently on first use.
The `review_queue_count` is polled every 10 seconds by ProjectBox for reviewer
agents and fed into the health store's `setReviewQueueDepth()` for attention
scoring. Review tasks contribute to the reviewer's attention score:
- 10 points per task in review status
- Capped at 50 points total
- Priority rank: between `file_conflict` (70) and `context_high` (40)
## Optimistic locking
Every task has a `version` field starting at 1. When an agent updates a task's
status, it must pass the expected version. If another agent modified the task
since it was read, the version will not match and the update fails with a
conflict error. The calling agent should re-read the task and retry.
This prevents race conditions when multiple agents attempt to move the same task
simultaneously.
## Frontend
The kanban board is rendered by `src/lib/components/Workspace/TaskBoardTab.svelte`,
available in the Tasks tab for Manager-role agents. It polls `list_tasks` every
5 seconds and supports drag-style status transitions between columns.
IPC adapter: `src/lib/adapters/bttask-bridge.ts`.

View file

@ -1,131 +0,0 @@
# ADR-001: Dual-Stack Frontend-Backend Binding Strategy
**Status:** Accepted
**Date:** 2026-03-22
**Deciders:** Human + Claude (tribunal consensus: Claude 72%, Codex 78%)
## Context
Agent Orchestrator runs on two backends: Tauri 2.x (Rust, production) and Electrobun (Bun/TypeScript, experimental). Both share the same Svelte 5 frontend but communicate through different IPC mechanisms:
- **Tauri:** `@tauri-apps/api/core` invoke() + listen()
- **Electrobun:** RPC request/response + message listeners
The initial implementation used 23 bridge adapter files that directly import `@tauri-apps/api`. This created three problems:
1. **Duplicate code** — Each backend reimplements the same IPC surface. Electrobun adapters must manually replicate every bridge function.
2. **Drift risk** — When a Tauri bridge gains a new function, the Electrobun equivalent silently falls behind. No compile-time enforcement.
3. **58 Codex findings** — Three independent Codex audits identified duplicate SQL schemas, inconsistent naming (snake_case vs camelCase across the wire), missing error handling in Electrobun paths, and untested adapter code.
The project needed a binding strategy that:
- Eliminates code duplication for shared frontend logic
- Provides compile-time guarantees that both backends implement the same surface
- Does not require a premature monolithic rewrite
- Supports incremental migration of existing bridge adapters
## Decision
Adopt the **S-1 + S-3 hybrid strategy**: a shared `BackendAdapter` interface (S-1) combined with scoped, audit-gated extraction (S-3).
### Core Mechanism
A single `BackendAdapter` TypeScript interface defines every operation the frontend can request from the backend. Two concrete implementations (`TauriAdapter`, `ElectrobunAdapter`) are compile-time selected via path aliases. A singleton `getBackend()` function provides the active adapter.
Frontend stores and components call `getBackend().someMethod()` instead of importing platform-specific bridge files. The `BackendCapabilities` flags allow UI to gracefully degrade features unavailable on a given backend.
### Package Structure
- **`@agor/types`** — Shared type definitions (agent, project, btmsg, bttask, health, settings, protocol, backend, ids). No runtime code.
- **`src/lib/backend/backend.ts`** — Singleton accessor (`getBackend()`, `setBackend()`, `setBackendForTesting()`).
- **`src/lib/backend/TauriAdapter.ts`** — Tauri 2.x implementation.
- **`src/lib/backend/ElectrobunAdapter.ts`** — Electrobun implementation.
### Canonical SQL
A single `schema/canonical.sql` (29 tables) is the source of truth for both backends. A `tools/validate-schema.ts` extracts DDL metadata for comparison. A `tools/migrate-db.ts` handles one-way Tauri-to-Electrobun data migration with version fencing.
## Phase 1 Scope (DONE)
Implemented 2026-03-22:
- `@agor/types` package: 10 type files covering all cross-backend contracts
- `BackendAdapter` interface with 15 methods + 5 event subscriptions
- `BackendCapabilities` with 8 boolean flags
- `TauriAdapter` implementing all methods via `invoke()`/`listen()`
- `ElectrobunAdapter` implementing all methods via RPC
- `backend.ts` singleton with test helpers
- Canonical SQL DDL (29 tables, CHECK constraints, FK CASCADE, 13 indexes)
- Schema validator and migration tool
- `pnpm-workspace.yaml` workspace setup
- `docs/SWITCHING.md` migration guide
## Phase 2 Scope (In Progress)
Store audit and selective migration to `@agor/stores`:
- Reactive dependency graph analysis across 13 stores and 23 bridges
- Categorization: CLEAN (4 stores) / BRIDGE-DEPENDENT (9 stores) / PLATFORM-SPECIFIC (0 stores)
- Settings domain migration: first cluster, replacing `settings-bridge.ts` with `getBackend()`
- 8 migration clusters identified, ordered by dependency depth
### Phase 2 Trigger Checklist (Frozen)
All items verified as implementable via BackendAdapter:
- [x] PTY session create/attach/detach/destroy
- [x] Agent dispatch with status tracking
- [x] Settings read/write persistence
- [x] Search index query
- [x] Provider credential management
- [x] Notification dispatch
- [x] Workspace CRUD
## Phase 3 Direction (Documented, Not Committed)
**agor-daemon:** A standalone background process that both Tauri and Electrobun connect to, eliminating per-backend reimplementation entirely. This is documented as the long-term direction but explicitly NOT committed to. The hybrid adapter approach is sufficient for the current two-backend scope.
**Turborepo threshold:** Adopt Turborepo when package count reaches 3. Currently at 2 (`@agor/types`, main app). The third package would likely be `@agor/stores` (migrated pure-state stores).
## Consequences
### Enables
- Compile-time guarantee that both backends implement the same API surface
- Incremental migration — bridge files can be replaced one at a time
- Capability-gated UI degradation (features disabled on backends that lack support)
- Testing with mock backends (`setBackendForTesting()`)
- Future backend additions (GPUI, Dioxus) only need a new adapter class
- Canonical SQL prevents schema drift between backends
### Costs
- Two adapter implementations must be maintained in parallel
- BackendAdapter interface grows as new IPC commands are added
- Bridge files coexist with BackendAdapter during transition (dual access paths)
- One-way migration (Tauri to Electrobun) requires version fencing to prevent data loss
## Risks
Identified by tribunal (Claude + Codex consensus) and three Codex audits:
### Semantic Drift
**Risk:** Shared types enforce shape consistency but not behavioral consistency. Two adapters may handle edge cases differently (null vs undefined, error message format, timing).
**Mitigation:** Integration tests per adapter. Type-narrowing at adapter boundary.
### Svelte Rune Coupling
**Risk:** Stores using `$state`/`$derived` are tightly coupled to Svelte 5's rune execution model. Moving to `@agor/stores` package requires the consumer to also use Svelte 5.
**Mitigation:** Only move stores that are pure state (no `$derived` chains that reference DOM). Keep reactive-heavy stores in app layer.
### SQLite Pragma Differences
**Risk:** Tauri uses rusqlite (bundled SQLite, WAL mode, 5s busy_timeout). Electrobun uses better-sqlite3 (system SQLite, different default pragmas).
**Mitigation:** Canonical SQL includes pragma requirements. Migration tool validates pragma state before copying data.
### Build Toolchain Cache Invalidation
**Risk:** pnpm workspace changes can invalidate build caches unpredictably. `@agor/types` changes rebuild all consumers.
**Mitigation:** Types package is stable (changes are additive). Turborepo deferred until package count warrants it.
### Version Fencing
**Risk:** One-way migration (Tauri to Electrobun) means Electrobun DB is a snapshot. User switching back to Tauri loses Electrobun-only changes.
**Mitigation:** `tools/migrate-db.ts` writes a version fence marker. Tauri startup checks for fence and warns if Electrobun has newer data.
### Plugin Bridge Orphaning
**Risk:** Not all bridges map cleanly to BackendAdapter (btmsg, bttask, audit, plugins, anchors, remote). These "orphan bridges" may never migrate.
**Mitigation:** Documented as acceptable. BackendAdapter covers the critical path (settings, groups, agents, PTY, files). Domain-specific bridges can remain as adapters that internally use BackendAdapter or direct IPC.

View file

@ -1,251 +0,0 @@
# Data Model & Layout System
This document covers agor's data model (SQLite schemas, project group config), layout system, xterm budget management, and keyboard shortcuts.
---
## SQLite Databases
The backend manages two SQLite databases, both in WAL mode with 5-second busy timeout for concurrent access:
| Database | Location | Purpose |
|----------|----------|---------|
| `sessions.db` | `~/.local/share/agor/` | Sessions, layout, settings, agent state, metrics, anchors |
| `btmsg.db` | `~/.local/share/agor/` | Inter-agent messages, tasks, agents registry, audit log |
WAL checkpoints run every 5 minutes via a background tokio task to prevent unbounded WAL growth.
All queries use **named column access** (`row.get("column_name")`) — never positional indices. Rust structs use `#[serde(rename_all = "camelCase")]` so TypeScript interfaces receive camelCase field names on the wire.
---
## Project Group Config (`~/.config/agor/groups.json`)
Human-editable JSON file defining workspaces. Each group contains up to 5 projects. Loaded at startup by `groups.rs`, not hot-reloaded.
```jsonc
{
"version": 1,
"groups": [
{
"id": "work-ai",
"name": "AI Projects",
"projects": [
{
"id": "agor",
"name": "Agents Orchestrator",
"identifier": "agor",
"description": "Terminal emulator with Claude integration",
"icon": "\uf120",
"cwd": "/home/user/code/Agents Orchestrator",
"profile": "default",
"enabled": true
}
]
}
],
"activeGroupId": "work-ai"
}
```
### TypeScript Types (`src/lib/types/groups.ts`)
```typescript
export interface ProjectConfig {
id: string;
name: string;
identifier: string;
description: string;
icon: string;
cwd: string;
profile: string;
enabled: boolean;
}
export interface GroupConfig {
id: string;
name: string;
projects: ProjectConfig[]; // max 5
}
export interface GroupsFile {
version: number;
groups: GroupConfig[];
activeGroupId: string;
}
```
---
## SQLite Schema (v3 Additions)
Beyond the core `sessions` and `settings` tables, v3 added project-scoped agent persistence:
```sql
ALTER TABLE sessions ADD COLUMN project_id TEXT DEFAULT '';
CREATE TABLE IF NOT EXISTS agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
project_id TEXT NOT NULL,
sdk_session_id TEXT,
message_type TEXT NOT NULL,
content TEXT NOT NULL,
parent_id TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS project_agent_state (
project_id TEXT PRIMARY KEY,
last_session_id TEXT NOT NULL,
sdk_session_id TEXT,
status TEXT NOT NULL,
cost_usd REAL DEFAULT 0,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
last_prompt TEXT,
updated_at INTEGER NOT NULL
);
```
---
## Layout System
### Project Grid (Flexbox + scroll-snap)
Projects are arranged horizontally in a flex container with CSS scroll-snap for clean project-to-project scrolling:
```css
.project-grid {
display: flex;
gap: 4px;
height: 100%;
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.project-box {
flex: 0 0 calc((100% - (N-1) * 4px) / N);
scroll-snap-align: start;
min-width: 480px;
}
```
N is computed from viewport width: `Math.min(projects.length, Math.max(1, Math.floor(containerWidth / 520)))`
### Project Box Internal Layout
Each project box uses a CSS grid with 4 rows:
```
+-- ProjectHeader (auto) --------------------+
+---------------------+---------------------+
| AgentSession | TeamAgentsPanel |
| (flex: 1) | (240px/overlay) |
+---------------------+---------------------+
| [Tab1] [Tab2] [+] TabBar auto |
+--------------------------------------------+
| Terminal content (xterm or scrollback) |
+--------------------------------------------+
```
Team panel: inline at >2560px viewport (240px wide), overlay at <2560px. Collapsed when no subagents running.
### Responsive Breakpoints
| Viewport Width | Visible Projects | Team Panel Mode |
|---------------|-----------------|-----------------|
| 5120px+ | 5 | inline 240px |
| 3840px | 4 | inline 200px |
| 2560px | 3 | overlay |
| 1920px | 3 | overlay |
| <1600px | 1 + project tabs | overlay |
---
## xterm.js Budget: 4 Active Instances
WebKit2GTK OOMs at ~5 simultaneous xterm.js instances. The budget system manages this:
| State | xterm.js Instance? | Memory |
|-------|--------------------|--------|
| Active-Focused | Yes | ~20MB |
| Active-Background | Yes (if budget allows) | ~20MB |
| Suspended | No (HTML pre scrollback) | ~200KB |
| Uninitialized | No (placeholder) | 0 |
On focus: serialize least-recent xterm scrollback, destroy it, create new for focused tab, reconnect PTY. Suspend/resume cycle < 50ms.
### Project Accent Colors
Each project slot gets a distinct Catppuccin accent color for visual distinction:
| Slot | Color | CSS Variable |
|------|-------|-------------|
| 1 | Blue | `var(--ctp-blue)` |
| 2 | Green | `var(--ctp-green)` |
| 3 | Mauve | `var(--ctp-mauve)` |
| 4 | Peach | `var(--ctp-peach)` |
| 5 | Pink | `var(--ctp-pink)` |
Applied to border tint and header accent via `var(--accent)` CSS custom property set per ProjectBox.
---
## Adapters (IPC Bridge Layer)
Adapters wrap Tauri `invoke()` calls and `listen()` event subscriptions. They isolate the frontend from IPC details and provide typed TypeScript interfaces.
| Adapter | Backend Module | Purpose |
|---------|---------------|---------|
| `agent-bridge.ts` | sidecar + commands/agent | Agent query/stop/restart |
| `pty-bridge.ts` | pty + commands/pty | Terminal spawn/write/resize |
| `claude-messages.ts` | (frontend-only) | Parse Claude SDK NDJSON -> AgentMessage |
| `codex-messages.ts` | (frontend-only) | Parse Codex ThreadEvents -> AgentMessage |
| `ollama-messages.ts` | (frontend-only) | Parse Ollama chunks -> AgentMessage |
| `message-adapters.ts` | (frontend-only) | Provider registry for message parsers |
| `provider-bridge.ts` | commands/claude | Generic provider bridge (profiles, skills) |
| `btmsg-bridge.ts` | btmsg | Inter-agent messaging |
| `bttask-bridge.ts` | bttask | Task board operations |
| `groups-bridge.ts` | groups | Group config load/save |
| `session-bridge.ts` | session | Session/layout persistence |
| `settings-bridge.ts` | session/settings | Key-value settings |
| `files-bridge.ts` | commands/files | File browser operations |
| `search-bridge.ts` | search | FTS5 search |
| `secrets-bridge.ts` | secrets | System keyring |
| `anchors-bridge.ts` | session/anchors | Session anchor CRUD |
| `remote-bridge.ts` | remote | Remote machine management |
| `ssh-bridge.ts` | session/ssh | SSH session CRUD |
| `ctx-bridge.ts` | ctx | Context database queries |
| `memora-bridge.ts` | memora | Memora database queries |
| `fs-watcher-bridge.ts` | fs_watcher | Filesystem change events |
| `audit-bridge.ts` | btmsg (audit_log) | Audit log queries |
| `telemetry-bridge.ts` | telemetry | Frontend -> Rust tracing |
| `notifications-bridge.ts` | notifications | Desktop notification trigger |
| `plugins-bridge.ts` | plugins | Plugin discovery |
---
## Keyboard Shortcuts
Three-layer shortcut system prevents conflicts between terminal input, workspace navigation, and app-level commands:
| Shortcut | Action | Layer |
|----------|--------|-------|
| Ctrl+K | Command palette | App |
| Ctrl+G | Switch group (palette filtered) | App |
| Ctrl+1..5 | Focus project by index | App |
| Alt+1..4 | Switch sidebar tab + open drawer | App |
| Ctrl+B | Toggle sidebar open/closed | App |
| Ctrl+, | Toggle settings panel | App |
| Escape | Close sidebar drawer | App |
| Ctrl+Shift+F | FTS5 search overlay | App |
| Ctrl+N | New terminal in focused project | Workspace |
| Ctrl+Shift+N | New agent query | Workspace |
| Ctrl+Tab | Next terminal tab | Project |
| Ctrl+W | Close terminal tab | Project |
| Ctrl+Shift+C/V | Copy/paste in terminal | Terminal |
Terminal layer captures raw keys only when focused. App layer has highest priority.

View file

@ -1,51 +0,0 @@
# Architecture Decisions Log
This document records significant architecture decisions made during the development of Agent Orchestrator (agor). Each entry captures the decision, its rationale, and the date it was made. Decisions are listed chronologically within each category.
---
## Data & Configuration
| Decision | Rationale | Date |
|----------|-----------|------|
| JSON for groups config, SQLite for session state | JSON is human-editable, shareable, version-controllable. SQLite for ephemeral runtime state. Load at startup only — no hot-reload, no split-brain risk. | 2026-03-07 |
| btmsg/bttask shared SQLite DB | Both CLI tools share `~/.local/share/agor/btmsg.db`. Single DB simplifies deployment — agents already have the path. Read-only for non-Manager roles via CLI permissions. | 2026-03-11 |
## Layout & UI
| Decision | Rationale | Date |
|----------|-----------|------|
| Adaptive project count from viewport width | `Math.min(projects.length, Math.max(1, Math.floor(containerWidth / 520)))` — 5 at 5120px, 3 at 1920px, scroll-snap for overflow. min-width 480px. Better than forcing 5 at all sizes. | 2026-03-07 |
| Flexbox + scroll-snap over CSS Grid | Allows horizontal scroll on narrow screens. Scroll-snap gives clean project-to-project scrolling. | 2026-03-07 |
| Team panel: inline >2560px, overlay <2560px | Adapts to available space. Collapsed when no subagents running. Saves ~240px on smaller screens. | 2026-03-07 |
| VSCode-style left sidebar (replaces top tab bar) | Vertical icon rail (2.75rem) + expandable drawer (max 50%) + always-visible workspace. Settings is a regular tab, not a special drawer. ProjectGrid always visible. Ctrl+B toggles. | 2026-03-08 |
| CSS relative units (rule 18) | rem/em for all layout CSS. Pixels only for icon sizes, borders, box shadows. Exception: `--ui-font-size`/`--term-font-size` store px for xterm.js API. | 2026-03-08 |
| Project accent colors from Catppuccin palette | Visual distinction: blue/green/mauve/peach/pink per slot 1-5. Applied to border + header tint via `var(--accent)`. | 2026-03-07 |
## Agent Architecture
| Decision | Rationale | Date |
|----------|-----------|------|
| Single shared sidecar (v3.0) | Existing multiplexed protocol handles concurrent sessions. Per-project pool deferred to v3.1 if crash isolation needed. Saves ~200MB RAM. | 2026-03-07 |
| xterm budget: 4 active, unlimited suspended | WebKit2GTK OOM at ~5 instances. Serialize scrollback to text buffer, destroy xterm, recreate on focus. PTY stays alive. Suspend/resume < 50ms. | 2026-03-07 |
| AgentPane splits into AgentSession + TeamAgentsPanel | Team agents shown inline in right panel, not as separate panes. Saves xterm/pane slots. | 2026-03-07 |
| Tier 1 agents as ProjectBoxes via `agentToProject()` | Agents render as full ProjectBoxes (not separate UI). `getAllWorkItems()` merges agents + projects. Unified rendering = less code, same capabilities. | 2026-03-11 |
| `extra_env` 5-layer passthrough for BTMSG_AGENT_ID | TS -> Rust AgentQueryOptions -> NDJSON -> JS runner -> SDK env. Minimal surface — only agent projects get env injection. | 2026-03-11 |
| Periodic system prompt re-injection (1 hour) | LLM context degrades over long sessions. 1-hour timer re-sends role/tools reminder when agent is idle. `autoPrompt`/`onautopromptconsumed` callback pattern. | 2026-03-11 |
| Role-specific tabs via conditional rendering | Manager=Tasks, Architect=Arch, Tester=Selenium+Tests, Reviewer=Tasks. PERSISTED-LAZY pattern (mount on first activation). Conditional on `isAgent && agentRole`. | 2026-03-11 |
| PlantUML via plantuml.com server (~h hex encoding) | Avoids Java dependency. Hex encoding simpler than deflate+base64. Works with free tier. Trade-off: requires internet. | 2026-03-11 |
## Themes & Typography
| Decision | Rationale | Date |
|----------|-----------|------|
| All 17 themes map to `--ctp-*` CSS vars | 4 Catppuccin + 7 Editor + 6 Deep Dark themes. All map to same 26 CSS custom properties — zero component changes when adding themes. Pure data operation. | 2026-03-07 |
| Typography via CSS custom properties | `--ui-font-family`/`--ui-font-size` + `--term-font-family`/`--term-font-size` in `:root`. Restored by `initTheme()` on startup. Persisted as SQLite settings. | 2026-03-07 |
## System Design
| Decision | Rationale | Date |
|----------|-----------|------|
| Keyboard shortcut layers: App > Workspace > Terminal | Prevents conflicts. Terminal captures raw keys only when focused. App layer uses Ctrl+K/G/B. | 2026-03-07 |
| Unmount/remount on group switch | Serialize xterm scrollbacks, destroy, remount new group. <100ms perceived. Frees ~80MB per switch. | 2026-03-07 |
| Remote machines deferred to v3.1 | Elevate to project level (`project.remote_machine_id`) but don't implement in MVP. Focus on local orchestration first. | 2026-03-07 |

View file

@ -1,160 +0,0 @@
# Research Findings
Research conducted during development — technology evaluations, architecture reviews, performance measurements, and design analysis. Each finding informed implementation decisions recorded in [decisions.md](decisions.md).
---
## 1. Claude Agent SDK
**Source:** https://platform.claude.com/docs/en/agent-sdk/overview
The Claude Agent SDK provides structured streaming, subagent detection, hooks, and telemetry — everything needed for a rich agent UI without terminal emulation.
**Key Insight:** The SDK gives structured data — we render it as rich UI (markdown, diff views, file cards, agent trees) instead of raw terminal text. Terminal emulation (xterm.js) is only needed for SSH, local shell, and legacy CLI sessions.
---
## 2. Tauri + xterm.js Integration
Integration pattern: `Frontend (xterm.js) <-> Tauri IPC <-> Rust PTY (portable-pty) <-> Shell/SSH/Claude`
Existing projects (tauri-terminal, Terminon, tauri-plugin-pty) validated the approach.
---
## 3. Terminal Performance Benchmarks
| Terminal | Latency | Notes |
|----------|---------|-------|
| xterm (native) | ~10ms | Gold standard |
| Alacritty | ~12ms | GPU-rendered Rust |
| VTE (GNOME Terminal) | ~50ms | GTK3/4 |
| Hyper (Electron+xterm.js) | ~40ms | Web-based worst case |
xterm.js in Tauri: ~20-30ms latency, ~20MB per instance. For AI output, perfectly fine. VTE in v1 GTK3 was actually slower at ~50ms.
---
## 4. Frontend Framework Choice
**Why Svelte 5:** Fine-grained reactivity (`$state`/`$derived` runes), no VDOM (critical for 4-8 panes streaming simultaneously), ~5KB runtime vs React's ~40KB. Larger ecosystem than Solid.js.
---
## 5. Adversarial Architecture Review (v3)
Three specialized agents reviewed the v3 Mission Control architecture before implementation. Caught 12 issues (4 critical) that would have required expensive rework if discovered later.
### Critical Issues Found
| # | Issue | Resolution |
|---|-------|------------|
| 1 | xterm.js 4-instance ceiling (WebKit2GTK OOM) | Budget system with suspend/resume |
| 2 | Single sidecar = SPOF | Supervisor with crash recovery, per-project pool deferred |
| 3 | Layout store has no workspace concept | Full rewrite to workspace.svelte.ts |
| 4 | 384px per project on 1920px (too narrow) | Adaptive count from viewport width |
8 more issues (Major/Minor) resolved before implementation.
---
## 6. Provider Adapter Coupling Analysis (v3)
Before implementing multi-provider support, mapped every Claude-specific dependency. 13+ files classified into 4 severity levels.
### Key Insights
1. **Sidecar is the natural abstraction boundary.** Each provider needs its own runner.
2. **Message format is the main divergence point.** Per-provider adapters normalize to `AgentMessage`.
3. **Capability flags eliminate provider switches.** UI checks `capabilities.hasProfiles` instead of `provider === 'claude'`.
4. **Env var stripping is provider-specific.**
---
## 7. Codebase Reuse Analysis: v2 to v3
### Survived (with modifications)
| Component | Modifications |
|-----------|---------------|
| TerminalPane.svelte | Added suspend/resume lifecycle |
| MarkdownPane.svelte | Unchanged |
| AgentTree.svelte | Reused inside AgentSession |
| agents.svelte.ts | Added projectId field |
| theme.svelte.ts | Unchanged |
| notifications.svelte.ts | Unchanged |
| All adapters | Minor updates for provider routing |
| All Rust backend | Added new modules (btmsg, bttask, search, secrets, plugins) |
### Replaced
| v2 Component | v3 Replacement | Reason |
|-------------|---------------|--------|
| layout.svelte.ts | workspace.svelte.ts | Pane-based -> project-group model |
| TilingGrid.svelte | ProjectGrid.svelte | Free-form grid -> fixed project boxes |
| PaneContainer.svelte | ProjectBox.svelte | Generic pane -> 11-tab container |
| SettingsDialog.svelte | SettingsTab.svelte | Modal -> sidebar drawer |
| AgentPane.svelte | AgentSession + TeamAgentsPanel | Monolithic -> split for teams |
| App.svelte | Full rewrite | VSCode-style sidebar layout |
---
## 8. Session Anchor Design (v3)
### Problem
When Claude's context window fills (~80% of model limit), the SDK automatically compacts older turns. Important early decisions and debugging breakthroughs can be permanently lost.
### Design Decisions
1. **Auto-anchor on first compaction** — Captures first 3 turns automatically.
2. **Observation masking** — Tool outputs compacted, reasoning preserved in full.
3. **Budget system** — Fixed scales (2K/6K/12K/20K tokens) instead of percentage-based.
4. **Re-injection via system prompt** — Simplest SDK integration.
---
## 9. Multi-Agent Orchestration Design (v3)
| Approach | Decision |
|----------|----------|
| Claude Agent Teams (native) | Supported but not primary (experimental, resume broken) |
| Message bus (Redis/NATS) | Rejected (runtime dependency) |
| Shared SQLite + CLI tools | **Selected** (zero deps, agents use shell) |
| MCP server for agent comm | Rejected (overhead, complexity) |
**Why SQLite + CLI:** Agents have full shell access. Python CLI tools reading/writing SQLite is lowest friction. Zero configuration, no runtime services, WAL handles concurrency.
---
## 10. Theme System Evolution
All 17 themes (4 Catppuccin + 7 Editor + 6 Deep Dark) map to the same 26 `--ctp-*` CSS custom properties. No component ever needs to know which theme is active. Adding new themes is a pure data operation.
---
## 11. Performance Measurements (v3)
### xterm.js Canvas Performance (WebKit2GTK, no WebGL)
- Latency: ~20-30ms per keystroke
- Memory: ~20MB per active instance
- OOM threshold: ~5 simultaneous instances
- Mitigation: 4-instance budget with suspend/resume
### Tauri IPC Latency
- Linux: ~5ms for typical payloads
- Terminal keystroke echo: 10-15ms total
- Agent message forwarding: negligible
### SQLite WAL Concurrent Access
WAL mode with 5s busy_timeout handles concurrent access reliably. 5-minute checkpoint prevents WAL growth.
### Workspace Switch Latency
- Serialize 4 xterm scrollbacks: ~30ms
- Destroy + unmount: ~15ms
- Mount new group + create xterm: ~55ms
- **Total perceived: ~100ms**

View file

@ -1,377 +0,0 @@
# GPUI Framework — Detailed Findings
**Date:** 2026-03-19 (updated 2026-03-19 22:30)
**Source:** Hands-on prototyping (ui-gpui/, 2,490 lines) + source code analysis of GPUI 0.2.2 + Zed editor + 4 parallel research agents
## BREAKTHROUGH: 4.5% → 0.83% CPU
**The #1 optimization rule in GPUI:** Never make animated state an Entity CHILD of the view that renders it. Make it a PEER Entity that the view READS.
```
WRONG (4.5% CPU):
Workspace → ProjectGrid → ProjectBox → PulsingDot (child Entity)
cx.notify() on PulsingDot → mark_view_dirty walks ancestors
→ Workspace dirty → refreshing=true → ALL .cached() children miss
RIGHT (0.83% CPU):
Workspace → ProjectGrid → ProjectBox (reads BlinkState)
BlinkState (peer Entity, not child)
cx.notify() on BlinkState → only dirties views that .read(cx) it
→ ProjectBox dirty, Workspace/ProjectGrid NOT dirty → siblings cached
```
Key mechanisms:
1. **`.cached(StyleRefinement::default())`** on Entity children → GPU scene replay (zero Rust code)
2. **Shared state Entity** instead of child Entity → isolates dirty propagation
3. **`mark_view_dirty()` walks ancestors** — this is GPUI's fundamental constraint
4. **`refreshing=true` on cache miss** cascades down → children can't cache if parent is dirty
## DEEP DIVE: Why 3% is the floor for div-based views (2026-03-19 late)
### The full dirty propagation chain (confirmed by source analysis)
```
cx.notify(entity_id)
→ App::notify → WindowInvalidator::invalidate_view(entity_id)
→ dirty_views.insert(entity_id)
→ draw() → invalidate_entities() → mark_view_dirty(entity_id)
→ dispatch_tree.view_path_reversed(entity_id) → walks parent chain
→ inserts entity + ALL render-time ancestors into dirty_views
→ AnyView::prepaint checks: dirty_views.contains(MY entity_id)
→ if true: cache MISS, re-render
→ if false: cache HIT, replay GPU commands
```
### Why Zed achieves <1% with the SAME mechanism
Zed's BlinkManager → observer → cx.notify(Editor) → Editor + Pane + Workspace ALL in dirty_views.
ALL re-render. But:
- Workspace::render() = ~5 div builders = <0.1ms
- Pane::render() = ~10 div builders = <0.2ms
- Editor::render() = returns EditorElement struct = <0.01ms
- EditorElement::prepaint/paint = real work, bounded to visible rows
Total: <1ms per blink frame. Our ProjectBox::render() = ~9 divs + 3 tab buttons = ~15ms.
The 15ms includes Taffy layout + element prepaint + paint pipeline overhead per div.
### SharedBlink pattern (Arc<AtomicBool>)
Replaced Entity<BlinkState> with `Arc<AtomicBool>`:
- Background timer toggles atomic bool every 500ms
- Timer calls cx.notify() on ProjectBox directly (no intermediate entity)
- ProjectBox::render() reads bool atomically — no Entity subscription overhead
- Same 3% CPU — confirms cost is in render() itself, not entity machinery
### Hierarchy flattening (4 levels → 2 levels)
Removed ProjectGrid entity level. ProjectBoxes render directly from Workspace.
Dispatch tree: Workspace → ProjectBox (2 levels, same as Zed's Workspace → Pane).
CPU unchanged at 3% — confirms cost is per-frame render(), not ancestor walk count.
### Path to <1%: Custom Element for ProjectBox header
Convert header (accent stripe + dot + name + CWD + tab bar) from div tree to
custom `impl Element` that paints directly via `window.paint_quad()` +
`window.text_system().shape_line().paint()`. This eliminates:
- 9 div() constructor calls
- 9 Taffy layout nodes
- 9 prepaint traversals
- 9 paint traversals
Replaced by ~6 direct GPU primitive insertions (paint_quad × 4 + shape_line × 2).
Expected reduction: 15ms → <1ms per frame.
## Overview
GPUI is the GPU-accelerated UI framework powering the Zed editor. Apache-2.0 licensed (the crate itself; Zed app is GPL-3.0). Pre-1.0 with breaking changes every 2-3 months. 76.7k stars (Zed repo), $32M Sequoia funding.
## Architecture
### Rendering Model
- Hybrid immediate + retained mode
- Target: 120 FPS (Metal on macOS, Vulkan via wgpu on Linux/Windows)
- No CSS — all styling via Rust method chains: `.bg()`, `.text_color()`, `.border()`, `.rounded()`, `.px()`, `.py()`, `.flex()`
- Text rendering via GPU glyph atlas
- Binary size: ~12MB (bare GPUI app)
- RAM baseline: ~73-200MB (depending on complexity)
### Component Model
```rust
struct MyView { /* state */ }
impl Render for MyView {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div().flex().bg(rgba(0x1e1e2eff)).child("Hello")
}
}
```
### Entity System
- `Entity<T>` — strong reference to a view/model (Arc-based)
- `WeakEntity<T>` — weak reference (used in async contexts)
- `Context<T>` — mutable access to entity + app state during render/update
- `cx.new(|cx| T::new())` — create child entity
- `entity.update(cx, |view, cx| { ... })` — mutate entity from parent
- `cx.notify()` — mark entity as dirty for re-render
### State Management
- `Entity<T>` for shared mutable state (like Svelte stores)
- `cx.observe(&entity, callback)` — react to entity changes
- `entity.read(cx)` — read entity state (SUBSCRIBES to changes in render context!)
- `cx.notify()` — trigger re-render of current entity
## Event Loop
### X11 (Linux)
- Uses `calloop` event loop with periodic timer at monitor refresh rate (from xrandr CRTC info, fallback 16.6ms = 60Hz)
- Each tick: check `invalidator.is_dirty()` → if false, skip frame (near-zero cost)
- If dirty: full window draw + present
- When window hidden: timer removed entirely (0% CPU)
- File: `crates/gpui_linux/src/linux/x11/client.rs`
### Wayland
- Entirely compositor-driven: `wl_surface.frame()` callback
- No fixed timer — compositor tells GPUI when to render
- Even more efficient than X11 (no polling)
- File: `crates/gpui_linux/src/linux/wayland/client.rs`
### Window Repaint Flow
```
cx.notify(entity_id)
→ App::notify() → push Effect::Notify
→ Window::invalidate() → set dirty=true
→ calloop tick → is_dirty()=true → Window::draw()
→ Window::draw() → render all dirty views → paint to GPU → present
→ is_dirty()=false → sleep until next tick or next notify
```
## Animation Patterns
### What We Tested (chronological)
| Approach | Result | CPU |
|----------|--------|-----|
| `request_animation_frame()` in render | Continuous vsync loop, full window repaint every frame | **90%** |
| `cx.spawn()` + `tokio::time::sleep()` | spawn didn't fire from `cx.new()` closure | N/A |
| Custom `Element` with `paint_quad()` + `request_animation_frame()` | Works but same vsync loop | **90%** |
| `cx.spawn()` + `background_executor().timer(200ms)` + `cx.notify()` | Works but timer spawns don't fire from `cx.new()` | **10-15%** |
| Zed BlinkManager pattern: `cx.spawn()` from `entity.update()` after registration + `timer(500ms)` + `cx.notify()` | **Works correctly** | **5%** |
| Same + cached SharedStrings + removed diagnostics | Same pattern, cheaper render tree | **4.5%** |
### The Correct Pattern (Zed BlinkManager)
From Zed's `crates/editor/src/blink_manager.rs`:
```rust
fn blink_cursors(&mut self, epoch: usize, cx: &mut Context<Self>) {
if epoch == self.blink_epoch && self.enabled && !self.blinking_paused {
self.visible = !self.visible;
cx.notify(); // marks view dirty, does NOT repaint immediately
let epoch = self.next_blink_epoch();
let interval = self.blink_interval;
cx.spawn(async move |this, cx| {
cx.background_executor().timer(interval).await;
this.update(cx, |this, cx| this.blink_cursors(epoch, cx));
}).detach();
}
}
```
Key elements:
1. **Epoch counter** — each `next_blink_epoch()` increments a counter. Stale timers check `epoch == self.blink_epoch` and bail out. No timer accumulation.
2. **Recursive spawn** — each blink schedules the next one. No continuous loop.
3. **`background_executor().timer()`** — sleeps the async task for the interval. NOT `tokio::time::sleep` (GPUI has its own executor).
4. **`cx.notify()`** — marks ONLY this view dirty. Window draws on next calloop tick.
### Critical Gotcha: `cx.spawn()` Inside `cx.new()`
**`cx.spawn()` called inside a `cx.new(|cx| { ... })` closure does NOT execute.** The entity is not yet registered with the window at that point. The spawn is created but the async task never runs.
**Fix:** Call `start_blinking()` via `entity.update(cx, |dot, cx| dot.start_blinking(cx))` AFTER `cx.new()` returns, from the parent's `init_subviews()`.
### Why 4.5% CPU Instead of ~1% (Zed)
`cx.notify()` on PulsingDot propagates to the window level. GPUI redraws ALL dirty views in the window — including parent views (Workspace, ProjectGrid, ProjectBox). Our prototype rebuilds ~200 div elements per window redraw.
Zed achieves ~1% because:
1. Its render tree is heavily optimized with caching
2. Static parts are pre-computed, not rebuilt per frame
3. Entity children (`Entity<T>`) passed via `.child(entity)` are cached by GPUI
4. Zed's element tree is much deeper but uses IDs for efficient diffing
Our remaining 4.5% optimization path:
- Move header, tab bar, content area into separate Entity views (GPUI caches them independently)
- Avoid re-building tab buttons and static text on every render
- Use `SharedString` everywhere (done — reduced from 5% to 4.5%)
## API Reference (GPUI 0.2.2)
### Entity Creation
```rust
let entity: Entity<T> = cx.new(|cx: &mut Context<T>| T::new());
```
### Entity Update (from parent)
```rust
entity.update(cx, |view: &mut T, cx: &mut Context<T>| {
view.do_something(cx);
});
```
### Async Spawn (from entity context)
```rust
cx.spawn(async move |weak: WeakEntity<Self>, cx: &mut AsyncApp| {
cx.background_executor().timer(Duration::from_millis(500)).await;
weak.update(cx, |view, cx| {
view.mutate();
cx.notify();
}).ok();
}).detach();
```
### Timer
```rust
cx.background_executor().timer(Duration::from_millis(500)).await;
```
### Color
```rust
// rgba(0xRRGGBBAA) — u32 hex
let green = rgba(0xa6e3a1ff);
let semi_transparent = rgba(0xa6e3a180);
// Note: alpha on bg() may not work on small elements; use color interpolation instead
```
### Layout
```rust
div()
.flex() // display: flex
.flex_col() // flex-direction: column
.flex_row() // flex-direction: row
.flex_1() // flex: 1
.w_full() // width: 100%
.h(px(36.0)) // height: 36px
.min_w(px(400.0))
.px(px(12.0)) // padding-left + padding-right
.py(px(8.0)) // padding-top + padding-bottom
.gap(px(8.0)) // gap
.rounded(px(8.0)) // border-radius
.border_1() // border-width: 1px
.border_color(color)
.bg(color)
.text_color(color)
.text_size(px(13.0))
.overflow_hidden()
.items_center() // align-items: center
.justify_center() // justify-content: center
.cursor_pointer()
.hover(|s| s.bg(hover_color))
.id("unique-id") // for diffing + hit testing
.child(element_or_entity)
.children(option_entity) // renders Some, skips None
```
### Custom Element (for direct GPU painting)
```rust
impl Element for MyElement {
type RequestLayoutState = ();
type PrepaintState = ();
fn id(&self) -> Option<ElementId> { None }
fn source_location(&self) -> Option<&'static Location<'static>> { None }
fn request_layout(&mut self, ..., window: &mut Window, cx: &mut App)
-> (LayoutId, ()) {
let layout_id = window.request_layout(Style { size: ..., .. }, [], cx);
(layout_id, ())
}
fn prepaint(&mut self, ..., bounds: Bounds<Pixels>, ...) -> () { () }
fn paint(&mut self, ..., bounds: Bounds<Pixels>, ..., window: &mut Window, ...) {
window.paint_quad(fill(bounds, color).corner_radii(radius));
}
}
```
### Animation Frame Scheduling
```rust
// From render() — schedules next vsync frame (CAUTION: 60fps = 90% CPU)
window.request_animation_frame();
// From anywhere — schedule callback on next frame
window.on_next_frame(|window, cx| { /* ... */ });
```
### Window
```rust
window.set_window_title("Title");
window.request_animation_frame(); // schedule next frame
window.on_next_frame(callback); // callback on next frame
window.paint_quad(quad); // direct GPU paint in Element::paint()
```
## Known Limitations (2026-03)
1. **Pre-1.0 API** — breaking changes every 2-3 months
2. **No per-view-only repaint**`cx.notify()` propagates to window level, redraws all dirty views
3. **`cx.spawn()` in `cx.new()` doesn't fire** — must call after entity registration
4. **`rgba()` alpha on `.bg()` unreliable** for small elements — use color interpolation
5. **No CSS** — every style must be expressed via Rust methods
6. **No WebDriver** — can't use existing E2E test infrastructure
7. **No plugin host API** — must build your own (WASM/wasmtime or subprocess)
8. **Sparse documentation** — "read Zed source" is the primary reference
9. **macOS-first** — Linux (X11/Wayland) added 2025, Windows added late 2025
10. **X11 calloop polls at monitor Hz** — non-zero baseline CPU even when idle (~0.5%)
## Comparison with Dioxus Blitz
| Aspect | GPUI | Dioxus Blitz |
|--------|------|-------------|
| Styling | Rust methods (`.bg()`, `.flex()`) | CSS (same as browser) |
| Animation | Spawn + timer + notify (~4.5% CPU) | Class toggle + no CSS transition (~5% CPU) |
| Animation limit | cx.notify propagates to window | CSS transition = full scene repaint |
| Custom paint | Yes (Element trait + paint_quad) | No (CSS only, no shader/canvas API) |
| Render model | Retained views + element diff | HTML/CSS via Vello compute shaders |
| Terminal | alacritty_terminal + GPUI rendering | xterm.js in WebView (or custom build) |
| Migration cost | Full rewrite (no web tech) | Low (same wry webview as Tauri) |
| Ecosystem | 60+ components (gpui-component) | CSS ecosystem (any web component) |
| Text rendering | GPU glyph atlas | Vello compute shader text |
## Files in Our Prototype
```
ui-gpui/
├── Cargo.toml
├── src/
│ ├── main.rs — App entry, window creation
│ ├── theme.rs — Catppuccin Mocha as const Rgba values
│ ├── state.rs — AppState, Project, AgentSession types
│ ├── backend.rs — GpuiEventSink + Backend (PtyManager bridge)
│ ├── workspace.rs — Root view (sidebar + grid + statusbar)
│ ├── components/
│ │ ├── sidebar.rs — Icon rail
│ │ ├── status_bar.rs — Bottom bar (agent counts, cost)
│ │ ├── project_grid.rs — Grid of ProjectBox entities
│ │ ├── project_box.rs — Project card (header, tabs, content, dot)
│ │ ├── agent_pane.rs — Message list + prompt
│ │ ├── pulsing_dot.rs — Animated status dot (BlinkManager pattern)
│ │ ├── settings.rs — Settings drawer
│ │ └── command_palette.rs — Ctrl+K overlay
│ └── terminal/
│ ├── renderer.rs — GPU terminal (alacritty_terminal cells)
│ └── pty_bridge.rs — PTY via agor-core
```
## Sources
- [GPUI crate (crates.io)](https://crates.io/crates/gpui)
- [GPUI README](https://github.com/zed-industries/zed/blob/main/crates/gpui/README.md)
- [Zed BlinkManager source](https://github.com/zed-industries/zed/blob/main/crates/editor/src/blink_manager.rs)
- [GPUI X11 client source](https://github.com/zed-industries/zed/blob/main/crates/gpui_linux/src/linux/x11/client.rs)
- [GPUI Wayland client source](https://github.com/zed-industries/zed/blob/main/crates/gpui_linux/src/linux/wayland/client.rs)
- [GPUI window.rs source](https://github.com/zed-industries/zed/blob/main/crates/gpui/src/window.rs)
- [gpui-component library](https://github.com/4t145/gpui-component)
- [awesome-gpui list](https://github.com/zed-industries/awesome-gpui)
- [Zed GPU rendering blog](https://zed.dev/blog/videogame)

View file

@ -1,246 +0,0 @@
# System Architecture — Overview
This document describes the end-to-end architecture of Agent Orchestrator (agor) — how the Rust backend, Svelte 5 frontend, and Node.js/Deno sidecar processes work together to provide a multi-project AI agent orchestration dashboard.
---
## High-Level Overview
Agent Orchestrator is a Tauri 2.x desktop application. Tauri provides a Rust backend process and a WebKit2GTK-based webview for the frontend. The application manages AI agent sessions by spawning sidecar child processes that communicate with AI provider APIs (Claude, Codex, Ollama).
```
+----------------------------------------------------------------+
| Agent Orchestrator (Tauri 2.x) |
| |
| +------------------+ Tauri IPC +--------------------+ |
| | WebView | <-------------> | Rust Backend | |
| | (Svelte 5) | invoke/listen | | |
| | | | +-- PtyManager | |
| | +-- ProjectGrid | | +-- SidecarManager | |
| | +-- AgentPane | | +-- SessionDb | |
| | +-- TerminalPane | | +-- BtmsgDb | |
| | +-- StatusBar | | +-- SearchDb | |
| | +-- Stores | | +-- SecretsManager | |
| +------------------+ | +-- RemoteManager | |
| | +-- FileWatchers | |
| +--------------------+ |
| | |
+-------------------------------------------+--------------------+
| stdio NDJSON
v
+-------------------+
| Sidecar Processes |
| (Deno or Node.js) |
| |
| claude-runner.mjs |
| codex-runner.mjs |
| ollama-runner.mjs |
+-------------------+
```
### Why Three Layers?
1. **Rust backend** — Manages OS-level resources (PTY processes, file watchers, SQLite databases) with memory safety and low overhead. Exposes everything to the frontend via Tauri IPC commands and events.
2. **Svelte 5 frontend** — Renders the UI with fine-grained reactivity (no VDOM). Svelte 5 runes (`$state`, `$derived`, `$effect`) provide signal-based reactivity comparable to Solid.js but with a larger ecosystem.
3. **Sidecar processes** — The Claude Agent SDK, OpenAI Codex SDK, and Ollama API are all JavaScript/TypeScript libraries. They cannot run in Rust or in the WebKit2GTK webview (no Node.js APIs). The sidecar layer bridges this gap: Rust spawns a JS process, communicates via stdio NDJSON, and forwards structured messages to the frontend.
---
## Rust Backend (`src-tauri/`)
The Rust backend is the central coordinator. It owns all OS resources and database connections.
### Cargo Workspace
```
agor/
+-- Cargo.toml # Workspace root
+-- agor-core/ # Shared crate
| +-- src/
| +-- lib.rs
| +-- pty.rs # PtyManager (portable-pty)
| +-- sidecar.rs # SidecarManager (multi-provider)
| +-- supervisor.rs # SidecarSupervisor (crash recovery)
| +-- sandbox.rs # Landlock sandbox
| +-- event.rs # EventSink trait
+-- agor-relay/ # Remote machine relay
| +-- src/main.rs # WebSocket server + token auth
+-- src-tauri/ # Tauri application
+-- src/
+-- lib.rs # AppState + setup + handler registration
+-- commands/ # 16 domain command modules
+-- btmsg.rs # Inter-agent messaging (SQLite)
+-- bttask.rs # Task board (SQLite, shared btmsg.db)
+-- search.rs # FTS5 full-text search
+-- secrets.rs # System keyring (libsecret)
+-- plugins.rs # Plugin discovery
+-- notifications.rs # Desktop notifications
+-- session/ # SessionDb (sessions, layout, settings, agents, metrics, anchors)
+-- remote.rs # RemoteManager (WebSocket client)
+-- ctx.rs # Read-only ctx database access
+-- memora.rs # Read-only Memora database access
+-- telemetry.rs # OpenTelemetry tracing
+-- groups.rs # Project groups config
+-- watcher.rs # File watcher (notify crate)
+-- fs_watcher.rs # Per-project filesystem watcher (inotify)
+-- event_sink.rs # TauriEventSink implementation
+-- pty.rs # Thin re-export from agor-core
+-- sidecar.rs # Thin re-export from agor-core
```
The `agor-core` crate exists so that both the Tauri application and the standalone `agor-relay` binary can share PtyManager and SidecarManager code. The `EventSink` trait abstracts event emission — TauriEventSink wraps Tauri's AppHandle, while the relay uses a WebSocket-based EventSink.
### AppState
All backend state lives in `AppState`, initialized during Tauri setup:
```rust
pub struct AppState {
pub pty_manager: Mutex<PtyManager>,
pub sidecar_manager: Mutex<SidecarManager>,
pub session_db: Mutex<SessionDb>,
pub remote_manager: Mutex<RemoteManager>,
pub telemetry: Option<TelemetryGuard>,
}
```
### Command Modules
Tauri commands are organized into 16 domain modules under `commands/`:
| Module | Commands | Purpose |
|--------|----------|---------|
| `pty` | spawn, write, resize, kill | Terminal management |
| `agent` | query, stop, ready, restart | Agent session lifecycle |
| `session` | session CRUD, layout, settings | Session persistence |
| `persistence` | agent state, messages | Agent session continuity |
| `knowledge` | ctx, memora queries | External knowledge bases |
| `claude` | profiles, skills | Claude-specific features |
| `groups` | load, save | Project group config |
| `files` | list_directory, read/write file | File browser |
| `watcher` | start, stop | File change monitoring |
| `remote` | 12 commands | Remote machine management |
| `bttask` | list, create, update, delete, comments | Task board |
| `search` | init, search, rebuild, index | FTS5 search |
| `secrets` | store, get, delete, list, has_keyring | Secrets management |
| `plugins` | discover, read_file | Plugin discovery |
| `notifications` | send_desktop | OS notifications |
| `misc` | test_mode, frontend_log | Utilities |
---
## Svelte 5 Frontend (`src/`)
The frontend uses Svelte 5 with runes for reactive state management. The UI follows a VSCode-inspired layout with a left icon rail, expandable drawer, project grid, and status bar.
### Component Hierarchy
```
App.svelte [Root -- VSCode-style layout]
+-- CommandPalette.svelte [Ctrl+K overlay, 18+ commands]
+-- SearchOverlay.svelte [Ctrl+Shift+F, FTS5 Spotlight-style]
+-- NotificationCenter.svelte [Bell icon + dropdown]
+-- GlobalTabBar.svelte [Left icon rail, 2.75rem wide]
+-- [Sidebar Panel] [Expandable drawer, max 50%]
| +-- SettingsTab.svelte [Global settings + group/project CRUD]
+-- ProjectGrid.svelte [Flex + scroll-snap, adaptive count]
| +-- ProjectBox.svelte [Per-project container, 11 tab types]
| +-- ProjectHeader.svelte [Icon + name + status + badges]
| +-- AgentSession.svelte [Main Claude session wrapper]
| | +-- AgentPane.svelte [Structured message rendering]
| | +-- TeamAgentsPanel.svelte [Tier 1 subagent cards]
| +-- TerminalTabs.svelte [Shell/SSH/agent-preview tabs]
| | +-- TerminalPane.svelte [xterm.js + Canvas addon]
| | +-- AgentPreviewPane.svelte [Read-only agent activity]
| +-- DocsTab / ContextTab / FilesTab / SshTab / MemoriesTab
| +-- MetricsPanel / TaskBoardTab / ArchitectureTab / TestingTab
+-- StatusBar.svelte [Agent counts, burn rate, attention queue]
```
### Stores (Svelte 5 Runes)
All store files use the `.svelte.ts` extension — required for Svelte 5 runes (`$state`, `$derived`, `$effect`). Files with plain `.ts` extension compile but fail at runtime with "rune_outside_svelte".
| Store | Purpose |
|-------|---------|
| `workspace.svelte.ts` | Project groups, active group, tabs, focus |
| `agents.svelte.ts` | Agent sessions, messages, cost, parent/child hierarchy |
| `health.svelte.ts` | Per-project health tracking, attention scoring, burn rate |
| `conflicts.svelte.ts` | File overlap + external write detection |
| `anchors.svelte.ts` | Session anchor management (auto/pinned/promoted) |
| `notifications.svelte.ts` | Toast + history (6 types, unread badge) |
| `plugins.svelte.ts` | Plugin command registry, event bus |
| `theme.svelte.ts` | 17 themes, font restoration |
| `machines.svelte.ts` | Remote machine state |
| `wake-scheduler.svelte.ts` | Manager auto-wake (3 strategies, per-manager timers) |
### Agent Dispatcher
The agent dispatcher (`agent-dispatcher.ts`, ~260 lines) is the central router between sidecar events and the agent store. When the Rust backend emits a `sidecar-message` Tauri event, the dispatcher:
1. Looks up the provider for the session (via `sessionProviderMap`)
2. Routes the raw message through the appropriate adapter (claude-messages.ts, codex-messages.ts, or ollama-messages.ts) via `message-adapters.ts`
3. Feeds the resulting `AgentMessage[]` into the agent store
4. Handles side effects: subagent pane spawning, session persistence, auto-anchoring, worktree detection, health tracking, conflict recording
The dispatcher delegates to four extracted utility modules:
- `utils/session-persistence.ts` — session-project maps, persistSessionForProject
- `utils/subagent-router.ts` — spawn + route subagent panes
- `utils/auto-anchoring.ts` — triggerAutoAnchor on first compaction event
- `utils/worktree-detection.ts` — detectWorktreeFromCwd pure function
---
## Data Flow: Agent Query Lifecycle
```
1. User types prompt in AgentPane
2. AgentPane calls agentBridge.queryAgent(options)
3. agent-bridge.ts invokes Tauri command 'agent_query'
4. Rust agent_query handler calls SidecarManager.query()
5. SidecarManager resolves provider runner (e.g., claude-runner.mjs)
6. SidecarManager writes QueryMessage as NDJSON to sidecar stdin
7. Sidecar runner calls provider SDK (e.g., Claude Agent SDK query())
8. Provider SDK streams responses
9. Runner forwards each response as NDJSON to stdout
10. SidecarManager reads stdout line-by-line
11. SidecarManager emits Tauri event 'sidecar-message' with sessionId + data
12. Frontend agent-dispatcher.ts receives event
13. Dispatcher routes through message-adapters.ts -> provider-specific parser
14. Parser converts to AgentMessage[]
15. Dispatcher feeds messages into agents.svelte.ts store
16. AgentPane reactively re-renders via $derived bindings
```
---
## Configuration
### Project Groups (`~/.config/agor/groups.json`)
Human-editable JSON file defining project groups and their projects. Loaded at startup by `groups.rs`. Not hot-reloaded — changes require app restart or group switch.
### SQLite Settings (`sessions.db` -> `settings` table)
Key-value store for user preferences: theme, fonts, shell, CWD, provider settings. Accessed via `settings-bridge.ts` -> `settings_get`/`settings_set` Tauri commands.
### Environment Variables
| Variable | Purpose |
|----------|---------|
| `AGOR_TEST` | Enables test mode (disables watchers, wake scheduler) |
| `AGOR_TEST_DATA_DIR` | Redirects SQLite database storage |
| `AGOR_TEST_CONFIG_DIR` | Redirects groups.json config |
| `AGOR_OTLP_ENDPOINT` | Enables OpenTelemetry OTLP export |
---
## Key Constraints
1. **WebKit2GTK has no WebGL** — xterm.js must use the Canvas addon explicitly. Maximum 4 active xterm.js instances to avoid OOM.
2. **Svelte 5 runes require `.svelte.ts`** — Store files using `$state`/`$derived` must have the `.svelte.ts` extension. The compiler silently accepts `.ts` but runes fail at runtime.
3. **Single shared sidecar** — All agent sessions share one SidecarManager. Per-project isolation is via `cwd`, `claude_config_dir`, and `session_id` routing. Per-project sidecar pools deferred to v3.1.
4. **SQLite WAL mode** — Both databases use WAL with 5s busy_timeout for concurrent access from Rust backend + Python CLIs (btmsg/bttask).
5. **camelCase wire format** — Rust uses `#[serde(rename_all = "camelCase")]`. TypeScript interfaces must match. This was a source of bugs during development (see [findings.md](findings.md) for context).

View file

@ -1,125 +0,0 @@
# Implementation Phases
See [overview.md](overview.md) for system architecture and [decisions.md](decisions.md) for design decisions.
---
## Phase 1: Project Scaffolding [complete]
- Tauri 2.x + Svelte 5 frontend initialized
- Catppuccin Mocha CSS variables, dev scripts
- portable-pty (used by WezTerm) over tauri-plugin-pty for reliability
---
## Phase 2: Terminal Pane + Layout [complete]
- CSS Grid layout with responsive breakpoints (ultrawide / standard / narrow)
- Pane resize via drag handles, layout presets (1-col, 2-col, 3-col, 2x2, master+stack)
- xterm.js with Canvas addon (no WebGL on WebKit2GTK), Catppuccin theme
- PTY spawn from Rust (portable-pty), stream via Tauri events
- Copy/paste (Ctrl+Shift+C/V), SSH via PTY shell args
---
## Phase 3: Agent SDK Integration [complete]
- Node.js/Deno sidecar using `@anthropic-ai/claude-agent-sdk` query() function
- Sidecar communication: Rust spawns process, stdio NDJSON
- SDK message adapter: 9 typed AgentMessage types
- Agent store with session state, message history, cost tracking
- AgentPane: text, tool calls/results, thinking, init, cost, errors, subagent spawn
- Session resume (resume_session_id to SDK)
---
## Phase 4: Session Management + Markdown Viewer [complete]
- SQLite persistence (rusqlite), session groups with collapsible headers
- Auto-restore layout on startup
- Markdown viewer with Shiki highlighting and live reload via file watcher
---
## Phase 5: Agent Tree + Polish [complete]
- SVG agent tree visualization with click-to-scroll and subtree cost
- Terminal theme hot-swap, pane drag-resize handles
- StatusBar with counts, notifications (toast system)
- Settings dialog, ctx integration, SSH session management
- 4 Catppuccin themes, detached pane mode, Shiki syntax highlighting
---
## Phase 6: Packaging + Distribution [complete]
- install-v2.sh build-from-source installer (Node.js 20+, Rust 1.77+, system libs)
- Tauri bundle: .deb (4.3 MB) + AppImage (103 MB)
- GitHub Actions release workflow on `v*` tags
- Auto-updater with signing key
---
## Phase 7: Agent Teams / Subagent Support [complete]
- Agent store parent/child hierarchy
- Dispatcher subagent detection and message routing
- AgentPane parent navigation + children bar
- Subagent cost aggregation
- 28 dispatcher tests including 10 for subagent routing
---
## Multi-Machine Support (Phases A-D) [complete]
Architecture in [../multi-machine/relay.md](../multi-machine/relay.md).
### Phase A: Extract `agor-core` crate
Cargo workspace with PtyManager, SidecarManager, EventSink trait in shared crate.
### Phase B: Build `agor-relay` binary
WebSocket server with token auth, rate limiting, per-connection isolation, structured command responses.
### Phase C: Add `RemoteManager` to controller
12 Tauri commands, heartbeat ping, exponential backoff reconnection with TCP probing.
### Phase D: Frontend integration
remote-bridge.ts adapter, machines.svelte.ts store, routing via Pane.remoteMachineId.
### Remaining
- [ ] Real-world relay testing (2 machines)
- [ ] TLS/certificate pinning
---
## Extras: Claude Profiles & Skill Discovery [complete]
### Claude Profile / Account Switching
- Reads ~/.config/switcher/profiles/ with profile.toml metadata
- Profile selector dropdown, config_dir passed as CLAUDE_CONFIG_DIR env override
### Skill Discovery & Autocomplete
- Reads ~/.claude/skills/ (dirs with SKILL.md or .md files)
- `/` prefix triggers autocomplete menu in AgentPane
- expandSkillPrompt() injects skill content as prompt
### Extended AgentQueryOptions
- setting_sources, system_prompt, model, claude_config_dir, additional_directories
- CLAUDE_CONFIG_DIR env injection for multi-account support
---
## System Requirements
- Node.js 20+ (for Agent SDK sidecar)
- Rust 1.77+ (for building from source)
- WebKit2GTK 4.1+ (Tauri runtime)
- Linux x86_64 (primary target)

View file

@ -1,229 +0,0 @@
# Configuration Reference
All configuration paths, environment variables, database files, and per-project
settings for Agents Orchestrator.
## Environment variables
### Core
| Variable | Default | Description |
|----------|---------|-------------|
| `AGOR_TEST` | unset | Set to `1` to enable test mode. Disables file watchers, wake scheduler, and telemetry. |
| `AGOR_OTLP_ENDPOINT` | unset | OpenTelemetry OTLP/HTTP endpoint (e.g. `http://localhost:4318`). When unset, telemetry is console-only. |
| `AGOR_TEST_DATA_DIR` | unset | Override data directory in test mode. Isolates SQLite databases. |
| `AGOR_TEST_CONFIG_DIR` | unset | Override config directory in test mode. Isolates groups.json and plugins. |
| `AGOR_TEST_CTX_DIR` | unset | Override ctx database directory in test mode. |
| `AGOR_EDITION` | `community` | Set to `pro` to enable commercial features (used by vitest and CI). |
### Provider-specific
Provider environment variables (`CLAUDE_*`, `CODEX_*`, `OLLAMA_*`) are stripped
from the sidecar process environment to prevent cross-contamination. The
whitelist `CLAUDE_CODE_EXPERIMENTAL_*` is preserved.
Variables the sidecar runner may read:
| Variable | Provider | Description |
|----------|----------|-------------|
| `ANTHROPIC_API_KEY` | Claude | API key (used by Claude CLI internally) |
| `OPENAI_API_KEY` | Codex | API key for Codex SDK |
| `OPENROUTER_API_KEY` | Aider | API key for OpenRouter-routed models |
| `CLAUDE_CONFIG_DIR` | Claude | Override Claude config directory (multi-account) |
| `BTMSG_AGENT_ID` | All (Tier 1) | Injected for management agents to enable btmsg/bttask CLI |
## Config files
Base directory: `~/.config/agor/`
### groups.json
Primary configuration file. Defines project groups, projects, and management
agents.
```
~/.config/agor/groups.json
```
Schema: `GroupsFile` (see `src/lib/types/groups.ts`).
Structure:
```json
{
"version": 1,
"groups": [
{
"id": "group-id",
"name": "Group Name",
"projects": [ ... ],
"agents": [ ... ]
}
],
"activeGroupId": "group-id"
}
```
Managed via the Settings tab in the UI or by editing the file directly. Changes
are picked up on next group load.
### accounts.json (Pro edition)
Commercial multi-account configuration.
```
~/.config/agor/accounts.json
```
Only present when `AGOR_EDITION=pro`. Not included in the community build.
### plugins/
Plugin discovery directory. Each plugin is a subdirectory containing a
`plugin.json` manifest.
```
~/.config/agor/plugins/
my-plugin/
plugin.json
index.js
```
Plugins run in Web Worker sandboxes with permission-gated APIs. See
`src/lib/plugins/plugin-host.ts`.
## Database files
Base directory: `~/.local/share/agor/`
All databases use SQLite WAL mode with 5-second busy timeout for concurrent
access.
### sessions.db
Session persistence, layout state, settings, session metrics, and anchors.
```
~/.local/share/agor/sessions.db
```
Tables:
| Table | Description |
|-------|-------------|
| `sessions` | Session state (project_id, group_name, layout data) |
| `agent_messages` | Per-project message persistence |
| `project_agent_state` | SDK session ID, cost, status per project |
| `session_metrics` | Historical session data (tokens, turns, cost, model) |
| `session_anchors` | Preserved turns through compaction chains |
| `settings` | Key-value application settings |
### btmsg.db
Shared database for inter-agent messaging (btmsg) and task board (bttask).
```
~/.local/share/agor/btmsg.db
```
Created by the `btmsg` CLI on first `btmsg register`. Used concurrently by
Python CLIs and the Rust backend.
Tables:
| Table | Description |
|-------|-------------|
| `agents` | Registered agents (id, name, role, group_id, tier, model, status) |
| `messages` | Direct messages between agents |
| `channels` | Named broadcast channels |
| `channel_messages` | Messages posted to channels |
| `contacts` | ACL for agent-to-agent visibility |
| `heartbeats` | Agent liveness tracking |
| `dead_letter_queue` | Undeliverable messages |
| `audit_log` | Agent action audit trail |
| `seen_messages` | Per-message read tracking (session_id, message_id) |
| `tasks` | Kanban task board entries |
| `task_comments` | Comments on tasks |
### search.db
FTS5 full-text search index.
```
~/.local/share/agor/search.db
```
Virtual tables:
| Table | Description |
|-------|-------------|
| `search_messages` | Indexed agent messages |
| `search_tasks` | Indexed task board entries |
| `search_btmsg` | Indexed btmsg messages |
Rebuilt on demand via the search overlay (`Ctrl+Shift+F`).
## Tauri configuration
### src-tauri/tauri.conf.json
Community edition Tauri config. Defines window properties, permissions, updater
endpoint, and bundle metadata.
### src-tauri/tauri.conf.commercial.json
Commercial edition overlay. Merged with the base config when building with
`--config src-tauri/tauri.conf.commercial.json`. Changes bundle identifier,
product name, and updater URL.
## Theme settings
17 themes in 3 groups, all mapping to the same 26 `--ctp-*` CSS custom
properties.
| Group | Themes |
|-------|--------|
| Catppuccin | Mocha (default), Macchiato, Frappe, Latte |
| Editor | VSCode Dark+, Atom One Dark, Monokai, Dracula, Nord, Solarized Dark, GitHub Dark |
| Deep Dark | Tokyo Night, Gruvbox Dark, Ayu Dark, Poimandres, Vesper, Midnight |
Settings keys (persisted in `sessions.db` settings table):
| Key | Default | Description |
|-----|---------|-------------|
| `theme` | `mocha` | Active theme ID |
| `ui_font_family` | system sans-serif | UI element font |
| `ui_font_size` | `14` (px) | UI font size (feeds CSS `--ui-font-size`) |
| `term_font_family` | system monospace | Terminal font |
| `term_font_size` | `14` (px) | Terminal font size (feeds CSS `--term-font-size`) |
| `default_shell` | system default | Default shell for terminal panes |
| `default_cwd` | `~` | Default working directory |
All font settings are restored from SQLite on startup via `initTheme()`.
## Per-project settings
These fields are set in `groups.json` per project entry (`ProjectConfig`):
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `provider` | `ProviderId` | `claude` | Agent provider |
| `model` | string | provider default | Model identifier override |
| `profile` | string | `default` | Claude profile name |
| `useWorktrees` | boolean | `false` | Git worktree isolation per session |
| `sandboxEnabled` | boolean | `false` | Landlock filesystem sandbox |
| `autonomousMode` | string | `restricted` | `restricted` or `autonomous` |
| `anchorBudgetScale` | string | `medium` | Anchor budget: `small` (2K), `medium` (6K), `large` (12K), `full` (20K) |
| `stallThresholdMin` | number | `15` | Minutes before idle agent is marked stalled (range 5--60) |
### Agent-specific fields
Set on `GroupAgentConfig` entries in the `agents` array:
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `role` | string | required | `manager`, `architect`, `tester`, or `reviewer` |
| `wakeIntervalMin` | number | `3` | Auto-wake check interval (Manager only) |
| `wakeStrategy` | string | `smart` | `persistent`, `on-demand`, or `smart` |
| `wakeThreshold` | number | `0.5` | Threshold for smart wake strategy (0.0--1.0) |
| `systemPrompt` | string | none | Custom system prompt appended to generated prompt |

View file

@ -1,170 +0,0 @@
# Dual-Repo Workflow
Agents Orchestrator uses a dual-repository model to maintain an open-source
community edition alongside a private commercial edition.
## Repositories
| Remote | Repository | Visibility | Purpose |
|--------|-----------|------------|---------|
| `origin` | DexterFromLab/agent-orchestrator | Public (MIT) | Community edition |
| `orchestrator` | agents-orchestrator/agents-orchestrator | Private | Commercial edition |
Both remotes are configured in every developer clone:
```
$ git remote -v
orchestrator git@github.com:agents-orchestrator/agents-orchestrator.git (fetch)
orchestrator git@github.com:agents-orchestrator/agents-orchestrator.git (push)
origin git@github.com:DexterFromLab/agent-orchestrator.git (fetch)
origin no-push-to-community (push)
```
Note: `origin` push URL is set to `no-push-to-community` -- a non-existent URL
that causes `git push origin` to fail. This is intentional. All pushes go to
`orchestrator`.
## Development flow
All daily development happens on the `orchestrator` remote. The community
edition receives curated exports stripped of commercial code.
```
Developer -> orchestrator (private) -> curated export -> origin (public)
```
### Branch model
| Branch | Scope | Description |
|--------|-------|-------------|
| `main` | Shared | Community-safe code. Synced between both remotes. |
| `hib_changes_v2` | Development | Active development branch on orchestrator. |
| `commercial/*` | Pro-only | Features exclusive to the commercial edition. |
### Typical workflow
1. Create a feature branch from `main` on `orchestrator`.
2. Develop and test.
3. Push to `orchestrator` and open a PR against `main`.
4. After merge, community sync happens via curated export.
## Syncing from community
To pull community contributions into the private repo:
```bash
make sync
```
This runs:
```bash
git fetch origin
git merge origin/main
```
Resolve any conflicts between community contributions and commercial code.
## Leak prevention
Three layers prevent commercial code from reaching the public repository.
### 1. Git pre-push hook
`.githooks/pre-push` scans commits being pushed to any remote matching
`DexterFromLab`. If any commit touches files in `agor-pro/` or
`src/lib/commercial/`, the push is blocked:
```
==========================================
PUSH BLOCKED: Commercial code detected!
==========================================
The following commercial files were found in commits being pushed:
- agor-pro/src/billing.rs
- src/lib/commercial/license.ts
You are pushing to the community remote (git@github.com:DexterFromLab/...).
Commercial code must NOT be pushed to this remote.
==========================================
```
Enable the hook after cloning:
```bash
make setup
# or manually:
git config core.hooksPath .githooks
```
### 2. CI leak check
`.github/workflows/leak-check.yml` runs on every push and PR to `main` on the
community repo. It fails the build if:
- `agor-pro/` directory exists
- `src/lib/commercial/` contains files beyond `.gitkeep`
- Any file contains `SPDX-License-Identifier: LicenseRef-Agor-Commercial`
### 3. SPDX headers
Commercial files carry an SPDX header identifying them:
```rust
// SPDX-License-Identifier: LicenseRef-Agor-Commercial
```
The CI leak check scans for this marker as a final safety net.
## File conventions
| Path | Edition | Description |
|------|---------|-------------|
| `agor-pro/` | Commercial | Pro Rust crate (billing, licensing, accounts) |
| `src/lib/commercial/` | Commercial | Pro frontend components |
| `src/lib/commercial/.gitkeep` | Community | Placeholder (no content) |
| Everything else | Community | MIT-licensed code |
Commercial code is conditionally compiled:
- **Rust:** `#[cfg(feature = "pro")]` gates, Cargo feature flag
- **Frontend:** `AGOR_EDITION` env var checked at test/build time
## Makefile targets
```bash
make setup # Configure git hooks + npm install
make build # Community build (cargo build + npm run build)
make build-pro # Commercial build (cargo build --features pro)
make test # Run all community tests (npm run test:all)
make test-pro # Run commercial tests (cargo test --features pro + vitest)
make sync # Pull community changes (git fetch origin + merge)
make clean # Remove build artifacts (cargo clean + vite cache)
```
### Building the commercial Tauri app
```bash
cargo tauri build -- --features pro \
--config src-tauri/tauri.conf.commercial.json
```
This merges the commercial Tauri config (different bundle ID, product name,
updater URL) with the base config and enables the `pro` Cargo feature.
## Adding commercial features
1. Place Rust code in `agor-pro/src/` or behind `#[cfg(feature = "pro")]`.
2. Place frontend code in `src/lib/commercial/`.
3. Add SPDX header to every new file.
4. Test with `make test-pro`.
5. Push to `orchestrator` only. Never push to `origin`.
## Removing commercial code for export
When preparing a community release:
1. Ensure `main` has no commercial file paths in its history.
2. The `no-push-to-community` push URL and pre-push hook prevent accidents.
3. If a commercial file is accidentally committed to `main`, rewrite history
before any push to `origin`. Rotate any exposed secrets.

View file

@ -1,180 +0,0 @@
# E2E Testing Facility
Agor's end-to-end testing uses **WebDriverIO + tauri-driver** to drive the real Tauri application through WebKit2GTK's inspector protocol. The facility has three pillars:
1. **Test Fixtures** — isolated fake environments with dummy projects
2. **Test Mode** — app-level env vars that disable watchers and redirect data/config paths
3. **LLM Judge** — Claude-powered semantic assertions for evaluating agent behavior
## Quick Start
```bash
# Run all tests (vitest + cargo + E2E)
npm run test:all:e2e
# Run E2E only (requires pre-built debug binary)
SKIP_BUILD=1 npm run test:e2e
# Build debug binary separately (faster iteration)
cargo tauri build --debug --no-bundle
# Run with LLM judge via CLI (default, auto-detected)
npm run test:e2e
# Force LLM judge to use API instead of CLI
LLM_JUDGE_BACKEND=api ANTHROPIC_API_KEY=sk-... npm run test:e2e
```
## Prerequisites
| Dependency | Purpose | Install |
|-----------|---------|---------|
| Rust + Cargo | Build Tauri backend | [rustup.rs](https://rustup.rs) |
| Node.js 20+ | Frontend + test runner | `mise install node` |
| tauri-driver | WebDriver bridge to WebKit2GTK | `cargo install tauri-driver` |
| X11 display | WebKit2GTK needs a display | Real X, or `xvfb-run` in CI |
| Claude CLI | LLM judge (optional) | [claude.ai/download](https://claude.ai/download) |
## Architecture
```
+-----------------------------------------------------+
| WebDriverIO (mocha runner) |
| specs/*.test.ts |
| +- browser.execute() -> DOM queries + assertions |
| +- assertWithJudge() -> LLM semantic evaluation |
+-----------------------------------------------------+
| tauri-driver (port 4444) |
| WebDriver protocol <-> WebKit2GTK inspector |
+-----------------------------------------------------+
| Agor debug binary |
| AGOR_TEST=1 (disables watchers, wake scheduler) |
| AGOR_TEST_DATA_DIR -> isolated SQLite DBs |
| AGOR_TEST_CONFIG_DIR -> test groups.json |
+-----------------------------------------------------+
```
## Pillar 1: Test Fixtures (`fixtures.ts`)
The fixture generator creates isolated temporary environments so tests never touch real user data. Each fixture includes:
- **Temp root dir** under `/tmp/agor-e2e-{timestamp}/`
- **Data dir** — empty, SQLite databases created at runtime
- **Config dir** — contains a generated `groups.json` with test projects
- **Project dir** — a real git repo with `README.md` and `hello.py` (for agent testing)
### Single-Project Fixture
```typescript
import { createTestFixture, destroyTestFixture } from '../fixtures';
const fixture = createTestFixture('my-test');
// fixture.rootDir -> /tmp/my-test-1710234567890/
// fixture.dataDir -> /tmp/my-test-1710234567890/data/
// fixture.configDir -> /tmp/my-test-1710234567890/config/
// fixture.projectDir -> /tmp/my-test-1710234567890/test-project/
// fixture.env -> { AGOR_TEST: '1', AGOR_TEST_DATA_DIR: '...', ... }
destroyTestFixture(fixture);
```
### Multi-Project Fixture
```typescript
import { createMultiProjectFixture } from '../fixtures';
const fixture = createMultiProjectFixture(3); // 3 separate git repos
```
### Fixture Environment Variables
| Variable | Effect |
|----------|--------|
| `AGOR_TEST=1` | Disables file watchers, wake scheduler, enables `is_test_mode` |
| `AGOR_TEST_DATA_DIR` | Redirects `sessions.db` and `btmsg.db` storage |
| `AGOR_TEST_CONFIG_DIR` | Redirects `groups.json` config loading |
## Pillar 2: Test Mode
When `AGOR_TEST=1` is set:
- **Rust backend**: `watcher.rs` and `fs_watcher.rs` skip file watchers
- **Frontend**: `is_test_mode` Tauri command returns true, wake scheduler disabled via `disableWakeScheduler()`
- **Data isolation**: `AGOR_TEST_DATA_DIR` / `AGOR_TEST_CONFIG_DIR` override default paths
The WebDriverIO config (`wdio.conf.js`) passes these env vars via `tauri:options.env` in capabilities.
## Pillar 3: LLM Judge (`llm-judge.ts`)
The LLM judge enables semantic assertions — evaluating whether agent output "looks right" rather than exact string matching.
### Dual Backend
| Backend | How it works | Requires |
|---------|-------------|----------|
| `cli` (default) | Spawns `claude` CLI with `--output-format text` | Claude CLI installed |
| `api` | Raw `fetch` to `https://api.anthropic.com/v1/messages` | `ANTHROPIC_API_KEY` env var |
**Auto-detection order**: CLI first -> API fallback -> skip test.
### API
```typescript
import { isJudgeAvailable, judge, assertWithJudge } from '../llm-judge';
if (!isJudgeAvailable()) { this.skip(); return; }
const verdict = await judge(
'The output should contain a file listing with at least one filename',
actualOutput,
'Agent was asked to list files in a directory containing README.md',
);
// verdict: { pass: boolean, reasoning: string, confidence: number }
```
## Test Spec Files
| File | Phase | Tests | Focus |
|------|-------|-------|-------|
| `agor.test.ts` | Smoke | ~50 | Basic UI rendering, CSS class selectors |
| `phase-a-structure.test.ts` | A | 12 | Structural integrity + settings (Scenarios 1-2) |
| `phase-a-agent.test.ts` | A | 15 | Agent pane + prompt submission (Scenarios 3+7) |
| `phase-a-navigation.test.ts` | A | 15 | Terminal tabs + palette + focus (Scenarios 4-6) |
| `phase-b.test.ts` | B | ~15 | Multi-project grid, LLM-judged agent responses |
| `phase-c.test.ts` | C | 27 | Hardening features (palette, search, notifications, keyboard, settings, health, metrics, context, files) |
## Test Results Tracking (`results-db.ts`)
A lightweight JSON store for tracking test runs and individual step results. Writes to `test-results/results.json`.
## CI Integration (`.github/workflows/e2e.yml`)
1. **Unit tests**`npm run test` (vitest)
2. **Cargo tests**`cargo test` (with `env -u AGOR_TEST` to prevent env leakage)
3. **E2E tests**`xvfb-run npm run test:e2e` (virtual framebuffer for headless WebKit2GTK)
LLM-judged tests are gated on the `ANTHROPIC_API_KEY` secret — they skip gracefully in forks.
## Writing New Tests
1. Pick the appropriate spec file (or create a new phase file)
2. Use `data-testid` selectors where possible
3. For DOM queries, use `browser.execute()` to run JS in the app context
4. For semantic assertions, use `assertWithJudge()` with clear criteria
### WebDriverIO Config (`wdio.conf.js`)
- **Single session**: `maxInstances: 1` — tauri-driver can't handle parallel sessions
- **Lifecycle**: `onPrepare` builds debug binary, `beforeSession` spawns tauri-driver with TCP readiness probe
- **Timeouts**: 60s per test, 10s waitfor, 30s connection retry
- **Skip build**: Set `SKIP_BUILD=1` to reuse existing binary
## Troubleshooting
| Problem | Solution |
|---------|----------|
| "Callback was not called before unload" | Stale binary — rebuild with `cargo tauri build --debug --no-bundle` |
| Tests hang on startup | Kill stale `tauri-driver` processes: `pkill -f tauri-driver` |
| All tests skip LLM judge | Install Claude CLI or set `ANTHROPIC_API_KEY` |
| SIGUSR2 / exit code 144 | Stale tauri-driver on port 4444 — kill and retry |
| `AGOR_TEST` leaking to cargo | Run cargo tests with `env -u AGOR_TEST cargo test` |
| No display available | Use `xvfb-run` or ensure X11/Wayland display is set |

View file

@ -1,188 +0,0 @@
# Quickstart
Get Agents Orchestrator (agor) running locally in under 10 minutes.
## Prerequisites
| Dependency | Version | Notes |
|------------|---------|-------|
| Node.js | 20+ | npm included |
| Rust | 1.77+ | Install via [rustup](https://rustup.rs/) |
| WebKit2GTK | 4.1 | Tauri 2.x rendering engine |
| System libs | -- | See below |
### System libraries (Debian/Ubuntu)
```bash
sudo apt install \
libwebkit2gtk-4.1-dev \
libgtk-3-dev \
libappindicator3-dev \
librsvg2-dev \
libssl-dev \
libsoup-3.0-dev \
javascriptcoregtk-4.1 \
patchelf
```
### Optional dependencies
- **Claude Code CLI** -- Required for the Claude provider. Auto-detected at
`~/.local/bin/claude`, `~/.claude/local/claude`, `/usr/local/bin/claude`, or
`/usr/bin/claude`.
- **Deno** -- Preferred sidecar runtime (faster startup). Falls back to Node.js.
- **Ollama** -- Required for the Ollama provider. Must be running on
`localhost:11434`.
## Clone and build
```bash
git clone git@github.com:DexterFromLab/agent-orchestrator.git
cd agent-orchestrator
npm install
npm run tauri dev
```
The dev server runs on port **9700**. Tauri opens a native window automatically.
### Production build
```bash
npm run tauri build
```
Output: `.deb` and AppImage in `src-tauri/target/release/bundle/`.
## Project configuration
Agor organizes work into **groups**, each containing one or more **projects**
(codebases) and optional **agents** (Tier 1 management roles).
Configuration lives in `~/.config/agor/groups.json`. On first launch, agor
creates a default file. You can also create it manually:
```json
{
"version": 1,
"groups": [
{
"id": "my-team",
"name": "My Team",
"projects": [
{
"id": "backend",
"name": "Backend API",
"identifier": "backend-api",
"description": "REST API service",
"icon": "B",
"cwd": "/home/user/code/backend",
"profile": "default",
"enabled": true,
"provider": "claude"
},
{
"id": "frontend",
"name": "Frontend App",
"identifier": "frontend-app",
"description": "Svelte web client",
"icon": "F",
"cwd": "/home/user/code/frontend",
"profile": "default",
"enabled": true,
"provider": "claude",
"model": "claude-sonnet-4-6"
}
],
"agents": [
{
"id": "mgr-1",
"name": "Manager",
"role": "manager",
"enabled": true,
"wakeIntervalMin": 3,
"wakeStrategy": "smart",
"wakeThreshold": 0.5
}
]
}
],
"activeGroupId": "my-team"
}
```
### Key fields
| Field | Required | Description |
|-------|----------|-------------|
| `id` | Yes | Unique identifier within the group |
| `cwd` | Yes | Absolute path to the project directory |
| `provider` | No | `claude` (default), `codex`, `ollama`, or `aider` |
| `model` | No | Model override; falls back to provider default |
| `profile` | No | Claude profile name from `~/.config/switcher/profiles/` |
| `useWorktrees` | No | Enable git worktree isolation per session |
| `sandboxEnabled` | No | Enable Landlock filesystem sandbox (Linux 5.13+) |
| `autonomousMode` | No | `restricted` (default) or `autonomous` |
| `anchorBudgetScale` | No | Anchor token budget: `small`, `medium`, `large`, `full` |
| `stallThresholdMin` | No | Minutes before idle agent is marked stalled (default 15) |
## Creating your first agent session
1. Launch agor (`npm run tauri dev` or the built binary).
2. The workspace shows your configured projects as cards in a grid.
3. Click a project card to focus it. The **Model** tab opens by default.
4. Type a prompt in the input area at the bottom and press Enter.
5. Agor spawns a sidecar process, connects to the provider, and streams
responses in real time.
The agent session persists across tab switches. Closing the project card stops
the agent.
## Keyboard shortcuts
| Shortcut | Action |
|----------|--------|
| `Ctrl+B` | Toggle sidebar |
| `Ctrl+,` | Open settings |
| `Ctrl+Shift+F` | Full-text search overlay |
| `Ctrl+K` | Command palette |
| `Alt+1` -- `Alt+5` | Focus project 1--5 |
| `Escape` | Close sidebar / overlay / palette |
## Running tests
```bash
# All tests (vitest frontend + cargo backend)
npm run test:all
# Frontend only
npm run test
# Backend only
npm run test:cargo
# E2E tests (requires built binary)
npm run test:all:e2e
```
## Directory layout
```
agent-orchestrator/
agor-core/ # Shared Rust crate (PTY, sidecar, supervisor, sandbox)
agor-relay/ # Standalone relay binary (WebSocket server)
src-tauri/ # Tauri app (Rust backend)
src/ # Svelte 5 frontend
sidecar/ # Provider runner scripts (compiled to .mjs)
docs/ # Project documentation
tests/e2e/ # WebDriverIO E2E tests
```
## Next steps
- [Configuration reference](../config/ref-settings.md) -- all env vars, config
files, and per-project settings.
- [Provider reference](../providers/ref-providers.md) -- Claude, Codex, Ollama,
Aider setup.
- [btmsg reference](../agents/ref-btmsg.md) -- inter-agent messaging.
- [bttask reference](../agents/ref-bttask.md) -- kanban task board.

View file

@ -1,155 +0,0 @@
# Multi-Machine Support
**Status: Implemented (Phases A-D complete, 2026-03-06)**
## Overview
Extends agor to manage Claude agent sessions and terminal panes running on **remote machines** over WebSocket, while keeping the local sidecar path unchanged.
## Architecture
### Three-Layer Model
```
+----------------------------------------------------------------+
| Agent Orchestrator (Controller) |
| |
| +----------+ Tauri IPC +------------------------------+ |
| | WebView | <------------> | Rust Backend | |
| | (Svelte) | | | |
| +----------+ | +-- PtyManager (local) | |
| | +-- SidecarManager (local) | |
| | +-- RemoteManager ----------+-+
| +------------------------------+ |
+----------------------------------------------------------------+
| |
| (local stdio) | (WebSocket wss://)
v v
+-----------+ +----------------------+
| Local | | Remote Machine |
| Sidecar | | +--------------+ |
| (Deno/ | | | agor-relay | |
| Node.js) | | | (Rust binary) | |
+-----------+ | | | |
| | +-- PTY mgr | |
| | +-- Sidecar | |
| | +-- WS server| |
| +--------------+ |
+----------------------+
```
### Components
#### 1. `agor-relay` — Remote Agent (Rust binary)
A standalone Rust binary that runs on each remote machine:
- Listens on a WebSocket port (default: 9750)
- Manages local PTYs and sidecar processes
- Forwards NDJSON events to the controller over WebSocket
- Receives commands (query, stop, resize, write) from the controller
Reuses `PtyManager` and `SidecarManager` from `agor-core`.
#### 2. `RemoteManager` — Controller-Side
Module in `src-tauri/src/remote.rs`. Manages WebSocket connections to multiple relays. 12 Tauri commands for remote operations.
#### 3. Frontend Adapters — Unified Interface
The frontend doesn't care whether a pane is local or remote. Bridge adapters check `remoteMachineId` and route accordingly.
## Protocol
### WebSocket Wire Format
Same NDJSON as local sidecar, wrapped in an envelope for multiplexing:
```typescript
// Controller -> Relay (commands)
interface RelayCommand {
id: string;
type: 'pty_create' | 'pty_write' | 'pty_resize' | 'pty_close'
| 'agent_query' | 'agent_stop' | 'sidecar_restart' | 'ping';
payload: Record<string, unknown>;
}
// Relay -> Controller (events)
interface RelayEvent {
type: 'pty_data' | 'pty_exit' | 'pty_created'
| 'sidecar_message' | 'sidecar_exited'
| 'error' | 'pong' | 'ready';
sessionId?: string;
payload: unknown;
}
```
### Authentication
1. **Pre-shared token** — relay starts with `--token <secret>`. Controller sends token in WebSocket upgrade headers.
2. **TLS required** — relay rejects non-TLS connections in production mode. Dev mode allows `ws://` with `--insecure` flag.
3. **Rate limiting** — 10 failed auth attempts triggers 5-minute lockout.
### Reconnection
- Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s cap
- Uses `attempt_tcp_probe()`: TCP-only, 5s timeout (avoids allocating resources on relay during probes)
- Emits `remote-machine-reconnecting` and `remote-machine-reconnect-ready` events
- Active agent sessions continue on relay regardless of controller connection
### Session Persistence Across Reconnects
Remote agents keep running even when the controller disconnects. On reconnect:
1. Relay sends state sync with active sessions and PTYs
2. Controller reconciles and updates pane states
3. Missed messages are NOT replayed (agent panes show "reconnected" notice)
## Implementation Summary
### Phase A: Extract `agor-core` crate
Cargo workspace with PtyManager, SidecarManager, EventSink trait extracted to shared crate.
### Phase B: Build `agor-relay` binary
WebSocket server with token auth, per-connection isolated managers, structured command responses with commandId correlation.
### Phase C: Add `RemoteManager` to controller
12 Tauri commands, heartbeat ping every 15s, exponential backoff reconnection.
### Phase D: Frontend integration
`remote-bridge.ts` adapter, `machines.svelte.ts` store, `Pane.remoteMachineId` routing field.
### Remaining Work
- [ ] Real-world relay testing (2 machines)
- [ ] TLS/certificate pinning
## Security
| Threat | Mitigation |
|--------|-----------|
| Token interception | TLS required |
| Token brute-force | Rate limit + lockout |
| Relay impersonation | Certificate pinning (future: mTLS) |
| Command injection | Payload schema validation |
| Lateral movement | Unprivileged user, no shell beyond PTY/sidecar |
| Data exfiltration | Agent output streams to controller only |
## Performance
| Concern | Mitigation |
|---------|-----------|
| WebSocket latency | LAN: <1ms, WAN: 20-100ms (acceptable for text) |
| Bandwidth | Agent NDJSON: ~50KB/s peak, Terminal: ~200KB/s peak |
| Connection count | Max 10 machines (UI constraint) |
| Message ordering | Single WebSocket per machine = ordered delivery |
## Future (Not Covered)
- Multi-controller (multiple agor instances observing same relay)
- Relay discovery (mDNS/Bonjour)
- Agent migration between machines
- Relay-to-relay communication
- mTLS for enterprise environments

View file

@ -1,283 +0,0 @@
# Plugin Development Guide
## Overview
Agents Orchestrator plugins are self-contained bundles that run in a sandboxed Web Worker. A plugin consists of a manifest file (`plugin.json`) and an entry point script (`index.js`). Plugins interact with the host application through a message-passing API gated by declared permissions.
## Plugin Anatomy
A minimal plugin directory:
```
~/.config/bterminal/plugins/my-plugin/
plugin.json -- Manifest (required)
index.js -- Entry point (required)
```
### Manifest: plugin.json
```json
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"description": "A brief description of what this plugin does.",
"author": "Your Name",
"entry": "index.js",
"permissions": [
"notifications",
"tasks"
]
}
```
**Required fields:**
| Field | Type | Description |
|-------|------|-------------|
| `id` | `string` | Unique identifier (lowercase, hyphens allowed) |
| `name` | `string` | Human-readable display name |
| `version` | `string` | Semver version string |
| `description` | `string` | Short description (under 200 characters) |
| `entry` | `string` | Relative path to entry point script |
| `permissions` | `string[]` | List of API permissions the plugin requires |
**Optional fields:**
| Field | Type | Description |
|-------|------|-------------|
| `author` | `string` | Plugin author name |
| `homepage` | `string` | URL to plugin documentation |
| `minVersion` | `string` | Minimum Agents Orchestrator version required |
### Entry Point: index.js
The entry point is loaded as a Web Worker module. The `agor` global object provides the plugin API.
```javascript
// index.js
const { meta, notifications } = agor;
console.log(`${meta.id} v${meta.version} loaded`);
notifications.send({
title: meta.name,
body: 'Plugin activated.',
type: 'info',
});
```
## Web Worker Sandbox
Plugins run in an isolated Web Worker context. The sandbox enforces strict boundaries:
**Not available:**
- Filesystem access (no `fs`, no `Deno.readFile`, no `fetch` to `file://`)
- Network access (no `fetch`, no `XMLHttpRequest`, no WebSocket)
- DOM access (no `document`, no `window`)
- Tauri IPC (no `invoke`, no event listeners)
- Dynamic imports (no `import()`, no `importScripts()`)
**Available:**
- Standard JavaScript built-ins (`JSON`, `Map`, `Set`, `Promise`, `Date`, etc.)
- `console.log/warn/error` (routed to host debug output)
- `setTimeout`, `setInterval`, `clearTimeout`, `clearInterval`
- The `agor` API object (permission-gated)
## Plugin API
All API methods are asynchronous and return Promises. Each API namespace requires a corresponding permission declared in `plugin.json`.
### agor.meta
Always available (no permission required).
```typescript
interface PluginMeta {
id: string; // Plugin ID from manifest
version: string; // Plugin version from manifest
name: string; // Plugin display name
}
```
### agor.palette
**Permission:** `"palette"`
Register commands in the application command palette.
```typescript
// Register a command
agor.palette.register({
id: 'my-plugin.greet',
label: 'My Plugin: Say Hello',
callback: () => {
agor.notifications.send({
title: 'Hello',
body: 'Greetings from the plugin.',
type: 'info',
});
},
});
// Unregister a command
agor.palette.unregister('my-plugin.greet');
```
### agor.messages
**Permission:** `"messages"` -- Read agent messages for the active session (read-only).
- `agor.messages.list(sessionId)` -- returns `Array<{ role, content, timestamp }>`.
- `agor.messages.count(sessionId)` -- returns message count.
### agor.tasks
**Permission:** `"tasks"` -- Read/write task board entries (see Tasks-as-KV below).
- `agor.tasks.create({ title, description, status })` -- returns task ID.
- `agor.tasks.list({ status?, limit? })` -- returns task array.
- `agor.tasks.updateStatus(taskId, status)` -- updates status.
- `agor.tasks.delete(taskId)` -- removes task.
### agor.events
**Permission:** `"events"` -- Subscribe to application events.
- `agor.events.on(eventName, callback)` -- returns unsubscribe function.
- Events: `agent:status`, `agent:message`, `agent:complete`, `agent:error`.
- Each event payload includes `sessionId` plus event-specific fields.
### agor.notifications
**Permission:** `"notifications"` -- Send toast notifications.
- `agor.notifications.send({ title, body, type })` -- type: info | success | warning | error.
## Permission Model
Each API namespace is gated by a permission string. If a plugin calls an API it has not declared in its `permissions` array, the call is rejected with a `PermissionDenied` error.
| Permission | API Namespace | Access Level |
|------------|---------------|-------------|
| `palette` | `agor.palette` | Register/unregister commands |
| `messages` | `agor.messages` | Read-only agent messages |
| `tasks` | `agor.tasks` | Read/write tasks |
| `events` | `agor.events` | Subscribe to events |
| `notifications` | `agor.notifications` | Send toast notifications |
Permissions are displayed to the user during plugin installation. Users must accept the permission list to proceed.
## Tasks-as-KV Pattern
Plugins that need persistent key-value storage use the task system with prefixed keys. This avoids adding a separate storage layer.
### Convention
Task titles use the format `plugin:{plugin-id}:{key}`. The task description field holds the value (string, or JSON-serialized for structured data).
```javascript
// Store a value
await agor.tasks.create({
title: `plugin:${agor.meta.id}:last-run`,
description: JSON.stringify({ timestamp: Date.now(), count: 42 }),
status: 'done',
});
// Retrieve a value
const tasks = await agor.tasks.list({ limit: 1 });
const entry = tasks.find(t => t.title === `plugin:${agor.meta.id}:last-run`);
const data = JSON.parse(entry.description);
```
### LRU Cap
Plugins should limit their KV entries to avoid unbounded growth. Recommended cap: 100 entries per plugin. When creating a new entry that would exceed the cap, delete the oldest entry first.
### Purge
On plugin uninstall, all tasks with the `plugin:{plugin-id}:` prefix are automatically purged.
## Shared Utilities
Two helper functions are injected into the Web Worker global scope for common patterns:
### safeGet(obj, path, defaultValue)
Safe property access for nested objects. Avoids `TypeError` on undefined intermediate properties.
```javascript
const value = safeGet(event, 'data.session.cost', 0);
```
### safeMsg(template, ...args)
String interpolation with type coercion and truncation (max 500 characters per argument).
```javascript
const msg = safeMsg('Session {} completed with {} turns', sessionId, turnCount);
```
## Example Plugin: hello-world
```json
{
"id": "hello-world",
"name": "Hello World",
"version": "1.0.0",
"description": "Minimal example plugin that greets the user.",
"entry": "index.js",
"permissions": ["palette", "notifications"]
}
```
```javascript
// index.js
const { meta, palette, notifications } = agor;
palette.register({
id: 'hello-world.greet',
label: 'Hello World: Greet',
callback: () => {
notifications.send({
title: 'Hello',
body: `Greetings from ${meta.name} v${meta.version}.`,
type: 'info',
});
},
});
```
## Publishing
To publish a plugin to the marketplace:
1. Create a directory in the `agents-orchestrator/agor-plugins` repository under `plugins/{plugin-id}/`.
2. Add the plugin files (`plugin.json`, `index.js`, and any supporting files).
3. Create a `.tar.gz` archive of the plugin directory.
4. Compute the SHA-256 checksum: `sha256sum my-plugin.tar.gz`.
5. Add an entry to `catalog.json` with the download URL, checksum, and metadata.
6. Submit a pull request to the `agor-plugins` repository.
The catalog maintainers review the plugin for security (no obfuscated code, reasonable permissions) and functionality before merging.
## Scaffolding
Use the scaffolding script to generate a new plugin skeleton:
```bash
./scripts/plugin-init.sh my-plugin "My Plugin" "A description of my plugin"
```
This creates:
```
~/.config/bterminal/plugins/my-plugin/
plugin.json -- Pre-filled manifest
index.js -- Minimal entry point with palette command stub
```
The script prompts for permissions to declare. Generated files include comments explaining each section.

View file

@ -1,110 +0,0 @@
# Agents Orchestrator Pro Edition
> This documentation covers Pro edition features available in the agents-orchestrator/agents-orchestrator private repository.
## Overview
Agents Orchestrator Pro extends the open-source community edition with commercial features for teams and organizations that need deeper analytics, cost controls, persistent agent knowledge, and a plugin marketplace. Pro features are architecturally isolated from the community codebase, implemented as a Tauri plugin crate and a separate Svelte module tree.
## Feature Summary
| Feature | Description | Documentation |
|---------|-------------|---------------|
| Analytics Dashboard | Session cost trends, model usage breakdown, daily statistics | [analytics.md](features/analytics.md) |
| Budget Governor | Per-project monthly token budgets with soft/hard limits | [cost-intelligence.md](features/cost-intelligence.md) |
| Smart Model Router | Cost-aware model selection with routing profiles | [cost-intelligence.md](features/cost-intelligence.md) |
| Persistent Agent Memory | Per-project knowledge fragments with FTS5 search and TTL | [knowledge-base.md](features/knowledge-base.md) |
| Codebase Symbol Graph | Regex-based symbol extraction and caller lookup | [knowledge-base.md](features/knowledge-base.md) |
| Git Context Injection | Branch, commit, and diff context for agent prompts | [git-integration.md](features/git-integration.md) |
| Branch Policy | Session-level protection for sensitive branches | [git-integration.md](features/git-integration.md) |
| Plugin Marketplace | Curated plugin catalog with install/update lifecycle | [marketplace/README.md](marketplace/README.md) |
## Architecture
Pro features are separated from the community codebase at two layers:
### Rust Backend: `agor-pro` Tauri Plugin Crate
The `agor-pro` crate is a standalone Tauri plugin located at `crates/agor-pro/` in the private repository. It contains all Pro-specific Rust logic: SQLite tables, commands, and business rules.
The crate exposes a single entry point:
```rust
pub fn init() -> TauriPlugin<tauri::Wry> { ... }
```
### Svelte Frontend: `src/lib/commercial/`
Pro UI components live under `src/lib/commercial/` with their own adapters and stores. Community components never import from this path. The commercial module tree mirrors the community structure:
```
src/lib/commercial/
adapters/ -- IPC bridges for pro commands
components/ -- Pro-specific UI (analytics, budgets, marketplace)
stores/ -- Pro-specific state
```
## Feature Flag
Pro features are gated at both compile time and runtime.
### Cargo Feature Flag
The `pro` feature flag controls Rust compilation:
```bash
# Community build (default)
cargo build -p bterminal
# Pro build
cargo build -p bterminal --features pro
```
When `--features pro` is active, `lib.rs` registers the plugin:
```rust
#[cfg(feature = "pro")]
app.handle().plugin(agor_pro::init());
```
### Frontend Edition Flag
The `AGOR_EDITION` environment variable controls frontend feature visibility:
```bash
# Community build (default)
AGOR_EDITION=community npm run tauri build
# Pro build
AGOR_EDITION=pro npm run tauri build
```
Svelte components check this at module level:
```typescript
const isPro = import.meta.env.VITE_AGOR_EDITION === 'pro';
```
## IPC Pattern
All Pro commands follow the Tauri plugin IPC convention. Commands are namespaced under `plugin:agor-pro`:
```typescript
import { invoke } from '@tauri-apps/api/core';
const summary = await invoke('plugin:agor-pro|pro_analytics_summary', {
period: 30,
});
```
Command names use the `pro_` prefix consistently. Arguments are passed as a single object. Return types are JSON-serialized Rust structs with `#[serde(rename_all = "camelCase")]`.
## Error Handling
Pro commands return `Result<T, String>` on the Rust side. The frontend adapters in `src/lib/commercial/adapters/` wrap `invoke()` calls and surface errors through the standard notification system. When the Pro plugin is not loaded (community build), invoke calls fail with a predictable `plugin not found` error that the adapters catch and handle silently.
## Database
Pro features use a dedicated SQLite database at `~/.local/share/bterminal/agor_pro.db` (WAL mode, 5s busy timeout). This keeps Pro data isolated from community tables. The `pro_budgets` and `pro_budget_log` tables are created on plugin init via migrations.
Read-only access to community tables (e.g., `session_metrics` in `sessions.db`) is done through a separate read-only connection.

View file

@ -1,177 +0,0 @@
# Analytics Dashboard
> This documentation covers Pro edition features available in the agents-orchestrator/agents-orchestrator private repository.
## Overview
The Analytics Dashboard provides historical cost and usage visibility across all projects. It reads from the community `session_metrics` table (no additional data collection required) and presents aggregated views through three commands and corresponding UI components.
## Data Source
All analytics are derived from the existing `session_metrics` table in `sessions.db`:
```sql
-- Community table (read-only access from Pro plugin)
CREATE TABLE session_metrics (
id INTEGER PRIMARY KEY,
project_id TEXT NOT NULL,
session_id TEXT NOT NULL,
started_at TEXT,
ended_at TEXT,
peak_tokens INTEGER,
turn_count INTEGER,
tool_call_count INTEGER,
cost_usd REAL,
model TEXT,
status TEXT,
error_message TEXT
);
```
The Pro plugin opens a read-only connection to `sessions.db` and queries this table. It never writes to community databases.
## Commands
### pro_analytics_summary
Returns an aggregated summary for the specified period.
**Arguments:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `period` | `u32` | Yes | Number of days to look back (7, 14, 30, or 90) |
| `projectId` | `String` | No | Filter to a single project. Omit for all projects. |
**Response: `AnalyticsSummary`**
```typescript
interface AnalyticsSummary {
totalCostUsd: number;
totalSessions: number;
totalTurns: number;
totalToolCalls: number;
avgCostPerSession: number;
avgTurnsPerSession: number;
peakTokensMax: number;
periodDays: number;
projectCount: number;
}
```
### pro_analytics_daily
Returns per-day statistics for charting.
**Arguments:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `period` | `u32` | Yes | Number of days (7, 14, 30, or 90) |
| `projectId` | `String` | No | Filter to a single project |
**Response: `Vec<DailyStats>`**
```typescript
interface DailyStats {
date: string; // ISO 8601 date (YYYY-MM-DD)
costUsd: number;
sessionCount: number;
turnCount: number;
toolCallCount: number;
}
```
Days with no sessions are included with zero values to ensure continuous chart data.
### pro_analytics_model_breakdown
Returns usage grouped by model.
**Arguments:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `period` | `u32` | Yes | Number of days (7, 14, 30, or 90) |
| `projectId` | `String` | No | Filter to a single project |
**Response: `Vec<ModelBreakdown>`**
```typescript
interface ModelBreakdown {
model: string;
sessionCount: number;
totalCostUsd: number;
avgCostPerSession: number;
totalTurns: number;
totalToolCalls: number;
pctOfTotalCost: number; // 0.0 to 100.0
}
```
Results are sorted by `totalCostUsd` descending.
## Period Selection
The UI presents four period options:
| Period | Label | Use Case |
|--------|-------|----------|
| 7 | Last 7 days | Recent activity check |
| 14 | Last 14 days | Sprint review |
| 30 | Last 30 days | Monthly cost review |
| 90 | Last 90 days | Quarterly trend analysis |
The selected period is stored in the component state and defaults to 30 days. Changing the period triggers a fresh query (no client-side caching).
## UI Components
### AnalyticsDashboard.svelte
Top-level Pro tab component. Contains:
1. **Period selector** -- segmented button group (7/14/30/90).
2. **Summary cards** -- four stat cards showing total cost, sessions, avg cost/session, peak tokens.
3. **Daily cost chart** -- SVG bar chart rendered from `DailyStats[]`.
4. **Model breakdown table** -- sortable table from `ModelBreakdown[]`.
### Daily Cost Chart
The chart is a pure SVG element (no charting library). Implementation details:
- Bars are sized relative to the maximum daily cost in the period.
- X-axis labels show abbreviated dates (e.g., "Mar 5").
- Y-axis shows cost in USD with appropriate precision ($0.01 for small values, $1.00 for large).
- Hover tooltip shows exact cost, session count, and turn count for the day.
- Colors use `var(--ctp-blue)` for bars, `var(--ctp-surface1)` for gridlines.
- Responsive: adapts bar width to container via `container-type: inline-size`.
### Model Breakdown Table
Standard sortable table with columns: Model, Sessions, Cost, Avg Cost, Turns, Tools, % of Total. Default sort by cost descending. The `% of Total` column includes a proportional bar using `var(--ctp-sapphire)`.
## IPC Examples
```typescript
// Get 30-day summary across all projects
const summary = await invoke('plugin:agor-pro|pro_analytics_summary', {
period: 30,
});
// Get daily stats for a specific project
const daily = await invoke('plugin:agor-pro|pro_analytics_daily', {
period: 14,
projectId: 'my-project',
});
// Get model breakdown
const models = await invoke('plugin:agor-pro|pro_analytics_model_breakdown', {
period: 90,
});
```
## Limitations
- Analytics are computed on-demand from raw session_metrics rows. For large datasets (10,000+ sessions), queries may take 100-200ms. No materialized views or pre-aggregation.
- Historical data depends on community session_metrics persistence. If sessions.db is deleted or migrated, analytics history resets.
- Cost values are only available for providers that report cost (Claude, Codex). Ollama sessions show $0.00.

View file

@ -1,238 +0,0 @@
# Cost Intelligence
> This documentation covers Pro edition features available in the agents-orchestrator/agents-orchestrator private repository.
Cost Intelligence comprises two systems: the Budget Governor (hard cost controls) and the Smart Model Router (cost-aware model selection). Both operate at the per-project level and integrate with the agent launch pipeline.
---
## Budget Governor
### Purpose
The Budget Governor enforces per-project monthly token budgets. It prevents runaway costs by warning at a soft limit and blocking agent launches at a hard limit.
### Budget Configuration
Each project can have a monthly budget with two thresholds:
| Threshold | Default | Behavior |
|-----------|---------|----------|
| Soft limit | 80% of budget | Emits a warning notification. Agent proceeds. |
| Hard limit | 100% of budget | Blocks agent launch. Returns error to UI. |
Budgets are denominated in USD. The governor tracks cumulative cost for the current calendar month by summing `cost_usd` from `session_metrics` and `pro_budget_log`.
### Emergency Override
When the hard limit is reached, the UI displays a confirmation dialog with the current spend and budget. The user can authorize an emergency override that allows the next session to proceed. Overrides are logged in `pro_budget_log` with `override = true`. Each override is single-use; subsequent launches require a new override.
### SQLite Tables
Both tables reside in `agor_pro.db`.
```sql
CREATE TABLE pro_budgets (
project_id TEXT PRIMARY KEY,
monthly_budget_usd REAL NOT NULL,
soft_limit_pct REAL NOT NULL DEFAULT 0.80,
hard_limit_pct REAL NOT NULL DEFAULT 1.00,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE pro_budget_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL,
session_id TEXT NOT NULL,
cost_usd REAL NOT NULL,
cumulative_usd REAL NOT NULL,
month TEXT NOT NULL, -- YYYY-MM format
override INTEGER NOT NULL DEFAULT 0,
logged_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
### Commands
#### pro_budget_set
Creates or updates a budget for a project.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
| `monthlyBudgetUsd` | `f64` | Yes | Monthly budget in USD |
| `softLimitPct` | `f64` | No | Soft limit percentage (default 0.80) |
| `hardLimitPct` | `f64` | No | Hard limit percentage (default 1.00) |
#### pro_budget_get
Returns the budget configuration for a project. Returns `null` if no budget is set.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
#### pro_budget_check
Pre-dispatch hook. Checks whether the project can launch a new agent session.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
**Response: `BudgetCheckResult`**
```typescript
interface BudgetCheckResult {
allowed: boolean;
currentSpendUsd: number;
budgetUsd: number;
pctUsed: number; // 0.0 to 1.0
softLimitReached: boolean;
hardLimitReached: boolean;
month: string; // YYYY-MM
}
```
#### pro_budget_log_usage
Records cost for a completed session. Called by the agent dispatcher on session end.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
| `sessionId` | `String` | Yes | Session identifier |
| `costUsd` | `f64` | Yes | Session cost |
| `override` | `bool` | No | Whether this was an emergency override |
#### pro_budget_reset
Resets the cumulative spend for a project's current month. Deletes all `pro_budget_log` entries for the current month.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
#### pro_budget_list
Returns budgets and current spend for all projects.
**Response: `Vec<ProjectBudgetStatus>`**
```typescript
interface ProjectBudgetStatus {
projectId: string;
monthlyBudgetUsd: number;
currentSpendUsd: number;
pctUsed: number;
softLimitReached: boolean;
hardLimitReached: boolean;
}
```
### Pre-Dispatch Integration
The budget check is wired into the agent launch path:
1. User triggers agent start in AgentPane.
2. `AgentSession.startQuery()` calls `pro_budget_check` before invoking the sidecar.
3. If `softLimitReached`: toast warning, agent proceeds.
4. If `hardLimitReached`: blocks launch, shows override dialog.
5. On override confirmation: calls `pro_budget_log_usage` with `override: true`, then proceeds.
6. On session completion: `agent-dispatcher` calls `pro_budget_log_usage` with actual cost.
---
## Smart Model Router
### Purpose
The Smart Model Router recommends which model to use for a given agent session based on the project's routing profile and the agent's role. It does not force model selection -- it provides a recommendation that the user can accept or override.
### Routing Profiles
Three built-in profiles define model preferences:
#### CostSaver
Minimizes cost by preferring smaller models for routine tasks.
| Role | Recommended Model |
|------|-------------------|
| Manager | claude-sonnet-4-5-20250514 |
| Architect | claude-sonnet-4-5-20250514 |
| Tester | claude-haiku-4-5-20250514 |
| Reviewer | claude-haiku-4-5-20250514 |
| Default (no role) | claude-sonnet-4-5-20250514 |
#### QualityFirst
Maximizes output quality by preferring the most capable model.
| Role | Recommended Model |
|------|-------------------|
| Manager | claude-opus-4-5-20250514 |
| Architect | claude-opus-4-5-20250514 |
| Tester | claude-sonnet-4-5-20250514 |
| Reviewer | claude-sonnet-4-5-20250514 |
| Default (no role) | claude-opus-4-5-20250514 |
#### Balanced
Default profile. Balances cost and quality per role.
| Role | Recommended Model |
|------|-------------------|
| Manager | claude-sonnet-4-5-20250514 |
| Architect | claude-opus-4-5-20250514 |
| Tester | claude-haiku-4-5-20250514 |
| Reviewer | claude-sonnet-4-5-20250514 |
| Default (no role) | claude-sonnet-4-5-20250514 |
### Commands
#### pro_router_recommend
Returns the recommended model for a given project and role.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
| `role` | `String` | No | Agent role (manager/architect/tester/reviewer) |
**Response:**
```typescript
interface RouterRecommendation {
model: string;
profile: string; // CostSaver | QualityFirst | Balanced
reason: string; // Human-readable explanation
}
```
#### pro_router_set_profile
Sets the routing profile for a project.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
| `profile` | `String` | Yes | Profile name: CostSaver, QualityFirst, or Balanced |
#### pro_router_get_profile
Returns the current routing profile for a project. Defaults to Balanced.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
#### pro_router_list_profiles
Returns all available routing profiles with their role-to-model mappings. Takes no arguments.
### Integration with Agent Launch
The router recommendation is surfaced in the AgentPane model selector. When a project has a routing profile set, the recommended model appears as a highlighted option in the dropdown. The user retains full control to select any available model.

View file

@ -1,196 +0,0 @@
# Git Integration
> This documentation covers Pro edition features available in the agents-orchestrator/agents-orchestrator private repository.
Git Integration provides two features: Git Context Injection (branch, commit, and diff information formatted for agent prompts) and Branch Policy (session-level protection for sensitive branches).
---
## Git Context Injection
### Purpose
Git Context Injection gathers repository state and formats it as markdown for inclusion in agent system prompts. This gives agents awareness of the current branch, recent commits, and modified files without requiring them to run git commands.
### Context Gathering
The system collects three categories of information by invoking the `git` CLI:
#### Branch Information
- Current branch name (`git rev-parse --abbrev-ref HEAD`)
- Tracking branch and ahead/behind counts (`git rev-list --left-right --count`)
- Last commit on branch (hash, author, date, subject)
#### Recent Commits
- Last N commits on the current branch (default: 10)
- Each commit includes: short hash, author, relative date, subject line
- Collected via `git log --oneline --format`
#### Modified Files
- Staged files (`git diff --cached --name-status`)
- Unstaged modifications (`git diff --name-status`)
- Untracked files (`git ls-files --others --exclude-standard`)
### Formatted Output
The collected information is formatted as a markdown section:
```markdown
## Git Context
**Branch:** feature/new-dashboard (ahead 3, behind 0 of origin/main)
### Recent Commits (last 10)
- `a1b2c3d` (2 hours ago) fix: resolve null check in analytics
- `e4f5g6h` (5 hours ago) feat: add daily cost chart
- ...
### Working Tree
**Staged:**
- M src/lib/components/Analytics.svelte
- A src/lib/stores/analytics.svelte.ts
**Modified:**
- M src/lib/adapters/pro-bridge.ts
**Untracked:**
- tests/analytics.test.ts
```
### Commands
#### pro_git_context
Gathers all git context for a project directory and returns formatted markdown.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `cwd` | `String` | Yes | Absolute path to the git repository |
| `commitCount` | `u32` | No | Number of recent commits to include (default: 10) |
**Response:**
```typescript
interface GitContext {
markdown: string; // Formatted markdown section
branch: string; // Current branch name
isDirty: boolean; // Has uncommitted changes
aheadBehind: [number, number]; // [ahead, behind]
}
```
#### pro_git_inject
Convenience command that gathers git context and prepends it to a given system prompt.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `cwd` | `String` | Yes | Repository path |
| `systemPrompt` | `String` | Yes | Existing system prompt to augment |
| `commitCount` | `u32` | No | Number of recent commits (default: 10) |
Returns the combined prompt string.
#### pro_git_branch_info
Returns structured branch information without formatting.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `cwd` | `String` | Yes | Repository path |
**Response:**
```typescript
interface BranchInfo {
name: string;
trackingBranch: string | null;
ahead: number;
behind: number;
lastCommitHash: string;
lastCommitSubject: string;
lastCommitDate: string;
}
```
### Implementation Notes
- All git commands are executed via `std::process::Command` (not libgit2). This avoids a heavy native dependency and matches the git CLI behavior users expect.
- Commands run with a 5-second timeout. If git is not installed or the directory is not a repository, commands return structured errors.
- Output encoding is handled as UTF-8 with lossy conversion for non-UTF-8 paths.
---
## Branch Policy
### Purpose
Branch Policy prevents agents from making commits or modifications on protected branches. This is a session-level safeguard -- the policy is checked when an agent session starts and when git operations are detected in tool calls.
### Protection Rules
Protected branch patterns are configurable per project. The defaults are:
| Pattern | Matches |
|---------|---------|
| `main` | Exact match |
| `master` | Exact match |
| `release/*` | Any branch starting with `release/` |
When an agent session starts on a protected branch, the system emits a warning notification. It does not block the session, because agents may need to read code on these branches. However, the branch name is included in the agent's system prompt with a clear instruction not to commit.
### Commands
#### pro_branch_check
Checks whether the current branch is protected.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `cwd` | `String` | Yes | Repository path |
| `projectId` | `String` | Yes | Project identifier (for project-specific policies) |
**Response:**
```typescript
interface BranchCheckResult {
branch: string;
isProtected: boolean;
matchedPattern: string | null; // Which pattern matched
}
```
#### pro_branch_policy_set
Sets custom protected branch patterns for a project. Replaces any existing patterns.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
| `patterns` | `Vec<String>` | Yes | Branch patterns (exact names or glob with `*`) |
#### pro_branch_policy_get
Returns the current branch policy for a project. Returns default patterns if none are set.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
#### pro_branch_policy_delete
Removes custom branch policy for a project, reverting to defaults.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
### Integration
Branch policy is checked at two points:
1. **Session start:** `AgentSession.startQuery()` calls `pro_branch_check`. If protected, a warning toast is shown and the branch protection instruction is appended to the system prompt.
2. **System prompt injection:** The formatted git context (from `pro_git_inject`) includes a `PROTECTED BRANCH` warning banner when applicable.

View file

@ -1,247 +0,0 @@
# Knowledge Base
> This documentation covers Pro edition features available in the agents-orchestrator/agents-orchestrator private repository.
The Knowledge Base provides two complementary systems: Persistent Agent Memory (structured knowledge fragments with search and TTL) and the Codebase Symbol Graph (regex-based symbol extraction for code navigation context).
---
## Persistent Agent Memory
### Purpose
Persistent Agent Memory stores knowledge fragments that agents produce during sessions and makes them available in future sessions. Unlike session anchors (community feature, per-session), memory fragments persist across sessions and are searchable via FTS5.
### Memory Fragments
A memory fragment is a discrete piece of knowledge with metadata:
```typescript
interface MemoryFragment {
id: number;
projectId: string;
content: string; // The knowledge itself (plain text or markdown)
source: string; // Where it came from (session ID, file path, user)
trustTier: TrustTier; // agent | human | auto
tags: string[]; // Categorization tags
createdAt: string; // ISO 8601
updatedAt: string; // ISO 8601
expiresAt: string | null; // ISO 8601, null = never expires
accessCount: number; // Times retrieved for injection
}
type TrustTier = 'agent' | 'human' | 'auto';
```
### Trust Tiers
| Tier | Source | Injection Priority | Editable |
|------|--------|-------------------|----------|
| `human` | Created or approved by user | Highest | Yes |
| `agent` | Extracted by agent during session | Medium | Yes |
| `auto` | Auto-extracted from patterns | Lowest | Yes |
When injecting memories into agent prompts, higher-trust memories are prioritized. Within the same tier, more recently accessed memories rank higher.
### TTL (Time-To-Live)
Memories can have an optional expiration date. Expired memories are excluded from search results and injection. A background cleanup runs on plugin init, deleting memories expired more than 30 days ago.
Default TTL by trust tier:
| Tier | Default TTL |
|------|-------------|
| `human` | None (permanent) |
| `agent` | 90 days |
| `auto` | 30 days |
Users can override TTL on any individual memory.
### Auto-Extraction
When an agent session completes, the dispatcher can trigger auto-extraction. The extractor scans the session transcript for:
- Explicit knowledge statements ("I learned that...", "Note:", "Important:")
- Error resolutions (error message followed by successful fix)
- Configuration discoveries (env vars, file paths, API endpoints)
Extracted fragments are created with `trustTier: 'auto'` and default TTL. The user can promote them to `agent` or `human` tier.
### Memory Injection
Before an agent session starts, the top-K most relevant memories are retrieved and formatted into a `## Project Knowledge` section in the system prompt. Relevance is determined by FTS5 rank score against the project context (project name, CWD, recent file paths).
Default K = 5. Configurable per project via `pro_memory_set_config`.
### SQLite Schema
In `agor_pro.db`:
```sql
CREATE TABLE pro_memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id TEXT NOT NULL,
content TEXT NOT NULL,
source TEXT NOT NULL,
trust_tier TEXT NOT NULL DEFAULT 'auto',
tags TEXT NOT NULL DEFAULT '[]', -- JSON array
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT,
access_count INTEGER NOT NULL DEFAULT 0
);
CREATE VIRTUAL TABLE pro_memories_fts USING fts5(
content,
tags,
content=pro_memories,
content_rowid=id
);
CREATE INDEX idx_pro_memories_project ON pro_memories(project_id);
CREATE INDEX idx_pro_memories_expires ON pro_memories(expires_at);
```
### Commands
#### pro_memory_create
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
| `content` | `String` | Yes | Memory content |
| `source` | `String` | Yes | Origin (session ID, "user", file path) |
| `trustTier` | `String` | No | agent, human, or auto (default: auto) |
| `tags` | `Vec<String>` | No | Categorization tags |
| `ttlDays` | `u32` | No | Days until expiration (null = tier default) |
#### pro_memory_search
FTS5 search across memory fragments for a project.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
| `query` | `String` | Yes | FTS5 search query |
| `limit` | `u32` | No | Max results (default: 10) |
#### pro_memory_get, pro_memory_update, pro_memory_delete
Standard CRUD by memory ID.
#### pro_memory_list
List memories for a project with optional filters.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
| `trustTier` | `String` | No | Filter by tier |
| `tag` | `String` | No | Filter by tag |
| `limit` | `u32` | No | Max results (default: 50) |
#### pro_memory_inject
Returns formatted memory text for system prompt injection.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
| `contextHints` | `Vec<String>` | No | Additional FTS5 terms for relevance |
| `topK` | `u32` | No | Number of memories to include (default: 5) |
#### pro_memory_set_config
Sets memory injection configuration for a project.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
| `topK` | `u32` | No | Default injection count |
| `autoExtract` | `bool` | No | Enable auto-extraction on session end |
---
## Codebase Symbol Graph
### Purpose
The Symbol Graph provides structural code awareness by scanning source files with regex patterns to extract function/method/class/struct definitions and build a lightweight call graph. This gives agents contextual knowledge about code structure without requiring a full language server.
### Scanning
The scanner processes files matching configurable glob patterns. Default extensions:
| Language | Extensions | Patterns Extracted |
|----------|------------|--------------------|
| TypeScript | `.ts`, `.tsx` | functions, classes, interfaces, type aliases, exports |
| Rust | `.rs` | functions, structs, enums, traits, impls |
| Python | `.py` | functions, classes, decorators |
Scanning is triggered manually or on project open. Results are stored in `agor_pro.db`. A full re-scan replaces all symbols for the project.
### Symbol Types
```typescript
interface CodeSymbol {
id: number;
projectId: string;
filePath: string; // Relative to project root
name: string; // Symbol name
kind: SymbolKind; // function | class | struct | enum | trait | interface | type
line: number; // Line number (1-based)
signature: string; // Full signature line
parentName: string | null; // Enclosing class/struct/impl
}
type SymbolKind = 'function' | 'class' | 'struct' | 'enum' | 'trait' | 'interface' | 'type';
```
### Commands
#### pro_symbols_scan
Triggers a full scan of the project's source files.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
| `rootPath` | `String` | Yes | Absolute path to project root |
| `extensions` | `Vec<String>` | No | File extensions to scan (default: ts,rs,py) |
#### pro_symbols_search
Search symbols by name (prefix match).
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
| `query` | `String` | Yes | Symbol name prefix |
| `kind` | `String` | No | Filter by symbol kind |
| `limit` | `u32` | No | Max results (default: 20) |
#### pro_symbols_find_callers
Searches for references to a symbol name across the project's scanned files using text matching.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
| `symbolName` | `String` | Yes | Symbol to find references to |
Returns file paths and line numbers where the symbol name appears (excluding its definition).
#### pro_symbols_file
Returns all symbols in a specific file.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `projectId` | `String` | Yes | Project identifier |
| `filePath` | `String` | Yes | Relative file path |
### Limitations
- Regex-based extraction is approximate. It does not parse ASTs and may miss symbols in unusual formatting or produce false positives in comments/strings.
- `find_callers` uses text matching, not semantic analysis. It will find string matches in comments and string literals.
- Large codebases (10,000+ files) may take several seconds to scan. Scanning runs on a background thread and emits a completion event.

View file

@ -1,173 +0,0 @@
# Plugin Marketplace
> This documentation covers Pro edition features available in the agents-orchestrator/agents-orchestrator private repository.
## Overview
The Plugin Marketplace provides a curated catalog of plugins for Agents Orchestrator. It handles discovery, installation, updates, and removal of plugins from a central GitHub-hosted repository. The community plugin runtime (Web Worker sandbox, permission model) is unchanged -- the marketplace adds distribution and lifecycle management on top.
## Catalog
The plugin catalog is a JSON file hosted in the `agents-orchestrator/agor-plugins` GitHub repository:
```
https://raw.githubusercontent.com/agents-orchestrator/agor-plugins/main/catalog.json
```
### Catalog Schema
```typescript
interface PluginCatalog {
version: number; // Catalog schema version
updatedAt: string; // ISO 8601 timestamp
plugins: CatalogEntry[];
}
interface CatalogEntry {
id: string; // Unique plugin identifier
name: string; // Display name
description: string; // Short description
version: string; // Semver version
author: string; // Author name
tier: 'free' | 'paid'; // Availability tier
downloadUrl: string; // HTTPS URL to plugin archive (.tar.gz)
checksum: string; // SHA-256 hex digest of the archive
size: number; // Archive size in bytes
permissions: string[]; // Required permissions
minVersion: string; // Minimum Agents Orchestrator version
tags: string[]; // Categorization tags
}
```
## Plugin Catalog
### Free Plugins (8)
| ID | Name | Description |
|----|------|-------------|
| `session-stats` | Session Stats | Displays token count, cost, and turn count as a compact overlay |
| `git-log-viewer` | Git Log Viewer | Shows recent git history for the active project in a formatted panel |
| `task-export` | Task Exporter | Exports bttask board contents to markdown or JSON |
| `prompt-library` | Prompt Library | User-managed collection of reusable prompt templates |
| `session-notes` | Session Notes | Per-session scratchpad persisted as tasks-KV entries |
| `time-tracker` | Time Tracker | Records wall-clock time per agent session with daily summaries |
| `diff-viewer` | Diff Viewer | Renders unified diffs from agent tool calls with syntax highlighting |
| `agent-logger` | Agent Logger | Streams agent messages to a local JSONL file for offline analysis |
### Paid Plugins (5)
| ID | Name | Description |
|----|------|-------------|
| `cost-alerts` | Cost Alerts | Configurable cost threshold notifications with Slack/webhook delivery |
| `team-dashboard` | Team Dashboard | Aggregated usage analytics across multiple workstations |
| `policy-engine` | Policy Engine | Custom rules for agent behavior (blocked commands, file restrictions) |
| `audit-export` | Audit Exporter | Exports audit logs to external SIEM systems (JSON, CEF formats) |
| `model-benchmark` | Model Benchmark | A/B testing framework for comparing model performance on identical tasks |
## Install Flow
1. User opens the Marketplace tab in the settings panel.
2. The UI fetches `catalog.json` from GitHub (cached for 1 hour).
3. User selects a plugin and clicks Install.
4. The backend downloads the plugin archive from `downloadUrl` over HTTPS.
5. The backend verifies the SHA-256 checksum against `checksum` in the catalog entry.
6. On checksum match, the archive is extracted to `~/.config/bterminal/plugins/{plugin-id}/`.
7. The plugin's `plugin.json` manifest is validated (required fields, permission declarations).
8. The plugin appears in the installed list and can be activated.
## Uninstall Flow
1. User selects an installed plugin and clicks Uninstall.
2. The backend calls `unloadPlugin()` if the plugin is currently active.
3. The plugin directory `~/.config/bterminal/plugins/{plugin-id}/` is deleted.
4. Any tasks-KV entries prefixed with `plugin:{plugin-id}:` are purged.
## Update Flow
1. On marketplace tab open, the UI compares installed plugin versions against catalog versions.
2. Plugins with available updates show an Update button.
3. Update performs: download new archive, verify checksum, unload plugin, replace directory contents, reload plugin.
4. The plugin's state (tasks-KV entries) is preserved across updates.
## Security
### Checksum Verification
Every plugin archive is verified against its SHA-256 checksum before extraction. If the checksum does not match, the install is aborted and an error is shown. This prevents tampered archives from being installed.
### HTTPS-Only Downloads
Plugin archives are only downloaded over HTTPS. The `downloadUrl` field is validated to start with `https://`. HTTP URLs are rejected.
### Path Traversal Protection
During archive extraction, all file paths are validated to ensure they resolve within the target plugin directory. Paths containing `..` segments or absolute paths are rejected. This prevents a malicious archive from writing files outside the plugin directory.
### Sandbox Isolation
Installed plugins run in the same Web Worker sandbox as community plugins. The marketplace does not grant additional privileges. Each plugin's permissions are declared in `plugin.json` and enforced by the plugin host at runtime.
## Commands
### pro_marketplace_fetch_catalog
Fetches the plugin catalog from GitHub. Returns cached data if fetched within the last hour.
**Response:** `PluginCatalog`
### pro_marketplace_install
Downloads, verifies, and installs a plugin.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `pluginId` | `String` | Yes | Plugin identifier from catalog |
### pro_marketplace_uninstall
Removes an installed plugin and its files.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `pluginId` | `String` | Yes | Plugin identifier |
### pro_marketplace_update
Updates an installed plugin to the latest catalog version.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `pluginId` | `String` | Yes | Plugin identifier |
### pro_marketplace_list_installed
Returns all installed plugins with their versions and active state.
**Response:** `Vec<InstalledPlugin>`
```typescript
interface InstalledPlugin {
id: string;
name: string;
version: string;
installedAt: string;
isActive: boolean;
hasUpdate: boolean;
latestVersion: string | null;
}
```
### pro_marketplace_check_updates
Compares installed plugins against the catalog and returns available updates.
**Response:** `Vec<PluginUpdate>`
```typescript
interface PluginUpdate {
id: string;
currentVersion: string;
latestVersion: string;
changelogUrl: string | null;
}
```

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