feat(electrobun): file management — CodeMirror editor, PDF viewer, CSV table, real file I/O

- CodeEditor: CodeMirror 6 with Catppuccin theme, 15+ languages, Ctrl+S save,
  dirty tracking, save-on-blur
- PdfViewer: pdfjs-dist canvas rendering, zoom 0.5-3x, HiDPI, lazy page load
- CsvTable: RFC 4180 parser, delimiter auto-detect, sortable columns, sticky header
- FileBrowser: real filesystem via files.list/read/write RPC, lazy dir loading,
  file type routing (code→editor, pdf→viewer, csv→table, images→display)
- 10MB size gate, binary detection, base64 encoding for non-text files
This commit is contained in:
Hibryda 2026-03-22 01:36:02 +01:00
parent 29a3370e79
commit 252fca70df
22 changed files with 8116 additions and 227 deletions

2675
ui-electrobun/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -11,11 +11,26 @@
"build:canary": "vite build && electrobun build --env=canary" "build:canary": "vite build && electrobun build --env=canary"
}, },
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.11",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/language": "^6.12.2",
"@codemirror/search": "^6.6.0",
"@codemirror/state": "^6.6.0",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.40.0",
"@xterm/addon-canvas": "^0.7.0", "@xterm/addon-canvas": "^0.7.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.9.0", "@xterm/addon-image": "^0.9.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"electrobun": "latest" "electrobun": "latest",
"pdfjs-dist": "^5.5.207"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.1", "@sveltejs/vite-plugin-svelte": "^5.0.1",

View file

@ -0,0 +1,379 @@
/**
* btmsg Inter-agent messaging SQLite store.
* DB: ~/.local/share/agor/btmsg.db (shared with btmsg CLI + bttask).
* Uses bun:sqlite. Schema matches Rust btmsg.rs.
*/
import { Database } from "bun:sqlite";
import { homedir } from "os";
import { mkdirSync } from "fs";
import { join } from "path";
import { randomUUID } from "crypto";
// ── DB path ──────────────────────────────────────────────────────────────────
const DATA_DIR = join(homedir(), ".local", "share", "agor");
const DB_PATH = join(DATA_DIR, "btmsg.db");
// ── Types ────────────────────────────────────────────────────────────────────
export interface BtmsgAgent {
id: string;
name: string;
role: string;
groupId: string;
tier: number;
model: string | null;
status: string;
unreadCount: number;
}
export interface BtmsgMessage {
id: string;
fromAgent: string;
toAgent: string;
content: string;
read: boolean;
replyTo: string | null;
createdAt: string;
senderName: string | null;
senderRole: string | null;
}
export interface BtmsgChannel {
id: string;
name: string;
groupId: string;
createdBy: string;
memberCount: number;
createdAt: string;
}
export interface BtmsgChannelMessage {
id: string;
channelId: string;
fromAgent: string;
content: string;
createdAt: string;
senderName: string;
senderRole: string;
}
export interface DeadLetter {
id: number;
fromAgent: string;
toAgent: string;
content: string;
error: string;
createdAt: string;
}
export interface AuditEntry {
id: number;
agentId: string;
eventType: string;
detail: string;
createdAt: string;
}
// ── Schema (create-if-absent, matches Rust open_db_or_create) ────────────────
const SCHEMA = `
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL,
group_id TEXT NOT NULL, tier INTEGER NOT NULL DEFAULT 2,
model TEXT, cwd TEXT, system_prompt TEXT,
status TEXT DEFAULT 'stopped', last_active_at TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS contacts (
agent_id TEXT NOT NULL, contact_id TEXT NOT NULL,
PRIMARY KEY (agent_id, contact_id)
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY, from_agent TEXT NOT NULL, to_agent TEXT NOT NULL,
content TEXT NOT NULL, read INTEGER DEFAULT 0, reply_to TEXT,
group_id TEXT NOT NULL, sender_group_id TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_messages_to ON messages(to_agent, read);
CREATE INDEX IF NOT EXISTS idx_messages_from ON messages(from_agent);
CREATE TABLE IF NOT EXISTS channels (
id TEXT PRIMARY KEY, name TEXT NOT NULL, group_id TEXT NOT NULL,
created_by TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS channel_members (
channel_id TEXT NOT NULL, agent_id TEXT NOT NULL,
joined_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (channel_id, agent_id)
);
CREATE TABLE IF NOT EXISTS channel_messages (
id TEXT PRIMARY KEY, channel_id TEXT NOT NULL, from_agent TEXT NOT NULL,
content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_channel_messages ON channel_messages(channel_id, created_at);
CREATE TABLE IF NOT EXISTS heartbeats (
agent_id TEXT PRIMARY KEY, timestamp INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS dead_letter_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT, from_agent TEXT NOT NULL,
to_agent TEXT NOT NULL, content TEXT NOT NULL, error TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL,
event_type TEXT NOT NULL, detail TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS seen_messages (
session_id TEXT NOT NULL, message_id TEXT NOT NULL,
seen_at INTEGER NOT NULL DEFAULT (unixepoch()),
PRIMARY KEY (session_id, message_id)
);
CREATE INDEX IF NOT EXISTS idx_seen_messages_session ON seen_messages(session_id);
`;
// Also create tasks/task_comments (shared DB with bttask)
const TASK_SCHEMA = `
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT DEFAULT '',
status TEXT DEFAULT 'todo', priority TEXT DEFAULT 'medium',
assigned_to TEXT, created_by TEXT NOT NULL, group_id TEXT NOT NULL,
parent_task_id TEXT, sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')), version INTEGER DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_tasks_group ON tasks(group_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE TABLE IF NOT EXISTS task_comments (
id TEXT PRIMARY KEY, task_id TEXT NOT NULL, agent_id TEXT NOT NULL,
content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments(task_id);
`;
// ── BtmsgDb class ────────────────────────────────────────────────────────────
export class BtmsgDb {
private db: Database;
constructor() {
mkdirSync(DATA_DIR, { recursive: true });
this.db = new Database(DB_PATH);
this.db.exec("PRAGMA journal_mode = WAL");
this.db.exec("PRAGMA busy_timeout = 5000");
this.db.exec("PRAGMA foreign_keys = ON");
this.db.exec(SCHEMA);
this.db.exec(TASK_SCHEMA);
}
// ── Agents ───────────────────────────────────────────────────────────────
registerAgent(
id: string, name: string, role: string,
groupId: string, tier: number, model?: string,
): void {
this.db.query(
`INSERT INTO agents (id, name, role, group_id, tier, model)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name, role=excluded.role,
group_id=excluded.group_id, tier=excluded.tier, model=excluded.model`
).run(id, name, role, groupId, tier, model ?? null);
}
getAgents(groupId: string): BtmsgAgent[] {
return this.db.query<{
id: string; name: string; role: string; group_id: string;
tier: number; model: string | null; status: string | null;
unread_count: number;
}, [string]>(
`SELECT a.*, (SELECT COUNT(*) FROM messages m
WHERE m.to_agent = a.id AND m.read = 0) as unread_count
FROM agents a WHERE a.group_id = ? ORDER BY a.tier, a.role, a.name`
).all(groupId).map(r => ({
id: r.id, name: r.name, role: r.role, groupId: r.group_id,
tier: r.tier, model: r.model, status: r.status ?? 'stopped',
unreadCount: r.unread_count,
}));
}
// ── Direct messages ──────────────────────────────────────────────────────
sendMessage(fromAgent: string, toAgent: string, content: string): string {
// Get sender's group_id
const sender = this.db.query<{ group_id: string }, [string]>(
"SELECT group_id FROM agents WHERE id = ?"
).get(fromAgent);
if (!sender) throw new Error(`Sender agent '${fromAgent}' not found`);
const id = randomUUID();
this.db.query(
`INSERT INTO messages (id, from_agent, to_agent, content, group_id, sender_group_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?5)`
).run(id, fromAgent, toAgent, content, sender.group_id);
return id;
}
listMessages(agentId: string, otherId: string, limit = 50): BtmsgMessage[] {
return this.db.query<{
id: string; from_agent: string; to_agent: string; content: string;
read: number; reply_to: string | null; created_at: string;
sender_name: string | null; sender_role: string | null;
}, [string, string, string, string, number]>(
`SELECT m.id, m.from_agent, m.to_agent, m.content, m.read,
m.reply_to, m.created_at,
a.name AS sender_name, a.role AS sender_role
FROM messages m JOIN agents a ON m.from_agent = a.id
WHERE (m.from_agent = ?1 AND m.to_agent = ?2)
OR (m.from_agent = ?3 AND m.to_agent = ?4)
ORDER BY m.created_at ASC LIMIT ?5`
).all(agentId, otherId, otherId, agentId, limit).map(r => ({
id: r.id, fromAgent: r.from_agent, toAgent: r.to_agent,
content: r.content, read: r.read !== 0, replyTo: r.reply_to,
createdAt: r.created_at, senderName: r.sender_name,
senderRole: r.sender_role,
}));
}
markRead(agentId: string, messageIds: string[]): void {
if (messageIds.length === 0) return;
const stmt = this.db.prepare(
"UPDATE messages SET read = 1 WHERE id = ? AND to_agent = ?"
);
const tx = this.db.transaction(() => {
for (const mid of messageIds) stmt.run(mid, agentId);
});
tx();
}
// ── Channels ─────────────────────────────────────────────────────────────
createChannel(name: string, groupId: string, createdBy: string): string {
const id = randomUUID();
this.db.query(
`INSERT INTO channels (id, name, group_id, created_by)
VALUES (?1, ?2, ?3, ?4)`
).run(id, name, groupId, createdBy);
return id;
}
listChannels(groupId: string): BtmsgChannel[] {
return this.db.query<{
id: string; name: string; group_id: string; created_by: string;
member_count: number; created_at: string;
}, [string]>(
`SELECT c.id, c.name, c.group_id, c.created_by, c.created_at,
(SELECT COUNT(*) FROM channel_members cm WHERE cm.channel_id = c.id) AS member_count
FROM channels c WHERE c.group_id = ? ORDER BY c.name`
).all(groupId).map(r => ({
id: r.id, name: r.name, groupId: r.group_id, createdBy: r.created_by,
memberCount: r.member_count, createdAt: r.created_at,
}));
}
getChannelMessages(channelId: string, limit = 100): BtmsgChannelMessage[] {
return this.db.query<{
id: string; channel_id: string; from_agent: string;
content: string; created_at: string;
sender_name: string; sender_role: string;
}, [string, number]>(
`SELECT cm.id, cm.channel_id, cm.from_agent, cm.content, cm.created_at,
COALESCE(a.name, cm.from_agent) AS sender_name,
COALESCE(a.role, 'unknown') AS sender_role
FROM channel_messages cm
LEFT JOIN agents a ON cm.from_agent = a.id
WHERE cm.channel_id = ?
ORDER BY cm.created_at ASC LIMIT ?`
).all(channelId, limit).map(r => ({
id: r.id, channelId: r.channel_id, fromAgent: r.from_agent,
content: r.content, createdAt: r.created_at,
senderName: r.sender_name, senderRole: r.sender_role,
}));
}
sendChannelMessage(channelId: string, fromAgent: string, content: string): string {
const id = randomUUID();
this.db.query(
`INSERT INTO channel_messages (id, channel_id, from_agent, content)
VALUES (?1, ?2, ?3, ?4)`
).run(id, channelId, fromAgent, content);
return id;
}
// ── Heartbeats ───────────────────────────────────────────────────────────
heartbeat(agentId: string): void {
const now = Math.floor(Date.now() / 1000);
this.db.query(
`INSERT INTO heartbeats (agent_id, timestamp) VALUES (?1, ?2)
ON CONFLICT(agent_id) DO UPDATE SET timestamp = excluded.timestamp`
).run(agentId, now);
}
// ── Dead letter queue ────────────────────────────────────────────────────
getDeadLetters(limit = 50): DeadLetter[] {
return this.db.query<{
id: number; from_agent: string; to_agent: string;
content: string; error: string; created_at: string;
}, [number]>(
`SELECT id, from_agent, to_agent, content, error, created_at
FROM dead_letter_queue ORDER BY created_at DESC LIMIT ?`
).all(limit).map(r => ({
id: r.id, fromAgent: r.from_agent, toAgent: r.to_agent,
content: r.content, error: r.error, createdAt: r.created_at,
}));
}
// ── Audit log ────────────────────────────────────────────────────────────
logAudit(agentId: string, eventType: string, detail: string): void {
this.db.query(
`INSERT INTO audit_log (agent_id, event_type, detail)
VALUES (?1, ?2, ?3)`
).run(agentId, eventType, detail);
}
getAuditLog(limit = 100): AuditEntry[] {
return this.db.query<{
id: number; agent_id: string; event_type: string;
detail: string; created_at: string;
}, [number]>(
`SELECT id, agent_id, event_type, detail, created_at
FROM audit_log ORDER BY created_at DESC LIMIT ?`
).all(limit).map(r => ({
id: r.id, agentId: r.agent_id, eventType: r.event_type,
detail: r.detail, createdAt: r.created_at,
}));
}
// ── Seen messages (per-session acknowledgment) ───────────────────────────
markSeen(sessionId: string, messageIds: string[]): void {
if (messageIds.length === 0) return;
const stmt = this.db.prepare(
"INSERT OR IGNORE INTO seen_messages (session_id, message_id) VALUES (?, ?)"
);
const tx = this.db.transaction(() => {
for (const mid of messageIds) stmt.run(sessionId, mid);
});
tx();
}
pruneSeen(maxAgeSecs = 7 * 24 * 3600): number {
const result = this.db.query(
"DELETE FROM seen_messages WHERE seen_at < unixepoch() - ?"
).run(maxAgeSecs);
return (result as { changes: number }).changes;
}
// ── Lifecycle ────────────────────────────────────────────────────────────
close(): void {
this.db.close();
}
}
// Singleton
export const btmsgDb = new BtmsgDb();

View file

@ -0,0 +1,187 @@
/**
* bttask Task board SQLite store.
* DB: ~/.local/share/agor/btmsg.db (shared with btmsg).
* Uses bun:sqlite. Schema matches Rust bttask.rs.
*/
import { Database } from "bun:sqlite";
import { homedir } from "os";
import { mkdirSync } from "fs";
import { join } from "path";
import { randomUUID } from "crypto";
// ── DB path (same DB as btmsg) ──────────────────────────────────────────────
const DATA_DIR = join(homedir(), ".local", "share", "agor");
const DB_PATH = join(DATA_DIR, "btmsg.db");
// ── Types ────────────────────────────────────────────────────────────────────
export interface Task {
id: string;
title: string;
description: string;
status: string;
priority: string;
assignedTo: string | null;
createdBy: string;
groupId: string;
parentTaskId: string | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
version: number;
}
export interface TaskComment {
id: string;
taskId: string;
agentId: string;
content: string;
createdAt: string;
}
const VALID_STATUSES = ["todo", "progress", "review", "done", "blocked"] as const;
// ── BttaskDb class ───────────────────────────────────────────────────────────
export class BttaskDb {
private db: Database;
constructor() {
mkdirSync(DATA_DIR, { recursive: true });
this.db = new Database(DB_PATH);
this.db.exec("PRAGMA journal_mode = WAL");
this.db.exec("PRAGMA busy_timeout = 5000");
// Ensure tables exist (idempotent — btmsg-db may have created them)
this.db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT DEFAULT '',
status TEXT DEFAULT 'todo', priority TEXT DEFAULT 'medium',
assigned_to TEXT, created_by TEXT NOT NULL, group_id TEXT NOT NULL,
parent_task_id TEXT, sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')), version INTEGER DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_tasks_group ON tasks(group_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE TABLE IF NOT EXISTS task_comments (
id TEXT PRIMARY KEY, task_id TEXT NOT NULL, agent_id TEXT NOT NULL,
content TEXT NOT NULL, created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments(task_id);
`);
// Migration: add version column if missing
try {
this.db.exec("ALTER TABLE tasks ADD COLUMN version INTEGER DEFAULT 1");
} catch { /* column already exists */ }
}
// ── Tasks ────────────────────────────────────────────────────────────────
listTasks(groupId: string): Task[] {
return this.db.query<{
id: string; title: string; description: string; status: string;
priority: string; assigned_to: string | null; created_by: string;
group_id: string; parent_task_id: string | null; sort_order: number;
created_at: string; updated_at: string; version: number;
}, [string]>(
`SELECT id, title, description, status, priority, assigned_to,
created_by, group_id, parent_task_id, sort_order,
created_at, updated_at, COALESCE(version, 1) AS version
FROM tasks WHERE group_id = ?
ORDER BY sort_order ASC, created_at DESC`
).all(groupId).map(r => ({
id: r.id, title: r.title, description: r.description ?? '',
status: r.status ?? 'todo', priority: r.priority ?? 'medium',
assignedTo: r.assigned_to, createdBy: r.created_by,
groupId: r.group_id, parentTaskId: r.parent_task_id,
sortOrder: r.sort_order ?? 0,
createdAt: r.created_at ?? '', updatedAt: r.updated_at ?? '',
version: r.version ?? 1,
}));
}
createTask(
title: string, description: string, priority: string,
groupId: string, createdBy: string, assignedTo?: string,
): string {
const id = randomUUID();
this.db.query(
`INSERT INTO tasks (id, title, description, priority, group_id, created_by, assigned_to)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`
).run(id, title, description, priority, groupId, createdBy, assignedTo ?? null);
return id;
}
/**
* Update task status with optimistic locking.
* Returns new version on success. Throws on version conflict.
*/
updateTaskStatus(taskId: string, status: string, expectedVersion: number): number {
if (!VALID_STATUSES.includes(status as typeof VALID_STATUSES[number])) {
throw new Error(`Invalid status '${status}'. Valid: ${VALID_STATUSES.join(', ')}`);
}
const result = this.db.query(
`UPDATE tasks SET status = ?1, version = version + 1, updated_at = datetime('now')
WHERE id = ?2 AND version = ?3`
).run(status, taskId, expectedVersion);
if ((result as { changes: number }).changes === 0) {
throw new Error("Task was modified by another agent (version conflict)");
}
return expectedVersion + 1;
}
deleteTask(taskId: string): void {
this.db.query("DELETE FROM task_comments WHERE task_id = ?").run(taskId);
this.db.query("DELETE FROM tasks WHERE id = ?").run(taskId);
}
// ── Comments ─────────────────────────────────────────────────────────────
addComment(taskId: string, agentId: string, content: string): string {
const id = randomUUID();
this.db.query(
`INSERT INTO task_comments (id, task_id, agent_id, content)
VALUES (?1, ?2, ?3, ?4)`
).run(id, taskId, agentId, content);
return id;
}
listComments(taskId: string): TaskComment[] {
return this.db.query<{
id: string; task_id: string; agent_id: string;
content: string; created_at: string;
}, [string]>(
`SELECT id, task_id, agent_id, content, created_at
FROM task_comments WHERE task_id = ?
ORDER BY created_at ASC`
).all(taskId).map(r => ({
id: r.id, taskId: r.task_id, agentId: r.agent_id,
content: r.content, createdAt: r.created_at ?? '',
}));
}
// ── Review queue ─────────────────────────────────────────────────────────
reviewQueueCount(groupId: string): number {
const row = this.db.query<{ cnt: number }, [string]>(
"SELECT COUNT(*) AS cnt FROM tasks WHERE group_id = ? AND status = 'review'"
).get(groupId);
return row?.cnt ?? 0;
}
// ── Lifecycle ────────────────────────────────────────────────────────────
close(): void {
this.db.close();
}
}
// Singleton
export const bttaskDb = new BttaskDb();

View file

@ -1,10 +1,17 @@
import path from "path"; import path from "path";
import fs from "fs";
import { BrowserWindow, BrowserView, Updater } from "electrobun/bun"; import { BrowserWindow, BrowserView, Updater } from "electrobun/bun";
import { PtyClient } from "./pty-client.ts"; import { PtyClient } from "./pty-client.ts";
import { settingsDb } from "./settings-db.ts"; import { settingsDb } from "./settings-db.ts";
import { sessionDb } from "./session-db.ts";
import { btmsgDb } from "./btmsg-db.ts";
import { bttaskDb } from "./bttask-db.ts";
import { SidecarManager } from "./sidecar-manager.ts"; import { SidecarManager } from "./sidecar-manager.ts";
import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts"; import type { PtyRPCSchema } from "../shared/pty-rpc-schema.ts";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import { SearchDb } from "./search-db.ts";
import { homedir } from "os";
import { join } from "path";
const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range const DEV_SERVER_PORT = 9760; // Project convention: 9700+ range
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`; const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
@ -13,6 +20,8 @@ const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
const ptyClient = new PtyClient(); const ptyClient = new PtyClient();
const sidecarManager = new SidecarManager(); const sidecarManager = new SidecarManager();
const searchDb = new SearchDb();
const PLUGINS_DIR = join(homedir(), ".config", "agor", "plugins");
async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> { async function connectToDaemon(retries = 5, delayMs = 500): Promise<boolean> {
for (let attempt = 1; attempt <= retries; attempt++) { for (let attempt = 1; attempt <= retries; attempt++) {
@ -200,6 +209,79 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
} }
}, },
// ── File I/O handlers ────────────────────────────────────────────────
"files.list": async ({ path: dirPath }) => {
try {
const dirents = fs.readdirSync(dirPath, { withFileTypes: true });
const entries = dirents
.filter((d) => !d.name.startsWith("."))
.map((d) => {
let size = 0;
if (d.isFile()) {
try {
size = fs.statSync(path.join(dirPath, d.name)).size;
} catch { /* ignore stat errors */ }
}
return {
name: d.name,
type: (d.isDirectory() ? "dir" : "file") as "file" | "dir",
size,
};
})
.sort((a, b) => {
// Directories first, then alphabetical
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
return a.name.localeCompare(b.name);
});
return { entries };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[files.list]", error);
return { entries: [], error };
}
},
"files.read": async ({ path: filePath }) => {
try {
const stat = fs.statSync(filePath);
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
if (stat.size > MAX_SIZE) {
return { encoding: "utf8" as const, size: stat.size, error: `File too large (${(stat.size / 1024 / 1024).toFixed(1)}MB). Maximum is 10MB.` };
}
// Detect binary by reading first 8KB
const buf = Buffer.alloc(Math.min(8192, stat.size));
const fd = fs.openSync(filePath, "r");
fs.readSync(fd, buf, 0, buf.length, 0);
fs.closeSync(fd);
const isBinary = buf.includes(0); // null byte = binary
if (isBinary) {
const content = fs.readFileSync(filePath).toString("base64");
return { content, encoding: "base64" as const, size: stat.size };
}
const content = fs.readFileSync(filePath, "utf8");
return { content, encoding: "utf8" as const, size: stat.size };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[files.read]", error);
return { encoding: "utf8" as const, size: 0, error };
}
},
"files.write": async ({ path: filePath, content }) => {
try {
fs.writeFileSync(filePath, content, "utf8");
return { ok: true };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[files.write]", error);
return { ok: false, error };
}
},
// ── Groups handlers ────────────────────────────────────────────────── // ── Groups handlers ──────────────────────────────────────────────────
"groups.list": () => { "groups.list": () => {
@ -445,6 +527,353 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
return { sessions: [] }; return { sessions: [] };
} }
}, },
// ── Session persistence handlers ──────────────────────────────────
"session.save": ({ projectId, sessionId, provider, status, costUsd, inputTokens, outputTokens, model, error, createdAt, updatedAt }) => {
try {
sessionDb.saveSession({
projectId, sessionId, provider, status, costUsd,
inputTokens, outputTokens, model, error, createdAt, updatedAt,
});
return { ok: true };
} catch (err) {
console.error("[session.save]", err);
return { ok: false };
}
},
"session.load": ({ projectId }) => {
try {
return { session: sessionDb.loadSession(projectId) };
} catch (err) {
console.error("[session.load]", err);
return { session: null };
}
},
"session.list": ({ projectId }) => {
try {
return { sessions: sessionDb.listSessionsByProject(projectId) };
} catch (err) {
console.error("[session.list]", err);
return { sessions: [] };
}
},
"session.messages.save": ({ messages }) => {
try {
sessionDb.saveMessages(messages.map((m) => ({
sessionId: m.sessionId, msgId: m.msgId, role: m.role,
content: m.content, toolName: m.toolName, toolInput: m.toolInput,
timestamp: m.timestamp, costUsd: m.costUsd ?? 0,
inputTokens: m.inputTokens ?? 0, outputTokens: m.outputTokens ?? 0,
})));
return { ok: true };
} catch (err) {
console.error("[session.messages.save]", err);
return { ok: false };
}
},
"session.messages.load": ({ sessionId }) => {
try {
return { messages: sessionDb.loadMessages(sessionId) };
} catch (err) {
console.error("[session.messages.load]", err);
return { messages: [] };
}
},
// ── btmsg handlers ────────────────────────────────────────────────
"btmsg.registerAgent": ({ id, name, role, groupId, tier, model }) => {
try {
btmsgDb.registerAgent(id, name, role, groupId, tier, model);
return { ok: true };
} catch (err) {
console.error("[btmsg.registerAgent]", err);
return { ok: false };
}
},
"btmsg.getAgents": ({ groupId }) => {
try {
return { agents: btmsgDb.getAgents(groupId) };
} catch (err) {
console.error("[btmsg.getAgents]", err);
return { agents: [] };
}
},
"btmsg.sendMessage": ({ fromAgent, toAgent, content }) => {
try {
const messageId = btmsgDb.sendMessage(fromAgent, toAgent, content);
return { ok: true, messageId };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[btmsg.sendMessage]", err);
return { ok: false, error };
}
},
"btmsg.listMessages": ({ agentId, otherId, limit }) => {
try {
return { messages: btmsgDb.listMessages(agentId, otherId, limit ?? 50) };
} catch (err) {
console.error("[btmsg.listMessages]", err);
return { messages: [] };
}
},
"btmsg.markRead": ({ agentId, messageIds }) => {
try {
btmsgDb.markRead(agentId, messageIds);
return { ok: true };
} catch (err) {
console.error("[btmsg.markRead]", err);
return { ok: false };
}
},
"btmsg.listChannels": ({ groupId }) => {
try {
return { channels: btmsgDb.listChannels(groupId) };
} catch (err) {
console.error("[btmsg.listChannels]", err);
return { channels: [] };
}
},
"btmsg.createChannel": ({ name, groupId, createdBy }) => {
try {
const channelId = btmsgDb.createChannel(name, groupId, createdBy);
return { ok: true, channelId };
} catch (err) {
console.error("[btmsg.createChannel]", err);
return { ok: false };
}
},
"btmsg.getChannelMessages": ({ channelId, limit }) => {
try {
return { messages: btmsgDb.getChannelMessages(channelId, limit ?? 100) };
} catch (err) {
console.error("[btmsg.getChannelMessages]", err);
return { messages: [] };
}
},
"btmsg.sendChannelMessage": ({ channelId, fromAgent, content }) => {
try {
const messageId = btmsgDb.sendChannelMessage(channelId, fromAgent, content);
return { ok: true, messageId };
} catch (err) {
console.error("[btmsg.sendChannelMessage]", err);
return { ok: false };
}
},
"btmsg.heartbeat": ({ agentId }) => {
try {
btmsgDb.heartbeat(agentId);
return { ok: true };
} catch (err) {
console.error("[btmsg.heartbeat]", err);
return { ok: false };
}
},
"btmsg.getDeadLetters": ({ limit }) => {
try {
return { letters: btmsgDb.getDeadLetters(limit ?? 50) };
} catch (err) {
console.error("[btmsg.getDeadLetters]", err);
return { letters: [] };
}
},
"btmsg.logAudit": ({ agentId, eventType, detail }) => {
try {
btmsgDb.logAudit(agentId, eventType, detail);
return { ok: true };
} catch (err) {
console.error("[btmsg.logAudit]", err);
return { ok: false };
}
},
"btmsg.getAuditLog": ({ limit }) => {
try {
return { entries: btmsgDb.getAuditLog(limit ?? 100) };
} catch (err) {
console.error("[btmsg.getAuditLog]", err);
return { entries: [] };
}
},
// ── bttask handlers ───────────────────────────────────────────────
"bttask.listTasks": ({ groupId }) => {
try {
return { tasks: bttaskDb.listTasks(groupId) };
} catch (err) {
console.error("[bttask.listTasks]", err);
return { tasks: [] };
}
},
"bttask.createTask": ({ title, description, priority, groupId, createdBy, assignedTo }) => {
try {
const taskId = bttaskDb.createTask(title, description, priority, groupId, createdBy, assignedTo);
return { ok: true, taskId };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[bttask.createTask]", err);
return { ok: false, error };
}
},
"bttask.updateTaskStatus": ({ taskId, status, expectedVersion }) => {
try {
const newVersion = bttaskDb.updateTaskStatus(taskId, status, expectedVersion);
return { ok: true, newVersion };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[bttask.updateTaskStatus]", err);
return { ok: false, error };
}
},
"bttask.deleteTask": ({ taskId }) => {
try {
bttaskDb.deleteTask(taskId);
return { ok: true };
} catch (err) {
console.error("[bttask.deleteTask]", err);
return { ok: false };
}
},
"bttask.addComment": ({ taskId, agentId, content }) => {
try {
const commentId = bttaskDb.addComment(taskId, agentId, content);
return { ok: true, commentId };
} catch (err) {
console.error("[bttask.addComment]", err);
return { ok: false };
}
},
"bttask.listComments": ({ taskId }) => {
try {
return { comments: bttaskDb.listComments(taskId) };
} catch (err) {
console.error("[bttask.listComments]", err);
return { comments: [] };
}
},
"bttask.reviewQueueCount": ({ groupId }) => {
try {
return { count: bttaskDb.reviewQueueCount(groupId) };
} catch (err) {
console.error("[bttask.reviewQueueCount]", err);
return { count: 0 };
}
},
// ── Search handlers ──────────────────────────────────────────────────
"search.query": ({ query, limit }) => {
try {
const results = searchDb.searchAll(query, limit ?? 20);
return { results };
} catch (err) {
console.error("[search.query]", err);
return { results: [] };
}
},
"search.indexMessage": ({ sessionId, role, content }) => {
try {
searchDb.indexMessage(sessionId, role, content);
return { ok: true };
} catch (err) {
console.error("[search.indexMessage]", err);
return { ok: false };
}
},
"search.rebuild": () => {
try {
searchDb.rebuildIndex();
return { ok: true };
} catch (err) {
console.error("[search.rebuild]", err);
return { ok: false };
}
},
// ── Plugin handlers ──────────────────────────────────────────────────
"plugin.discover": () => {
try {
const plugins: Array<{
id: string; name: string; version: string;
description: string; main: string; permissions: string[];
}> = [];
if (!fs.existsSync(PLUGINS_DIR)) return { plugins };
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const manifestPath = join(PLUGINS_DIR, entry.name, "plugin.json");
if (!fs.existsSync(manifestPath)) continue;
try {
const raw = fs.readFileSync(manifestPath, "utf-8");
const manifest = JSON.parse(raw);
plugins.push({
id: manifest.id ?? entry.name,
name: manifest.name ?? entry.name,
version: manifest.version ?? "0.0.0",
description: manifest.description ?? "",
main: manifest.main ?? "index.js",
permissions: Array.isArray(manifest.permissions) ? manifest.permissions : [],
});
} catch (parseErr) {
console.error(`[plugin.discover] Bad manifest in ${entry.name}:`, parseErr);
}
}
return { plugins };
} catch (err) {
console.error("[plugin.discover]", err);
return { plugins: [] };
}
},
"plugin.readFile": ({ pluginId, filePath }) => {
try {
// Path traversal protection: resolve and verify within plugins dir
const pluginDir = join(PLUGINS_DIR, pluginId);
const resolved = path.resolve(pluginDir, filePath);
if (!resolved.startsWith(pluginDir + path.sep) && resolved !== pluginDir) {
return { ok: false, content: "", error: "Path traversal blocked" };
}
if (!fs.existsSync(resolved)) {
return { ok: false, content: "", error: "File not found" };
}
const content = fs.readFileSync(resolved, "utf-8");
return { ok: true, content };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error("[plugin.readFile]", err);
return { ok: false, content: "", error };
}
},
}, },
messages: {}, messages: {},

View file

@ -0,0 +1,202 @@
/**
* FTS5 full-text search database bun:sqlite.
*
* Three virtual tables: search_messages, search_tasks, search_btmsg.
* Provides indexing and unified search across all tables.
* DB path: ~/.local/share/agor/search.db
*/
import { Database } from "bun:sqlite";
import { homedir } from "os";
import { mkdirSync } from "fs";
import { join } from "path";
// ── Types ────────────────────────────────────────────────────────────────────
export interface SearchResult {
resultType: string;
id: string;
title: string;
snippet: string;
score: number;
}
// ── DB path ──────────────────────────────────────────────────────────────────
const DATA_DIR = join(homedir(), ".local", "share", "agor");
const DB_PATH = join(DATA_DIR, "search.db");
// ── SearchDb class ───────────────────────────────────────────────────────────
export class SearchDb {
private db: Database;
constructor(dbPath?: string) {
const path = dbPath ?? DB_PATH;
const dir = join(path, "..");
mkdirSync(dir, { recursive: true });
this.db = new Database(path);
this.db.run("PRAGMA journal_mode = WAL");
this.db.run("PRAGMA busy_timeout = 2000");
this.createTables();
}
private createTables(): void {
this.db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS search_messages USING fts5(
session_id,
role,
content,
timestamp
)
`);
this.db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS search_tasks USING fts5(
task_id,
title,
description,
status,
assigned_to
)
`);
this.db.run(`
CREATE VIRTUAL TABLE IF NOT EXISTS search_btmsg USING fts5(
message_id,
from_agent,
to_agent,
content,
channel_name
)
`);
}
/** Index an agent message. */
indexMessage(sessionId: string, role: string, content: string): void {
const ts = new Date().toISOString();
this.db.run(
"INSERT INTO search_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)",
[sessionId, role, content, ts],
);
}
/** Index a task. */
indexTask(taskId: string, title: string, description: string, status: string, assignedTo: string): void {
this.db.run(
"INSERT INTO search_tasks (task_id, title, description, status, assigned_to) VALUES (?, ?, ?, ?, ?)",
[taskId, title, description, status, assignedTo],
);
}
/** Index a btmsg message. */
indexBtmsg(msgId: string, fromAgent: string, toAgent: string, content: string, channel: string): void {
this.db.run(
"INSERT INTO search_btmsg (message_id, from_agent, to_agent, content, channel_name) VALUES (?, ?, ?, ?, ?)",
[msgId, fromAgent, toAgent, content, channel],
);
}
/** Search across all FTS5 tables. */
searchAll(query: string, limit = 20): SearchResult[] {
if (!query.trim()) return [];
const results: SearchResult[] = [];
// Search messages
try {
const msgRows = this.db
.prepare(
`SELECT session_id, role,
snippet(search_messages, 2, '<b>', '</b>', '...', 32) as snip,
rank
FROM search_messages
WHERE search_messages MATCH ?
ORDER BY rank
LIMIT ?`,
)
.all(query, limit) as Array<{ session_id: string; role: string; snip: string; rank: number }>;
for (const row of msgRows) {
results.push({
resultType: "message",
id: row.session_id,
title: row.role,
snippet: row.snip ?? "",
score: Math.abs(row.rank ?? 0),
});
}
} catch {
// FTS5 syntax error — skip messages
}
// Search tasks
try {
const taskRows = this.db
.prepare(
`SELECT task_id, title,
snippet(search_tasks, 2, '<b>', '</b>', '...', 32) as snip,
rank
FROM search_tasks
WHERE search_tasks MATCH ?
ORDER BY rank
LIMIT ?`,
)
.all(query, limit) as Array<{ task_id: string; title: string; snip: string; rank: number }>;
for (const row of taskRows) {
results.push({
resultType: "task",
id: row.task_id,
title: row.title,
snippet: row.snip ?? "",
score: Math.abs(row.rank ?? 0),
});
}
} catch {
// FTS5 syntax error — skip tasks
}
// Search btmsg
try {
const btmsgRows = this.db
.prepare(
`SELECT message_id, from_agent,
snippet(search_btmsg, 3, '<b>', '</b>', '...', 32) as snip,
rank
FROM search_btmsg
WHERE search_btmsg MATCH ?
ORDER BY rank
LIMIT ?`,
)
.all(query, limit) as Array<{ message_id: string; from_agent: string; snip: string; rank: number }>;
for (const row of btmsgRows) {
results.push({
resultType: "btmsg",
id: row.message_id,
title: row.from_agent,
snippet: row.snip ?? "",
score: Math.abs(row.rank ?? 0),
});
}
} catch {
// FTS5 syntax error — skip btmsg
}
// Sort by score (lower = more relevant for FTS5 rank)
results.sort((a, b) => a.score - b.score);
return results.slice(0, limit);
}
/** Drop and recreate all FTS5 tables. */
rebuildIndex(): void {
this.db.run("DROP TABLE IF EXISTS search_messages");
this.db.run("DROP TABLE IF EXISTS search_tasks");
this.db.run("DROP TABLE IF EXISTS search_btmsg");
this.createTables();
}
close(): void {
this.db.close();
}
}

View file

@ -0,0 +1,326 @@
/**
* Session persistence SQLite-backed agent session & message storage.
* Uses bun:sqlite. DB: ~/.config/agor/settings.db (shared with SettingsDb).
*
* Tables: agent_sessions, agent_messages
*/
import { Database } from "bun:sqlite";
import { homedir } from "os";
import { mkdirSync } from "fs";
import { join } from "path";
// ── DB path ──────────────────────────────────────────────────────────────────
const CONFIG_DIR = join(homedir(), ".config", "agor");
const DB_PATH = join(CONFIG_DIR, "settings.db");
// ── Types ────────────────────────────────────────────────────────────────────
export interface StoredSession {
projectId: string;
sessionId: string;
provider: string;
status: string;
costUsd: number;
inputTokens: number;
outputTokens: number;
model: string;
error?: string;
createdAt: number;
updatedAt: number;
}
export interface StoredMessage {
sessionId: string;
msgId: string;
role: string;
content: string;
toolName?: string;
toolInput?: string;
timestamp: number;
costUsd: number;
inputTokens: number;
outputTokens: number;
}
// ── Schema ───────────────────────────────────────────────────────────────────
const SESSION_SCHEMA = `
CREATE TABLE IF NOT EXISTS agent_sessions (
project_id TEXT NOT NULL,
session_id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'idle',
cost_usd REAL NOT NULL DEFAULT 0,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
model TEXT NOT NULL DEFAULT '',
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_agent_sessions_project
ON agent_sessions(project_id);
CREATE TABLE IF NOT EXISTS agent_messages (
session_id TEXT NOT NULL,
msg_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
tool_name TEXT,
tool_input TEXT,
timestamp INTEGER NOT NULL,
cost_usd REAL NOT NULL DEFAULT 0,
input_tokens INTEGER NOT NULL DEFAULT 0,
output_tokens INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (session_id, msg_id),
FOREIGN KEY (session_id) REFERENCES agent_sessions(session_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_agent_messages_session
ON agent_messages(session_id, timestamp);
`;
// ── SessionDb class ──────────────────────────────────────────────────────────
export class SessionDb {
private db: Database;
constructor() {
mkdirSync(CONFIG_DIR, { recursive: true });
this.db = new Database(DB_PATH);
this.db.exec("PRAGMA journal_mode = WAL");
this.db.exec("PRAGMA busy_timeout = 500");
this.db.exec("PRAGMA foreign_keys = ON");
this.db.exec(SESSION_SCHEMA);
}
// ── Sessions ─────────────────────────────────────────────────────────────
saveSession(s: StoredSession): void {
this.db
.query(
`INSERT INTO agent_sessions
(project_id, session_id, provider, status, cost_usd,
input_tokens, output_tokens, model, error, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(session_id) DO UPDATE SET
status = excluded.status,
cost_usd = excluded.cost_usd,
input_tokens = excluded.input_tokens,
output_tokens = excluded.output_tokens,
model = excluded.model,
error = excluded.error,
updated_at = excluded.updated_at`
)
.run(
s.projectId,
s.sessionId,
s.provider,
s.status,
s.costUsd,
s.inputTokens,
s.outputTokens,
s.model,
s.error ?? null,
s.createdAt,
s.updatedAt
);
}
loadSession(projectId: string): StoredSession | null {
const row = this.db
.query<
{
project_id: string;
session_id: string;
provider: string;
status: string;
cost_usd: number;
input_tokens: number;
output_tokens: number;
model: string;
error: string | null;
created_at: number;
updated_at: number;
},
[string]
>(
`SELECT * FROM agent_sessions
WHERE project_id = ?
ORDER BY updated_at DESC LIMIT 1`
)
.get(projectId);
if (!row) return null;
return {
projectId: row.project_id,
sessionId: row.session_id,
provider: row.provider,
status: row.status,
costUsd: row.cost_usd,
inputTokens: row.input_tokens,
outputTokens: row.output_tokens,
model: row.model,
error: row.error ?? undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
listSessionsByProject(projectId: string): StoredSession[] {
const rows = this.db
.query<
{
project_id: string;
session_id: string;
provider: string;
status: string;
cost_usd: number;
input_tokens: number;
output_tokens: number;
model: string;
error: string | null;
created_at: number;
updated_at: number;
},
[string]
>(
`SELECT * FROM agent_sessions
WHERE project_id = ?
ORDER BY updated_at DESC LIMIT 20`
)
.all(projectId);
return rows.map((r) => ({
projectId: r.project_id,
sessionId: r.session_id,
provider: r.provider,
status: r.status,
costUsd: r.cost_usd,
inputTokens: r.input_tokens,
outputTokens: r.output_tokens,
model: r.model,
error: r.error ?? undefined,
createdAt: r.created_at,
updatedAt: r.updated_at,
}));
}
// ── Messages ─────────────────────────────────────────────────────────────
saveMessage(m: StoredMessage): void {
this.db
.query(
`INSERT INTO agent_messages
(session_id, msg_id, role, content, tool_name, tool_input,
timestamp, cost_usd, input_tokens, output_tokens)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(session_id, msg_id) DO NOTHING`
)
.run(
m.sessionId,
m.msgId,
m.role,
m.content,
m.toolName ?? null,
m.toolInput ?? null,
m.timestamp,
m.costUsd,
m.inputTokens,
m.outputTokens
);
}
saveMessages(msgs: StoredMessage[]): void {
const stmt = this.db.prepare(
`INSERT INTO agent_messages
(session_id, msg_id, role, content, tool_name, tool_input,
timestamp, cost_usd, input_tokens, output_tokens)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
ON CONFLICT(session_id, msg_id) DO NOTHING`
);
const tx = this.db.transaction(() => {
for (const m of msgs) {
stmt.run(
m.sessionId,
m.msgId,
m.role,
m.content,
m.toolName ?? null,
m.toolInput ?? null,
m.timestamp,
m.costUsd,
m.inputTokens,
m.outputTokens
);
}
});
tx();
}
loadMessages(sessionId: string): StoredMessage[] {
const rows = this.db
.query<
{
session_id: string;
msg_id: string;
role: string;
content: string;
tool_name: string | null;
tool_input: string | null;
timestamp: number;
cost_usd: number;
input_tokens: number;
output_tokens: number;
},
[string]
>(
`SELECT * FROM agent_messages
WHERE session_id = ?
ORDER BY timestamp ASC`
)
.all(sessionId);
return rows.map((r) => ({
sessionId: r.session_id,
msgId: r.msg_id,
role: r.role,
content: r.content,
toolName: r.tool_name ?? undefined,
toolInput: r.tool_input ?? undefined,
timestamp: r.timestamp,
costUsd: r.cost_usd,
inputTokens: r.input_tokens,
outputTokens: r.output_tokens,
}));
}
// ── Cleanup ──────────────────────────────────────────────────────────────
/** Delete sessions older than maxAgeDays for a project, keeping at most keepCount. */
pruneOldSessions(projectId: string, keepCount = 10): void {
this.db
.query(
`DELETE FROM agent_sessions
WHERE project_id = ?1
AND session_id NOT IN (
SELECT session_id FROM agent_sessions
WHERE project_id = ?1
ORDER BY updated_at DESC
LIMIT ?2
)`
)
.run(projectId, keepCount);
}
close(): void {
this.db.close();
}
}
// Singleton
export const sessionDb = new SessionDb();

View file

@ -5,9 +5,12 @@
import CommandPalette from './CommandPalette.svelte'; import CommandPalette from './CommandPalette.svelte';
import ToastContainer from './ToastContainer.svelte'; import ToastContainer from './ToastContainer.svelte';
import NotifDrawer, { type Notification } from './NotifDrawer.svelte'; import NotifDrawer, { type Notification } from './NotifDrawer.svelte';
import StatusBar from './StatusBar.svelte';
import SearchOverlay from './SearchOverlay.svelte';
import { themeStore } from './theme-store.svelte.ts'; import { themeStore } from './theme-store.svelte.ts';
import { fontStore } from './font-store.svelte.ts'; import { fontStore } from './font-store.svelte.ts';
import { keybindingStore } from './keybinding-store.svelte.ts'; import { keybindingStore } from './keybinding-store.svelte.ts';
import { trackProject } from './health-store.svelte.ts';
import { appRpc } from './rpc.ts'; import { appRpc } from './rpc.ts';
// ── Types ───────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────
@ -137,6 +140,7 @@
let settingsOpen = $state(false); let settingsOpen = $state(false);
let paletteOpen = $state(false); let paletteOpen = $state(false);
let drawerOpen = $state(false); let drawerOpen = $state(false);
let searchOpen = $state(false);
let sessionStart = $state(Date.now()); let sessionStart = $state(Date.now());
let notifications = $state<Notification[]>([ let notifications = $state<Notification[]>([
@ -236,15 +240,8 @@
} }
// ── Status bar aggregates ────────────────────────────────────── // ── Status bar aggregates ──────────────────────────────────────
let runningCount = $derived(PROJECTS.filter(p => p.status === 'running').length);
let idleCount = $derived(PROJECTS.filter(p => p.status === 'idle').length);
let stalledCount = $derived(PROJECTS.filter(p => p.status === 'stalled').length);
let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0)); let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0)); let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
let attentionItems = $derived(PROJECTS.filter(p => p.status === 'stalled' || (p.contextPct ?? 0) >= 75));
function fmtTokens(n: number): string { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); }
function fmtCost(n: number): string { return `$${n.toFixed(3)}`; }
// ── DEBUG: Visual click diagnostics overlay (gated behind DEBUG env) ──── // ── DEBUG: Visual click diagnostics overlay (gated behind DEBUG env) ────
const DEBUG_ENABLED = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debug'); const DEBUG_ENABLED = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debug');
@ -299,13 +296,29 @@
keybindingStore.on('group4', () => setActiveGroup(groups[3]?.id)); keybindingStore.on('group4', () => setActiveGroup(groups[3]?.id));
keybindingStore.on('minimize', () => handleMinimize()); keybindingStore.on('minimize', () => handleMinimize());
// Ctrl+Shift+F for search overlay
function handleSearchShortcut(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
e.preventDefault();
searchOpen = !searchOpen;
}
}
document.addEventListener('keydown', handleSearchShortcut);
// Track projects for health monitoring
for (const p of PROJECTS) trackProject(p.id);
const cleanup = keybindingStore.installListener(); const cleanup = keybindingStore.installListener();
return cleanup; return () => {
cleanup();
document.removeEventListener('keydown', handleSearchShortcut);
};
}); });
</script> </script>
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} /> <SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} /> <CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
<SearchOverlay open={searchOpen} onClose={() => searchOpen = false} />
<ToastContainer /> <ToastContainer />
<NotifDrawer <NotifDrawer
open={drawerOpen} open={drawerOpen}
@ -423,59 +436,14 @@
</aside> </aside>
</div> </div>
<!-- Status bar --> <!-- Status bar (health-backed) -->
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status"> <StatusBar
{#if runningCount > 0} projectCount={filteredProjects.length}
<span class="status-segment"> {totalTokens}
<span class="status-dot-sm green" aria-hidden="true"></span> totalCost={totalCost}
<span class="status-value">{runningCount}</span> {sessionDuration}
<span>running</span> groupName={activeGroup?.name ?? ''}
</span> />
{/if}
{#if idleCount > 0}
<span class="status-segment">
<span class="status-dot-sm gray" aria-hidden="true"></span>
<span class="status-value">{idleCount}</span>
<span>idle</span>
</span>
{/if}
{#if stalledCount > 0}
<span class="status-segment">
<span class="status-dot-sm orange" aria-hidden="true"></span>
<span class="status-value">{stalledCount}</span>
<span>stalled</span>
</span>
{/if}
{#if attentionItems.length > 0}
<span class="status-segment attn-badge" title="Needs attention: {attentionItems.map(p => p.name).join(', ')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="attn-icon">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span class="status-value">{attentionItems.length}</span>
<span>attention</span>
</span>
{/if}
<span class="status-bar-spacer"></span>
<span class="status-segment" title="Active group">
<span class="status-value">{activeGroup?.name}</span>
</span>
<span class="status-segment" title="Session duration">
<span>session</span>
<span class="status-value">{sessionDuration}</span>
</span>
<span class="status-segment" title="Total tokens used">
<span>tokens</span>
<span class="status-value">{fmtTokens(totalTokens)}</span>
</span>
<span class="status-segment" title="Total session cost">
<span>cost</span>
<span class="status-value">{fmtCost(totalCost)}</span>
</span>
<kbd class="palette-hint" title="Open command palette">Ctrl+K</kbd>
</footer>
{#if DEBUG_ENABLED && debugLog.length > 0} {#if DEBUG_ENABLED && debugLog.length > 0}
<!-- DEBUG: visible click log (enable with ?debug URL param) --> <!-- DEBUG: visible click log (enable with ?debug URL param) -->
@ -728,55 +696,5 @@
line-height: 1; line-height: 1;
} }
/* ── Status bar ───────────────────────────────────────────── */ /* Status bar styles are in StatusBar.svelte */
.status-bar {
height: var(--status-bar-height);
background: var(--ctp-crust);
border-top: 1px solid var(--ctp-surface0);
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0 0.625rem;
flex-shrink: 0;
font-size: 0.6875rem;
color: var(--ctp-subtext0);
}
.status-segment {
display: flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
.status-dot-sm {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot-sm.green { background: var(--ctp-green); }
.status-dot-sm.gray { background: var(--ctp-overlay0); }
.status-dot-sm.orange { background: var(--ctp-peach); }
.status-value { color: var(--ctp-text); font-weight: 500; }
.status-bar-spacer { flex: 1; }
.attn-badge { color: var(--ctp-yellow); }
.attn-icon { width: 0.75rem; height: 0.75rem; stroke: var(--ctp-yellow); }
.palette-hint {
padding: 0.1rem 0.3rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem;
font-size: 0.6rem;
color: var(--ctp-overlay0);
font-family: var(--ui-font-family);
cursor: pointer;
transition: color 0.1s;
}
.palette-hint:hover { color: var(--ctp-subtext0); }
</style> </style>

View file

@ -0,0 +1,248 @@
<script lang="ts">
import { onMount, onDestroy, untrack } from 'svelte';
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, dropCursor } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
import { closeBrackets, closeBracketsKeymap, autocompletion, completionKeymap } from '@codemirror/autocomplete';
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
interface Props {
content: string;
lang: string;
readonly?: boolean;
onchange?: (content: string) => void;
onsave?: () => void;
onblur?: () => void;
}
let { content, lang, readonly = false, onchange, onsave, onblur }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let view: EditorView | undefined = $state();
/** Map file extension hint to CodeMirror language extension (dynamic import). */
async function getLangExtension(l: string) {
switch (l) {
case 'javascript':
case 'jsx': {
const { javascript } = await import('@codemirror/lang-javascript');
return javascript({ jsx: true });
}
case 'typescript':
case 'tsx': {
const { javascript } = await import('@codemirror/lang-javascript');
return javascript({ jsx: true, typescript: true });
}
case 'html':
case 'svelte': {
const { html } = await import('@codemirror/lang-html');
return html();
}
case 'css':
case 'scss':
case 'less': {
const { css } = await import('@codemirror/lang-css');
return css();
}
case 'json': {
const { json } = await import('@codemirror/lang-json');
return json();
}
case 'markdown':
case 'md': {
const { markdown } = await import('@codemirror/lang-markdown');
return markdown();
}
case 'python':
case 'py': {
const { python } = await import('@codemirror/lang-python');
return python();
}
case 'rust':
case 'rs': {
const { rust } = await import('@codemirror/lang-rust');
return rust();
}
default:
return null;
}
}
/** Catppuccin Mocha theme via CSS custom properties. */
const catppuccinTheme = EditorView.theme({
'&': {
backgroundColor: 'var(--ctp-base)',
color: 'var(--ctp-text)',
fontFamily: 'var(--term-font-family, "JetBrains Mono", monospace)',
fontSize: '0.775rem',
},
'.cm-content': {
caretColor: 'var(--ctp-rosewater)',
lineHeight: '1.55',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--ctp-rosewater)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'color-mix(in srgb, var(--ctp-blue) 25%, transparent)',
},
'.cm-panels': {
backgroundColor: 'var(--ctp-mantle)',
color: 'var(--ctp-text)',
},
'.cm-panels.cm-panels-top': {
borderBottom: '1px solid var(--ctp-surface0)',
},
'.cm-searchMatch': {
backgroundColor: 'color-mix(in srgb, var(--ctp-yellow) 25%, transparent)',
},
'.cm-searchMatch.cm-searchMatch-selected': {
backgroundColor: 'color-mix(in srgb, var(--ctp-peach) 30%, transparent)',
},
'.cm-activeLine': {
backgroundColor: 'color-mix(in srgb, var(--ctp-surface0) 40%, transparent)',
},
'.cm-selectionMatch': {
backgroundColor: 'color-mix(in srgb, var(--ctp-teal) 15%, transparent)',
},
'.cm-matchingBracket, .cm-nonmatchingBracket': {
backgroundColor: 'color-mix(in srgb, var(--ctp-blue) 20%, transparent)',
outline: '1px solid color-mix(in srgb, var(--ctp-blue) 40%, transparent)',
},
'.cm-gutters': {
backgroundColor: 'var(--ctp-mantle)',
color: 'var(--ctp-overlay0)',
border: 'none',
borderRight: '1px solid var(--ctp-surface0)',
},
'.cm-activeLineGutter': {
backgroundColor: 'color-mix(in srgb, var(--ctp-surface0) 40%, transparent)',
color: 'var(--ctp-text)',
},
'.cm-foldPlaceholder': {
backgroundColor: 'var(--ctp-surface0)',
border: 'none',
color: 'var(--ctp-overlay1)',
},
'.cm-tooltip': {
backgroundColor: 'var(--ctp-surface0)',
color: 'var(--ctp-text)',
border: '1px solid var(--ctp-surface1)',
borderRadius: '0.25rem',
},
}, { dark: true });
function buildExtensions(langExt: ReturnType<Awaited<ReturnType<typeof getLangExtension>>> | null) {
const exts = [
lineNumbers(),
highlightActiveLineGutter(),
history(),
foldGutter(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
closeBrackets(),
autocompletion(),
highlightActiveLine(),
highlightSelectionMatches(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
indentWithTab,
{ key: 'Mod-s', run: () => { onsave?.(); return true; } },
]),
catppuccinTheme,
EditorView.updateListener.of(update => {
if (update.docChanged) {
onchange?.(update.state.doc.toString());
}
}),
EditorView.domEventHandlers({
blur: () => { onblur?.(); },
}),
EditorView.lineWrapping,
];
if (readonly) exts.push(EditorState.readOnly.of(true));
if (langExt) exts.push(langExt);
return exts;
}
async function createEditor() {
if (!container) return;
const langExt = await getLangExtension(lang);
view = new EditorView({
state: EditorState.create({ doc: content, extensions: buildExtensions(langExt) }),
parent: container,
});
}
onMount(() => { createEditor(); });
onDestroy(() => { view?.destroy(); });
// When content prop changes externally (different file loaded), replace editor content
let lastContent = $state(untrack(() => content));
$effect(() => {
const c = content;
if (view && c !== lastContent) {
const currentDoc = view.state.doc.toString();
if (c !== currentDoc) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: c },
});
}
lastContent = c;
}
});
// When lang changes, recreate editor
let lastLang = $state(untrack(() => lang));
$effect(() => {
const l = lang;
if (l !== lastLang && view) {
lastLang = l;
const currentContent = view.state.doc.toString();
view.destroy();
queueMicrotask(async () => {
const langExt = await getLangExtension(l);
view = new EditorView({
state: EditorState.create({ doc: currentContent, extensions: buildExtensions(langExt) }),
parent: container!,
});
});
}
});
export function getContent(): string {
return view?.state.doc.toString() ?? content;
}
</script>
<div class="code-editor" bind:this={container}></div>
<style>
.code-editor {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.code-editor :global(.cm-editor) {
flex: 1;
overflow: hidden;
}
.code-editor :global(.cm-scroller) {
overflow: auto;
}
</style>

View file

@ -0,0 +1,521 @@
<script lang="ts">
import { appRpc } from './rpc.ts';
// ── Types ────────────────────────────────────────────────────────────
interface Channel {
id: string;
name: string;
groupId: string;
createdBy: string;
memberCount: number;
createdAt: string;
}
interface ChannelMessage {
id: string;
channelId: string;
fromAgent: string;
content: string;
createdAt: string;
senderName: string;
senderRole: string;
}
interface Agent {
id: string;
name: string;
role: string;
groupId: string;
tier: number;
status: string;
unreadCount: number;
}
interface DM {
id: string;
fromAgent: string;
toAgent: string;
content: string;
read: boolean;
createdAt: string;
senderName: string | null;
senderRole: string | null;
}
interface Props {
groupId: string;
/** Agent ID for this project's perspective (defaults to 'admin'). */
agentId?: string;
}
let { groupId, agentId = 'admin' }: Props = $props();
// ── State ────────────────────────────────────────────────────────────
type TabMode = 'channels' | 'dms';
let mode = $state<TabMode>('channels');
let channels = $state<Channel[]>([]);
let agents = $state<Agent[]>([]);
let activeChannelId = $state<string | null>(null);
let activeDmAgentId = $state<string | null>(null);
let channelMessages = $state<ChannelMessage[]>([]);
let dmMessages = $state<DM[]>([]);
let input = $state('');
let loading = $state(false);
// ── Data fetching ────────────────────────────────────────────────────
async function loadChannels() {
try {
const res = await appRpc.request['btmsg.listChannels']({ groupId });
channels = res.channels;
if (channels.length > 0 && !activeChannelId) {
activeChannelId = channels[0].id;
await loadChannelMessages(channels[0].id);
}
} catch (err) {
console.error('[CommsTab] loadChannels:', err);
}
}
async function loadAgents() {
try {
const res = await appRpc.request['btmsg.getAgents']({ groupId });
agents = res.agents.filter((a: Agent) => a.id !== agentId);
} catch (err) {
console.error('[CommsTab] loadAgents:', err);
}
}
async function loadChannelMessages(channelId: string) {
try {
loading = true;
const res = await appRpc.request['btmsg.getChannelMessages']({
channelId, limit: 100,
});
channelMessages = res.messages;
} catch (err) {
console.error('[CommsTab] loadChannelMessages:', err);
} finally {
loading = false;
}
}
async function loadDmMessages(otherId: string) {
try {
loading = true;
const res = await appRpc.request['btmsg.listMessages']({
agentId, otherId, limit: 50,
});
dmMessages = res.messages;
} catch (err) {
console.error('[CommsTab] loadDmMessages:', err);
} finally {
loading = false;
}
}
function selectChannel(id: string) {
activeChannelId = id;
loadChannelMessages(id);
}
function selectDm(otherId: string) {
activeDmAgentId = otherId;
loadDmMessages(otherId);
}
async function sendMessage() {
const text = input.trim();
if (!text) return;
input = '';
try {
if (mode === 'channels' && activeChannelId) {
await appRpc.request['btmsg.sendChannelMessage']({
channelId: activeChannelId, fromAgent: agentId, content: text,
});
await loadChannelMessages(activeChannelId);
} else if (mode === 'dms' && activeDmAgentId) {
await appRpc.request['btmsg.sendMessage']({
fromAgent: agentId, toAgent: activeDmAgentId, content: text,
});
await loadDmMessages(activeDmAgentId);
}
} catch (err) {
console.error('[CommsTab] sendMessage:', err);
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
// ── Init + polling ───────────────────────────────────────────────────
let pollTimer: ReturnType<typeof setInterval> | null = null;
$effect(() => {
loadChannels();
loadAgents();
pollTimer = setInterval(() => {
if (mode === 'channels' && activeChannelId) {
loadChannelMessages(activeChannelId);
} else if (mode === 'dms' && activeDmAgentId) {
loadDmMessages(activeDmAgentId);
}
}, 5000);
return () => { if (pollTimer) clearInterval(pollTimer); };
});
</script>
<div class="comms-tab">
<!-- Mode toggle -->
<div class="comms-mode-bar">
<button
class="mode-btn"
class:active={mode === 'channels'}
onclick={() => { mode = 'channels'; }}
>Channels</button>
<button
class="mode-btn"
class:active={mode === 'dms'}
onclick={() => { mode = 'dms'; loadAgents(); }}
>DMs</button>
</div>
<div class="comms-body">
<!-- Sidebar: channel list or agent list -->
<div class="comms-sidebar">
{#if mode === 'channels'}
{#each channels as ch}
<button
class="sidebar-item"
class:active={activeChannelId === ch.id}
onclick={() => selectChannel(ch.id)}
>
<span class="ch-hash">#</span>
<span class="ch-name">{ch.name}</span>
</button>
{/each}
{#if channels.length === 0}
<div class="sidebar-empty">No channels</div>
{/if}
{:else}
{#each agents as ag}
<button
class="sidebar-item"
class:active={activeDmAgentId === ag.id}
onclick={() => selectDm(ag.id)}
>
<span class="agent-dot {ag.status}"></span>
<span class="agent-name">{ag.name}</span>
<span class="agent-role">{ag.role}</span>
{#if ag.unreadCount > 0}
<span class="unread-badge">{ag.unreadCount}</span>
{/if}
</button>
{/each}
{#if agents.length === 0}
<div class="sidebar-empty">No agents</div>
{/if}
{/if}
</div>
<!-- Message area -->
<div class="comms-messages">
{#if loading}
<div class="msg-loading">Loading...</div>
{:else if mode === 'channels'}
<div class="msg-list">
{#each channelMessages as msg}
<div class="msg-row">
<span class="msg-sender">{msg.senderName}</span>
<span class="msg-role">{msg.senderRole}</span>
<span class="msg-time">{msg.createdAt.slice(11, 16)}</span>
<div class="msg-content">{msg.content}</div>
</div>
{/each}
{#if channelMessages.length === 0}
<div class="msg-empty">No messages in this channel</div>
{/if}
</div>
{:else}
<div class="msg-list">
{#each dmMessages as msg}
<div class="msg-row" class:msg-mine={msg.fromAgent === agentId}>
<span class="msg-sender">{msg.senderName ?? msg.fromAgent}</span>
<span class="msg-time">{msg.createdAt.slice(11, 16)}</span>
<div class="msg-content">{msg.content}</div>
</div>
{/each}
{#if dmMessages.length === 0 && activeDmAgentId}
<div class="msg-empty">No messages yet</div>
{/if}
{#if !activeDmAgentId}
<div class="msg-empty">Select an agent to message</div>
{/if}
</div>
{/if}
<!-- Input bar -->
<div class="msg-input-bar">
<input
class="msg-input"
type="text"
placeholder={mode === 'channels' ? 'Message channel...' : 'Send DM...'}
bind:value={input}
onkeydown={handleKeydown}
/>
<button class="msg-send-btn" onclick={sendMessage} disabled={!input.trim()}>
Send
</button>
</div>
</div>
</div>
</div>
<style>
.comms-tab {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.comms-mode-bar {
display: flex;
gap: 0.125rem;
padding: 0.25rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.mode-btn {
flex: 1;
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid transparent;
border-radius: 0.25rem;
color: var(--ctp-subtext0);
font-family: var(--ui-font-family);
font-size: 0.75rem;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.mode-btn:hover { color: var(--ctp-text); }
.mode-btn.active {
background: var(--ctp-surface0);
color: var(--ctp-text);
border-color: var(--ctp-surface1);
}
.comms-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.comms-sidebar {
width: 10rem;
flex-shrink: 0;
border-right: 1px solid var(--ctp-surface0);
overflow-y: auto;
padding: 0.25rem 0;
background: var(--ctp-mantle);
}
.sidebar-item {
display: flex;
align-items: center;
gap: 0.375rem;
width: 100%;
padding: 0.375rem 0.5rem;
background: transparent;
border: none;
color: var(--ctp-subtext0);
font-family: var(--ui-font-family);
font-size: 0.75rem;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.sidebar-item:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
.sidebar-item.active { background: var(--ctp-surface0); color: var(--ctp-text); }
.ch-hash {
color: var(--ctp-overlay0);
font-weight: 700;
flex-shrink: 0;
}
.ch-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--ctp-overlay0);
flex-shrink: 0;
}
.agent-dot.active { background: var(--ctp-green); }
.agent-dot.running { background: var(--ctp-green); }
.agent-dot.stopped { background: var(--ctp-overlay0); }
.agent-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-role {
font-size: 0.625rem;
color: var(--ctp-overlay0);
flex-shrink: 0;
}
.unread-badge {
background: var(--ctp-red);
color: var(--ctp-base);
font-size: 0.5625rem;
font-weight: 700;
padding: 0 0.25rem;
border-radius: 0.5rem;
min-width: 0.875rem;
text-align: center;
flex-shrink: 0;
}
.sidebar-empty {
padding: 1rem 0.5rem;
color: var(--ctp-overlay0);
font-size: 0.75rem;
font-style: italic;
text-align: center;
}
.comms-messages {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.msg-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.msg-row {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: baseline;
}
.msg-mine { flex-direction: row-reverse; }
.msg-sender {
font-size: 0.6875rem;
font-weight: 600;
color: var(--ctp-blue);
flex-shrink: 0;
}
.msg-role {
font-size: 0.5625rem;
color: var(--ctp-overlay0);
flex-shrink: 0;
}
.msg-time {
font-size: 0.5625rem;
color: var(--ctp-overlay0);
flex-shrink: 0;
margin-left: auto;
}
.msg-content {
width: 100%;
font-size: 0.8125rem;
color: var(--ctp-text);
line-height: 1.4;
word-break: break-word;
}
.msg-empty, .msg-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--ctp-overlay0);
font-size: 0.8125rem;
font-style: italic;
}
.msg-input-bar {
display: flex;
gap: 0.25rem;
padding: 0.375rem;
border-top: 1px solid var(--ctp-surface0);
background: var(--ctp-mantle);
flex-shrink: 0;
}
.msg-input {
flex: 1;
height: 1.75rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.8125rem;
padding: 0 0.5rem;
outline: none;
}
.msg-input:focus { border-color: var(--ctp-mauve); }
.msg-send-btn {
padding: 0 0.625rem;
height: 1.75rem;
background: color-mix(in srgb, var(--ctp-blue) 20%, transparent);
border: 1px solid var(--ctp-blue);
border-radius: 0.25rem;
color: var(--ctp-blue);
font-family: var(--ui-font-family);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: background 0.1s;
flex-shrink: 0;
}
.msg-send-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--ctp-blue) 35%, transparent);
}
.msg-send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,243 @@
<script lang="ts">
interface Props {
content: string;
filename: string;
}
let { content, filename }: Props = $props();
/** RFC 4180 CSV parser with quoted field support. */
function parseCsv(text: string, delimiter: string): string[][] {
const rows: string[][] = [];
let i = 0;
const len = text.length;
while (i < len) {
const row: string[] = [];
while (i < len) {
let field = '';
if (text[i] === '"') {
i++; // skip opening quote
while (i < len) {
if (text[i] === '"') {
if (i + 1 < len && text[i + 1] === '"') {
field += '"';
i += 2;
} else {
i++; // skip closing quote
break;
}
} else {
field += text[i];
i++;
}
}
} else {
while (i < len && text[i] !== delimiter && text[i] !== '\n' && text[i] !== '\r') {
field += text[i];
i++;
}
}
row.push(field);
if (i < len && text[i] === delimiter) {
i++;
} else {
if (i < len && text[i] === '\r') i++;
if (i < len && text[i] === '\n') i++;
break;
}
}
if (row.length > 0 && !(row.length === 1 && row[0] === '')) {
rows.push(row);
}
}
return rows;
}
/** Detect delimiter from first line: comma, semicolon, tab, or pipe. */
function detectDelimiter(text: string): string {
const firstLine = text.split('\n')[0] ?? '';
const counts: [string, number][] = [
[',', (firstLine.match(/,/g) ?? []).length],
[';', (firstLine.match(/;/g) ?? []).length],
['\t', (firstLine.match(/\t/g) ?? []).length],
['|', (firstLine.match(/\|/g) ?? []).length],
];
counts.sort((a, b) => b[1] - a[1]);
return counts[0][1] > 0 ? counts[0][0] : ',';
}
let delimiter = $derived(detectDelimiter(content));
let parsed = $derived(parseCsv(content, delimiter));
let headers = $derived(parsed[0] ?? []);
let dataRows = $derived(parsed.slice(1));
let totalRows = $derived(dataRows.length);
let colCount = $derived(Math.max(...parsed.map(r => r.length), 0));
// Sort state
let sortCol = $state<number | null>(null);
let sortAsc = $state(true);
let sortedRows = $derived.by(() => {
if (sortCol === null) return dataRows;
const col = sortCol;
const asc = sortAsc;
return [...dataRows].sort((a, b) => {
const va = a[col] ?? '';
const vb = b[col] ?? '';
const na = Number(va);
const nb = Number(vb);
if (!isNaN(na) && !isNaN(nb)) {
return asc ? na - nb : nb - na;
}
return asc ? va.localeCompare(vb) : vb.localeCompare(va);
});
});
function toggleSort(col: number) {
if (sortCol === col) {
sortAsc = !sortAsc;
} else {
sortCol = col;
sortAsc = true;
}
}
function sortIndicator(col: number): string {
if (sortCol !== col) return '';
return sortAsc ? ' ▲' : ' ▼';
}
</script>
<div class="csv-table-wrapper">
<div class="csv-toolbar">
<span class="csv-info">
{totalRows} row{totalRows !== 1 ? 's' : ''} x {colCount} col{colCount !== 1 ? 's' : ''}
</span>
<span class="csv-filename">{filename}</span>
</div>
<div class="csv-scroll">
<table class="csv-table">
<thead>
<tr>
<th class="row-num">#</th>
{#each headers as header, i}
<th onclick={() => toggleSort(i)} class="sortable">
{header}{sortIndicator(i)}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sortedRows as row, rowIdx (rowIdx)}
<tr>
<td class="row-num">{rowIdx + 1}</td>
{#each { length: colCount } as _, colIdx}
<td>{row[colIdx] ?? ''}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<style>
.csv-table-wrapper {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--ctp-base);
}
.csv-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.csv-info {
font-size: 0.7rem;
color: var(--ctp-overlay1);
font-variant-numeric: tabular-nums;
}
.csv-filename {
font-size: 0.65rem;
color: var(--ctp-overlay0);
font-family: var(--term-font-family, monospace);
}
.csv-scroll {
flex: 1;
overflow: auto;
}
.csv-table {
width: 100%;
border-collapse: collapse;
font-size: 0.725rem;
font-family: var(--term-font-family, monospace);
white-space: nowrap;
}
.csv-table thead {
position: sticky;
top: 0;
z-index: 1;
}
.csv-table th {
background: var(--ctp-mantle);
color: var(--ctp-subtext1);
font-weight: 600;
text-align: left;
padding: 0.3125rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface1);
user-select: none;
}
.csv-table th.sortable {
cursor: pointer;
transition: background 0.12s;
}
.csv-table th.sortable:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.csv-table td {
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
color: var(--ctp-text);
max-width: 20rem;
overflow: hidden;
text-overflow: ellipsis;
}
.csv-table tbody tr:hover td {
background: color-mix(in srgb, var(--ctp-surface0) 50%, transparent);
}
.row-num {
color: var(--ctp-overlay0);
font-size: 0.625rem;
text-align: right;
width: 2.5rem;
min-width: 2.5rem;
padding-right: 0.625rem;
border-right: 1px solid var(--ctp-surface0);
}
thead .row-num {
border-bottom: 1px solid var(--ctp-surface1);
}
</style>

View file

@ -1,133 +1,352 @@
<script lang="ts"> <script lang="ts">
interface FileNode { import { appRpc } from './rpc.ts';
import CodeEditor from './CodeEditor.svelte';
import PdfViewer from './PdfViewer.svelte';
import CsvTable from './CsvTable.svelte';
interface Props {
cwd: string;
}
let { cwd }: Props = $props();
interface DirEntry {
name: string; name: string;
type: 'file' | 'dir'; type: 'file' | 'dir';
children?: FileNode[]; size: number;
} }
// Demo directory tree // Tree state: track loaded children and open/closed per path
const TREE: FileNode[] = [ let childrenCache = $state<Map<string, DirEntry[]>>(new Map());
{ let openDirs = $state<Set<string>>(new Set());
name: 'src', type: 'dir', children: [ let loadingDirs = $state<Set<string>>(new Set());
{
name: 'lib', type: 'dir', children: [
{
name: 'stores', type: 'dir', children: [
{ name: 'workspace.svelte.ts', type: 'file' },
{ name: 'agents.svelte.ts', type: 'file' },
{ name: 'health.svelte.ts', type: 'file' },
],
},
{
name: 'adapters', type: 'dir', children: [
{ name: 'claude-messages.ts', type: 'file' },
{ name: 'agent-bridge.ts', type: 'file' },
],
},
{ name: 'agent-dispatcher.ts', type: 'file' },
],
},
{ name: 'App.svelte', type: 'file' },
],
},
{
name: 'src-tauri', type: 'dir', children: [
{
name: 'src', type: 'dir', children: [
{ name: 'lib.rs', type: 'file' },
{ name: 'btmsg.rs', type: 'file' },
],
},
],
},
{ name: 'Cargo.toml', type: 'file' },
{ name: 'package.json', type: 'file' },
{ name: 'vite.config.ts', type: 'file' },
];
let openDirs = $state<Set<string>>(new Set(['src', 'src/lib', 'src/lib/stores']));
let selectedFile = $state<string | null>(null); let selectedFile = $state<string | null>(null);
function toggleDir(path: string) { // File viewer state
const s = new Set(openDirs); let fileContent = $state<string | null>(null);
if (s.has(path)) s.delete(path); let fileEncoding = $state<'utf8' | 'base64'>('utf8');
else s.add(path); let fileSize = $state(0);
openDirs = s; let fileError = $state<string | null>(null);
let fileLoading = $state(false);
let isDirty = $state(false);
let editorContent = $state('');
// Extension-based type detection
const CODE_EXTS = new Set([
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
'py', 'rs', 'go', 'css', 'scss', 'less',
'html', 'svelte', 'vue',
'json', 'md', 'yaml', 'yml', 'toml', 'sh', 'bash',
'xml', 'sql', 'c', 'cpp', 'h', 'java', 'php',
]);
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp']);
const PDF_EXTS = new Set(['pdf']);
const CSV_EXTS = new Set(['csv', 'tsv']);
function getExt(name: string): string {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
} }
function selectFile(path: string) { type FileType = 'code' | 'image' | 'pdf' | 'csv' | 'text';
selectedFile = path;
function detectFileType(name: string): FileType {
const ext = getExt(name);
if (PDF_EXTS.has(ext)) return 'pdf';
if (CSV_EXTS.has(ext)) return 'csv';
if (IMAGE_EXTS.has(ext)) return 'image';
if (CODE_EXTS.has(ext)) return 'code';
return 'text';
}
/** Map extension to CodeMirror language name. */
function extToLang(name: string): string {
const ext = getExt(name);
const map: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
mjs: 'javascript', cjs: 'javascript',
py: 'python', rs: 'rust', go: 'go',
css: 'css', scss: 'css', less: 'css',
html: 'html', svelte: 'html', vue: 'html',
json: 'json', md: 'markdown', yaml: 'yaml', yml: 'yaml',
toml: 'toml', sh: 'bash', bash: 'bash',
xml: 'xml', sql: 'sql', c: 'c', cpp: 'cpp', h: 'c',
java: 'java', php: 'php',
};
return map[ext] ?? 'text';
}
let selectedType = $derived<FileType>(selectedFile ? detectFileType(selectedFile) : 'text');
let selectedName = $derived(selectedFile ? selectedFile.split('/').pop() ?? '' : '');
/** Load directory entries via RPC. */
async function loadDir(dirPath: string) {
if (childrenCache.has(dirPath)) return;
const key = dirPath;
loadingDirs = new Set([...loadingDirs, key]);
try {
const result = await appRpc.request["files.list"]({ path: dirPath });
if (result.error) {
console.error(`[files.list] ${dirPath}: ${result.error}`);
return;
}
const next = new Map(childrenCache);
next.set(dirPath, result.entries);
childrenCache = next;
} catch (err) {
console.error('[files.list]', err);
} finally {
const s = new Set(loadingDirs);
s.delete(key);
loadingDirs = s;
}
}
/** Toggle a directory open/closed. Lazy-loads on first open. */
async function toggleDir(dirPath: string) {
const s = new Set(openDirs);
if (s.has(dirPath)) {
s.delete(dirPath);
openDirs = s;
} else {
s.add(dirPath);
openDirs = s;
await loadDir(dirPath);
}
}
/** Select and load a file. */
async function selectFile(filePath: string) {
if (selectedFile === filePath) return;
selectedFile = filePath;
isDirty = false;
fileContent = null;
fileError = null;
fileLoading = true;
const type = detectFileType(filePath);
// PDF uses its own loader via PdfViewer
if (type === 'pdf') {
fileLoading = false;
return;
}
// Images: read as base64 for display
if (type === 'image') {
try {
const result = await appRpc.request["files.read"]({ path: filePath });
if (result.error) {
fileError = result.error;
return;
}
fileContent = result.content ?? '';
fileEncoding = result.encoding;
fileSize = result.size;
} catch (err) {
fileError = err instanceof Error ? err.message : String(err);
} finally {
fileLoading = false;
}
return;
}
try {
const result = await appRpc.request["files.read"]({ path: filePath });
if (result.error) {
fileError = result.error;
return;
}
fileContent = result.content ?? '';
fileEncoding = result.encoding;
fileSize = result.size;
editorContent = fileContent;
} catch (err) {
fileError = err instanceof Error ? err.message : String(err);
} finally {
fileLoading = false;
}
}
/** Save current file. */
async function saveFile() {
if (!selectedFile || !isDirty) return;
try {
const result = await appRpc.request["files.write"]({
path: selectedFile,
content: editorContent,
});
if (result.ok) {
isDirty = false;
fileContent = editorContent;
} else if (result.error) {
console.error('[files.write]', result.error);
}
} catch (err) {
console.error('[files.write]', err);
}
}
function onEditorChange(newContent: string) {
editorContent = newContent;
isDirty = newContent !== fileContent;
} }
function fileIcon(name: string): string { function fileIcon(name: string): string {
if (name.endsWith('.ts') || name.endsWith('.svelte.ts')) return '⟨/⟩'; const ext = getExt(name);
if (name.endsWith('.svelte')) return '◈'; if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'].includes(ext)) return '</>';
if (name.endsWith('.rs')) return '⊕'; if (ext === 'svelte' || ext === 'vue') return '~';
if (name.endsWith('.toml')) return '⚙'; if (ext === 'rs') return 'Rs';
if (name.endsWith('.json')) return '{}'; if (ext === 'py') return 'Py';
return '·'; if (ext === 'go') return 'Go';
if (['json', 'toml', 'yaml', 'yml'].includes(ext)) return '{}';
if (ext === 'md') return 'M';
if (ext === 'css' || ext === 'scss') return '#';
if (ext === 'html') return '<>';
if (IMAGE_EXTS.has(ext)) return 'Im';
if (ext === 'pdf') return 'Pd';
if (CSV_EXTS.has(ext)) return 'Tb';
return '..';
} }
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// Load root directory on mount
$effect(() => {
if (cwd) {
loadDir(cwd);
const s = new Set(openDirs);
s.add(cwd);
openDirs = s;
}
});
</script> </script>
<div class="file-browser"> <div class="file-browser">
<!-- Tree panel -->
<div class="fb-tree"> <div class="fb-tree">
{#snippet renderNode(node: FileNode, path: string, depth: number)} {#snippet renderEntries(dirPath: string, depth: number)}
{#if node.type === 'dir'} {#if childrenCache.has(dirPath)}
<button {#each childrenCache.get(dirPath) ?? [] as entry}
class="fb-row fb-dir" {@const fullPath = `${dirPath}/${entry.name}`}
style:padding-left="{0.5 + depth * 0.875}rem" {#if entry.type === 'dir'}
onclick={() => toggleDir(path)} <button
aria-expanded={openDirs.has(path)} class="fb-row fb-dir"
> style:padding-left="{0.5 + depth * 0.875}rem"
<span class="fb-chevron" class:open={openDirs.has(path)}></span> onclick={() => toggleDir(fullPath)}
<span class="fb-icon dir-icon">📁</span> aria-expanded={openDirs.has(fullPath)}
<span class="fb-name">{node.name}</span> >
</button> <span class="fb-chevron" class:open={openDirs.has(fullPath)}>
{#if openDirs.has(path) && node.children} {#if loadingDirs.has(fullPath)}...{:else}>{/if}
{#each node.children as child} </span>
{@render renderNode(child, `${path}/${child.name}`, depth + 1)} <span class="fb-name">{entry.name}</span>
{/each} </button>
{/if} {#if openDirs.has(fullPath)}
{:else} {@render renderEntries(fullPath, depth + 1)}
<button {/if}
class="fb-row fb-file" {:else}
class:selected={selectedFile === path} <button
style:padding-left="{0.5 + depth * 0.875}rem" class="fb-row fb-file"
onclick={() => selectFile(path)} class:selected={selectedFile === fullPath}
title={path} style:padding-left="{0.5 + depth * 0.875}rem"
> onclick={() => selectFile(fullPath)}
<span class="fb-icon file-type">{fileIcon(node.name)}</span> title={`${entry.name} (${formatSize(entry.size)})`}
<span class="fb-name">{node.name}</span> >
</button> <span class="fb-icon file-type">{fileIcon(entry.name)}</span>
<span class="fb-name">{entry.name}</span>
{#if selectedFile === fullPath && isDirty}
<span class="dirty-dot" title="Unsaved changes"></span>
{/if}
</button>
{/if}
{/each}
{:else if loadingDirs.has(dirPath)}
<div class="fb-loading" style:padding-left="{0.5 + depth * 0.875}rem">Loading...</div>
{/if} {/if}
{/snippet} {/snippet}
{#each TREE as node} {@render renderEntries(cwd, 0)}
{@render renderNode(node, node.name, 0)}
{/each}
</div> </div>
{#if selectedFile} <!-- Viewer panel -->
<div class="fb-preview"> <div class="fb-viewer">
<div class="fb-preview-label">{selectedFile}</div> {#if !selectedFile}
<div class="fb-preview-content">(click to open in editor)</div> <div class="fb-empty">Select a file to view</div>
</div> {:else if fileLoading}
{/if} <div class="fb-empty">Loading...</div>
{:else if fileError}
<div class="fb-error">{fileError}</div>
{:else if selectedType === 'pdf'}
<PdfViewer filePath={selectedFile} />
{:else if selectedType === 'csv' && fileContent != null}
<CsvTable content={fileContent} filename={selectedName} />
{:else if selectedType === 'image' && fileContent}
{@const ext = getExt(selectedName)}
{@const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg'}
<div class="fb-image-wrap">
<div class="fb-image-label">{selectedName} ({formatSize(fileSize)})</div>
<img
class="fb-image"
src="data:{mime};base64,{fileEncoding === 'base64' ? fileContent : btoa(fileContent)}"
alt={selectedName}
/>
</div>
{:else if selectedType === 'code' && fileContent != null}
<div class="fb-editor-header">
<span class="fb-editor-path" title={selectedFile}>
{selectedName}
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
</span>
<span class="fb-editor-size">{formatSize(fileSize)}</span>
</div>
<CodeEditor
content={fileContent}
lang={extToLang(selectedFile)}
onsave={saveFile}
onchange={onEditorChange}
onblur={saveFile}
/>
{:else if fileContent != null}
<!-- Raw text fallback -->
<div class="fb-editor-header">
<span class="fb-editor-path" title={selectedFile}>
{selectedName}
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
</span>
<span class="fb-editor-size">{formatSize(fileSize)}</span>
</div>
<CodeEditor
content={fileContent}
lang="text"
onsave={saveFile}
onchange={onEditorChange}
onblur={saveFile}
/>
{/if}
</div>
</div> </div>
<style> <style>
.file-browser { .file-browser {
display: flex; display: flex;
flex-direction: column;
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
font-size: 0.8125rem; font-size: 0.8125rem;
} }
/* ── Tree panel ── */
.fb-tree { .fb-tree {
flex: 1; width: 14rem;
min-width: 10rem;
overflow-y: auto; overflow-y: auto;
padding: 0.25rem 0; padding: 0.25rem 0;
border-right: 1px solid var(--ctp-surface0);
background: var(--ctp-mantle);
flex-shrink: 0;
} }
.fb-tree::-webkit-scrollbar { width: 0.25rem; } .fb-tree::-webkit-scrollbar { width: 0.25rem; }
@ -163,25 +382,25 @@
.fb-chevron { .fb-chevron {
display: inline-block; display: inline-block;
width: 0.875rem; width: 0.875rem;
font-size: 0.875rem; font-size: 0.75rem;
color: var(--ctp-overlay1); color: var(--ctp-overlay1);
transition: transform 0.12s; transition: transform 0.12s;
transform: rotate(0deg); transform: rotate(0deg);
flex-shrink: 0; flex-shrink: 0;
line-height: 1; line-height: 1;
font-family: var(--term-font-family, monospace);
} }
.fb-chevron.open { transform: rotate(90deg); } .fb-chevron.open { transform: rotate(90deg); }
.fb-icon { .fb-icon { flex-shrink: 0; font-style: normal; }
flex-shrink: 0;
font-style: normal;
}
.file-type { .file-type {
font-size: 0.6875rem; font-size: 0.6rem;
color: var(--ctp-overlay1); color: var(--ctp-overlay1);
font-family: var(--term-font-family); font-family: var(--term-font-family, monospace);
width: 1.25rem;
text-align: center;
} }
.fb-name { .fb-name {
@ -192,26 +411,104 @@
.fb-dir .fb-name { color: var(--ctp-subtext1); font-weight: 500; } .fb-dir .fb-name { color: var(--ctp-subtext1); font-weight: 500; }
.fb-preview { .fb-loading {
border-top: 1px solid var(--ctp-surface0); font-size: 0.7rem;
padding: 0.5rem 0.75rem; color: var(--ctp-overlay0);
font-style: italic;
padding: 0.15rem 0;
}
.dirty-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ctp-peach);
flex-shrink: 0;
margin-left: auto;
}
/* ── Viewer panel ── */
.fb-viewer {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--ctp-base);
}
.fb-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-overlay0);
font-size: 0.8rem;
font-style: italic;
}
.fb-error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-red);
font-size: 0.8rem;
padding: 1rem;
}
.fb-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.625rem;
background: var(--ctp-mantle); background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0; flex-shrink: 0;
} }
.fb-preview-label { .fb-editor-path {
font-size: 0.75rem; font-size: 0.7rem;
color: var(--ctp-subtext0); color: var(--ctp-subtext0);
font-family: var(--term-font-family); font-family: var(--term-font-family, monospace);
margin-bottom: 0.2rem;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.fb-preview-content { .dirty-indicator {
font-size: 0.75rem; color: var(--ctp-peach);
color: var(--ctp-overlay0);
font-style: italic; font-style: italic;
} }
.fb-editor-size {
font-size: 0.65rem;
color: var(--ctp-overlay0);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
margin-left: 0.5rem;
}
.fb-image-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 0.5rem;
}
.fb-image-label {
font-size: 0.75rem;
color: var(--ctp-subtext0);
font-family: var(--term-font-family, monospace);
flex-shrink: 0;
padding: 0.5rem;
}
.fb-image {
max-width: 100%;
max-height: calc(100% - 2rem);
object-fit: contain;
border-radius: 0.25rem;
}
</style> </style>

View file

@ -0,0 +1,298 @@
<script lang="ts">
import { onMount, onDestroy, untrack } from 'svelte';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
import { appRpc } from './rpc.ts';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl;
interface Props {
filePath: string;
}
let { filePath }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let pageCount = $state(0);
let currentScale = $state(1.0);
let loading = $state(true);
let error = $state<string | null>(null);
let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null;
let observer: IntersectionObserver | null = null;
let renderedPages = new Set<number>();
let renderingPages = new Set<number>();
const SCALE_STEP = 0.25;
const MIN_SCALE = 0.5;
const MAX_SCALE = 3.0;
async function loadPdf(fp: string) {
loading = true;
error = null;
cleanup();
try {
// Read file as base64 via RPC, then convert to Uint8Array
const result = await appRpc.request["files.read"]({ path: fp });
if (result.error) {
error = result.error;
return;
}
if (!result.content) {
error = 'Empty file';
return;
}
let data: Uint8Array;
if (result.encoding === 'base64') {
const binary = atob(result.content);
data = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) data[i] = binary.charCodeAt(i);
} else {
// Shouldn't happen for PDF but handle gracefully
const encoder = new TextEncoder();
data = encoder.encode(result.content);
}
const loadingTask = pdfjsLib.getDocument({ data });
pdfDoc = await loadingTask.promise;
pageCount = pdfDoc.numPages;
createPlaceholders();
} catch (e) {
error = `Failed to load PDF: ${e}`;
console.warn('PDF load error:', e);
} finally {
loading = false;
}
}
function createPlaceholders() {
if (!pdfDoc || !container) return;
container.innerHTML = '';
renderedPages.clear();
renderingPages.clear();
observer?.disconnect();
observer = new IntersectionObserver(onIntersect, {
root: container,
rootMargin: '200px 0px',
});
for (let i = 1; i <= pdfDoc.numPages; i++) {
const placeholder = document.createElement('div');
placeholder.className = 'pdf-page-slot';
placeholder.dataset.page = String(i);
placeholder.style.width = '100%';
placeholder.style.minHeight = '20rem';
container.appendChild(placeholder);
observer.observe(placeholder);
}
}
function onIntersect(entries: IntersectionObserverEntry[]) {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const pageNum = Number((entry.target as HTMLElement).dataset.page);
if (!pageNum || renderedPages.has(pageNum) || renderingPages.has(pageNum)) continue;
renderPage(pageNum, entry.target as HTMLElement);
}
}
async function renderPage(pageNum: number, slot: HTMLElement) {
if (!pdfDoc) return;
renderingPages.add(pageNum);
try {
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: currentScale * window.devicePixelRatio });
const displayViewport = page.getViewport({ scale: currentScale });
const canvas = document.createElement('canvas');
canvas.className = 'pdf-page-canvas';
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${displayViewport.width}px`;
canvas.style.height = `${displayViewport.height}px`;
slot.innerHTML = '';
slot.style.minHeight = '';
slot.appendChild(canvas);
const ctx = canvas.getContext('2d');
if (!ctx) return;
const task = page.render({ canvasContext: ctx, viewport });
await task.promise;
renderedPages.add(pageNum);
observer?.unobserve(slot);
} catch (e: unknown) {
if (e && typeof e === 'object' && 'name' in e && (e as { name: string }).name !== 'RenderingCancelledException') {
console.warn(`Failed to render page ${pageNum}:`, e);
}
} finally {
renderingPages.delete(pageNum);
}
}
function rerender() {
renderedPages.clear();
renderingPages.clear();
createPlaceholders();
}
function zoomIn() {
if (currentScale >= MAX_SCALE) return;
currentScale = Math.min(MAX_SCALE, currentScale + SCALE_STEP);
rerender();
}
function zoomOut() {
if (currentScale <= MIN_SCALE) return;
currentScale = Math.max(MIN_SCALE, currentScale - SCALE_STEP);
rerender();
}
function resetZoom() {
currentScale = 1.0;
rerender();
}
function cleanup() {
observer?.disconnect();
observer = null;
renderedPages.clear();
renderingPages.clear();
if (container) container.innerHTML = '';
if (pdfDoc) {
pdfDoc.destroy();
pdfDoc = null;
}
}
onMount(() => { loadPdf(filePath); });
let lastPath = $state(untrack(() => filePath));
$effect(() => {
const p = filePath;
if (p !== lastPath) {
lastPath = p;
loadPdf(p);
}
});
onDestroy(() => { cleanup(); });
</script>
<div class="pdf-viewer">
<div class="pdf-toolbar">
<span class="pdf-info">
{#if loading}
Loading...
{:else if error}
Error
{:else}
{pageCount} page{pageCount !== 1 ? 's' : ''}
{/if}
</span>
<div class="pdf-zoom-controls">
<button class="zoom-btn" onclick={zoomOut} disabled={currentScale <= MIN_SCALE} title="Zoom out">-</button>
<button class="zoom-label" onclick={resetZoom} title="Reset zoom">{Math.round(currentScale * 100)}%</button>
<button class="zoom-btn" onclick={zoomIn} disabled={currentScale >= MAX_SCALE} title="Zoom in">+</button>
</div>
</div>
{#if error}
<div class="pdf-error">{error}</div>
{:else}
<div class="pdf-pages" bind:this={container}></div>
{/if}
</div>
<style>
.pdf-viewer {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--ctp-crust);
}
.pdf-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.pdf-info {
font-size: 0.7rem;
color: var(--ctp-overlay1);
}
.pdf-zoom-controls {
display: flex;
align-items: center;
gap: 0.125rem;
}
.zoom-btn, .zoom-label {
background: transparent;
border: 1px solid var(--ctp-surface1);
color: var(--ctp-subtext0);
font-size: 0.7rem;
padding: 0.125rem 0.375rem;
border-radius: 0.1875rem;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.zoom-btn:hover:not(:disabled), .zoom-label:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.zoom-btn:disabled {
opacity: 0.4;
cursor: default;
}
.zoom-label {
min-width: 3rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
.pdf-pages {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
}
.pdf-pages :global(.pdf-page-slot) {
display: flex;
justify-content: center;
}
.pdf-pages :global(.pdf-page-canvas) {
box-shadow: 0 1px 4px color-mix(in srgb, var(--ctp-crust) 60%, transparent);
border-radius: 2px;
}
.pdf-error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-red);
font-size: 0.8rem;
padding: 1rem;
}
</style>

View file

@ -3,12 +3,15 @@
import TerminalTabs from './TerminalTabs.svelte'; import TerminalTabs from './TerminalTabs.svelte';
import FileBrowser from './FileBrowser.svelte'; import FileBrowser from './FileBrowser.svelte';
import MemoryTab from './MemoryTab.svelte'; import MemoryTab from './MemoryTab.svelte';
import CommsTab from './CommsTab.svelte';
import TaskBoardTab from './TaskBoardTab.svelte';
import { import {
startAgent, stopAgent, sendPrompt, getSession, hasSession, startAgent, stopAgent, sendPrompt, getSession, hasSession,
loadLastSession,
type AgentStatus, type AgentMessage, type AgentStatus, type AgentMessage,
} from './agent-store.svelte.ts'; } from './agent-store.svelte.ts';
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memory'; type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memory' | 'comms' | 'tasks';
interface Props { interface Props {
id: string; id: string;
@ -29,6 +32,8 @@
clonesAtMax?: boolean; clonesAtMax?: boolean;
/** Callback when user requests cloning (receives projectId and branch name). */ /** Callback when user requests cloning (receives projectId and branch name). */
onClone?: (projectId: string, branch: string) => void; onClone?: (projectId: string, branch: string) => void;
/** Group ID for btmsg/bttask context. */
groupId?: string;
} }
let { let {
@ -315,7 +320,7 @@
role="tabpanel" role="tabpanel"
aria-label="Files" aria-label="Files"
> >
<FileBrowser /> <FileBrowser {cwd} />
</div> </div>
{/if} {/if}

View file

@ -0,0 +1,301 @@
<script lang="ts">
import { appRpc } from './rpc.ts';
interface Props {
open: boolean;
onClose: () => void;
onNavigate?: (resultType: string, id: string) => void;
}
let { open, onClose, onNavigate }: Props = $props();
interface SearchResult {
resultType: string;
id: string;
title: string;
snippet: string;
score: number;
}
let query = $state('');
let results = $state<SearchResult[]>([]);
let loading = $state(false);
let selectedIndex = $state(0);
let inputEl: HTMLInputElement | undefined = $state();
// Debounce timer
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Group results by type
let grouped = $derived(() => {
const groups: Record<string, SearchResult[]> = {};
for (const r of results) {
const key = r.resultType;
if (!groups[key]) groups[key] = [];
groups[key].push(r);
}
return groups;
});
let groupLabels: Record<string, string> = {
message: 'Messages',
task: 'Tasks',
btmsg: 'Communications',
};
function handleInput() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doSearch(), 300);
}
async function doSearch() {
const q = query.trim();
if (!q) {
results = [];
return;
}
loading = true;
try {
const res = await appRpc.request['search.query']({ query: q, limit: 20 });
results = res.results ?? [];
selectedIndex = 0;
} catch (err) {
console.error('[search]', err);
results = [];
} finally {
loading = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (selectedIndex < results.length - 1) selectedIndex++;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (selectedIndex > 0) selectedIndex--;
} else if (e.key === 'Enter' && results.length > 0) {
e.preventDefault();
const item = results[selectedIndex];
if (item) {
onNavigate?.(item.resultType, item.id);
onClose();
}
}
}
function selectResult(idx: number) {
const item = results[idx];
if (item) {
onNavigate?.(item.resultType, item.id);
onClose();
}
}
// Focus input when opened
$effect(() => {
if (open && inputEl) {
requestAnimationFrame(() => inputEl?.focus());
}
if (!open) {
query = '';
results = [];
selectedIndex = 0;
}
});
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay-backdrop" onclick={onClose} onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay-panel" onclick={(e) => e.stopPropagation()} onkeydown={handleKeydown}>
<div class="search-input-wrap">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
bind:this={inputEl}
class="search-input"
type="text"
placeholder="Search messages, tasks, communications..."
bind:value={query}
oninput={handleInput}
/>
{#if loading}
<span class="loading-dot" aria-label="Searching"></span>
{/if}
<kbd class="esc-hint">Esc</kbd>
</div>
{#if results.length > 0}
<div class="results-list">
{#each Object.entries(grouped()) as [type, items]}
<div class="result-group">
<div class="group-label">{groupLabels[type] ?? type}</div>
{#each items as item, i}
{@const flatIdx = results.indexOf(item)}
<button
class="result-item"
class:selected={flatIdx === selectedIndex}
onclick={() => selectResult(flatIdx)}
onmouseenter={() => selectedIndex = flatIdx}
>
<span class="result-title">{item.title}</span>
<span class="result-snippet">{@html item.snippet}</span>
</button>
{/each}
</div>
{/each}
</div>
{:else if query.trim() && !loading}
<div class="no-results">No results for "{query}"</div>
{/if}
</div>
</div>
{/if}
<style>
.overlay-backdrop {
position: fixed;
inset: 0;
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
z-index: 200;
display: flex;
justify-content: center;
padding-top: 15vh;
}
.overlay-panel {
width: min(36rem, 90vw);
max-height: 60vh;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
box-shadow: 0 1rem 3rem color-mix(in srgb, var(--ctp-crust) 50%, transparent);
display: flex;
flex-direction: column;
overflow: hidden;
}
.search-input-wrap {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--ctp-surface0);
}
.search-icon {
width: 1rem;
height: 1rem;
color: var(--ctp-overlay1);
flex-shrink: 0;
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--ctp-text);
font-size: 0.875rem;
font-family: var(--ui-font-family, system-ui, sans-serif);
}
.search-input::placeholder {
color: var(--ctp-overlay0);
}
.loading-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--ctp-blue);
animation: pulse 0.8s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.esc-hint {
padding: 0.1rem 0.3rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem;
font-size: 0.625rem;
color: var(--ctp-overlay0);
font-family: var(--ui-font-family, system-ui, sans-serif);
}
.results-list {
overflow-y: auto;
padding: 0.25rem 0;
}
.result-group {
padding: 0.25rem 0;
}
.group-label {
font-size: 0.625rem;
font-weight: 600;
color: var(--ctp-overlay0);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.25rem 1rem;
}
.result-item {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.375rem 1rem;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
color: var(--ctp-text);
font: inherit;
font-size: 0.8125rem;
}
.result-item:hover, .result-item.selected {
background: var(--ctp-surface0);
}
.result-title {
font-weight: 500;
color: var(--ctp-text);
}
.result-snippet {
font-size: 0.75rem;
color: var(--ctp-subtext0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-snippet :global(b) {
color: var(--ctp-yellow);
font-weight: 600;
}
.no-results {
padding: 1.5rem 1rem;
text-align: center;
color: var(--ctp-overlay0);
font-size: 0.8125rem;
}
.results-list::-webkit-scrollbar { width: 0.375rem; }
.results-list::-webkit-scrollbar-track { background: transparent; }
.results-list::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
</style>

View file

@ -0,0 +1,275 @@
<script lang="ts">
import {
getHealthAggregates,
getAttentionQueue,
type ProjectHealth,
} from './health-store.svelte.ts';
interface Props {
projectCount: number;
totalTokens: number;
totalCost: number;
sessionDuration: string;
groupName: string;
onFocusProject?: (projectId: string) => void;
}
let {
projectCount,
totalTokens,
totalCost,
sessionDuration,
groupName,
onFocusProject,
}: Props = $props();
let health = $derived(getHealthAggregates());
let attentionQueue = $derived(getAttentionQueue(5));
let showAttention = $state(false);
function formatRate(rate: number): string {
if (rate < 0.01) return '$0/hr';
if (rate < 1) return `$${rate.toFixed(2)}/hr`;
return `$${rate.toFixed(1)}/hr`;
}
function fmtTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
function fmtCost(n: number): string {
return `$${n.toFixed(3)}`;
}
function attentionColor(item: ProjectHealth): string {
if (item.attentionScore >= 90) return 'var(--ctp-red)';
if (item.attentionScore >= 70) return 'var(--ctp-peach)';
if (item.attentionScore >= 40) return 'var(--ctp-yellow)';
return 'var(--ctp-overlay1)';
}
function focusProject(projectId: string) {
onFocusProject?.(projectId);
showAttention = false;
}
</script>
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status">
<!-- Left: agent state counts -->
{#if health.running > 0}
<span class="status-segment">
<span class="dot green pulse-dot" aria-hidden="true"></span>
<span class="val">{health.running}</span>
<span>running</span>
</span>
{/if}
{#if health.idle > 0}
<span class="status-segment">
<span class="dot gray" aria-hidden="true"></span>
<span class="val">{health.idle}</span>
<span>idle</span>
</span>
{/if}
{#if health.stalled > 0}
<span class="status-segment stalled">
<span class="dot orange" aria-hidden="true"></span>
<span class="val">{health.stalled}</span>
<span>stalled</span>
</span>
{/if}
<!-- Attention queue -->
{#if attentionQueue.length > 0}
<button
class="status-segment attn-btn"
class:attn-open={showAttention}
onclick={() => showAttention = !showAttention}
title="Needs attention"
>
<span class="dot orange pulse-dot" aria-hidden="true"></span>
<span class="val">{attentionQueue.length}</span>
<span>attention</span>
</button>
{/if}
<span class="spacer"></span>
<!-- Right: aggregates -->
{#if health.totalBurnRatePerHour > 0}
<span class="status-segment burn" title="Burn rate">
<span class="val">{formatRate(health.totalBurnRatePerHour)}</span>
</span>
{/if}
<span class="status-segment" title="Active group">
<span class="val">{groupName}</span>
</span>
<span class="status-segment" title="Projects">
<span class="val">{projectCount}</span>
<span>projects</span>
</span>
<span class="status-segment" title="Session duration">
<span>session</span>
<span class="val">{sessionDuration}</span>
</span>
<span class="status-segment" title="Total tokens">
<span>tokens</span>
<span class="val">{fmtTokens(totalTokens)}</span>
</span>
<span class="status-segment" title="Total cost">
<span>cost</span>
<span class="val cost">{fmtCost(totalCost)}</span>
</span>
<kbd class="palette-hint" title="Search (Ctrl+Shift+F)">Ctrl+Shift+F</kbd>
</footer>
<!-- Attention dropdown -->
{#if showAttention && attentionQueue.length > 0}
<div class="attention-panel">
{#each attentionQueue as item (item.projectId)}
<button
class="attention-card"
onclick={() => focusProject(item.projectId)}
>
<span class="card-id">{item.projectId.slice(0, 12)}</span>
<span class="card-reason" style="color: {attentionColor(item)}">{item.attentionReason}</span>
{#if item.contextPressure !== null && item.contextPressure > 0.5}
<span class="card-ctx">ctx {Math.round(item.contextPressure * 100)}%</span>
{/if}
</button>
{/each}
</div>
{/if}
<style>
.status-bar {
height: var(--status-bar-height, 1.5rem);
background: var(--ctp-crust);
border-top: 1px solid var(--ctp-surface0);
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0 0.625rem;
flex-shrink: 0;
font-size: 0.6875rem;
color: var(--ctp-subtext0);
font-family: var(--ui-font-family, system-ui, sans-serif);
user-select: none;
position: relative;
}
.status-segment {
display: flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
.dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
flex-shrink: 0;
}
.dot.green { background: var(--ctp-green); }
.dot.gray { background: var(--ctp-overlay0); }
.dot.orange { background: var(--ctp-peach); }
.pulse-dot {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.val { color: var(--ctp-text); font-weight: 500; }
.cost { color: var(--ctp-yellow); }
.burn { color: var(--ctp-mauve); font-weight: 600; }
.stalled { color: var(--ctp-peach); font-weight: 600; }
.spacer { flex: 1; }
/* Attention button */
.attn-btn {
background: none;
border: none;
color: var(--ctp-peach);
font: inherit;
font-size: inherit;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0;
font-weight: 600;
}
.attn-btn:hover, .attn-btn.attn-open {
color: var(--ctp-red);
}
/* Attention panel */
.attention-panel {
position: absolute;
bottom: var(--status-bar-height, 1.5rem);
left: 0;
right: 0;
background: var(--ctp-surface0);
border-top: 1px solid var(--ctp-surface1);
display: flex;
gap: 1px;
padding: 0.25rem 0.5rem;
z-index: 100;
overflow-x: auto;
}
.attention-card {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font: inherit;
font-size: 0.6875rem;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.attention-card:hover {
background: var(--ctp-surface0);
border-color: var(--ctp-surface2);
}
.card-id { font-weight: 600; }
.card-reason { font-size: 0.625rem; }
.card-ctx {
font-size: 0.5625rem;
color: var(--ctp-overlay0);
background: color-mix(in srgb, var(--ctp-yellow) 10%, transparent);
padding: 0 0.25rem;
border-radius: 0.125rem;
}
.palette-hint {
padding: 0.1rem 0.3rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem;
font-size: 0.6rem;
color: var(--ctp-overlay0);
font-family: var(--ui-font-family, system-ui, sans-serif);
cursor: pointer;
transition: color 0.1s;
}
.palette-hint:hover { color: var(--ctp-subtext0); }
</style>

View file

@ -0,0 +1,515 @@
<script lang="ts">
import { appRpc } from './rpc.ts';
// ── Types ────────────────────────────────────────────────────────────
interface Task {
id: string;
title: string;
description: string;
status: string;
priority: string;
assignedTo: string | null;
createdBy: string;
groupId: string;
version: number;
}
interface Props {
groupId: string;
/** Agent ID perspective for creating tasks (defaults to 'admin'). */
agentId?: string;
}
let { groupId, agentId = 'admin' }: Props = $props();
// ── State ────────────────────────────────────────────────────────────
const COLUMNS = ['todo', 'progress', 'review', 'done', 'blocked'] as const;
const COL_LABELS: Record<string, string> = {
todo: 'To Do', progress: 'In Progress', review: 'Review',
done: 'Done', blocked: 'Blocked',
};
const PRIORITY_COLORS: Record<string, string> = {
high: 'var(--ctp-red)', medium: 'var(--ctp-peach)',
low: 'var(--ctp-teal)',
};
let tasks = $state<Task[]>([]);
let showCreateForm = $state(false);
let newTitle = $state('');
let newDesc = $state('');
let newPriority = $state('medium');
let error = $state('');
// Drag state
let draggedTaskId = $state<string | null>(null);
let dragOverCol = $state<string | null>(null);
// ── Derived ──────────────────────────────────────────────────────────
let tasksByCol = $derived(
COLUMNS.reduce((acc, col) => {
acc[col] = tasks.filter(t => t.status === col);
return acc;
}, {} as Record<string, Task[]>)
);
// ── Data fetching ────────────────────────────────────────────────────
async function loadTasks() {
try {
const res = await appRpc.request['bttask.listTasks']({ groupId });
tasks = res.tasks;
} catch (err) {
console.error('[TaskBoard] loadTasks:', err);
}
}
async function createTask() {
const title = newTitle.trim();
if (!title) { error = 'Title required'; return; }
error = '';
try {
const res = await appRpc.request['bttask.createTask']({
title,
description: newDesc.trim(),
priority: newPriority,
groupId,
createdBy: agentId,
});
if (res.ok) {
newTitle = '';
newDesc = '';
newPriority = 'medium';
showCreateForm = false;
await loadTasks();
} else {
error = res.error ?? 'Failed to create task';
}
} catch (err) {
console.error('[TaskBoard] createTask:', err);
error = 'Failed to create task';
}
}
async function moveTask(taskId: string, newStatus: string) {
const task = tasks.find(t => t.id === taskId);
if (!task || task.status === newStatus) return;
try {
const res = await appRpc.request['bttask.updateTaskStatus']({
taskId,
status: newStatus,
expectedVersion: task.version,
});
if (res.ok) {
// Optimistic update
task.status = newStatus;
task.version = res.newVersion ?? task.version + 1;
tasks = [...tasks]; // trigger reactivity
} else {
error = res.error ?? 'Version conflict';
await loadTasks(); // reload on conflict
}
} catch (err) {
console.error('[TaskBoard] moveTask:', err);
await loadTasks();
}
}
async function deleteTask(taskId: string) {
try {
await appRpc.request['bttask.deleteTask']({ taskId });
tasks = tasks.filter(t => t.id !== taskId);
} catch (err) {
console.error('[TaskBoard] deleteTask:', err);
}
}
// ── Drag handlers ────────────────────────────────────────────────────
function onDragStart(e: DragEvent, taskId: string) {
draggedTaskId = taskId;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', taskId);
}
}
function onDragOver(e: DragEvent, col: string) {
e.preventDefault();
dragOverCol = col;
}
function onDragLeave() {
dragOverCol = null;
}
function onDrop(e: DragEvent, col: string) {
e.preventDefault();
dragOverCol = null;
if (draggedTaskId) {
moveTask(draggedTaskId, col);
draggedTaskId = null;
}
}
function onDragEnd() {
draggedTaskId = null;
dragOverCol = null;
}
// ── Init + polling ───────────────────────────────────────────────────
let pollTimer: ReturnType<typeof setInterval> | null = null;
$effect(() => {
loadTasks();
pollTimer = setInterval(loadTasks, 5000);
return () => { if (pollTimer) clearInterval(pollTimer); };
});
</script>
<div class="task-board">
<!-- Toolbar -->
<div class="tb-toolbar">
<span class="tb-title">Task Board</span>
<span class="tb-count">{tasks.length} tasks</span>
<button class="tb-add-btn" onclick={() => { showCreateForm = !showCreateForm; }}>
{showCreateForm ? 'Cancel' : '+ Task'}
</button>
</div>
<!-- Create form -->
{#if showCreateForm}
<div class="tb-create-form">
<input
class="tb-input"
type="text"
placeholder="Task title"
bind:value={newTitle}
onkeydown={(e) => { if (e.key === 'Enter') createTask(); }}
/>
<input
class="tb-input tb-desc"
type="text"
placeholder="Description (optional)"
bind:value={newDesc}
/>
<div class="tb-form-row">
<select class="tb-select" bind:value={newPriority}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<button class="tb-submit" onclick={createTask}>Create</button>
</div>
{#if error}
<span class="tb-error">{error}</span>
{/if}
</div>
{/if}
<!-- Kanban columns -->
<div class="tb-columns">
{#each COLUMNS as col}
<div
class="tb-column"
class:drag-over={dragOverCol === col}
ondragover={(e) => onDragOver(e, col)}
ondragleave={onDragLeave}
ondrop={(e) => onDrop(e, col)}
role="list"
aria-label="{COL_LABELS[col]} column"
>
<div class="tb-col-header">
<span class="tb-col-label">{COL_LABELS[col]}</span>
<span class="tb-col-count">{tasksByCol[col]?.length ?? 0}</span>
</div>
<div class="tb-col-body">
{#each tasksByCol[col] ?? [] as task (task.id)}
<div
class="tb-card"
class:dragging={draggedTaskId === task.id}
draggable="true"
ondragstart={(e) => onDragStart(e, task.id)}
ondragend={onDragEnd}
role="listitem"
>
<div class="card-header">
<span
class="priority-dot"
style:background={PRIORITY_COLORS[task.priority] ?? 'var(--ctp-overlay0)'}
title="Priority: {task.priority}"
></span>
<span class="card-title">{task.title}</span>
<button
class="card-delete"
onclick={() => deleteTask(task.id)}
title="Delete task"
aria-label="Delete task"
>&times;</button>
</div>
{#if task.description}
<div class="card-desc">{task.description}</div>
{/if}
{#if task.assignedTo}
<div class="card-assignee">
<span class="assignee-icon">@</span>
{task.assignedTo}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
<style>
.task-board {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.tb-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.tb-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--ctp-text);
}
.tb-count {
font-size: 0.6875rem;
color: var(--ctp-overlay0);
margin-left: auto;
}
.tb-add-btn {
padding: 0.125rem 0.5rem;
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
border: 1px solid var(--ctp-green);
border-radius: 0.25rem;
color: var(--ctp-green);
font-family: var(--ui-font-family);
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
transition: background 0.1s;
}
.tb-add-btn:hover {
background: color-mix(in srgb, var(--ctp-green) 30%, transparent);
}
.tb-create-form {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.tb-input {
height: 1.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.75rem;
padding: 0 0.375rem;
outline: none;
}
.tb-input:focus { border-color: var(--ctp-mauve); }
.tb-form-row {
display: flex;
gap: 0.25rem;
align-items: center;
}
.tb-select {
height: 1.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.6875rem;
padding: 0 0.25rem;
outline: none;
}
.tb-submit {
padding: 0.125rem 0.625rem;
height: 1.625rem;
background: color-mix(in srgb, var(--ctp-mauve) 20%, transparent);
border: 1px solid var(--ctp-mauve);
border-radius: 0.25rem;
color: var(--ctp-mauve);
font-family: var(--ui-font-family);
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
margin-left: auto;
}
.tb-submit:hover {
background: color-mix(in srgb, var(--ctp-mauve) 35%, transparent);
}
.tb-error {
font-size: 0.6875rem;
color: var(--ctp-red);
}
.tb-columns {
display: flex;
flex: 1;
min-height: 0;
overflow-x: auto;
gap: 1px;
background: var(--ctp-surface0);
}
.tb-column {
flex: 1;
min-width: 8rem;
display: flex;
flex-direction: column;
background: var(--ctp-base);
transition: background 0.15s;
}
.tb-column.drag-over {
background: color-mix(in srgb, var(--ctp-blue) 8%, var(--ctp-base));
}
.tb-col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.tb-col-label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ctp-subtext0);
}
.tb-col-count {
font-size: 0.625rem;
color: var(--ctp-overlay0);
background: var(--ctp-surface0);
padding: 0 0.25rem;
border-radius: 0.25rem;
}
.tb-col-body {
flex: 1;
overflow-y: auto;
padding: 0.375rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.tb-card {
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface0);
border-radius: 0.375rem;
padding: 0.375rem 0.5rem;
cursor: grab;
transition: border-color 0.12s, opacity 0.12s;
}
.tb-card:hover { border-color: var(--ctp-surface1); }
.tb-card.dragging { opacity: 0.4; }
.card-header {
display: flex;
align-items: center;
gap: 0.375rem;
}
.priority-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.card-title {
flex: 1;
font-size: 0.75rem;
font-weight: 600;
color: var(--ctp-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-delete {
background: transparent;
border: none;
color: var(--ctp-overlay0);
font-size: 0.875rem;
cursor: pointer;
padding: 0;
line-height: 1;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.1s, color 0.1s;
}
.tb-card:hover .card-delete { opacity: 1; }
.card-delete:hover { color: var(--ctp-red); }
.card-desc {
font-size: 0.6875rem;
color: var(--ctp-subtext0);
margin-top: 0.125rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-assignee {
font-size: 0.625rem;
color: var(--ctp-overlay1);
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 0.125rem;
}
.assignee-icon {
color: var(--ctp-blue);
font-weight: 700;
}
</style>

View file

@ -74,6 +74,54 @@ let sessions = $state<Record<string, AgentSession>>({});
// Grace period timers for cleanup after done/error // Grace period timers for cleanup after done/error
const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>(); const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
// Debounce timer for message persistence
const msgPersistTimers = new Map<string, ReturnType<typeof setTimeout>>();
// ── Session persistence helpers ─────────────────────────────────────────────
function persistSession(session: AgentSession): void {
appRpc.request['session.save']({
projectId: session.projectId,
sessionId: session.sessionId,
provider: session.provider,
status: session.status,
costUsd: session.costUsd,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
model: session.model,
error: session.error,
createdAt: session.messages[0]?.timestamp ?? Date.now(),
updatedAt: Date.now(),
}).catch((err: unknown) => {
console.error('[session.save] persist error:', err);
});
}
function persistMessages(session: AgentSession): void {
// Debounce: batch message saves every 2 seconds
const existing = msgPersistTimers.get(session.sessionId);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
msgPersistTimers.delete(session.sessionId);
const msgs = session.messages.map((m) => ({
sessionId: session.sessionId,
msgId: m.id,
role: m.role,
content: m.content,
toolName: m.toolName,
toolInput: m.toolInput,
timestamp: m.timestamp,
}));
if (msgs.length === 0) return;
appRpc.request['session.messages.save']({ messages: msgs }).catch((err: unknown) => {
console.error('[session.messages.save] persist error:', err);
});
}, 2000);
msgPersistTimers.set(session.sessionId, timer);
}
// ── RPC event listeners (registered once) ──────────────────────────────────── // ── RPC event listeners (registered once) ────────────────────────────────────
let listenersRegistered = false; let listenersRegistered = false;
@ -104,6 +152,7 @@ function ensureListeners() {
if (converted.length > 0) { if (converted.length > 0) {
session.messages = [...session.messages, ...converted]; session.messages = [...session.messages, ...converted];
persistMessages(session);
} }
}); });
@ -119,8 +168,18 @@ function ensureListeners() {
session.status = normalizeStatus(payload.status); session.status = normalizeStatus(payload.status);
if (payload.error) session.error = payload.error; if (payload.error) session.error = payload.error;
// Persist on every status change
persistSession(session);
// Schedule cleanup after done/error (Fix #2) // Schedule cleanup after done/error (Fix #2)
if (session.status === 'done' || session.status === 'error') { if (session.status === 'done' || session.status === 'error') {
// Flush any pending message persistence immediately
const pendingTimer = msgPersistTimers.get(session.sessionId);
if (pendingTimer) {
clearTimeout(pendingTimer);
msgPersistTimers.delete(session.sessionId);
}
persistMessages(session);
scheduleCleanup(session.sessionId, session.projectId); scheduleCleanup(session.sessionId, session.projectId);
} }
}); });
@ -427,5 +486,57 @@ export function clearSession(projectId: string): void {
} }
} }
/**
* Load the last session for a project from SQLite (for restart recovery).
* Restores session state + messages into the reactive store.
* Only restores done/error sessions (running sessions are gone after restart).
*/
export async function loadLastSession(projectId: string): Promise<boolean> {
ensureListeners();
try {
const { session } = await appRpc.request['session.load']({ projectId });
if (!session) return false;
// Only restore completed sessions (running sessions can't be resumed)
if (session.status !== 'done' && session.status !== 'error') return false;
// Load messages for this session
const { messages: storedMsgs } = await appRpc.request['session.messages.load']({
sessionId: session.sessionId,
});
const restoredMessages: AgentMessage[] = storedMsgs.map((m: {
msgId: string; role: string; content: string;
toolName?: string; toolInput?: string; timestamp: number;
}) => ({
id: m.msgId,
role: m.role as MsgRole,
content: m.content,
toolName: m.toolName,
toolInput: m.toolInput,
timestamp: m.timestamp,
}));
sessions[session.sessionId] = {
sessionId: session.sessionId,
projectId: session.projectId,
provider: session.provider,
status: normalizeStatus(session.status),
messages: restoredMessages,
costUsd: session.costUsd,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
model: session.model,
error: session.error,
};
projectSessionMap.set(projectId, session.sessionId);
return true;
} catch (err) {
console.error('[loadLastSession] error:', err);
return false;
}
}
/** Initialize listeners on module load. */ /** Initialize listeners on module load. */
ensureListeners(); ensureListeners();

View file

@ -0,0 +1,229 @@
/**
* Per-project health tracking Svelte 5 runes.
*
* Tracks activity state, burn rate (5-min EMA from cost snapshots),
* context pressure (tokens / model limit), and attention scoring.
* 5-second tick timer drives derived state updates.
*/
// ── Types ────────────────────────────────────────────────────────────────────
export type ActivityState = 'inactive' | 'running' | 'idle' | 'stalled';
export interface ProjectHealth {
projectId: string;
activityState: ActivityState;
activeTool: string | null;
idleDurationMs: number;
burnRatePerHour: number;
contextPressure: number | null;
fileConflictCount: number;
attentionScore: number;
attentionReason: string | null;
}
// ── Configuration ────────────────────────────────────────────────────────────
const DEFAULT_STALL_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
const TICK_INTERVAL_MS = 5_000;
const BURN_RATE_WINDOW_MS = 5 * 60 * 1000; // 5-minute window
const DEFAULT_CONTEXT_LIMIT = 200_000;
// ── Internal state ───────────────────────────────────────────────────────────
interface ProjectTracker {
projectId: string;
lastActivityTs: number;
lastToolName: string | null;
toolInFlight: boolean;
costSnapshots: Array<[number, number]>; // [timestamp, costUsd]
totalTokens: number;
totalCost: number;
status: 'inactive' | 'running' | 'idle' | 'done' | 'error';
}
let trackers = $state<Map<string, ProjectTracker>>(new Map());
let tickTs = $state<number>(Date.now());
let tickInterval: ReturnType<typeof setInterval> | null = null;
// ── Attention scoring (pure) ─────────────────────────────────────────────────
function scoreAttention(
activityState: ActivityState,
contextPressure: number | null,
fileConflictCount: number,
status: string,
): { score: number; reason: string | null } {
if (status === 'error') return { score: 90, reason: 'Agent error' };
if (activityState === 'stalled') return { score: 100, reason: 'Agent stalled (>15 min)' };
if (contextPressure !== null && contextPressure > 0.9) return { score: 80, reason: 'Context >90%' };
if (fileConflictCount > 0) return { score: 70, reason: `${fileConflictCount} file conflict(s)` };
if (contextPressure !== null && contextPressure > 0.75) return { score: 40, reason: 'Context >75%' };
return { score: 0, reason: null };
}
// ── Burn rate calculation ────────────────────────────────────────────────────
function computeBurnRate(snapshots: Array<[number, number]>): number {
if (snapshots.length < 2) return 0;
const windowStart = Date.now() - BURN_RATE_WINDOW_MS;
const recent = snapshots.filter(([ts]) => ts >= windowStart);
if (recent.length < 2) return 0;
const first = recent[0];
const last = recent[recent.length - 1];
const elapsedHours = (last[0] - first[0]) / 3_600_000;
if (elapsedHours < 0.001) return 0;
const costDelta = last[1] - first[1];
return Math.max(0, costDelta / elapsedHours);
}
// ── Derived health per project ───────────────────────────────────────────────
function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
let activityState: ActivityState;
let idleDurationMs = 0;
let activeTool: string | null = null;
if (tracker.status === 'inactive' || tracker.status === 'done' || tracker.status === 'error') {
activityState = 'inactive';
} else if (tracker.toolInFlight) {
activityState = 'running';
activeTool = tracker.lastToolName;
} else {
idleDurationMs = now - tracker.lastActivityTs;
activityState = idleDurationMs >= DEFAULT_STALL_THRESHOLD_MS ? 'stalled' : 'idle';
}
let contextPressure: number | null = null;
if (tracker.totalTokens > 0) {
contextPressure = Math.min(1, tracker.totalTokens / DEFAULT_CONTEXT_LIMIT);
}
const burnRatePerHour = computeBurnRate(tracker.costSnapshots);
const attention = scoreAttention(activityState, contextPressure, 0, tracker.status);
return {
projectId: tracker.projectId,
activityState,
activeTool,
idleDurationMs,
burnRatePerHour,
contextPressure,
fileConflictCount: 0,
attentionScore: attention.score,
attentionReason: attention.reason,
};
}
// ── Public API ───────────────────────────────────────────────────────────────
/** Register a project for health tracking. */
export function trackProject(projectId: string): void {
if (trackers.has(projectId)) return;
trackers.set(projectId, {
projectId,
lastActivityTs: Date.now(),
lastToolName: null,
toolInFlight: false,
costSnapshots: [],
totalTokens: 0,
totalCost: 0,
status: 'inactive',
});
if (!tickInterval) startHealthTick();
}
/** Record activity (call on every agent message). */
export function recordActivity(projectId: string, toolName?: string): void {
const t = trackers.get(projectId);
if (!t) return;
t.lastActivityTs = Date.now();
t.status = 'running';
if (toolName !== undefined) {
t.lastToolName = toolName;
t.toolInFlight = true;
}
if (!tickInterval) startHealthTick();
}
/** Record tool completion. */
export function recordToolDone(projectId: string): void {
const t = trackers.get(projectId);
if (!t) return;
t.lastActivityTs = Date.now();
t.toolInFlight = false;
}
/** Record a token/cost snapshot for burn rate calculation. */
export function recordTokenSnapshot(projectId: string, totalTokens: number, costUsd: number): void {
const t = trackers.get(projectId);
if (!t) return;
const now = Date.now();
t.totalTokens = totalTokens;
t.totalCost = costUsd;
t.costSnapshots.push([now, costUsd]);
const cutoff = now - BURN_RATE_WINDOW_MS * 2;
t.costSnapshots = t.costSnapshots.filter(([ts]) => ts > cutoff);
}
/** Update project status. */
export function setProjectStatus(projectId: string, status: 'running' | 'idle' | 'done' | 'error'): void {
const t = trackers.get(projectId);
if (t) t.status = status;
}
/** Get health for a single project (reactive via tickTs). */
export function getProjectHealth(projectId: string): ProjectHealth | null {
const now = tickTs;
const t = trackers.get(projectId);
if (!t) return null;
return computeHealth(t, now);
}
/** Get top N items needing attention. */
export function getAttentionQueue(limit = 5): ProjectHealth[] {
const now = tickTs;
const results: ProjectHealth[] = [];
for (const t of trackers.values()) {
const h = computeHealth(t, now);
if (h.attentionScore > 0) results.push(h);
}
results.sort((a, b) => b.attentionScore - a.attentionScore);
return results.slice(0, limit);
}
/** Get aggregate stats across all tracked projects. */
export function getHealthAggregates(): {
running: number;
idle: number;
stalled: number;
totalBurnRatePerHour: number;
} {
const now = tickTs;
let running = 0, idle = 0, stalled = 0, totalBurnRatePerHour = 0;
for (const t of trackers.values()) {
const h = computeHealth(t, now);
if (h.activityState === 'running') running++;
else if (h.activityState === 'idle') idle++;
else if (h.activityState === 'stalled') stalled++;
totalBurnRatePerHour += h.burnRatePerHour;
}
return { running, idle, stalled, totalBurnRatePerHour };
}
/** Start the health tick timer. */
function startHealthTick(): void {
if (tickInterval) return;
tickInterval = setInterval(() => {
tickTs = Date.now();
}, TICK_INTERVAL_MS);
}
/** Stop the health tick timer. */
export function stopHealthTick(): void {
if (tickInterval) {
clearInterval(tickInterval);
tickInterval = null;
}
}

View file

@ -0,0 +1,287 @@
/**
* Plugin Host Web Worker sandbox for Electrobun plugins.
*
* Each plugin runs in a dedicated Web Worker with no DOM/IPC access.
* Communication: Main <-> Worker via postMessage.
* Permission-gated API (messages, events, notifications, palette).
* On unload, Worker is terminated all plugin state destroyed.
*/
import { appRpc } from './rpc.ts';
// ── Types ────────────────────────────────────────────────────────────────────
export interface PluginMeta {
id: string;
name: string;
version: string;
description: string;
main: string;
permissions: string[];
}
interface LoadedPlugin {
meta: PluginMeta;
worker: Worker;
callbacks: Map<string, () => void>;
eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }>;
cleanup: () => void;
}
type PluginCommandCallback = () => void;
// ── State ────────────────────────────────────────────────────────────────────
const loadedPlugins = new Map<string, LoadedPlugin>();
// External command/event registries (set by plugin-store)
let commandRegistry: ((pluginId: string, label: string, callback: PluginCommandCallback) => void) | null = null;
let commandRemover: ((pluginId: string) => void) | null = null;
let eventBus: { on: (event: string, handler: (data: unknown) => void) => void; off: (event: string, handler: (data: unknown) => void) => void } | null = null;
/** Wire up external registries (called by plugin-store on init). */
export function setPluginRegistries(opts: {
addCommand: (pluginId: string, label: string, cb: PluginCommandCallback) => void;
removeCommands: (pluginId: string) => void;
eventBus: { on: (e: string, h: (d: unknown) => void) => void; off: (e: string, h: (d: unknown) => void) => void };
}): void {
commandRegistry = opts.addCommand;
commandRemover = opts.removeCommands;
eventBus = opts.eventBus;
}
// ── Worker script builder ────────────────────────────────────────────────────
function buildWorkerScript(): string {
return `
"use strict";
const _callbacks = new Map();
let _callbackId = 0;
function _nextCbId() { return '__cb_' + (++_callbackId); }
const _pending = new Map();
let _rpcId = 0;
function _rpc(method, args) {
return new Promise((resolve, reject) => {
const id = '__rpc_' + (++_rpcId);
_pending.set(id, { resolve, reject });
self.postMessage({ type: 'rpc', id, method, args });
});
}
self.onmessage = function(e) {
const msg = e.data;
if (msg.type === 'init') {
const permissions = msg.permissions || [];
const meta = msg.meta;
const api = { meta: Object.freeze(meta) };
if (permissions.includes('palette')) {
api.palette = {
registerCommand(label, callback) {
if (typeof label !== 'string' || !label.trim()) throw new Error('Command label must be non-empty string');
if (typeof callback !== 'function') throw new Error('Command callback must be a function');
const cbId = _nextCbId();
_callbacks.set(cbId, callback);
self.postMessage({ type: 'palette-register', label, callbackId: cbId });
},
};
}
if (permissions.includes('notifications')) {
api.notifications = {
show(message) {
self.postMessage({ type: 'notification', message: String(message) });
},
};
}
if (permissions.includes('messages')) {
api.messages = {
list() { return _rpc('messages.list', {}); },
};
}
if (permissions.includes('events')) {
api.events = {
on(event, callback) {
if (typeof event !== 'string' || typeof callback !== 'function') {
throw new Error('events.on requires (string, function)');
}
const cbId = _nextCbId();
_callbacks.set(cbId, callback);
self.postMessage({ type: 'event-on', event, callbackId: cbId });
},
off(event) {
self.postMessage({ type: 'event-off', event });
},
};
}
Object.freeze(api);
try {
const fn = (0, eval)('(function(agor) { "use strict"; ' + msg.code + '\\n})');
fn(api);
self.postMessage({ type: 'loaded' });
} catch (err) {
self.postMessage({ type: 'error', message: String(err) });
}
}
if (msg.type === 'invoke-callback') {
const cb = _callbacks.get(msg.callbackId);
if (cb) {
try { cb(msg.data); }
catch (err) { self.postMessage({ type: 'callback-error', callbackId: msg.callbackId, message: String(err) }); }
}
}
if (msg.type === 'rpc-result') {
const pending = _pending.get(msg.id);
if (pending) {
_pending.delete(msg.id);
if (msg.error) pending.reject(new Error(msg.error));
else pending.resolve(msg.result);
}
}
};
`;
}
let workerBlobUrl: string | null = null;
function getWorkerBlobUrl(): string {
if (!workerBlobUrl) {
const blob = new Blob([buildWorkerScript()], { type: 'application/javascript' });
workerBlobUrl = URL.createObjectURL(blob);
}
return workerBlobUrl;
}
// ── Public API ───────────────────────────────────────────────────────────────
/**
* Load and execute a plugin in a Web Worker sandbox.
* Reads plugin code via RPC from Bun process.
*/
export async function loadPlugin(meta: PluginMeta): Promise<void> {
if (loadedPlugins.has(meta.id)) {
console.warn(`Plugin '${meta.id}' is already loaded`);
return;
}
// Validate permissions
const validPerms = new Set(['palette', 'notifications', 'messages', 'events']);
for (const p of meta.permissions) {
if (!validPerms.has(p)) {
throw new Error(`Plugin '${meta.id}' requests unknown permission: ${p}`);
}
}
// Read plugin code via RPC
let code: string;
try {
const res = await appRpc.request['plugin.readFile']({ pluginId: meta.id, filePath: meta.main });
if (!res.ok) throw new Error(res.error ?? 'Failed to read plugin file');
code = res.content;
} catch (e) {
throw new Error(`Failed to read plugin '${meta.id}' entry '${meta.main}': ${e}`);
}
const worker = new Worker(getWorkerBlobUrl(), { type: 'classic' });
const callbacks = new Map<string, () => void>();
const eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }> = [];
await new Promise<void>((resolve, reject) => {
worker.onmessage = (e) => {
const msg = e.data;
switch (msg.type) {
case 'loaded':
resolve();
break;
case 'error':
commandRemover?.(meta.id);
for (const sub of eventSubscriptions) eventBus?.off(sub.event, sub.handler);
worker.terminate();
reject(new Error(`Plugin '${meta.id}' failed: ${msg.message}`));
break;
case 'palette-register': {
const cbId = msg.callbackId as string;
const invoke = () => worker.postMessage({ type: 'invoke-callback', callbackId: cbId });
callbacks.set(cbId, invoke);
commandRegistry?.(meta.id, msg.label, invoke);
break;
}
case 'notification':
console.log(`[plugin:${meta.id}] notification:`, msg.message);
break;
case 'event-on': {
const cbId = msg.callbackId as string;
const handler = (data: unknown) => {
worker.postMessage({ type: 'invoke-callback', callbackId: cbId, data });
};
eventSubscriptions.push({ event: msg.event, handler });
eventBus?.on(msg.event, handler);
break;
}
case 'event-off': {
const idx = eventSubscriptions.findIndex(s => s.event === msg.event);
if (idx >= 0) {
eventBus?.off(eventSubscriptions[idx].event, eventSubscriptions[idx].handler);
eventSubscriptions.splice(idx, 1);
}
break;
}
case 'callback-error':
console.error(`Plugin '${meta.id}' callback error:`, msg.message);
break;
}
};
worker.onerror = (err) => reject(new Error(`Plugin '${meta.id}' worker error: ${err.message}`));
worker.postMessage({
type: 'init',
code,
permissions: meta.permissions,
meta: { id: meta.id, name: meta.name, version: meta.version, description: meta.description },
});
});
const cleanup = () => {
commandRemover?.(meta.id);
for (const sub of eventSubscriptions) eventBus?.off(sub.event, sub.handler);
eventSubscriptions.length = 0;
callbacks.clear();
worker.terminate();
};
loadedPlugins.set(meta.id, { meta, worker, callbacks, eventSubscriptions, cleanup });
}
/** Unload a plugin. */
export function unloadPlugin(id: string): void {
const plugin = loadedPlugins.get(id);
if (!plugin) return;
plugin.cleanup();
loadedPlugins.delete(id);
}
/** Get all loaded plugin metas. */
export function getLoadedPlugins(): PluginMeta[] {
return Array.from(loadedPlugins.values()).map(p => p.meta);
}
/** Unload all plugins. */
export function unloadAllPlugins(): void {
for (const [id] of loadedPlugins) unloadPlugin(id);
}

View file

@ -0,0 +1,136 @@
/**
* Plugin store Svelte 5 runes.
*
* Discovers plugins from ~/.config/agor/plugins/ via RPC.
* Manages command registry (for palette integration) and event bus.
* Coordinates with plugin-host.ts for Web Worker lifecycle.
*/
import { appRpc } from './rpc.ts';
import {
loadPlugin,
unloadPlugin,
unloadAllPlugins,
getLoadedPlugins,
setPluginRegistries,
type PluginMeta,
} from './plugin-host.ts';
// ── Types ────────────────────────────────────────────────────────────────────
export interface PluginCommand {
pluginId: string;
label: string;
callback: () => void;
}
// ── State ────────────────────────────────────────────────────────────────────
let discovered = $state<PluginMeta[]>([]);
let commands = $state<PluginCommand[]>([]);
let loaded = $derived(getLoadedPlugins());
// ── Event bus (simple pub/sub) ───────────────────────────────────────────────
type EventHandler = (data: unknown) => void;
const eventListeners = new Map<string, Set<EventHandler>>();
const pluginEventBus = {
on(event: string, handler: EventHandler): void {
let set = eventListeners.get(event);
if (!set) {
set = new Set();
eventListeners.set(event, set);
}
set.add(handler);
},
off(event: string, handler: EventHandler): void {
eventListeners.get(event)?.delete(handler);
},
emit(event: string, data: unknown): void {
const set = eventListeners.get(event);
if (!set) return;
for (const handler of set) {
try { handler(data); }
catch (err) { console.error(`[plugin-event] ${event}:`, err); }
}
},
};
// ── Command registry ─────────────────────────────────────────────────────────
function addPluginCommand(pluginId: string, label: string, callback: () => void): void {
commands = [...commands, { pluginId, label, callback }];
}
function removePluginCommands(pluginId: string): void {
commands = commands.filter(c => c.pluginId !== pluginId);
}
// Wire up registries to plugin-host
setPluginRegistries({
addCommand: addPluginCommand,
removeCommands: removePluginCommands,
eventBus: pluginEventBus,
});
// ── Public API ───────────────────────────────────────────────────────────────
/** Discover plugins from ~/.config/agor/plugins/ via RPC. */
export async function discoverPlugins(): Promise<PluginMeta[]> {
try {
const res = await appRpc.request['plugin.discover']({});
discovered = res.plugins ?? [];
return discovered;
} catch (err) {
console.error('[plugin-store] discover error:', err);
discovered = [];
return [];
}
}
/** Load a discovered plugin by id. */
export async function loadPluginById(pluginId: string): Promise<void> {
const meta = discovered.find(p => p.id === pluginId);
if (!meta) throw new Error(`Plugin not found: ${pluginId}`);
await loadPlugin(meta);
}
/** Unload a plugin by id. */
export function unloadPluginById(pluginId: string): void {
unloadPlugin(pluginId);
removePluginCommands(pluginId);
}
/** Load all discovered plugins. */
export async function loadAllPlugins(): Promise<void> {
const plugins = await discoverPlugins();
for (const meta of plugins) {
try {
await loadPlugin(meta);
} catch (err) {
console.error(`[plugin-store] Failed to load '${meta.id}':`, err);
}
}
}
/** Unload all plugins. */
export function unloadAll(): void {
unloadAllPlugins();
commands = [];
}
/** Get discovered plugins (reactive). */
export function getDiscoveredPlugins(): PluginMeta[] {
return discovered;
}
/** Get registered commands (reactive, for palette integration). */
export function getPluginCommands(): PluginCommand[] {
return commands;
}
/** Emit an event to all plugins listening for it. */
export function emitPluginEvent(event: string, data: unknown): void {
pluginEventBus.emit(event, data);
}

View file

@ -90,6 +90,36 @@ export type PtyRPCRequests = {
response: { ok: boolean }; response: { ok: boolean };
}; };
// ── File I/O RPC ──────────────────────────────────────────────────────────
/** List directory children (files + subdirs). Returns sorted entries. */
"files.list": {
params: { path: string };
response: {
entries: Array<{
name: string;
type: "file" | "dir";
size: number;
}>;
error?: string;
};
};
/** Read a file's content. Returns text for text files, base64 for binary. */
"files.read": {
params: { path: string };
response: {
content?: string;
encoding: "utf8" | "base64";
size: number;
error?: string;
};
};
/** Write text content to a file. */
"files.write": {
params: { path: string; content: string };
response: { ok: boolean; error?: string };
};
// ── Groups RPC ───────────────────────────────────────────────────────────── // ── Groups RPC ─────────────────────────────────────────────────────────────
/** Return all project groups. */ /** Return all project groups. */
@ -190,6 +220,268 @@ export type PtyRPCRequests = {
}>; }>;
}; };
}; };
// ── Session persistence RPC ────────────────────────────────────────────
/** Save/update a session record. */
"session.save": {
params: {
projectId: string; sessionId: string; provider: string;
status: string; costUsd: number; inputTokens: number;
outputTokens: number; model: string; error?: string;
createdAt: number; updatedAt: number;
};
response: { ok: boolean };
};
/** Load the most recent session for a project. */
"session.load": {
params: { projectId: string };
response: {
session: {
projectId: string; sessionId: string; provider: string;
status: string; costUsd: number; inputTokens: number;
outputTokens: number; model: string; error?: string;
createdAt: number; updatedAt: number;
} | null;
};
};
/** List sessions for a project (max 20). */
"session.list": {
params: { projectId: string };
response: {
sessions: Array<{
projectId: string; sessionId: string; provider: string;
status: string; costUsd: number; inputTokens: number;
outputTokens: number; model: string; error?: string;
createdAt: number; updatedAt: number;
}>;
};
};
/** Save agent messages (batch). */
"session.messages.save": {
params: {
messages: Array<{
sessionId: string; msgId: string; role: string; content: string;
toolName?: string; toolInput?: string; timestamp: number;
costUsd?: number; inputTokens?: number; outputTokens?: number;
}>;
};
response: { ok: boolean };
};
/** Load all messages for a session. */
"session.messages.load": {
params: { sessionId: string };
response: {
messages: Array<{
sessionId: string; msgId: string; role: string; content: string;
toolName?: string; toolInput?: string; timestamp: number;
costUsd: number; inputTokens: number; outputTokens: number;
}>;
};
};
// ── btmsg RPC ──────────────────────────────────────────────────────────
/** Register an agent in btmsg. */
"btmsg.registerAgent": {
params: {
id: string; name: string; role: string;
groupId: string; tier: number; model?: string;
};
response: { ok: boolean };
};
/** List agents for a group. */
"btmsg.getAgents": {
params: { groupId: string };
response: {
agents: Array<{
id: string; name: string; role: string; groupId: string;
tier: number; model: string | null; status: string; unreadCount: number;
}>;
};
};
/** Send a direct message between agents. */
"btmsg.sendMessage": {
params: { fromAgent: string; toAgent: string; content: string };
response: { ok: boolean; messageId?: string; error?: string };
};
/** Get message history between two agents. */
"btmsg.listMessages": {
params: { agentId: string; otherId: string; limit?: number };
response: {
messages: Array<{
id: string; fromAgent: string; toAgent: string; content: string;
read: boolean; replyTo: string | null; createdAt: string;
senderName: string | null; senderRole: string | null;
}>;
};
};
/** Mark messages as read. */
"btmsg.markRead": {
params: { agentId: string; messageIds: string[] };
response: { ok: boolean };
};
/** List channels for a group. */
"btmsg.listChannels": {
params: { groupId: string };
response: {
channels: Array<{
id: string; name: string; groupId: string; createdBy: string;
memberCount: number; createdAt: string;
}>;
};
};
/** Create a channel. */
"btmsg.createChannel": {
params: { name: string; groupId: string; createdBy: string };
response: { ok: boolean; channelId?: string };
};
/** Get channel messages. */
"btmsg.getChannelMessages": {
params: { channelId: string; limit?: number };
response: {
messages: Array<{
id: string; channelId: string; fromAgent: string; content: string;
createdAt: string; senderName: string; senderRole: string;
}>;
};
};
/** Send a channel message. */
"btmsg.sendChannelMessage": {
params: { channelId: string; fromAgent: string; content: string };
response: { ok: boolean; messageId?: string };
};
/** Record agent heartbeat. */
"btmsg.heartbeat": {
params: { agentId: string };
response: { ok: boolean };
};
/** Get dead letter queue entries. */
"btmsg.getDeadLetters": {
params: { limit?: number };
response: {
letters: Array<{
id: number; fromAgent: string; toAgent: string;
content: string; error: string; createdAt: string;
}>;
};
};
/** Log an audit event. */
"btmsg.logAudit": {
params: { agentId: string; eventType: string; detail: string };
response: { ok: boolean };
};
/** Get audit log. */
"btmsg.getAuditLog": {
params: { limit?: number };
response: {
entries: Array<{
id: number; agentId: string; eventType: string;
detail: string; createdAt: string;
}>;
};
};
// ── bttask RPC ─────────────────────────────────────────────────────────
/** List tasks for a group. */
"bttask.listTasks": {
params: { groupId: string };
response: {
tasks: Array<{
id: string; title: string; description: string; status: string;
priority: string; assignedTo: string | null; createdBy: string;
groupId: string; parentTaskId: string | null; sortOrder: number;
createdAt: string; updatedAt: string; version: number;
}>;
};
};
/** Create a task. */
"bttask.createTask": {
params: {
title: string; description: string; priority: string;
groupId: string; createdBy: string; assignedTo?: string;
};
response: { ok: boolean; taskId?: string; error?: string };
};
/** Update task status with optimistic locking. */
"bttask.updateTaskStatus": {
params: { taskId: string; status: string; expectedVersion: number };
response: { ok: boolean; newVersion?: number; error?: string };
};
/** Delete a task. */
"bttask.deleteTask": {
params: { taskId: string };
response: { ok: boolean };
};
/** Add a comment to a task. */
"bttask.addComment": {
params: { taskId: string; agentId: string; content: string };
response: { ok: boolean; commentId?: string };
};
/** List comments for a task. */
"bttask.listComments": {
params: { taskId: string };
response: {
comments: Array<{
id: string; taskId: string; agentId: string;
content: string; createdAt: string;
}>;
};
};
/** Count tasks in 'review' status. */
"bttask.reviewQueueCount": {
params: { groupId: string };
response: { count: number };
};
// ── Search RPC ──────────────────────────────────────────────────────────
/** Full-text search across messages, tasks, and btmsg. */
"search.query": {
params: { query: string; limit?: number };
response: {
results: Array<{
resultType: string;
id: string;
title: string;
snippet: string;
score: number;
}>;
};
};
/** Index a message for search. */
"search.indexMessage": {
params: { sessionId: string; role: string; content: string };
response: { ok: boolean };
};
/** Rebuild the entire search index. */
"search.rebuild": {
params: Record<string, never>;
response: { ok: boolean };
};
// ── Plugin RPC ──────────────────────────────────────────────────────────
/** Discover plugins from ~/.config/agor/plugins/. */
"plugin.discover": {
params: Record<string, never>;
response: {
plugins: Array<{
id: string;
name: string;
version: string;
description: string;
main: string;
permissions: string[];
}>;
};
};
/** Read a plugin file (path-traversal-safe). */
"plugin.readFile": {
params: { pluginId: string; filePath: string };
response: { ok: boolean; content: string; error?: string };
};
}; };
// ── Messages (Bun → WebView, fire-and-forget) ──────────────────────────────── // ── Messages (Bun → WebView, fire-and-forget) ────────────────────────────────