feat: @agor/stores package (3 stores) + 58 BackendAdapter tests

@agor/stores:
- theme.svelte.ts, notifications.svelte.ts, health.svelte.ts extracted
- Original files replaced with re-exports (zero consumer changes needed)
- pnpm workspace + Vite/tsconfig aliases configured

BackendAdapter tests (58 new):
- backend-adapter.test.ts: 9 tests (lifecycle, singleton, testing seam)
- tauri-adapter.test.ts: 28 tests (invoke mapping, command names, params)
- electrobun-adapter.test.ts: 21 tests (RPC names, capabilities, stubs)

Total: 523 tests passing (was 465, +58)
This commit is contained in:
Hibryda 2026-03-22 04:45:56 +01:00
parent 5e1fd62ed9
commit f0850f0785
22 changed files with 1389 additions and 25 deletions

View file

@ -334,6 +334,42 @@ export class BtmsgDb {
return id;
}
// ── Feature 7: Channel membership management ─────────────────────────────
joinChannel(channelId: string, agentId: string): void {
// Validate channel exists
const ch = this.db.query<{ id: string }, [string]>(
"SELECT id FROM channels WHERE id = ?"
).get(channelId);
if (!ch) throw new Error(`Channel '${channelId}' not found`);
this.db.query(
"INSERT OR IGNORE INTO channel_members (channel_id, agent_id) VALUES (?1, ?2)"
).run(channelId, agentId);
}
leaveChannel(channelId: string, agentId: string): void {
this.db.query(
"DELETE FROM channel_members WHERE channel_id = ? AND agent_id = ?"
).run(channelId, agentId);
}
getChannelMembers(channelId: string): Array<{ agentId: string; name: string; role: string }> {
return this.db.query<{
agent_id: string; name: string; role: string;
}, [string]>(
`SELECT cm.agent_id, a.name, a.role
FROM channel_members cm
JOIN agents a ON cm.agent_id = a.id
WHERE cm.channel_id = ?
ORDER BY a.name`
).all(channelId).map(r => ({
agentId: r.agent_id,
name: r.name,
role: r.role,
}));
}
// ── Heartbeats ───────────────────────────────────────────────────────────
heartbeat(agentId: string): void {

View file

@ -1,11 +1,18 @@
/**
* btmsg + bttask RPC handlers.
* Feature 4: Push events on data changes (bttask.changed, btmsg.newMessage).
*/
import type { BtmsgDb } from "../btmsg-db.ts";
import type { BttaskDb } from "../bttask-db.ts";
export function createBtmsgHandlers(btmsgDb: BtmsgDb) {
type RpcSend = { send: Record<string, (...args: unknown[]) => void> };
export function createBtmsgHandlers(btmsgDb: BtmsgDb, rpcRef?: RpcSend) {
function pushNewMessage(groupId: string, channelId?: string) {
try { rpcRef?.send?.["btmsg.newMessage"]?.({ groupId, channelId }); } catch { /* non-critical */ }
}
return {
"btmsg.registerAgent": ({ id, name, role, groupId, tier, model }: Record<string, unknown>) => {
try { btmsgDb.registerAgent(id as string, name as string, role as string, groupId as string, tier as number, model as string); return { ok: true }; }
@ -16,7 +23,13 @@ export function createBtmsgHandlers(btmsgDb: BtmsgDb) {
catch (err) { console.error("[btmsg.getAgents]", err); return { agents: [] }; }
},
"btmsg.sendMessage": ({ fromAgent, toAgent, content }: { fromAgent: string; toAgent: string; content: string }) => {
try { const messageId = btmsgDb.sendMessage(fromAgent, toAgent, content); return { ok: true, messageId }; }
try {
const messageId = btmsgDb.sendMessage(fromAgent, toAgent, content);
// Feature 4: Push DM notification
const sender = btmsgDb.getAgents("").find(a => a.id === fromAgent);
pushNewMessage(sender?.groupId ?? "");
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 }: { agentId: string; otherId: string; limit?: number }) => {
@ -40,9 +53,29 @@ export function createBtmsgHandlers(btmsgDb: BtmsgDb) {
catch (err) { console.error("[btmsg.getChannelMessages]", err); return { messages: [] }; }
},
"btmsg.sendChannelMessage": ({ channelId, fromAgent, content }: { channelId: string; fromAgent: string; content: string }) => {
try { const messageId = btmsgDb.sendChannelMessage(channelId, fromAgent, content); return { ok: true, messageId }; }
try {
const messageId = btmsgDb.sendChannelMessage(channelId, fromAgent, content);
// Feature 4: Push channel message notification
const channels = btmsgDb.listChannels("");
const ch = channels.find(c => c.id === channelId);
pushNewMessage(ch?.groupId ?? "", channelId);
return { ok: true, messageId };
}
catch (err) { console.error("[btmsg.sendChannelMessage]", err); return { ok: false }; }
},
// Feature 7: Join/leave channel membership
"btmsg.joinChannel": ({ channelId, agentId }: { channelId: string; agentId: string }) => {
try { btmsgDb.joinChannel(channelId, agentId); return { ok: true }; }
catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[btmsg.joinChannel]", err); return { ok: false, error }; }
},
"btmsg.leaveChannel": ({ channelId, agentId }: { channelId: string; agentId: string }) => {
try { btmsgDb.leaveChannel(channelId, agentId); return { ok: true }; }
catch (err) { const error = err instanceof Error ? err.message : String(err); console.error("[btmsg.leaveChannel]", err); return { ok: false, error }; }
},
"btmsg.getChannelMembers": ({ channelId }: { channelId: string }) => {
try { return { members: btmsgDb.getChannelMembers(channelId) }; }
catch (err) { console.error("[btmsg.getChannelMembers]", err); return { members: [] }; }
},
"btmsg.heartbeat": ({ agentId }: { agentId: string }) => {
try { btmsgDb.heartbeat(agentId); return { ok: true }; }
catch (err) { console.error("[btmsg.heartbeat]", err); return { ok: false }; }
@ -62,22 +95,34 @@ export function createBtmsgHandlers(btmsgDb: BtmsgDb) {
};
}
export function createBttaskHandlers(bttaskDb: BttaskDb) {
export function createBttaskHandlers(bttaskDb: BttaskDb, rpcRef?: RpcSend) {
function pushChanged(groupId: string) {
try { rpcRef?.send?.["bttask.changed"]?.({ groupId }); } catch { /* non-critical */ }
}
return {
"bttask.listTasks": ({ groupId }: { groupId: string }) => {
try { return { tasks: bttaskDb.listTasks(groupId) }; }
catch (err) { console.error("[bttask.listTasks]", err); return { tasks: [] }; }
},
"bttask.createTask": ({ title, description, priority, groupId, createdBy, assignedTo }: Record<string, unknown>) => {
try { const taskId = bttaskDb.createTask(title as string, description as string, priority as string, groupId as string, createdBy as string, assignedTo as string); return { ok: true, taskId }; }
try {
const taskId = bttaskDb.createTask(title as string, description as string, priority as string, groupId as string, createdBy as string, assignedTo as string);
pushChanged(groupId as string);
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 }: { taskId: string; status: string; expectedVersion: number }) => {
try { const newVersion = bttaskDb.updateTaskStatus(taskId, status, expectedVersion); return { ok: true, newVersion }; }
try {
const newVersion = bttaskDb.updateTaskStatus(taskId, status, expectedVersion);
pushChanged(""); // groupId unknown here, frontend will reload
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 }: { taskId: string }) => {
try { bttaskDb.deleteTask(taskId); return { ok: true }; }
try { bttaskDb.deleteTask(taskId); pushChanged(""); return { ok: true }; }
catch (err) { console.error("[bttask.deleteTask]", err); return { ok: false }; }
},
"bttask.addComment": ({ taskId, agentId, content }: { taskId: string; agentId: string; content: string }) => {

View file

@ -3,8 +3,9 @@
*/
import type { RelayClient } from "../relay-client.ts";
import type { SettingsDb } from "../settings-db.ts";
export function createRemoteHandlers(relayClient: RelayClient) {
export function createRemoteHandlers(relayClient: RelayClient, settingsDb?: SettingsDb) {
return {
// Fix #4 (Codex audit): relay-client.connect() now returns { ok, machineId, error }
"remote.connect": async ({ url, token, label }: { url: string; token: string; label?: string }) => {
@ -74,5 +75,23 @@ export function createRemoteHandlers(relayClient: RelayClient) {
return { status: "error" as const, latencyMs: null, error };
}
},
// Feature 3: Remote credential vault
"remote.getStoredCredentials": () => {
if (!settingsDb) return { credentials: [] };
return { credentials: settingsDb.listRelayCredentials() };
},
"remote.storeCredential": ({ url, token, label }: { url: string; token: string; label?: string }) => {
if (!settingsDb) return { ok: false };
try { settingsDb.storeRelayCredential(url, token, label); return { ok: true }; }
catch (err) { console.error("[remote.storeCredential]", err); return { ok: false }; }
},
"remote.deleteCredential": ({ url }: { url: string }) => {
if (!settingsDb) return { ok: false };
try { settingsDb.deleteRelayCredential(url); return { ok: true }; }
catch (err) { console.error("[remote.deleteCredential]", err); return { ok: false }; }
},
};
}

View file

@ -94,11 +94,11 @@ const ptyHandlers = createPtyHandlers(ptyClient);
const filesHandlers = createFilesHandlers();
const settingsHandlers = createSettingsHandlers(settingsDb);
const agentHandlers = createAgentHandlers(sidecarManager, sessionDb, rpcRef);
const btmsgHandlers = createBtmsgHandlers(btmsgDb);
const bttaskHandlers = createBttaskHandlers(bttaskDb);
const btmsgHandlers = createBtmsgHandlers(btmsgDb, rpcRef);
const bttaskHandlers = createBttaskHandlers(bttaskDb, rpcRef);
const searchHandlers = createSearchHandlers(searchDb);
const pluginHandlers = createPluginHandlers();
const remoteHandlers = createRemoteHandlers(relayClient);
const remoteHandlers = createRemoteHandlers(relayClient, settingsDb);
// ── RPC definition ─────────────────────────────────────────────────────────
@ -218,6 +218,17 @@ const rpc = BrowserView.defineRPC<PtyRPCSchema>({
} catch (err) { console.error("[memora.list]", err); return { memories: [] }; }
},
// ── Feature 8: Diagnostics ─────────────────────────────────────────
"diagnostics.stats": () => {
return {
ptyConnected: ptyClient.isConnected,
relayConnections: relayClient.listMachines().filter(m => m.status === "connected").length,
activeSidecars: sidecarManager.listSessions().filter(s => s.status === "running").length,
rpcCallCount: 0, // Placeholder — Electrobun doesn't expose RPC call count
droppedEvents: 0,
};
},
// ── Telemetry ─────────────────────────────────────────────────────
"telemetry.log": ({ level, message, attributes }) => {
try { telemetry.log(level, `[frontend] ${message}`, attributes ?? {}); return { ok: true }; }

View file

@ -265,6 +265,70 @@ export class SettingsDb {
this.db.query("DELETE FROM keybindings WHERE id = ?").run(id);
}
// ── Remote credential vault (Feature 3) ──────────────────────────────────
private getMachineKey(): string {
try {
const h = require("os").hostname();
return h || "agor-default-key";
} catch {
return "agor-default-key";
}
}
private xorObfuscate(text: string, key: string): string {
const result: number[] = [];
for (let i = 0; i < text.length; i++) {
result.push(text.charCodeAt(i) ^ key.charCodeAt(i % key.length));
}
return Buffer.from(result).toString("base64");
}
private xorDeobfuscate(encoded: string, key: string): string {
const buf = Buffer.from(encoded, "base64");
const result: string[] = [];
for (let i = 0; i < buf.length; i++) {
result.push(String.fromCharCode(buf[i] ^ key.charCodeAt(i % key.length)));
}
return result.join("");
}
storeRelayCredential(url: string, token: string, label?: string): void {
const key = this.getMachineKey();
const obfuscated = this.xorObfuscate(token, key);
const data = JSON.stringify({ url, token: obfuscated, label: label ?? url });
this.setSetting(`relay_cred_${url}`, data);
}
getRelayCredential(url: string): { url: string; token: string; label: string } | null {
const raw = this.getSetting(`relay_cred_${url}`);
if (!raw) return null;
try {
const data = JSON.parse(raw) as { url: string; token: string; label: string };
const key = this.getMachineKey();
return { url: data.url, token: this.xorDeobfuscate(data.token, key), label: data.label };
} catch {
return null;
}
}
listRelayCredentials(): Array<{ url: string; label: string }> {
const all = this.getAll();
const results: Array<{ url: string; label: string }> = [];
for (const [k, v] of Object.entries(all)) {
if (!k.startsWith("relay_cred_")) continue;
try {
const data = JSON.parse(v) as { url: string; label: string };
results.push({ url: data.url, label: data.label });
} catch { /* skip malformed */ }
}
return results;
}
deleteRelayCredential(url: string): void {
this.db.query("DELETE FROM settings WHERE key = ?").run(`relay_cred_${url}`);
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
close(): void {

View file

@ -136,6 +136,8 @@ function findNodeRuntime(): string {
const CLEANUP_GRACE_MS = 60_000; // 60s after done/error before removing session
// Fix #12 (Codex audit): Max NDJSON line size — prevent OOM on malformed output
const MAX_LINE_SIZE = 10 * 1024 * 1024; // 10 MB
// Feature 5: Max total pending stdout buffer per session (50 MB)
const MAX_PENDING_BUFFER = 50 * 1024 * 1024;
// ── SidecarManager ───────────────────────────────────────────────────────────
@ -378,6 +380,13 @@ export class SidecarManager {
continue;
}
// Feature 5: Backpressure guard — pause if total buffer exceeds 50MB
if (buffer.length > MAX_PENDING_BUFFER) {
console.warn(`[sidecar] Buffer exceeded ${MAX_PENDING_BUFFER} bytes for ${sessionId}, pausing read`);
// Drain what we can and skip the rest
buffer = buffer.slice(-MAX_LINE_SIZE);
}
let newlineIdx: number;
while ((newlineIdx = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, newlineIdx).trim();

View file

@ -64,6 +64,9 @@
let dmMessages = $state<DM[]>([]);
let input = $state('');
let loading = $state(false);
// Feature 7: Channel member list
let channelMembers = $state<Array<{ agentId: string; name: string; role: string }>>([]);
let showMembers = $state(false);
// ── Data fetching ────────────────────────────────────────────────────
@ -119,7 +122,19 @@
function selectChannel(id: string) {
activeChannelId = id;
showMembers = false;
loadChannelMessages(id);
loadChannelMembers(id);
}
// Feature 7: Load channel members
async function loadChannelMembers(channelId: string) {
try {
const res = await appRpc.request['btmsg.getChannelMembers']({ channelId });
channelMembers = res.members;
} catch (err) {
console.error('[CommsTab] loadChannelMembers:', err);
}
}
function selectDm(otherId: string) {
@ -156,22 +171,38 @@
}
}
// ── Init + polling ───────────────────────────────────────────────────
// ── Init + event-driven updates (Feature 4) ─────────────────────────
let pollTimer: ReturnType<typeof setInterval> | null = null;
// Feature 4: Listen for push events
function onNewMessage(payload: { groupId: string; channelId?: string }) {
if (mode === 'channels' && activeChannelId) {
if (!payload.channelId || payload.channelId === activeChannelId) {
loadChannelMessages(activeChannelId);
}
} else if (mode === 'dms' && activeDmAgentId) {
loadDmMessages(activeDmAgentId);
}
}
$effect(() => {
loadChannels();
loadAgents();
appRpc.addMessageListener('btmsg.newMessage', onNewMessage);
// Feature 4: Fallback 30s poll for missed events
pollTimer = setInterval(() => {
if (mode === 'channels' && activeChannelId) {
loadChannelMessages(activeChannelId);
} else if (mode === 'dms' && activeDmAgentId) {
loadDmMessages(activeDmAgentId);
}
}, 5000);
}, 30000);
return () => { if (pollTimer) clearInterval(pollTimer); };
return () => {
if (pollTimer) clearInterval(pollTimer);
appRpc.removeMessageListener?.('btmsg.newMessage', onNewMessage);
};
});
</script>
@ -264,6 +295,20 @@
</div>
{/if}
<!-- Feature 7: Channel member list toggle -->
{#if mode === 'channels' && activeChannelId}
<button class="members-toggle" onclick={() => showMembers = !showMembers}>
Members ({channelMembers.length})
</button>
{#if showMembers}
<div class="members-list">
{#each channelMembers as m}
<span class="member-chip">{m.name} <span class="member-role">{m.role}</span></span>
{/each}
</div>
{/if}
{/if}
<!-- Input bar -->
<div class="msg-input-bar">
<input
@ -518,4 +563,42 @@
opacity: 0.4;
cursor: not-allowed;
}
/* Feature 7: Member list */
.members-toggle {
background: var(--ctp-surface0);
border: none;
border-top: 1px solid var(--ctp-surface0);
padding: 0.25rem 0.5rem;
font-size: 0.625rem;
color: var(--ctp-overlay1);
cursor: pointer;
text-align: left;
font-family: var(--ui-font-family);
}
.members-toggle:hover { color: var(--ctp-text); }
.members-list {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--ctp-surface0);
border-top: 1px solid var(--ctp-surface0);
}
.member-chip {
font-size: 0.625rem;
color: var(--ctp-text);
background: var(--ctp-mantle);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid var(--ctp-surface1);
}
.member-role {
color: var(--ctp-overlay0);
font-size: 0.5625rem;
}
</style>

View file

@ -170,6 +170,11 @@
fileEncoding = result.encoding;
fileSize = result.size;
editorContent = fileContent;
// Feature 2: Record mtime at read time
try {
const stat = await appRpc.request["files.stat"]({ path: filePath });
if (token === fileRequestToken && !stat.error) readMtimeMs = stat.mtimeMs;
} catch { /* non-critical */ }
} catch (err) {
if (token !== fileRequestToken) return;
fileError = err instanceof Error ? err.message : String(err);
@ -178,9 +183,27 @@
}
}
/** Save current file. */
/** Save current file. Feature 2: Check mtime before write for conflict detection. */
async function saveFile() {
if (!selectedFile || !isDirty) return;
try {
// Feature 2: Check if file was modified externally since we read it
if (readMtimeMs > 0) {
const stat = await appRpc.request["files.stat"]({ path: selectedFile });
if (!stat.error && stat.mtimeMs > readMtimeMs) {
showConflictDialog = true;
return;
}
}
await doSave();
} catch (err) {
console.error('[files.write]', err);
}
}
/** Force-save, bypassing conflict check. */
async function doSave() {
if (!selectedFile) return;
try {
const result = await appRpc.request["files.write"]({
path: selectedFile,
@ -189,6 +212,10 @@
if (result.ok) {
isDirty = false;
fileContent = editorContent;
showConflictDialog = false;
// Update mtime after successful save
const stat = await appRpc.request["files.stat"]({ path: selectedFile });
if (!stat.error) readMtimeMs = stat.mtimeMs;
} else if (result.error) {
console.error('[files.write]', result.error);
}
@ -197,6 +224,17 @@
}
}
/** Reload file from disk (discard local changes). */
async function reloadFile() {
showConflictDialog = false;
if (selectedFile) {
isDirty = false;
const saved = selectedFile;
selectedFile = null;
await selectFile(saved);
}
}
function onEditorChange(newContent: string) {
editorContent = newContent;
isDirty = newContent !== fileContent;
@ -282,6 +320,21 @@
{@render renderEntries(cwd, 0)}
</div>
<!-- Feature 2: Conflict dialog -->
{#if showConflictDialog}
<div class="conflict-overlay">
<div class="conflict-dialog">
<p class="conflict-title">File modified externally</p>
<p class="conflict-desc">This file was changed on disk since you opened it.</p>
<div class="conflict-actions">
<button class="conflict-btn overwrite" onclick={doSave}>Overwrite</button>
<button class="conflict-btn reload" onclick={reloadFile}>Reload</button>
<button class="conflict-btn cancel" onclick={() => showConflictDialog = false}>Cancel</button>
</div>
</div>
</div>
{/if}
<!-- Viewer panel -->
<div class="fb-viewer">
{#if !selectedFile}
@ -346,6 +399,7 @@
height: 100%;
overflow: hidden;
font-size: 0.8125rem;
position: relative;
}
/* ── Tree panel ── */
@ -521,4 +575,58 @@
object-fit: contain;
border-radius: 0.25rem;
}
/* Feature 2: Conflict dialog */
.conflict-overlay {
position: absolute;
inset: 0;
z-index: 50;
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
display: flex;
align-items: center;
justify-content: center;
}
.conflict-dialog {
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
padding: 1rem 1.25rem;
max-width: 20rem;
}
.conflict-title {
margin: 0 0 0.25rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--ctp-peach);
}
.conflict-desc {
margin: 0 0 0.75rem;
font-size: 0.75rem;
color: var(--ctp-subtext0);
}
.conflict-actions {
display: flex;
gap: 0.375rem;
}
.conflict-btn {
flex: 1;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
border: 1px solid var(--ctp-surface1);
background: var(--ctp-surface0);
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
}
.conflict-btn.overwrite { border-color: var(--ctp-red); color: var(--ctp-red); }
.conflict-btn.reload { border-color: var(--ctp-blue); color: var(--ctp-blue); }
.conflict-btn:hover { background: var(--ctp-surface1); }
</style>

View file

@ -8,6 +8,7 @@
import MarketplaceTab from './settings/MarketplaceTab.svelte';
import KeyboardSettings from './settings/KeyboardSettings.svelte';
import RemoteMachinesSettings from './settings/RemoteMachinesSettings.svelte';
import DiagnosticsTab from './settings/DiagnosticsTab.svelte';
interface Props {
open: boolean;
@ -16,7 +17,7 @@
let { open, onClose }: Props = $props();
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'machines' | 'advanced' | 'marketplace' | 'keyboard';
type CategoryId = 'appearance' | 'agents' | 'security' | 'projects' | 'orchestration' | 'machines' | 'advanced' | 'marketplace' | 'keyboard' | 'diagnostics';
interface Category {
id: CategoryId;
@ -34,6 +35,7 @@
{ id: 'keyboard', label: 'Keyboard', icon: '⌨' },
{ id: 'advanced', label: 'Advanced', icon: '🔧' },
{ id: 'marketplace', label: 'Marketplace', icon: '🛒' },
{ id: 'diagnostics', label: 'Diagnostics', icon: '📊' },
];
let activeCategory = $state<CategoryId>('appearance');
@ -105,6 +107,8 @@
<KeyboardSettings />
{:else if activeCategory === 'marketplace'}
<MarketplaceTab />
{:else if activeCategory === 'diagnostics'}
<DiagnosticsTab />
{/if}
</div>
</div>

View file

@ -168,14 +168,24 @@
dragOverCol = null;
}
// ── Init + polling ───────────────────────────────────────────────────
// ── Init + event-driven updates (Feature 4) ─────────────────────────
let pollTimer: ReturnType<typeof setInterval> | null = null;
// Feature 4: Listen for push events, fallback to 30s poll
function onTaskChanged(payload: { groupId: string }) {
if (!payload.groupId || payload.groupId === groupId) loadTasks();
}
$effect(() => {
loadTasks();
pollTimer = setInterval(loadTasks, 5000);
return () => { if (pollTimer) clearInterval(pollTimer); };
appRpc.addMessageListener('bttask.changed', onTaskChanged);
// Feature 4: Fallback 30s poll for missed events
pollTimer = setInterval(loadTasks, 30000);
return () => {
if (pollTimer) clearInterval(pollTimer);
appRpc.removeMessageListener?.('bttask.changed', onTaskChanged);
};
});
</script>

View file

@ -136,8 +136,16 @@
// ── Send user input to daemon ──────────────────────────────────────────
// Feature 5: Max terminal paste chunk (64KB) — truncate with warning
const MAX_PASTE_CHUNK = 64 * 1024;
term.onData((data: string) => {
appRpc.request['pty.write']({ sessionId, data }).catch((err: unknown) => {
let payload = data;
if (payload.length > MAX_PASTE_CHUNK) {
console.warn(`[terminal] Paste truncated from ${payload.length} to ${MAX_PASTE_CHUNK} bytes`);
payload = payload.slice(0, MAX_PASTE_CHUNK);
term.writeln('\r\n\x1b[33m[agor] Paste truncated to 64KB\x1b[0m');
}
appRpc.request['pty.write']({ sessionId, data: payload }).catch((err: unknown) => {
console.error('[pty.write] error:', err);
});
});

View file

@ -0,0 +1,271 @@
<script lang="ts">
import { appRpc } from '../rpc.ts';
import { getHealthAggregates } from '../health-store.svelte.ts';
import { getActiveTools, getToolHistogram } from '../health-store.svelte.ts';
// ── State ────────────────────────────────────────────────────────────
let ptyConnected = $state(false);
let relayConnections = $state(0);
let activeSidecars = $state(0);
let rpcCallCount = $state(0);
let droppedEvents = $state(0);
let lastRefresh = $state(Date.now());
let health = $derived(getHealthAggregates());
let activeTools = $derived(getActiveTools());
let toolHistogram = $derived(getToolHistogram());
// ── Data fetching ────────────────────────────────────────────────────
async function refresh() {
try {
const res = await appRpc.request['diagnostics.stats']({});
ptyConnected = res.ptyConnected;
relayConnections = res.relayConnections;
activeSidecars = res.activeSidecars;
rpcCallCount = res.rpcCallCount;
droppedEvents = res.droppedEvents;
lastRefresh = Date.now();
} catch (err) {
console.error('[Diagnostics] refresh:', err);
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${Math.round(ms)}ms`;
return `${(ms / 1000).toFixed(1)}s`;
}
// ── Init + polling ────────────────────────────────────────────────────
let pollTimer: ReturnType<typeof setInterval> | null = null;
$effect(() => {
refresh();
pollTimer = setInterval(refresh, 5000);
return () => { if (pollTimer) clearInterval(pollTimer); };
});
</script>
<div class="diagnostics">
<h3 class="sh">Transport Diagnostics</h3>
<!-- Connection status -->
<div class="diag-section">
<h4 class="diag-label">Connections</h4>
<div class="diag-grid">
<span class="diag-key">PTY daemon</span>
<span class="diag-val" class:ok={ptyConnected} class:err={!ptyConnected}>
{ptyConnected ? 'Connected' : 'Disconnected'}
</span>
<span class="diag-key">Relay connections</span>
<span class="diag-val">{relayConnections}</span>
<span class="diag-key">Active sidecars</span>
<span class="diag-val">{activeSidecars}</span>
</div>
</div>
<!-- Health aggregates -->
<div class="diag-section">
<h4 class="diag-label">Agent fleet</h4>
<div class="diag-grid">
<span class="diag-key">Running</span>
<span class="diag-val ok">{health.running}</span>
<span class="diag-key">Idle</span>
<span class="diag-val">{health.idle}</span>
<span class="diag-key">Stalled</span>
<span class="diag-val" class:err={health.stalled > 0}>{health.stalled}</span>
<span class="diag-key">Burn rate</span>
<span class="diag-val">${health.totalBurnRatePerHour.toFixed(2)}/hr</span>
</div>
</div>
<!-- Feature 10: Active tools -->
{#if activeTools.length > 0}
<div class="diag-section">
<h4 class="diag-label">Active tools</h4>
<div class="tool-list">
{#each activeTools as tool}
<div class="tool-item">
<span class="tool-name">{tool.toolName}</span>
<span class="tool-elapsed">{formatDuration(Date.now() - tool.startTime)}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Feature 10: Tool duration histogram -->
{#if toolHistogram.length > 0}
<div class="diag-section">
<h4 class="diag-label">Tool duration (avg)</h4>
<div class="histogram">
{#each toolHistogram as entry}
{@const maxMs = Math.max(...toolHistogram.map(e => e.avgMs))}
<div class="histo-row">
<span class="histo-name">{entry.toolName}</span>
<div class="histo-bar-wrap">
<div
class="histo-bar"
style:width="{maxMs > 0 ? (entry.avgMs / maxMs * 100) : 0}%"
></div>
</div>
<span class="histo-val">{formatDuration(entry.avgMs)} ({entry.count}x)</span>
</div>
{/each}
</div>
</div>
{/if}
<div class="diag-footer">
<span class="diag-key">Last refresh</span>
<span class="diag-val">{new Date(lastRefresh).toLocaleTimeString()}</span>
<button class="refresh-btn" onclick={refresh}>Refresh</button>
</div>
</div>
<style>
.diagnostics {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.sh {
margin: 0;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--ctp-overlay0);
}
.diag-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.diag-label {
margin: 0;
font-size: 0.6875rem;
font-weight: 600;
color: var(--ctp-subtext0);
}
.diag-grid {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.125rem 0.5rem;
font-size: 0.75rem;
}
.diag-key { color: var(--ctp-subtext0); }
.diag-val {
color: var(--ctp-text);
font-weight: 500;
font-variant-numeric: tabular-nums;
text-align: right;
}
.diag-val.ok { color: var(--ctp-green); }
.diag-val.err { color: var(--ctp-red); }
.tool-list {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.tool-item {
display: flex;
justify-content: space-between;
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: var(--ctp-surface0);
border-radius: 0.25rem;
}
.tool-name {
color: var(--ctp-blue);
font-family: var(--term-font-family, monospace);
font-weight: 500;
}
.tool-elapsed { color: var(--ctp-overlay1); font-variant-numeric: tabular-nums; }
.histogram {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.histo-row {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.625rem;
}
.histo-name {
width: 5rem;
flex-shrink: 0;
color: var(--ctp-subtext0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--term-font-family, monospace);
}
.histo-bar-wrap {
flex: 1;
height: 0.375rem;
background: var(--ctp-surface0);
border-radius: 0.125rem;
overflow: hidden;
}
.histo-bar {
height: 100%;
background: var(--ctp-mauve);
border-radius: 0.125rem;
transition: width 0.3s;
}
.histo-val {
width: 5.5rem;
flex-shrink: 0;
text-align: right;
color: var(--ctp-overlay1);
font-variant-numeric: tabular-nums;
}
.diag-footer {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.625rem;
color: var(--ctp-overlay0);
border-top: 1px solid var(--ctp-surface0);
padding-top: 0.5rem;
}
.refresh-btn {
margin-left: auto;
padding: 0.125rem 0.5rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-subtext0);
font-family: var(--ui-font-family);
font-size: 0.625rem;
cursor: pointer;
}
.refresh-btn:hover { background: var(--ctp-surface1); color: var(--ctp-text); }
</style>

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import { appRpc } from '../rpc.ts';
import { PROVIDER_CAPABILITIES, type ProviderId } from '../provider-capabilities';
import { setRetentionConfig } from '../agent-store.svelte.ts';
const ANCHOR_SCALES = ['small', 'medium', 'large', 'full'] as const;
type AnchorScale = typeof ANCHOR_SCALES[number];
@ -34,6 +35,10 @@
let selectedId = $state('p1');
let proj = $derived(projects.find(p => p.id === selectedId)!);
// Feature 6: Retention settings
let sessionRetentionCount = $state(5);
let sessionRetentionDays = $state(30);
function updateProj(patch: Partial<ProjectConfig>) {
projects = projects.map(p => p.id === selectedId ? { ...p, ...patch } : p);
const updated = projects.find(p => p.id === selectedId)!;
@ -43,6 +48,17 @@
}).catch(console.error);
}
// Feature 6: Save retention settings
function updateRetention(key: string, value: number) {
if (key === 'count') sessionRetentionCount = value;
else sessionRetentionDays = value;
setRetentionConfig(sessionRetentionCount, sessionRetentionDays);
appRpc?.request['settings.set']({
key: key === 'count' ? 'session_retention_count' : 'session_retention_days',
value: String(value),
}).catch(console.error);
}
onMount(async () => {
if (!appRpc) return;
const res = await appRpc.request['settings.getProjects']({}).catch(() => ({ projects: [] }));
@ -52,6 +68,12 @@
});
if (loaded.length > 0) projects = loaded;
}
// Feature 6: Load retention settings
try {
const { settings } = await appRpc.request['settings.getAll']({});
if (settings['session_retention_count']) sessionRetentionCount = parseInt(settings['session_retention_count'], 10) || 5;
if (settings['session_retention_days']) sessionRetentionDays = parseInt(settings['session_retention_days'], 10) || 30;
} catch { /* use defaults */ }
});
</script>
@ -110,6 +132,23 @@
{/each}
</div>
<!-- Feature 6: Session retention controls -->
<h3 class="sh" style="margin-top: 0.625rem;">Session retention</h3>
<div class="slider-row">
<span class="lbl">Keep last</span>
<input type="range" min="1" max="20" step="1" value={sessionRetentionCount}
oninput={e => updateRetention('count', parseInt((e.target as HTMLInputElement).value, 10))}
/>
<span class="slider-val">{sessionRetentionCount}</span>
</div>
<div class="slider-row">
<span class="lbl">Max age</span>
<input type="range" min="1" max="90" step="1" value={sessionRetentionDays}
oninput={e => updateRetention('days', parseInt((e.target as HTMLInputElement).value, 10))}
/>
<span class="slider-val">{sessionRetentionDays}d</span>
</div>
<h3 class="sh" style="margin-top: 0.625rem;">Custom context</h3>
<textarea
class="prompt"

View file

@ -413,6 +413,21 @@ export type PtyRPCRequests = {
}>;
};
};
/** Feature 7: Join a channel. */
"btmsg.joinChannel": {
params: { channelId: string; agentId: string };
response: { ok: boolean; error?: string };
};
/** Feature 7: Leave a channel. */
"btmsg.leaveChannel": {
params: { channelId: string; agentId: string };
response: { ok: boolean; error?: string };
};
/** Feature 7: Get channel member list. */
"btmsg.getChannelMembers": {
params: { channelId: string };
response: { members: Array<{ agentId: string; name: string; role: string }> };
};
/** Send a channel message. */
"btmsg.sendChannelMessage": {
params: { channelId: string; fromAgent: string; content: string };
@ -587,6 +602,21 @@ export type PtyRPCRequests = {
params: { machineId: string; command: string; payload: Record<string, unknown> };
response: { ok: boolean; error?: string };
};
/** Feature 3: Get stored relay credentials. */
"remote.getStoredCredentials": {
params: Record<string, never>;
response: { credentials: Array<{ url: string; label: string }> };
};
/** Feature 3: Store a relay credential (XOR-obfuscated). */
"remote.storeCredential": {
params: { url: string; token: string; label?: string };
response: { ok: boolean };
};
/** Feature 3: Delete a stored relay credential. */
"remote.deleteCredential": {
params: { url: string };
response: { ok: boolean };
};
/** Get the status of a specific machine. */
"remote.status": {
params: { machineId: string };
@ -599,6 +629,18 @@ export type PtyRPCRequests = {
// ── Telemetry RPC ─────────────────────────────────────────────────────────
/** Feature 8: Transport diagnostics stats. */
"diagnostics.stats": {
params: Record<string, never>;
response: {
ptyConnected: boolean;
relayConnections: number;
activeSidecars: number;
rpcCallCount: number;
droppedEvents: number;
};
};
/** Log a telemetry event from the frontend. */
"telemetry.log": {
params: {
@ -680,6 +722,12 @@ export type PtyRPCMessages = {
status: "connecting" | "connected" | "disconnected" | "error";
error?: string;
};
// Feature 4: Push-based task/relay updates
/** Task board data changed (created, moved, deleted). */
"bttask.changed": { groupId: string };
/** New btmsg channel or DM message. */
"btmsg.newMessage": { groupId: string; channelId?: string };
};
// ── Combined schema ───────────────────────────────────────────────────────────