refactor: migrate all 7 bridge clusters to BackendAdapter (WIP)

70+ files changed, net -688 lines. Bridge files being replaced with
BackendAdapter calls. Clusters 2-8 in progress: theme, groups/workspace,
agent, PTY/terminal, files, orchestration, infrastructure.
This commit is contained in:
Hibryda 2026-03-22 04:28:12 +01:00
parent 579157f6da
commit 105107dd84
72 changed files with 1835 additions and 2523 deletions

View file

@ -1,9 +1,14 @@
// BackendAdapter — abstraction layer for Tauri and Electrobun backends // BackendAdapter — abstraction layer for Tauri and Electrobun backends
import type { AgentStartOptions, AgentMessage, AgentStatus } from './agent'; import type { AgentStartOptions, AgentMessage, AgentStatus, ProviderId } from './agent';
import type { FileEntry, FileContent, PtyCreateOptions } from './protocol'; import type { FileEntry, FileContent, PtyCreateOptions, SearchResult } from './protocol';
import type { SettingsMap } from './settings'; import type { SettingsMap } from './settings';
import type { GroupsFile } from './project'; import type { GroupsFile } from './project';
import type { AgentId, GroupId, SessionId, ProjectId } from './ids';
import type {
BtmsgAgent, BtmsgMessage, BtmsgChannel, BtmsgChannelMessage, DeadLetterEntry, AuditEntry,
} from './btmsg';
import type { Task, TaskComment } from './bttask';
// ── Backend capabilities ───────────────────────────────────────────────────── // ── Backend capabilities ─────────────────────────────────────────────────────
@ -31,9 +36,223 @@ export interface BackendCapabilities {
/** Call to remove an event listener */ /** Call to remove an event listener */
export type UnsubscribeFn = () => void; export type UnsubscribeFn = () => void;
// ── Domain-specific sub-interfaces ──────────────────────────────────────────
export interface SessionPersistenceAdapter {
listSessions(): Promise<PersistedSession[]>;
saveSession(session: PersistedSession): Promise<void>;
deleteSession(id: string): Promise<void>;
updateSessionTitle(id: string, title: string): Promise<void>;
touchSession(id: string): Promise<void>;
updateSessionGroup(id: string, groupName: string): Promise<void>;
saveLayout(layout: PersistedLayout): Promise<void>;
loadLayout(): Promise<PersistedLayout>;
}
export interface AgentPersistenceAdapter {
saveAgentMessages(
sessionId: SessionId, projectId: ProjectId, sdkSessionId: string | undefined,
messages: AgentMessageRecord[],
): Promise<void>;
loadAgentMessages(projectId: ProjectId): Promise<AgentMessageRecord[]>;
saveProjectAgentState(state: ProjectAgentState): Promise<void>;
loadProjectAgentState(projectId: ProjectId): Promise<ProjectAgentState | null>;
saveSessionMetric(metric: Omit<SessionMetricRecord, 'id'>): Promise<void>;
loadSessionMetrics(projectId: ProjectId, limit?: number): Promise<SessionMetricRecord[]>;
getCliGroup(): Promise<string | null>;
discoverMarkdownFiles(cwd: string): Promise<MdFileEntry[]>;
}
export interface BtmsgAdapter {
btmsgGetAgents(groupId: GroupId): Promise<BtmsgAgent[]>;
btmsgUnreadCount(agentId: AgentId): Promise<number>;
btmsgUnreadMessages(agentId: AgentId): Promise<BtmsgMessage[]>;
btmsgHistory(agentId: AgentId, otherId: AgentId, limit?: number): Promise<BtmsgMessage[]>;
btmsgSend(fromAgent: AgentId, toAgent: AgentId, content: string): Promise<string>;
btmsgSetStatus(agentId: AgentId, status: string): Promise<void>;
btmsgEnsureAdmin(groupId: GroupId): Promise<void>;
btmsgAllFeed(groupId: GroupId, limit?: number): Promise<BtmsgFeedMessage[]>;
btmsgMarkRead(readerId: AgentId, senderId: AgentId): Promise<void>;
btmsgGetChannels(groupId: GroupId): Promise<BtmsgChannel[]>;
btmsgChannelMessages(channelId: string, limit?: number): Promise<BtmsgChannelMessage[]>;
btmsgChannelSend(channelId: string, fromAgent: AgentId, content: string): Promise<string>;
btmsgCreateChannel(name: string, groupId: GroupId, createdBy: AgentId): Promise<string>;
btmsgAddChannelMember(channelId: string, agentId: AgentId): Promise<void>;
btmsgRegisterAgents(config: GroupsFile): Promise<void>;
btmsgUnseenMessages(agentId: AgentId, sessionId: string): Promise<BtmsgMessage[]>;
btmsgMarkSeen(sessionId: string, messageIds: string[]): Promise<void>;
btmsgPruneSeen(): Promise<number>;
btmsgRecordHeartbeat(agentId: AgentId): Promise<void>;
btmsgGetStaleAgents(groupId: GroupId, thresholdSecs?: number): Promise<string[]>;
btmsgGetDeadLetters(groupId: GroupId, limit?: number): Promise<DeadLetterEntry[]>;
btmsgClearDeadLetters(groupId: GroupId): Promise<void>;
btmsgClearAllComms(groupId: GroupId): Promise<void>;
}
export interface BttaskAdapter {
bttaskList(groupId: GroupId): Promise<Task[]>;
bttaskComments(taskId: string): Promise<TaskComment[]>;
bttaskUpdateStatus(taskId: string, status: string, version: number): Promise<number>;
bttaskAddComment(taskId: string, agentId: AgentId, content: string): Promise<string>;
bttaskCreate(
title: string, description: string, priority: string,
groupId: GroupId, createdBy: AgentId, assignedTo?: AgentId,
): Promise<string>;
bttaskDelete(taskId: string): Promise<void>;
bttaskReviewQueueCount(groupId: GroupId): Promise<number>;
}
export interface AnchorsAdapter {
saveSessionAnchors(anchors: SessionAnchorRecord[]): Promise<void>;
loadSessionAnchors(projectId: string): Promise<SessionAnchorRecord[]>;
deleteSessionAnchor(id: string): Promise<void>;
clearProjectAnchors(projectId: string): Promise<void>;
updateAnchorType(id: string, anchorType: string): Promise<void>;
}
export interface SearchAdapter {
searchInit(): Promise<void>;
searchAll(query: string, limit?: number): Promise<SearchResult[]>;
searchRebuild(): Promise<void>;
searchIndexMessage(sessionId: string, role: string, content: string): Promise<void>;
}
export interface AuditAdapter {
logAuditEvent(agentId: AgentId, eventType: AuditEventType, detail: string): Promise<void>;
getAuditLog(groupId: GroupId, limit?: number, offset?: number): Promise<AuditEntry[]>;
getAuditLogForAgent(agentId: AgentId, limit?: number): Promise<AuditEntry[]>;
}
export interface NotificationsAdapter {
sendDesktopNotification(title: string, body: string, urgency?: NotificationUrgency): void;
}
export interface TelemetryAdapter {
telemetryLog(level: TelemetryLevel, message: string, context?: Record<string, unknown>): void;
}
export interface SecretsAdapter {
storeSecret(key: string, value: string): Promise<void>;
getSecret(key: string): Promise<string | null>;
deleteSecret(key: string): Promise<void>;
listSecrets(): Promise<string[]>;
hasKeyring(): Promise<boolean>;
knownSecretKeys(): Promise<string[]>;
}
export interface FsWatcherAdapter {
fsWatchProject(projectId: string, cwd: string): Promise<void>;
fsUnwatchProject(projectId: string): Promise<void>;
onFsWriteDetected(callback: (event: FsWriteEvent) => void): UnsubscribeFn;
fsWatcherStatus(): Promise<FsWatcherStatus>;
}
export interface CtxAdapter {
ctxInitDb(): Promise<void>;
ctxRegisterProject(name: string, description: string, workDir?: string): Promise<void>;
ctxGetContext(project: string): Promise<CtxEntry[]>;
ctxGetShared(): Promise<CtxEntry[]>;
ctxGetSummaries(project: string, limit?: number): Promise<CtxSummary[]>;
ctxSearch(query: string): Promise<CtxEntry[]>;
}
export interface MemoraAdapter {
memoraAvailable(): Promise<boolean>;
memoraList(options?: { tags?: string[]; limit?: number; offset?: number }): Promise<MemoraSearchResult>;
memoraSearch(query: string, options?: { tags?: string[]; limit?: number }): Promise<MemoraSearchResult>;
memoraGet(id: number): Promise<MemoraNode | null>;
}
export interface SshAdapter {
listSshSessions(): Promise<SshSessionRecord[]>;
saveSshSession(session: SshSessionRecord): Promise<void>;
deleteSshSession(id: string): Promise<void>;
}
export interface PluginsAdapter {
discoverPlugins(): Promise<PluginMeta[]>;
readPluginFile(pluginId: string, filename: string): Promise<string>;
}
export interface ClaudeProviderAdapter {
listProfiles(): Promise<ClaudeProfile[]>;
listSkills(): Promise<ClaudeSkill[]>;
readSkill(path: string): Promise<string>;
}
export interface RemoteMachineAdapter {
listRemoteMachines(): Promise<RemoteMachineInfo[]>;
addRemoteMachine(config: RemoteMachineConfig): Promise<string>;
removeRemoteMachine(machineId: string): Promise<void>;
connectRemoteMachine(machineId: string): Promise<void>;
disconnectRemoteMachine(machineId: string): Promise<void>;
probeSpki(url: string): Promise<string>;
addSpkiPin(machineId: string, pin: string): Promise<void>;
removeSpkiPin(machineId: string, pin: string): Promise<void>;
onRemoteSidecarMessage(callback: (msg: RemoteSidecarMessage) => void): UnsubscribeFn;
onRemotePtyData(callback: (msg: RemotePtyData) => void): UnsubscribeFn;
onRemotePtyExit(callback: (msg: RemotePtyExit) => void): UnsubscribeFn;
onRemoteMachineReady(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn;
onRemoteMachineDisconnected(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn;
onRemoteStateSync(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn;
onRemoteError(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn;
onRemoteMachineReconnecting(callback: (msg: RemoteReconnectingEvent) => void): UnsubscribeFn;
onRemoteMachineReconnectReady(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn;
onRemoteSpkiTofu(callback: (msg: RemoteSpkiTofuEvent) => void): UnsubscribeFn;
}
export interface FileWatcherAdapter {
watchFile(paneId: string, path: string): Promise<string>;
unwatchFile(paneId: string): Promise<void>;
readWatchedFile(path: string): Promise<string>;
onFileChanged(callback: (payload: FileChangedPayload) => void): UnsubscribeFn;
}
export interface AgentBridgeAdapter {
/** Direct agent IPC — supports remote machines and resume */
queryAgent(options: AgentQueryOptions): Promise<void>;
stopAgentDirect(sessionId: string, remoteMachineId?: string): Promise<void>;
isAgentReady(): Promise<boolean>;
restartAgentSidecar(): Promise<void>;
setSandbox(projectCwds: string[], worktreeRoots: string[], enabled: boolean): Promise<void>;
onSidecarMessage(callback: (msg: SidecarMessagePayload) => void): UnsubscribeFn;
onSidecarExited(callback: () => void): UnsubscribeFn;
}
export interface PtyBridgeAdapter {
/** Per-session PTY — supports remote machines */
spawnPty(options: PtySpawnOptions): Promise<string>;
writePtyDirect(id: string, data: string, remoteMachineId?: string): Promise<void>;
resizePtyDirect(id: string, cols: number, rows: number, remoteMachineId?: string): Promise<void>;
killPty(id: string, remoteMachineId?: string): Promise<void>;
onPtyData(id: string, callback: (data: string) => void): UnsubscribeFn;
onPtyExit(id: string, callback: () => void): UnsubscribeFn;
}
// ── Backend adapter interface ──────────────────────────────────────────────── // ── Backend adapter interface ────────────────────────────────────────────────
export interface BackendAdapter { export interface BackendAdapter extends
SessionPersistenceAdapter,
AgentPersistenceAdapter,
BtmsgAdapter,
BttaskAdapter,
AnchorsAdapter,
SearchAdapter,
AuditAdapter,
NotificationsAdapter,
TelemetryAdapter,
SecretsAdapter,
FsWatcherAdapter,
CtxAdapter,
MemoraAdapter,
SshAdapter,
PluginsAdapter,
ClaudeProviderAdapter,
RemoteMachineAdapter,
FileWatcherAdapter,
AgentBridgeAdapter,
PtyBridgeAdapter {
readonly capabilities: BackendCapabilities; readonly capabilities: BackendCapabilities;
// ── Lifecycle ──────────────────────────────────────────────────────────── // ── Lifecycle ────────────────────────────────────────────────────────────
@ -55,13 +274,13 @@ export interface BackendAdapter {
loadGroups(): Promise<GroupsFile>; loadGroups(): Promise<GroupsFile>;
saveGroups(groups: GroupsFile): Promise<void>; saveGroups(groups: GroupsFile): Promise<void>;
// ── Agent ──────────────────────────────────────────────────────────────── // ── Agent (simplified) ────────────────────────────────────────────────────
startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }>; startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }>;
stopAgent(sessionId: string): Promise<{ ok: boolean; error?: string }>; stopAgent(sessionId: string): Promise<{ ok: boolean; error?: string }>;
sendPrompt(sessionId: string, prompt: string): Promise<{ ok: boolean; error?: string }>; sendPrompt(sessionId: string, prompt: string): Promise<{ ok: boolean; error?: string }>;
// ── PTY ────────────────────────────────────────────────────────────────── // ── PTY (simplified) ──────────────────────────────────────────────────────
createPty(options: PtyCreateOptions): Promise<string>; createPty(options: PtyCreateOptions): Promise<string>;
writePty(sessionId: string, data: string): Promise<void>; writePty(sessionId: string, data: string): Promise<void>;
@ -82,3 +301,273 @@ export interface BackendAdapter {
onPtyOutput(callback: (sessionId: string, data: string) => void): UnsubscribeFn; onPtyOutput(callback: (sessionId: string, data: string) => void): UnsubscribeFn;
onPtyClosed(callback: (sessionId: string, exitCode: number | null) => void): UnsubscribeFn; onPtyClosed(callback: (sessionId: string, exitCode: number | null) => void): UnsubscribeFn;
} }
// ── Shared record types ─────────────────────────────────────────────────────
export interface PersistedSession {
id: string;
type: string;
title: string;
shell?: string;
cwd?: string;
args?: string[];
group_name?: string;
created_at: number;
last_used_at: number;
}
export interface PersistedLayout {
preset: string;
pane_ids: string[];
}
export interface AgentMessageRecord {
id: number;
session_id: SessionId;
project_id: ProjectId;
sdk_session_id: string | null;
message_type: string;
content: string;
parent_id: string | null;
created_at: number;
}
export interface ProjectAgentState {
project_id: ProjectId;
last_session_id: SessionId;
sdk_session_id: string | null;
status: string;
cost_usd: number;
input_tokens: number;
output_tokens: number;
last_prompt: string | null;
updated_at: number;
}
export interface SessionMetricRecord {
id: number;
project_id: ProjectId;
session_id: SessionId;
start_time: number;
end_time: number;
peak_tokens: number;
turn_count: number;
tool_call_count: number;
cost_usd: number;
model: string | null;
status: string;
error_message: string | null;
}
export interface MdFileEntry {
name: string;
path: string;
priority: boolean;
}
export interface SessionAnchorRecord {
id: string;
project_id: string;
message_id: string;
anchor_type: string;
content: string;
estimated_tokens: number;
turn_index: number;
created_at: number;
}
export type AuditEventType =
| 'prompt_injection'
| 'wake_event'
| 'btmsg_sent'
| 'btmsg_received'
| 'status_change'
| 'heartbeat_missed'
| 'dead_letter';
export type NotificationUrgency = 'low' | 'normal' | 'critical';
export type TelemetryLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace';
export interface FsWriteEvent {
project_id: string;
file_path: string;
timestamp_ms: number;
}
export interface FsWatcherStatus {
max_watches: number;
estimated_watches: number;
usage_ratio: number;
active_projects: number;
warning: string | null;
}
export interface CtxEntry {
project: string;
key: string;
value: string;
updated_at: string;
}
export interface CtxSummary {
project: string;
summary: string;
created_at: string;
}
export interface MemoraNode {
id: number;
content: string;
tags: string[];
metadata?: Record<string, unknown>;
created_at?: string;
updated_at?: string;
}
export interface MemoraSearchResult {
nodes: MemoraNode[];
total: number;
}
export interface SshSessionRecord {
id: string;
name: string;
host: string;
port: number;
username: string;
key_file: string;
folder: string;
color: string;
created_at: number;
last_used_at: number;
}
export interface PluginMeta {
id: string;
name: string;
version: string;
description: string;
main: string;
permissions: string[];
}
export interface ClaudeProfile {
name: string;
email: string | null;
subscription_type: string | null;
display_name: string | null;
config_dir: string;
}
export interface ClaudeSkill {
name: string;
description: string;
source_path: string;
}
export interface RemoteMachineConfig {
label: string;
url: string;
token: string;
auto_connect: boolean;
spki_pins?: string[];
}
export interface RemoteMachineInfo {
id: string;
label: string;
url: string;
status: string;
auto_connect: boolean;
spki_pins: string[];
}
export interface RemoteSidecarMessage {
machineId: string;
sessionId?: string;
event?: Record<string, unknown>;
}
export interface RemotePtyData {
machineId: string;
sessionId?: string;
data?: string;
}
export interface RemotePtyExit {
machineId: string;
sessionId?: string;
}
export interface RemoteMachineEvent {
machineId: string;
payload?: unknown;
error?: unknown;
}
export interface RemoteReconnectingEvent {
machineId: string;
backoffSecs: number;
}
export interface RemoteSpkiTofuEvent {
machineId: string;
hash: string;
}
export interface FileChangedPayload {
pane_id: string;
path: string;
content: string;
}
export interface BtmsgFeedMessage {
id: string;
fromAgent: AgentId;
toAgent: AgentId;
content: string;
createdAt: string;
replyTo: string | null;
senderName: string;
senderRole: string;
recipientName: string;
recipientRole: string;
}
export interface AgentQueryOptions {
provider?: ProviderId;
session_id: string;
prompt: string;
cwd?: string;
max_turns?: number;
max_budget_usd?: number;
resume_session_id?: string;
permission_mode?: string;
setting_sources?: string[];
system_prompt?: string;
model?: string;
claude_config_dir?: string;
additional_directories?: string[];
worktree_name?: string;
provider_config?: Record<string, unknown>;
extra_env?: Record<string, string>;
remote_machine_id?: string;
}
export interface SidecarMessagePayload {
type: string;
sessionId?: string;
event?: Record<string, unknown>;
message?: string;
exitCode?: number | null;
signal?: string | null;
}
export interface PtySpawnOptions {
shell?: string;
cwd?: string;
args?: string[];
cols?: number;
rows?: number;
remote_machine_id?: string;
}

View file

@ -11,7 +11,7 @@
import { OLLAMA_PROVIDER } from './lib/providers/ollama'; import { OLLAMA_PROVIDER } from './lib/providers/ollama';
import { AIDER_PROVIDER } from './lib/providers/aider'; import { AIDER_PROVIDER } from './lib/providers/aider';
import { registerMemoryAdapter } from './lib/adapters/memory-adapter'; import { registerMemoryAdapter } from './lib/adapters/memory-adapter';
import { MemoraAdapter } from './lib/adapters/memora-bridge'; import { MemoraAdapter } from './lib/adapters/memora-adapter';
import { import {
loadWorkspace, getActiveTab, setActiveTab, setActiveProject, loadWorkspace, getActiveTab, setActiveTab, setActiveProject,
getEnabledProjects, getAllWorkItems, getActiveProjectId, getEnabledProjects, getAllWorkItems, getActiveProjectId,
@ -20,7 +20,7 @@
import { disableWakeScheduler } from './lib/stores/wake-scheduler.svelte'; import { disableWakeScheduler } from './lib/stores/wake-scheduler.svelte';
import { initGlobalErrorHandler } from './lib/utils/global-error-handler'; import { initGlobalErrorHandler } from './lib/utils/global-error-handler';
import { handleInfraError } from './lib/utils/handle-error'; import { handleInfraError } from './lib/utils/handle-error';
import { pruneSeen } from './lib/adapters/btmsg-bridge'; import { getBackend } from './lib/backend/backend';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
// Workspace components // Workspace components
@ -119,7 +119,7 @@
// Step 2: Agent dispatcher // Step 2: Agent dispatcher
startAgentDispatcher(); startAgentDispatcher();
startHealthTick(); startHealthTick();
pruneSeen().catch(e => handleInfraError(e, 'app.pruneSeen')); // housekeeping: remove stale seen_messages on startup getBackend().btmsgPruneSeen().catch(e => handleInfraError(e, 'app.pruneSeen')); // housekeeping: remove stale seen_messages on startup
markStep(2); markStep(2);
// Disable wake scheduler in test mode to prevent timer interference // Disable wake scheduler in test mode to prevent timer interference

View file

@ -1,170 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Use vi.hoisted to declare mocks that are accessible inside vi.mock factories
const { mockInvoke, mockListen } = vi.hoisted(() => ({
mockInvoke: vi.fn(),
mockListen: vi.fn(),
}));
vi.mock('@tauri-apps/api/core', () => ({
invoke: mockInvoke,
}));
vi.mock('@tauri-apps/api/event', () => ({
listen: mockListen,
}));
import {
queryAgent,
stopAgent,
isAgentReady,
restartAgent,
onSidecarMessage,
onSidecarExited,
type AgentQueryOptions,
} from './agent-bridge';
beforeEach(() => {
vi.clearAllMocks();
});
describe('agent-bridge', () => {
describe('queryAgent', () => {
it('invokes agent_query with options', async () => {
mockInvoke.mockResolvedValue(undefined);
const options: AgentQueryOptions = {
session_id: 'sess-1',
prompt: 'Hello Claude',
cwd: '/tmp',
max_turns: 10,
max_budget_usd: 1.0,
};
await queryAgent(options);
expect(mockInvoke).toHaveBeenCalledWith('agent_query', { options });
});
it('passes minimal options (only required fields)', async () => {
mockInvoke.mockResolvedValue(undefined);
const options: AgentQueryOptions = {
session_id: 'sess-2',
prompt: 'Do something',
};
await queryAgent(options);
expect(mockInvoke).toHaveBeenCalledWith('agent_query', { options });
});
it('propagates invoke errors', async () => {
mockInvoke.mockRejectedValue(new Error('Sidecar not running'));
await expect(
queryAgent({ session_id: 'sess-3', prompt: 'test' }),
).rejects.toThrow('Sidecar not running');
});
});
describe('stopAgent', () => {
it('invokes agent_stop with session ID', async () => {
mockInvoke.mockResolvedValue(undefined);
await stopAgent('sess-1');
expect(mockInvoke).toHaveBeenCalledWith('agent_stop', { sessionId: 'sess-1' });
});
});
describe('isAgentReady', () => {
it('returns true when sidecar is ready', async () => {
mockInvoke.mockResolvedValue(true);
const result = await isAgentReady();
expect(result).toBe(true);
expect(mockInvoke).toHaveBeenCalledWith('agent_ready');
});
it('returns false when sidecar is not ready', async () => {
mockInvoke.mockResolvedValue(false);
const result = await isAgentReady();
expect(result).toBe(false);
});
});
describe('restartAgent', () => {
it('invokes agent_restart', async () => {
mockInvoke.mockResolvedValue(undefined);
await restartAgent();
expect(mockInvoke).toHaveBeenCalledWith('agent_restart');
});
});
describe('onSidecarMessage', () => {
it('registers listener on sidecar-message event', async () => {
const unlisten = vi.fn();
mockListen.mockResolvedValue(unlisten);
const callback = vi.fn();
const result = await onSidecarMessage(callback);
expect(mockListen).toHaveBeenCalledWith('sidecar-message', expect.any(Function));
expect(result).toBe(unlisten);
});
it('extracts payload and passes to callback', async () => {
mockListen.mockImplementation(async (_event: string, handler: (e: unknown) => void) => {
// Simulate Tauri event delivery
handler({
payload: {
type: 'agent_event',
sessionId: 'sess-1',
event: { type: 'system', subtype: 'init' },
},
});
return vi.fn();
});
const callback = vi.fn();
await onSidecarMessage(callback);
expect(callback).toHaveBeenCalledWith({
type: 'agent_event',
sessionId: 'sess-1',
event: { type: 'system', subtype: 'init' },
});
});
});
describe('onSidecarExited', () => {
it('registers listener on sidecar-exited event', async () => {
const unlisten = vi.fn();
mockListen.mockResolvedValue(unlisten);
const callback = vi.fn();
const result = await onSidecarExited(callback);
expect(mockListen).toHaveBeenCalledWith('sidecar-exited', expect.any(Function));
expect(result).toBe(unlisten);
});
it('invokes callback without arguments on exit', async () => {
mockListen.mockImplementation(async (_event: string, handler: () => void) => {
handler();
return vi.fn();
});
const callback = vi.fn();
await onSidecarExited(callback);
expect(callback).toHaveBeenCalledWith();
});
});
});

View file

@ -1,86 +0,0 @@
// Agent Bridge — Tauri IPC adapter for sidecar communication
// Mirrors pty-bridge.ts pattern: invoke for commands, listen for events
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import type { ProviderId } from '../providers/types';
export interface AgentQueryOptions {
provider?: ProviderId;
session_id: string;
prompt: string;
cwd?: string;
max_turns?: number;
max_budget_usd?: number;
resume_session_id?: string;
permission_mode?: string;
setting_sources?: string[];
system_prompt?: string;
model?: string;
claude_config_dir?: string;
additional_directories?: string[];
/** When set, agent runs in a git worktree for isolation */
worktree_name?: string;
provider_config?: Record<string, unknown>;
/** Extra environment variables injected into the agent process (e.g. BTMSG_AGENT_ID) */
extra_env?: Record<string, string>;
remote_machine_id?: string;
}
export async function queryAgent(options: AgentQueryOptions): Promise<void> {
if (options.remote_machine_id) {
const { remote_machine_id: machineId, ...agentOptions } = options;
return invoke('remote_agent_query', { machineId, options: agentOptions });
}
return invoke('agent_query', { options });
}
export async function stopAgent(sessionId: string, remoteMachineId?: string): Promise<void> {
if (remoteMachineId) {
return invoke('remote_agent_stop', { machineId: remoteMachineId, sessionId });
}
return invoke('agent_stop', { sessionId });
}
export async function isAgentReady(): Promise<boolean> {
return invoke<boolean>('agent_ready');
}
export async function restartAgent(): Promise<void> {
return invoke('agent_restart');
}
/** Update Landlock sandbox config and restart sidecar to apply. */
export async function setSandbox(
projectCwds: string[],
worktreeRoots: string[],
enabled: boolean,
): Promise<void> {
return invoke('agent_set_sandbox', { projectCwds, worktreeRoots, enabled });
}
export interface SidecarMessage {
type: string;
sessionId?: string;
event?: Record<string, unknown>;
message?: string;
exitCode?: number | null;
signal?: string | null;
}
export async function onSidecarMessage(
callback: (msg: SidecarMessage) => void,
): Promise<UnlistenFn> {
return listen<SidecarMessage>('sidecar-message', (event) => {
const payload = event.payload;
if (typeof payload !== 'object' || payload === null) return;
callback(payload as SidecarMessage);
});
}
export async function onSidecarExited(callback: () => void): Promise<UnlistenFn> {
return listen('sidecar-exited', () => {
callback();
});
}

View file

@ -1,25 +0,0 @@
// Anchors Bridge — Tauri IPC adapter for session anchor CRUD
// Mirrors groups-bridge.ts pattern
import { invoke } from '@tauri-apps/api/core';
import type { SessionAnchorRecord } from '../types/anchors';
export async function saveSessionAnchors(anchors: SessionAnchorRecord[]): Promise<void> {
return invoke('session_anchors_save', { anchors });
}
export async function loadSessionAnchors(projectId: string): Promise<SessionAnchorRecord[]> {
return invoke('session_anchors_load', { projectId });
}
export async function deleteSessionAnchor(id: string): Promise<void> {
return invoke('session_anchor_delete', { id });
}
export async function clearProjectAnchors(projectId: string): Promise<void> {
return invoke('session_anchors_clear', { projectId });
}
export async function updateAnchorType(id: string, anchorType: string): Promise<void> {
return invoke('session_anchor_update_type', { id, anchorType });
}

View file

@ -1,57 +0,0 @@
/**
* Audit log bridge reads/writes audit events via Tauri IPC.
* Used by agent-dispatcher, wake-scheduler, and AgentSession for event tracking.
*/
import { invoke } from '@tauri-apps/api/core';
import type { AgentId, GroupId } from '../types/ids';
export interface AuditEntry {
id: number;
agentId: string;
eventType: string;
detail: string;
createdAt: string;
}
/** Audit event types */
export type AuditEventType =
| 'prompt_injection'
| 'wake_event'
| 'btmsg_sent'
| 'btmsg_received'
| 'status_change'
| 'heartbeat_missed'
| 'dead_letter';
/**
* Log an audit event for an agent.
*/
export async function logAuditEvent(
agentId: AgentId,
eventType: AuditEventType,
detail: string,
): Promise<void> {
return invoke('audit_log_event', { agentId, eventType, detail });
}
/**
* Get audit log entries for a group (reverse chronological).
*/
export async function getAuditLog(
groupId: GroupId,
limit: number = 200,
offset: number = 0,
): Promise<AuditEntry[]> {
return invoke('audit_log_list', { groupId, limit, offset });
}
/**
* Get audit log entries for a specific agent.
*/
export async function getAuditLogForAgent(
agentId: AgentId,
limit: number = 50,
): Promise<AuditEntry[]> {
return invoke('audit_log_for_agent', { agentId, limit });
}

View file

@ -1,251 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockInvoke } = vi.hoisted(() => ({
mockInvoke: vi.fn(),
}));
vi.mock('@tauri-apps/api/core', () => ({
invoke: mockInvoke,
}));
import {
getGroupAgents,
getUnreadCount,
getUnreadMessages,
getHistory,
sendMessage,
setAgentStatus,
ensureAdmin,
getAllFeed,
markRead,
getChannels,
getChannelMessages,
sendChannelMessage,
createChannel,
addChannelMember,
registerAgents,
type BtmsgAgent,
type BtmsgMessage,
type BtmsgFeedMessage,
type BtmsgChannel,
type BtmsgChannelMessage,
} from './btmsg-bridge';
import { GroupId, AgentId } from '../types/ids';
beforeEach(() => {
vi.clearAllMocks();
});
describe('btmsg-bridge', () => {
// ---- REGRESSION: camelCase field names ----
// Bug: TypeScript interfaces used snake_case (group_id, unread_count, from_agent, etc.)
// but Rust serde(rename_all = "camelCase") sends camelCase.
describe('BtmsgAgent camelCase fields', () => {
it('receives camelCase fields from Rust backend', async () => {
const agent: BtmsgAgent = {
id: AgentId('a1'),
name: 'Coder',
role: 'developer',
groupId: GroupId('g1'), // was: group_id
tier: 1,
model: 'claude-4',
status: 'active',
unreadCount: 3, // was: unread_count
};
mockInvoke.mockResolvedValue([agent]);
const result = await getGroupAgents(GroupId('g1'));
expect(result).toHaveLength(1);
expect(result[0].groupId).toBe('g1');
expect(result[0].unreadCount).toBe(3);
// Verify snake_case fields do NOT exist
expect((result[0] as Record<string, unknown>)['group_id']).toBeUndefined();
expect((result[0] as Record<string, unknown>)['unread_count']).toBeUndefined();
});
it('invokes btmsg_get_agents with groupId', async () => {
mockInvoke.mockResolvedValue([]);
await getGroupAgents(GroupId('g1'));
expect(mockInvoke).toHaveBeenCalledWith('btmsg_get_agents', { groupId: 'g1' });
});
});
describe('BtmsgMessage camelCase fields', () => {
it('receives camelCase fields from Rust backend', async () => {
const msg: BtmsgMessage = {
id: 'm1',
fromAgent: AgentId('a1'), // was: from_agent
toAgent: AgentId('a2'), // was: to_agent
content: 'hello',
read: false,
replyTo: null, // was: reply_to
createdAt: '2026-01-01', // was: created_at
senderName: 'Coder', // was: sender_name
senderRole: 'dev', // was: sender_role
};
mockInvoke.mockResolvedValue([msg]);
const result = await getUnreadMessages(AgentId('a2'));
expect(result[0].fromAgent).toBe('a1');
expect(result[0].toAgent).toBe('a2');
expect(result[0].replyTo).toBeNull();
expect(result[0].createdAt).toBe('2026-01-01');
expect(result[0].senderName).toBe('Coder');
expect(result[0].senderRole).toBe('dev');
});
});
describe('BtmsgFeedMessage camelCase fields', () => {
it('receives camelCase fields including recipient info', async () => {
const feed: BtmsgFeedMessage = {
id: 'm1',
fromAgent: AgentId('a1'),
toAgent: AgentId('a2'),
content: 'review this',
createdAt: '2026-01-01',
replyTo: null,
senderName: 'Coder',
senderRole: 'developer',
recipientName: 'Reviewer',
recipientRole: 'reviewer',
};
mockInvoke.mockResolvedValue([feed]);
const result = await getAllFeed(GroupId('g1'));
expect(result[0].senderName).toBe('Coder');
expect(result[0].recipientName).toBe('Reviewer');
expect(result[0].recipientRole).toBe('reviewer');
});
});
describe('BtmsgChannel camelCase fields', () => {
it('receives camelCase fields', async () => {
const channel: BtmsgChannel = {
id: 'ch1',
name: 'general',
groupId: GroupId('g1'), // was: group_id
createdBy: AgentId('admin'), // was: created_by
memberCount: 5, // was: member_count
createdAt: '2026-01-01',
};
mockInvoke.mockResolvedValue([channel]);
const result = await getChannels(GroupId('g1'));
expect(result[0].groupId).toBe('g1');
expect(result[0].createdBy).toBe('admin');
expect(result[0].memberCount).toBe(5);
});
});
describe('BtmsgChannelMessage camelCase fields', () => {
it('receives camelCase fields', async () => {
const msg: BtmsgChannelMessage = {
id: 'cm1',
channelId: 'ch1', // was: channel_id
fromAgent: AgentId('a1'),
content: 'hello',
createdAt: '2026-01-01',
senderName: 'Coder',
senderRole: 'dev',
};
mockInvoke.mockResolvedValue([msg]);
const result = await getChannelMessages('ch1');
expect(result[0].channelId).toBe('ch1');
expect(result[0].fromAgent).toBe('a1');
expect(result[0].senderName).toBe('Coder');
});
});
// ---- IPC command name tests ----
describe('IPC commands', () => {
it('getUnreadCount invokes btmsg_unread_count', async () => {
mockInvoke.mockResolvedValue(5);
const result = await getUnreadCount(AgentId('a1'));
expect(result).toBe(5);
expect(mockInvoke).toHaveBeenCalledWith('btmsg_unread_count', { agentId: 'a1' });
});
it('getHistory invokes btmsg_history', async () => {
mockInvoke.mockResolvedValue([]);
await getHistory(AgentId('a1'), AgentId('a2'), 50);
expect(mockInvoke).toHaveBeenCalledWith('btmsg_history', { agentId: 'a1', otherId: 'a2', limit: 50 });
});
it('getHistory defaults limit to 20', async () => {
mockInvoke.mockResolvedValue([]);
await getHistory(AgentId('a1'), AgentId('a2'));
expect(mockInvoke).toHaveBeenCalledWith('btmsg_history', { agentId: 'a1', otherId: 'a2', limit: 20 });
});
it('sendMessage invokes btmsg_send', async () => {
mockInvoke.mockResolvedValue('msg-id');
const result = await sendMessage(AgentId('a1'), AgentId('a2'), 'hello');
expect(result).toBe('msg-id');
expect(mockInvoke).toHaveBeenCalledWith('btmsg_send', { fromAgent: 'a1', toAgent: 'a2', content: 'hello' });
});
it('setAgentStatus invokes btmsg_set_status', async () => {
mockInvoke.mockResolvedValue(undefined);
await setAgentStatus(AgentId('a1'), 'active');
expect(mockInvoke).toHaveBeenCalledWith('btmsg_set_status', { agentId: 'a1', status: 'active' });
});
it('ensureAdmin invokes btmsg_ensure_admin', async () => {
mockInvoke.mockResolvedValue(undefined);
await ensureAdmin(GroupId('g1'));
expect(mockInvoke).toHaveBeenCalledWith('btmsg_ensure_admin', { groupId: 'g1' });
});
it('markRead invokes btmsg_mark_read', async () => {
mockInvoke.mockResolvedValue(undefined);
await markRead(AgentId('a2'), AgentId('a1'));
expect(mockInvoke).toHaveBeenCalledWith('btmsg_mark_read', { readerId: 'a2', senderId: 'a1' });
});
it('sendChannelMessage invokes btmsg_channel_send', async () => {
mockInvoke.mockResolvedValue('cm-id');
const result = await sendChannelMessage('ch1', AgentId('a1'), 'hello channel');
expect(result).toBe('cm-id');
expect(mockInvoke).toHaveBeenCalledWith('btmsg_channel_send', { channelId: 'ch1', fromAgent: 'a1', content: 'hello channel' });
});
it('createChannel invokes btmsg_create_channel', async () => {
mockInvoke.mockResolvedValue('ch-id');
const result = await createChannel('general', GroupId('g1'), AgentId('admin'));
expect(result).toBe('ch-id');
expect(mockInvoke).toHaveBeenCalledWith('btmsg_create_channel', { name: 'general', groupId: 'g1', createdBy: 'admin' });
});
it('addChannelMember invokes btmsg_add_channel_member', async () => {
mockInvoke.mockResolvedValue(undefined);
await addChannelMember('ch1', AgentId('a1'));
expect(mockInvoke).toHaveBeenCalledWith('btmsg_add_channel_member', { channelId: 'ch1', agentId: 'a1' });
});
it('registerAgents invokes btmsg_register_agents with groups config', async () => {
mockInvoke.mockResolvedValue(undefined);
const config = {
version: 1,
groups: [{ id: 'g1', name: 'Test', projects: [], agents: [] }],
activeGroupId: 'g1',
};
await registerAgents(config as any);
expect(mockInvoke).toHaveBeenCalledWith('btmsg_register_agents', { config });
});
});
describe('error propagation', () => {
it('propagates invoke errors', async () => {
mockInvoke.mockRejectedValue(new Error('btmsg database not found'));
await expect(getGroupAgents(GroupId('g1'))).rejects.toThrow('btmsg database not found');
});
});
});

View file

@ -1,241 +0,0 @@
/**
* btmsg bridge reads btmsg SQLite database for agent notifications.
* Used by GroupAgentsPanel to show unread counts and agent statuses.
* Polls the database periodically for new messages.
*/
import { invoke } from '@tauri-apps/api/core';
import type { GroupId, AgentId } from '../types/ids';
export interface BtmsgAgent {
id: AgentId;
name: string;
role: string;
groupId: GroupId;
tier: number;
model: string | null;
status: string;
unreadCount: number;
}
export interface BtmsgMessage {
id: string;
fromAgent: AgentId;
toAgent: AgentId;
content: string;
read: boolean;
replyTo: string | null;
createdAt: string;
senderName?: string;
senderRole?: string;
}
export interface BtmsgFeedMessage {
id: string;
fromAgent: AgentId;
toAgent: AgentId;
content: string;
createdAt: string;
replyTo: string | null;
senderName: string;
senderRole: string;
recipientName: string;
recipientRole: string;
}
export interface BtmsgChannel {
id: string;
name: string;
groupId: GroupId;
createdBy: AgentId;
memberCount: number;
createdAt: string;
}
export interface BtmsgChannelMessage {
id: string;
channelId: string;
fromAgent: AgentId;
content: string;
createdAt: string;
senderName: string;
senderRole: string;
}
/**
* Get all agents in a group with their unread counts.
*/
export async function getGroupAgents(groupId: GroupId): Promise<BtmsgAgent[]> {
return invoke('btmsg_get_agents', { groupId });
}
/**
* Get unread message count for an agent.
*/
export async function getUnreadCount(agentId: AgentId): Promise<number> {
return invoke('btmsg_unread_count', { agentId });
}
/**
* Get unread messages for an agent.
*/
export async function getUnreadMessages(agentId: AgentId): Promise<BtmsgMessage[]> {
return invoke('btmsg_unread_messages', { agentId });
}
/**
* Get conversation history between two agents.
*/
export async function getHistory(agentId: AgentId, otherId: AgentId, limit: number = 20): Promise<BtmsgMessage[]> {
return invoke('btmsg_history', { agentId, otherId, limit });
}
/**
* Send a message from one agent to another.
*/
export async function sendMessage(fromAgent: AgentId, toAgent: AgentId, content: string): Promise<string> {
return invoke('btmsg_send', { fromAgent, toAgent, content });
}
/**
* Update agent status (active/sleeping/stopped).
*/
export async function setAgentStatus(agentId: AgentId, status: string): Promise<void> {
return invoke('btmsg_set_status', { agentId, status });
}
/**
* Ensure admin agent exists with contacts to all agents.
*/
export async function ensureAdmin(groupId: GroupId): Promise<void> {
return invoke('btmsg_ensure_admin', { groupId });
}
/**
* Get all messages in group (admin global feed).
*/
export async function getAllFeed(groupId: GroupId, limit: number = 100): Promise<BtmsgFeedMessage[]> {
return invoke('btmsg_all_feed', { groupId, limit });
}
/**
* Mark all messages from sender to reader as read.
*/
export async function markRead(readerId: AgentId, senderId: AgentId): Promise<void> {
return invoke('btmsg_mark_read', { readerId, senderId });
}
/**
* Get channels in a group.
*/
export async function getChannels(groupId: GroupId): Promise<BtmsgChannel[]> {
return invoke('btmsg_get_channels', { groupId });
}
/**
* Get messages in a channel.
*/
export async function getChannelMessages(channelId: string, limit: number = 100): Promise<BtmsgChannelMessage[]> {
return invoke('btmsg_channel_messages', { channelId, limit });
}
/**
* Send a message to a channel.
*/
export async function sendChannelMessage(channelId: string, fromAgent: AgentId, content: string): Promise<string> {
return invoke('btmsg_channel_send', { channelId, fromAgent, content });
}
/**
* Create a new channel.
*/
export async function createChannel(name: string, groupId: GroupId, createdBy: AgentId): Promise<string> {
return invoke('btmsg_create_channel', { name, groupId, createdBy });
}
/**
* Add a member to a channel.
*/
export async function addChannelMember(channelId: string, agentId: AgentId): Promise<void> {
return invoke('btmsg_add_channel_member', { channelId, agentId });
}
/**
* Register all agents from groups config into the btmsg database.
* Creates/updates agent records, sets up contact permissions, ensures review channels.
* Should be called whenever groups are loaded or switched.
*/
export async function registerAgents(config: import('../types/groups').GroupsFile): Promise<void> {
return invoke('btmsg_register_agents', { config });
}
// ---- Per-message acknowledgment (seen_messages) ----
/**
* Get messages not yet seen by this session (per-session tracking).
*/
export async function getUnseenMessages(agentId: AgentId, sessionId: string): Promise<BtmsgMessage[]> {
return invoke('btmsg_unseen_messages', { agentId, sessionId });
}
/**
* Mark specific message IDs as seen by this session.
*/
export async function markMessagesSeen(sessionId: string, messageIds: string[]): Promise<void> {
return invoke('btmsg_mark_seen', { sessionId, messageIds });
}
/**
* Prune old seen_messages entries (7-day default, emergency 3-day at 200k rows).
*/
export async function pruneSeen(): Promise<number> {
return invoke('btmsg_prune_seen');
}
// ---- Heartbeat monitoring ----
/**
* Record a heartbeat for an agent (upserts timestamp).
*/
export async function recordHeartbeat(agentId: AgentId): Promise<void> {
return invoke('btmsg_record_heartbeat', { agentId });
}
/**
* Get stale agents in a group (no heartbeat within threshold).
*/
export async function getStaleAgents(groupId: GroupId, thresholdSecs: number = 300): Promise<string[]> {
return invoke('btmsg_get_stale_agents', { groupId, thresholdSecs });
}
// ---- Dead letter queue ----
export interface DeadLetter {
id: number;
fromAgent: string;
toAgent: string;
content: string;
error: string;
createdAt: string;
}
/**
* Get dead letter queue entries for a group.
*/
export async function getDeadLetters(groupId: GroupId, limit: number = 50): Promise<DeadLetter[]> {
return invoke('btmsg_get_dead_letters', { groupId, limit });
}
/**
* Clear all dead letters for a group.
*/
export async function clearDeadLetters(groupId: GroupId): Promise<void> {
return invoke('btmsg_clear_dead_letters', { groupId });
}
/**
* Clear ALL communications for a group: messages, channel messages, seen tracking, dead letters.
*/
export async function clearAllComms(groupId: GroupId): Promise<void> {
return invoke('btmsg_clear_all_comms', { groupId });
}

View file

@ -1,152 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockInvoke } = vi.hoisted(() => ({
mockInvoke: vi.fn(),
}));
vi.mock('@tauri-apps/api/core', () => ({
invoke: mockInvoke,
}));
import {
listTasks,
getTaskComments,
updateTaskStatus,
addTaskComment,
createTask,
deleteTask,
type Task,
type TaskComment,
} from './bttask-bridge';
import { GroupId, AgentId } from '../types/ids';
beforeEach(() => {
vi.clearAllMocks();
});
describe('bttask-bridge', () => {
// ---- REGRESSION: camelCase field names ----
describe('Task camelCase fields', () => {
it('receives camelCase fields from Rust backend', async () => {
const task: Task = {
id: 't1',
title: 'Fix bug',
description: 'Critical fix',
status: 'progress',
priority: 'high',
assignedTo: AgentId('a1'), // was: assigned_to
createdBy: AgentId('admin'), // was: created_by
groupId: GroupId('g1'), // was: group_id
parentTaskId: null, // was: parent_task_id
sortOrder: 1, // was: sort_order
createdAt: '2026-01-01', // was: created_at
updatedAt: '2026-01-01', // was: updated_at
};
mockInvoke.mockResolvedValue([task]);
const result = await listTasks(GroupId('g1'));
expect(result).toHaveLength(1);
expect(result[0].assignedTo).toBe('a1');
expect(result[0].createdBy).toBe('admin');
expect(result[0].groupId).toBe('g1');
expect(result[0].parentTaskId).toBeNull();
expect(result[0].sortOrder).toBe(1);
// Verify no snake_case leaks
expect((result[0] as Record<string, unknown>)['assigned_to']).toBeUndefined();
expect((result[0] as Record<string, unknown>)['created_by']).toBeUndefined();
expect((result[0] as Record<string, unknown>)['group_id']).toBeUndefined();
});
});
describe('TaskComment camelCase fields', () => {
it('receives camelCase fields from Rust backend', async () => {
const comment: TaskComment = {
id: 'c1',
taskId: 't1', // was: task_id
agentId: AgentId('a1'), // was: agent_id
content: 'Working on it',
createdAt: '2026-01-01',
};
mockInvoke.mockResolvedValue([comment]);
const result = await getTaskComments('t1');
expect(result[0].taskId).toBe('t1');
expect(result[0].agentId).toBe('a1');
expect((result[0] as Record<string, unknown>)['task_id']).toBeUndefined();
expect((result[0] as Record<string, unknown>)['agent_id']).toBeUndefined();
});
});
// ---- IPC command name tests ----
describe('IPC commands', () => {
it('listTasks invokes bttask_list', async () => {
mockInvoke.mockResolvedValue([]);
await listTasks(GroupId('g1'));
expect(mockInvoke).toHaveBeenCalledWith('bttask_list', { groupId: 'g1' });
});
it('getTaskComments invokes bttask_comments', async () => {
mockInvoke.mockResolvedValue([]);
await getTaskComments('t1');
expect(mockInvoke).toHaveBeenCalledWith('bttask_comments', { taskId: 't1' });
});
it('updateTaskStatus invokes bttask_update_status with version', async () => {
mockInvoke.mockResolvedValue(2);
const newVersion = await updateTaskStatus('t1', 'done', 1);
expect(newVersion).toBe(2);
expect(mockInvoke).toHaveBeenCalledWith('bttask_update_status', { taskId: 't1', status: 'done', version: 1 });
});
it('addTaskComment invokes bttask_add_comment', async () => {
mockInvoke.mockResolvedValue('c-id');
const result = await addTaskComment('t1', AgentId('a1'), 'Done!');
expect(result).toBe('c-id');
expect(mockInvoke).toHaveBeenCalledWith('bttask_add_comment', { taskId: 't1', agentId: 'a1', content: 'Done!' });
});
it('createTask invokes bttask_create with all fields', async () => {
mockInvoke.mockResolvedValue('t-id');
const result = await createTask('Fix bug', 'desc', 'high', GroupId('g1'), AgentId('admin'), AgentId('a1'));
expect(result).toBe('t-id');
expect(mockInvoke).toHaveBeenCalledWith('bttask_create', {
title: 'Fix bug',
description: 'desc',
priority: 'high',
groupId: 'g1',
createdBy: 'admin',
assignedTo: 'a1',
});
});
it('createTask invokes bttask_create without assignedTo', async () => {
mockInvoke.mockResolvedValue('t-id');
await createTask('Add tests', '', 'medium', GroupId('g1'), AgentId('a1'));
expect(mockInvoke).toHaveBeenCalledWith('bttask_create', {
title: 'Add tests',
description: '',
priority: 'medium',
groupId: 'g1',
createdBy: 'a1',
assignedTo: undefined,
});
});
it('deleteTask invokes bttask_delete', async () => {
mockInvoke.mockResolvedValue(undefined);
await deleteTask('t1');
expect(mockInvoke).toHaveBeenCalledWith('bttask_delete', { taskId: 't1' });
});
});
describe('error propagation', () => {
it('propagates invoke errors', async () => {
mockInvoke.mockRejectedValue(new Error('btmsg database not found'));
await expect(listTasks(GroupId('g1'))).rejects.toThrow('btmsg database not found');
});
});
});

View file

@ -1,65 +0,0 @@
// bttask Bridge — Tauri IPC adapter for task board
import { invoke } from '@tauri-apps/api/core';
import type { GroupId, AgentId } from '../types/ids';
export interface Task {
id: string;
title: string;
description: string;
status: 'todo' | 'progress' | 'review' | 'done' | 'blocked';
priority: 'low' | 'medium' | 'high' | 'critical';
assignedTo: AgentId | null;
createdBy: AgentId;
groupId: GroupId;
parentTaskId: string | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
version: number;
}
export interface TaskComment {
id: string;
taskId: string;
agentId: AgentId;
content: string;
createdAt: string;
}
export async function listTasks(groupId: GroupId): Promise<Task[]> {
return invoke<Task[]>('bttask_list', { groupId });
}
export async function getTaskComments(taskId: string): Promise<TaskComment[]> {
return invoke<TaskComment[]>('bttask_comments', { taskId });
}
/** Update task status with optimistic locking. Returns the new version number. */
export async function updateTaskStatus(taskId: string, status: string, version: number): Promise<number> {
return invoke<number>('bttask_update_status', { taskId, status, version });
}
export async function addTaskComment(taskId: string, agentId: AgentId, content: string): Promise<string> {
return invoke<string>('bttask_add_comment', { taskId, agentId, content });
}
export async function createTask(
title: string,
description: string,
priority: string,
groupId: GroupId,
createdBy: AgentId,
assignedTo?: AgentId,
): Promise<string> {
return invoke<string>('bttask_create', { title, description, priority, groupId, createdBy, assignedTo });
}
export async function deleteTask(taskId: string): Promise<void> {
return invoke('bttask_delete', { taskId });
}
/** Count tasks currently in 'review' status for a group */
export async function reviewQueueCount(groupId: GroupId): Promise<number> {
return invoke<number>('bttask_review_queue_count', { groupId });
}

View file

@ -1,28 +0,0 @@
// Claude Bridge — Tauri IPC adapter for Claude profiles and skills
import { invoke } from '@tauri-apps/api/core';
export interface ClaudeProfile {
name: string;
email: string | null;
subscription_type: string | null;
display_name: string | null;
config_dir: string;
}
export interface ClaudeSkill {
name: string;
description: string;
source_path: string;
}
export async function listProfiles(): Promise<ClaudeProfile[]> {
return invoke<ClaudeProfile[]>('claude_list_profiles');
}
export async function listSkills(): Promise<ClaudeSkill[]> {
return invoke<ClaudeSkill[]>('claude_list_skills');
}
export async function readSkill(path: string): Promise<string> {
return invoke<string>('claude_read_skill', { path });
}

View file

@ -1,38 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
export interface CtxEntry {
project: string;
key: string;
value: string;
updated_at: string;
}
export interface CtxSummary {
project: string;
summary: string;
created_at: string;
}
export async function ctxInitDb(): Promise<void> {
return invoke('ctx_init_db');
}
export async function ctxRegisterProject(name: string, description: string, workDir?: string): Promise<void> {
return invoke('ctx_register_project', { name, description, workDir: workDir ?? null });
}
export async function ctxGetContext(project: string): Promise<CtxEntry[]> {
return invoke('ctx_get_context', { project });
}
export async function ctxGetShared(): Promise<CtxEntry[]> {
return invoke('ctx_get_shared');
}
export async function ctxGetSummaries(project: string, limit: number = 5): Promise<CtxSummary[]> {
return invoke('ctx_get_summaries', { project, limit });
}
export async function ctxSearch(query: string): Promise<CtxEntry[]> {
return invoke('ctx_search', { query });
}

View file

@ -1,29 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
export interface FileChangedPayload {
pane_id: string;
path: string;
content: string;
}
/** Start watching a file; returns initial content */
export async function watchFile(paneId: string, path: string): Promise<string> {
return invoke('file_watch', { paneId, path });
}
export async function unwatchFile(paneId: string): Promise<void> {
return invoke('file_unwatch', { paneId });
}
export async function readFile(path: string): Promise<string> {
return invoke('file_read', { path });
}
export async function onFileChanged(
callback: (payload: FileChangedPayload) => void
): Promise<UnlistenFn> {
return listen<FileChangedPayload>('file-changed', (event) => {
callback(event.payload);
});
}

View file

@ -1,26 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
export interface DirEntry {
name: string;
path: string;
is_dir: boolean;
size: number;
ext: string;
}
export type FileContent =
| { type: 'Text'; content: string; lang: string }
| { type: 'Binary'; message: string }
| { type: 'TooLarge'; size: number };
export function listDirectoryChildren(path: string): Promise<DirEntry[]> {
return invoke<DirEntry[]>('list_directory_children', { path });
}
export function readFileContent(path: string): Promise<FileContent> {
return invoke<FileContent>('read_file_content', { path });
}
export function writeFileContent(path: string, content: string): Promise<void> {
return invoke<void>('write_file_content', { path, content });
}

View file

@ -1,41 +0,0 @@
// Filesystem watcher bridge — listens for inotify-based write events from Rust
// Part of S-1 Phase 2: real-time filesystem write detection
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
export interface FsWriteEvent {
project_id: string;
file_path: string;
timestamp_ms: number;
}
/** Start watching a project's CWD for filesystem writes */
export function fsWatchProject(projectId: string, cwd: string): Promise<void> {
return invoke('fs_watch_project', { projectId, cwd });
}
/** Stop watching a project's CWD */
export function fsUnwatchProject(projectId: string): Promise<void> {
return invoke('fs_unwatch_project', { projectId });
}
/** Listen for filesystem write events from all watched projects */
export function onFsWriteDetected(
callback: (event: FsWriteEvent) => void,
): Promise<UnlistenFn> {
return listen<FsWriteEvent>('fs-write-detected', (e) => callback(e.payload));
}
export interface FsWatcherStatus {
max_watches: number;
estimated_watches: number;
usage_ratio: number;
active_projects: number;
warning: string | null;
}
/** Get inotify watcher status including kernel limit check */
export function fsWatcherStatus(): Promise<FsWatcherStatus> {
return invoke('fs_watcher_status');
}

View file

@ -1,111 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import type { GroupsFile, ProjectConfig, GroupConfig } from '../types/groups';
import type { SessionId, ProjectId } from '../types/ids';
export type { GroupsFile, ProjectConfig, GroupConfig };
export interface MdFileEntry {
name: string;
path: string;
priority: boolean;
}
export interface AgentMessageRecord {
id: number;
session_id: SessionId;
project_id: ProjectId;
sdk_session_id: string | null;
message_type: string;
content: string;
parent_id: string | null;
created_at: number;
}
export interface ProjectAgentState {
project_id: ProjectId;
last_session_id: SessionId;
sdk_session_id: string | null;
status: string;
cost_usd: number;
input_tokens: number;
output_tokens: number;
last_prompt: string | null;
updated_at: number;
}
// --- Group config ---
export async function loadGroups(): Promise<GroupsFile> {
return invoke('groups_load');
}
export async function saveGroups(config: GroupsFile): Promise<void> {
return invoke('groups_save', { config });
}
// --- Markdown discovery ---
export async function discoverMarkdownFiles(cwd: string): Promise<MdFileEntry[]> {
return invoke('discover_markdown_files', { cwd });
}
// --- Agent message persistence ---
export async function saveAgentMessages(
sessionId: SessionId,
projectId: ProjectId,
sdkSessionId: string | undefined,
messages: AgentMessageRecord[],
): Promise<void> {
return invoke('agent_messages_save', {
sessionId,
projectId,
sdkSessionId: sdkSessionId ?? null,
messages,
});
}
export async function loadAgentMessages(projectId: ProjectId): Promise<AgentMessageRecord[]> {
return invoke('agent_messages_load', { projectId });
}
// --- Project agent state ---
export async function saveProjectAgentState(state: ProjectAgentState): Promise<void> {
return invoke('project_agent_state_save', { state });
}
export async function loadProjectAgentState(projectId: ProjectId): Promise<ProjectAgentState | null> {
return invoke('project_agent_state_load', { projectId });
}
// --- Session metrics ---
export interface SessionMetric {
id: number;
project_id: ProjectId;
session_id: SessionId;
start_time: number;
end_time: number;
peak_tokens: number;
turn_count: number;
tool_call_count: number;
cost_usd: number;
model: string | null;
status: string;
error_message: string | null;
}
export async function saveSessionMetric(metric: Omit<SessionMetric, 'id'>): Promise<void> {
return invoke('session_metric_save', { metric: { id: 0, ...metric } });
}
export async function loadSessionMetrics(projectId: ProjectId, limit = 20): Promise<SessionMetric[]> {
return invoke('session_metrics_load', { projectId, limit });
}
// --- CLI arguments ---
export async function getCliGroup(): Promise<string | null> {
return invoke('cli_get_group');
}

View file

@ -0,0 +1,62 @@
/**
* Memora adapter uses BackendAdapter for database access.
* Replaces the Tauri-coupled MemoraAdapter from memora-bridge.ts.
*/
import { getBackend } from '../backend/backend';
import type { MemoryAdapter, MemoryNode, MemorySearchResult } from './memory-adapter';
function toMemoryNode(n: { id: number; content: string; tags: string[]; metadata?: Record<string, unknown>; created_at?: string; updated_at?: string }): MemoryNode {
return {
id: n.id,
content: n.content,
tags: n.tags,
metadata: n.metadata,
created_at: n.created_at,
updated_at: n.updated_at,
};
}
export class MemoraAdapter implements MemoryAdapter {
readonly name = 'memora';
private _available: boolean | null = null;
get available(): boolean {
return this._available ?? true;
}
async checkAvailability(): Promise<boolean> {
this._available = await getBackend().memoraAvailable();
return this._available;
}
async list(options?: {
tags?: string[];
limit?: number;
offset?: number;
}): Promise<MemorySearchResult> {
const result = await getBackend().memoraList(options);
this._available = true;
return { nodes: result.nodes.map(toMemoryNode), total: result.total };
}
async search(
query: string,
options?: { tags?: string[]; limit?: number },
): Promise<MemorySearchResult> {
const result = await getBackend().memoraSearch(query, options);
this._available = true;
return { nodes: result.nodes.map(toMemoryNode), total: result.total };
}
async get(id: string | number): Promise<MemoryNode | null> {
const numId = typeof id === 'string' ? parseInt(id, 10) : id;
if (isNaN(numId)) return null;
const node = await getBackend().memoraGet(numId);
if (node) {
this._available = true;
return toMemoryNode(node);
}
return null;
}
}

View file

@ -1,171 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { mockInvoke } = vi.hoisted(() => ({
mockInvoke: vi.fn(),
}));
vi.mock('@tauri-apps/api/core', () => ({
invoke: mockInvoke,
}));
import {
memoraAvailable,
memoraList,
memoraSearch,
memoraGet,
MemoraAdapter,
} from './memora-bridge';
beforeEach(() => {
vi.clearAllMocks();
});
describe('memora IPC wrappers', () => {
it('memoraAvailable invokes memora_available', async () => {
mockInvoke.mockResolvedValue(true);
const result = await memoraAvailable();
expect(result).toBe(true);
expect(mockInvoke).toHaveBeenCalledWith('memora_available');
});
it('memoraList invokes memora_list with defaults', async () => {
mockInvoke.mockResolvedValue({ nodes: [], total: 0 });
await memoraList();
expect(mockInvoke).toHaveBeenCalledWith('memora_list', {
tags: null,
limit: 50,
offset: 0,
});
});
it('memoraList passes tags and pagination', async () => {
mockInvoke.mockResolvedValue({ nodes: [], total: 0 });
await memoraList({ tags: ['bterminal'], limit: 10, offset: 5 });
expect(mockInvoke).toHaveBeenCalledWith('memora_list', {
tags: ['bterminal'],
limit: 10,
offset: 5,
});
});
it('memoraSearch invokes memora_search', async () => {
mockInvoke.mockResolvedValue({ nodes: [], total: 0 });
await memoraSearch('test query', { tags: ['foo'], limit: 20 });
expect(mockInvoke).toHaveBeenCalledWith('memora_search', {
query: 'test query',
tags: ['foo'],
limit: 20,
});
});
it('memoraSearch uses defaults when no options', async () => {
mockInvoke.mockResolvedValue({ nodes: [], total: 0 });
await memoraSearch('hello');
expect(mockInvoke).toHaveBeenCalledWith('memora_search', {
query: 'hello',
tags: null,
limit: 50,
});
});
it('memoraGet invokes memora_get', async () => {
const node = { id: 42, content: 'test', tags: ['a'], metadata: null, created_at: null, updated_at: null };
mockInvoke.mockResolvedValue(node);
const result = await memoraGet(42);
expect(result).toEqual(node);
expect(mockInvoke).toHaveBeenCalledWith('memora_get', { id: 42 });
});
it('memoraGet returns null for missing', async () => {
mockInvoke.mockResolvedValue(null);
const result = await memoraGet(999);
expect(result).toBeNull();
});
});
describe('MemoraAdapter', () => {
it('has name "memora"', () => {
const adapter = new MemoraAdapter();
expect(adapter.name).toBe('memora');
});
it('available is true by default (optimistic)', () => {
const adapter = new MemoraAdapter();
expect(adapter.available).toBe(true);
});
it('checkAvailability updates available state', async () => {
mockInvoke.mockResolvedValue(false);
const adapter = new MemoraAdapter();
const result = await adapter.checkAvailability();
expect(result).toBe(false);
expect(adapter.available).toBe(false);
});
it('list returns mapped MemorySearchResult', async () => {
mockInvoke.mockResolvedValue({
nodes: [
{ id: 1, content: 'hello', tags: ['a', 'b'], metadata: { key: 'val' }, created_at: '2026-01-01', updated_at: null },
],
total: 1,
});
const adapter = new MemoraAdapter();
const result = await adapter.list({ limit: 10 });
expect(result.total).toBe(1);
expect(result.nodes).toHaveLength(1);
expect(result.nodes[0].id).toBe(1);
expect(result.nodes[0].content).toBe('hello');
expect(result.nodes[0].tags).toEqual(['a', 'b']);
expect(result.nodes[0].metadata).toEqual({ key: 'val' });
});
it('search returns mapped results', async () => {
mockInvoke.mockResolvedValue({
nodes: [{ id: 5, content: 'found', tags: ['x'], metadata: null, created_at: null, updated_at: null }],
total: 1,
});
const adapter = new MemoraAdapter();
const result = await adapter.search('found', { limit: 5 });
expect(result.nodes[0].content).toBe('found');
expect(adapter.available).toBe(true);
});
it('get returns mapped node', async () => {
mockInvoke.mockResolvedValue({
id: 10, content: 'node', tags: ['t'], metadata: null, created_at: '2026-01-01', updated_at: '2026-01-02',
});
const adapter = new MemoraAdapter();
const node = await adapter.get(10);
expect(node).not.toBeNull();
expect(node!.id).toBe(10);
expect(node!.updated_at).toBe('2026-01-02');
});
it('get returns null for missing node', async () => {
mockInvoke.mockResolvedValue(null);
const adapter = new MemoraAdapter();
const node = await adapter.get(999);
expect(node).toBeNull();
});
it('get handles string id', async () => {
mockInvoke.mockResolvedValue({
id: 7, content: 'x', tags: [], metadata: null, created_at: null, updated_at: null,
});
const adapter = new MemoraAdapter();
const node = await adapter.get('7');
expect(node).not.toBeNull();
expect(mockInvoke).toHaveBeenCalledWith('memora_get', { id: 7 });
});
it('get returns null for non-numeric string id', async () => {
const adapter = new MemoraAdapter();
const node = await adapter.get('abc');
expect(node).toBeNull();
expect(mockInvoke).not.toHaveBeenCalled();
});
});

View file

@ -1,122 +0,0 @@
/**
* Memora IPC bridge read-only access to the Memora memory database.
* Wraps Tauri commands and provides a MemoryAdapter implementation.
*/
import { invoke } from '@tauri-apps/api/core';
import type { MemoryAdapter, MemoryNode, MemorySearchResult } from './memory-adapter';
// --- Raw IPC types (match Rust structs) ---
interface MemoraNode {
id: number;
content: string;
tags: string[];
metadata?: Record<string, unknown>;
created_at?: string;
updated_at?: string;
}
interface MemoraSearchResult {
nodes: MemoraNode[];
total: number;
}
// --- IPC wrappers ---
export async function memoraAvailable(): Promise<boolean> {
return invoke<boolean>('memora_available');
}
export async function memoraList(options?: {
tags?: string[];
limit?: number;
offset?: number;
}): Promise<MemoraSearchResult> {
return invoke<MemoraSearchResult>('memora_list', {
tags: options?.tags ?? null,
limit: options?.limit ?? 50,
offset: options?.offset ?? 0,
});
}
export async function memoraSearch(
query: string,
options?: { tags?: string[]; limit?: number },
): Promise<MemoraSearchResult> {
return invoke<MemoraSearchResult>('memora_search', {
query,
tags: options?.tags ?? null,
limit: options?.limit ?? 50,
});
}
export async function memoraGet(id: number): Promise<MemoraNode | null> {
return invoke<MemoraNode | null>('memora_get', { id });
}
// --- MemoryAdapter implementation ---
function toMemoryNode(n: MemoraNode): MemoryNode {
return {
id: n.id,
content: n.content,
tags: n.tags,
metadata: n.metadata,
created_at: n.created_at,
updated_at: n.updated_at,
};
}
function toSearchResult(r: MemoraSearchResult): MemorySearchResult {
return {
nodes: r.nodes.map(toMemoryNode),
total: r.total,
};
}
export class MemoraAdapter implements MemoryAdapter {
readonly name = 'memora';
private _available: boolean | null = null;
get available(): boolean {
// Optimistic: assume available until first check proves otherwise.
// Actual availability is checked lazily on first operation.
return this._available ?? true;
}
async checkAvailability(): Promise<boolean> {
this._available = await memoraAvailable();
return this._available;
}
async list(options?: {
tags?: string[];
limit?: number;
offset?: number;
}): Promise<MemorySearchResult> {
const result = await memoraList(options);
this._available = true;
return toSearchResult(result);
}
async search(
query: string,
options?: { tags?: string[]; limit?: number },
): Promise<MemorySearchResult> {
const result = await memoraSearch(query, options);
this._available = true;
return toSearchResult(result);
}
async get(id: string | number): Promise<MemoryNode | null> {
const numId = typeof id === 'string' ? parseInt(id, 10) : id;
if (isNaN(numId)) return null;
const node = await memoraGet(numId);
if (node) {
this._available = true;
return toMemoryNode(node);
}
return null;
}
}

View file

@ -7,7 +7,7 @@ import { adaptSDKMessage } from './claude-messages';
import { adaptCodexMessage } from './codex-messages'; import { adaptCodexMessage } from './codex-messages';
import { adaptOllamaMessage } from './ollama-messages'; import { adaptOllamaMessage } from './ollama-messages';
import { adaptAiderMessage } from './aider-messages'; import { adaptAiderMessage } from './aider-messages';
import { tel } from './telemetry-bridge'; import { tel } from '../utils/telemetry';
/** Function signature for a provider message adapter */ /** Function signature for a provider message adapter */
export type MessageAdapter = (raw: Record<string, unknown>) => AgentMessage[]; export type MessageAdapter = (raw: Record<string, unknown>) => AgentMessage[];

View file

@ -1,22 +0,0 @@
// Notifications bridge — wraps Tauri desktop notification command
import { invoke } from '@tauri-apps/api/core';
export type NotificationUrgency = 'low' | 'normal' | 'critical';
/**
* Send an OS desktop notification via notify-rust.
* Fire-and-forget: errors are swallowed (notification daemon may not be running).
*/
export function sendDesktopNotification(
title: string,
body: string,
urgency: NotificationUrgency = 'normal',
): void {
invoke('notify_desktop', { title, body, urgency }).catch((_e: unknown) => {
// Intentional: notification daemon may not be running. Cannot use handleInfraError
// here — it calls tel.error, and notify() calls sendDesktopNotification, creating a loop.
// eslint-disable-next-line no-console
console.warn('[notifications-bridge] Desktop notification failed:', _e);
});
}

View file

@ -1,22 +0,0 @@
// Plugin discovery and file access — Tauri IPC adapter
import { invoke } from '@tauri-apps/api/core';
export interface PluginMeta {
id: string;
name: string;
version: string;
description: string;
main: string;
permissions: string[];
}
/** Discover all plugins in ~/.config/agor/plugins/ */
export async function discoverPlugins(): Promise<PluginMeta[]> {
return invoke<PluginMeta[]>('plugins_discover');
}
/** Read a file from a plugin's directory (path-traversal safe) */
export async function readPluginFile(pluginId: string, filename: string): Promise<string> {
return invoke<string>('plugin_read_file', { pluginId, filename });
}

View file

@ -1,26 +0,0 @@
// Provider Bridge — generic adapter that delegates to provider-specific bridges
// Currently only Claude is implemented; future providers add their own bridge files
import type { ProviderId } from '../providers/types';
import { listProfiles as claudeListProfiles, listSkills as claudeListSkills, readSkill as claudeReadSkill, type ClaudeProfile, type ClaudeSkill } from './claude-bridge';
// Re-export types for consumers
export type { ClaudeProfile, ClaudeSkill };
/** List profiles for a given provider (only Claude supports this) */
export async function listProviderProfiles(provider: ProviderId): Promise<ClaudeProfile[]> {
if (provider === 'claude') return claudeListProfiles();
return [];
}
/** List skills for a given provider (only Claude supports this) */
export async function listProviderSkills(provider: ProviderId): Promise<ClaudeSkill[]> {
if (provider === 'claude') return claudeListSkills();
return [];
}
/** Read a skill file (only Claude supports this) */
export async function readProviderSkill(provider: ProviderId, path: string): Promise<string> {
if (provider === 'claude') return claudeReadSkill(path);
return '';
}

View file

@ -1,52 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
export interface PtyOptions {
shell?: string;
cwd?: string;
args?: string[];
cols?: number;
rows?: number;
remote_machine_id?: string;
}
export async function spawnPty(options: PtyOptions): Promise<string> {
if (options.remote_machine_id) {
const { remote_machine_id: machineId, ...ptyOptions } = options;
return invoke<string>('remote_pty_spawn', { machineId, options: ptyOptions });
}
return invoke<string>('pty_spawn', { options });
}
export async function writePty(id: string, data: string, remoteMachineId?: string): Promise<void> {
if (remoteMachineId) {
return invoke('remote_pty_write', { machineId: remoteMachineId, id, data });
}
return invoke('pty_write', { id, data });
}
export async function resizePty(id: string, cols: number, rows: number, remoteMachineId?: string): Promise<void> {
if (remoteMachineId) {
return invoke('remote_pty_resize', { machineId: remoteMachineId, id, cols, rows });
}
return invoke('pty_resize', { id, cols, rows });
}
export async function killPty(id: string, remoteMachineId?: string): Promise<void> {
if (remoteMachineId) {
return invoke('remote_pty_kill', { machineId: remoteMachineId, id });
}
return invoke('pty_kill', { id });
}
export async function onPtyData(id: string, callback: (data: string) => void): Promise<UnlistenFn> {
return listen<string>(`pty-data-${id}`, (event) => {
callback(event.payload);
});
}
export async function onPtyExit(id: string, callback: () => void): Promise<UnlistenFn> {
return listen(`pty-exit-${id}`, () => {
callback();
});
}

View file

@ -1,180 +0,0 @@
// Remote Machine Bridge — Tauri IPC adapter for multi-machine management
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
export interface RemoteMachineConfig {
label: string;
url: string;
token: string;
auto_connect: boolean;
/** SPKI SHA-256 pin(s) for certificate verification. Empty = TOFU on first connect. */
spki_pins?: string[];
}
export interface RemoteMachineInfo {
id: string;
label: string;
url: string;
status: string;
auto_connect: boolean;
/** Currently stored SPKI pin hashes (hex-encoded SHA-256) */
spki_pins: string[];
}
// --- Machine management ---
export async function listRemoteMachines(): Promise<RemoteMachineInfo[]> {
return invoke('remote_list');
}
export async function addRemoteMachine(config: RemoteMachineConfig): Promise<string> {
return invoke('remote_add', { config });
}
export async function removeRemoteMachine(machineId: string): Promise<void> {
return invoke('remote_remove', { machineId });
}
export async function connectRemoteMachine(machineId: string): Promise<void> {
return invoke('remote_connect', { machineId });
}
export async function disconnectRemoteMachine(machineId: string): Promise<void> {
return invoke('remote_disconnect', { machineId });
}
// --- SPKI certificate pinning ---
/** Probe a relay server's TLS certificate and return its SHA-256 hash (hex-encoded). */
export async function probeSpki(url: string): Promise<string> {
return invoke('remote_probe_spki', { url });
}
/** Add an SPKI pin hash to a machine's trusted pins. */
export async function addSpkiPin(machineId: string, pin: string): Promise<void> {
return invoke('remote_add_pin', { machineId, pin });
}
/** Remove an SPKI pin hash from a machine's trusted pins. */
export async function removeSpkiPin(machineId: string, pin: string): Promise<void> {
return invoke('remote_remove_pin', { machineId, pin });
}
// --- Remote event listeners ---
export interface RemoteSidecarMessage {
machineId: string;
sessionId?: string;
event?: Record<string, unknown>;
}
export interface RemotePtyData {
machineId: string;
sessionId?: string;
data?: string;
}
export interface RemotePtyExit {
machineId: string;
sessionId?: string;
}
export interface RemoteMachineEvent {
machineId: string;
payload?: unknown;
error?: unknown;
}
export async function onRemoteSidecarMessage(
callback: (msg: RemoteSidecarMessage) => void,
): Promise<UnlistenFn> {
return listen<RemoteSidecarMessage>('remote-sidecar-message', (event) => {
callback(event.payload);
});
}
export async function onRemotePtyData(
callback: (msg: RemotePtyData) => void,
): Promise<UnlistenFn> {
return listen<RemotePtyData>('remote-pty-data', (event) => {
callback(event.payload);
});
}
export async function onRemotePtyExit(
callback: (msg: RemotePtyExit) => void,
): Promise<UnlistenFn> {
return listen<RemotePtyExit>('remote-pty-exit', (event) => {
callback(event.payload);
});
}
export async function onRemoteMachineReady(
callback: (msg: RemoteMachineEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteMachineEvent>('remote-machine-ready', (event) => {
callback(event.payload);
});
}
export async function onRemoteMachineDisconnected(
callback: (msg: RemoteMachineEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteMachineEvent>('remote-machine-disconnected', (event) => {
callback(event.payload);
});
}
export async function onRemoteStateSync(
callback: (msg: RemoteMachineEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteMachineEvent>('remote-state-sync', (event) => {
callback(event.payload);
});
}
export async function onRemoteError(
callback: (msg: RemoteMachineEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteMachineEvent>('remote-error', (event) => {
callback(event.payload);
});
}
export interface RemoteReconnectingEvent {
machineId: string;
backoffSecs: number;
}
export async function onRemoteMachineReconnecting(
callback: (msg: RemoteReconnectingEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteReconnectingEvent>('remote-machine-reconnecting', (event) => {
callback(event.payload);
});
}
export async function onRemoteMachineReconnectReady(
callback: (msg: RemoteMachineEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteMachineEvent>('remote-machine-reconnect-ready', (event) => {
callback(event.payload);
});
}
// --- SPKI TOFU event ---
export interface RemoteSpkiTofuEvent {
machineId: string;
hash: string;
}
/** Listen for TOFU (Trust On First Use) events when a new SPKI pin is auto-stored. */
export async function onRemoteSpkiTofu(
callback: (msg: RemoteSpkiTofuEvent) => void,
): Promise<UnlistenFn> {
return listen<RemoteSpkiTofuEvent>('remote-spki-tofu', (event) => {
callback(event.payload);
});
}

View file

@ -1,31 +0,0 @@
// Search Bridge — Tauri IPC adapter for FTS5 full-text search
import { invoke } from '@tauri-apps/api/core';
export interface SearchResult {
resultType: string;
id: string;
title: string;
snippet: string;
score: number;
}
/** Confirm search database is ready (no-op, initialized at app startup). */
export async function initSearch(): Promise<void> {
return invoke('search_init');
}
/** Search across all FTS5 tables (messages, tasks, btmsg). */
export async function searchAll(query: string, limit?: number): Promise<SearchResult[]> {
return invoke<SearchResult[]>('search_query', { query, limit: limit ?? 20 });
}
/** Drop and recreate all FTS5 tables (clears the index). */
export async function rebuildIndex(): Promise<void> {
return invoke('search_rebuild');
}
/** Index an agent message into the search database. */
export async function indexMessage(sessionId: string, role: string, content: string): Promise<void> {
return invoke('search_index_message', { sessionId, role, content });
}

View file

@ -1,40 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
/** Store a secret in the system keyring. */
export async function storeSecret(key: string, value: string): Promise<void> {
return invoke('secrets_store', { key, value });
}
/** Retrieve a secret from the system keyring. Returns null if not found. */
export async function getSecret(key: string): Promise<string | null> {
return invoke('secrets_get', { key });
}
/** Delete a secret from the system keyring. */
export async function deleteSecret(key: string): Promise<void> {
return invoke('secrets_delete', { key });
}
/** List keys that have been stored in the keyring. */
export async function listSecrets(): Promise<string[]> {
return invoke('secrets_list');
}
/** Check if the system keyring is available. */
export async function hasKeyring(): Promise<boolean> {
return invoke('secrets_has_keyring');
}
/** Get the list of known/recognized secret key identifiers. */
export async function knownSecretKeys(): Promise<string[]> {
return invoke('secrets_known_keys');
}
/** Human-readable labels for known secret keys. */
export const SECRET_KEY_LABELS: Record<string, string> = {
anthropic_api_key: 'Anthropic API Key',
openai_api_key: 'OpenAI API Key',
openrouter_api_key: 'OpenRouter API Key',
github_token: 'GitHub Token',
relay_token: 'Relay Token',
};

View file

@ -1,50 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
export interface PersistedSession {
id: string;
type: string;
title: string;
shell?: string;
cwd?: string;
args?: string[];
group_name?: string;
created_at: number;
last_used_at: number;
}
export interface PersistedLayout {
preset: string;
pane_ids: string[];
}
export async function listSessions(): Promise<PersistedSession[]> {
return invoke('session_list');
}
export async function saveSession(session: PersistedSession): Promise<void> {
return invoke('session_save', { session });
}
export async function deleteSession(id: string): Promise<void> {
return invoke('session_delete', { id });
}
export async function updateSessionTitle(id: string, title: string): Promise<void> {
return invoke('session_update_title', { id, title });
}
export async function touchSession(id: string): Promise<void> {
return invoke('session_touch', { id });
}
export async function updateSessionGroup(id: string, groupName: string): Promise<void> {
return invoke('session_update_group', { id, group_name: groupName });
}
export async function saveLayout(layout: PersistedLayout): Promise<void> {
return invoke('layout_save', { layout });
}
export async function loadLayout(): Promise<PersistedLayout> {
return invoke('layout_load');
}

View file

@ -1,26 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
export interface SshSession {
id: string;
name: string;
host: string;
port: number;
username: string;
key_file: string;
folder: string;
color: string;
created_at: number;
last_used_at: number;
}
export async function listSshSessions(): Promise<SshSession[]> {
return invoke('ssh_session_list');
}
export async function saveSshSession(session: SshSession): Promise<void> {
return invoke('ssh_session_save', { session });
}
export async function deleteSshSession(id: string): Promise<void> {
return invoke('ssh_session_delete', { id });
}

View file

@ -1,29 +0,0 @@
// Telemetry bridge — routes frontend events to Rust tracing via IPC
// No browser OTEL SDK needed (WebKit2GTK incompatible)
import { invoke } from '@tauri-apps/api/core';
type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace';
/** Emit a structured log event to the Rust tracing layer */
export function telemetryLog(
level: LogLevel,
message: string,
context?: Record<string, unknown>,
): void {
invoke('frontend_log', { level, message, context: context ?? null }).catch((_e: unknown) => {
// Intentional: telemetry must never break the app or trigger notification loops.
// Cannot use handleInfraError here — it calls tel.error which would recurse.
// eslint-disable-next-line no-console
console.warn('[telemetry-bridge] IPC failed:', _e);
});
}
/** Convenience wrappers */
export const tel = {
error: (msg: string, ctx?: Record<string, unknown>) => telemetryLog('error', msg, ctx),
warn: (msg: string, ctx?: Record<string, unknown>) => telemetryLog('warn', msg, ctx),
info: (msg: string, ctx?: Record<string, unknown>) => telemetryLog('info', msg, ctx),
debug: (msg: string, ctx?: Record<string, unknown>) => telemetryLog('debug', msg, ctx),
trace: (msg: string, ctx?: Record<string, unknown>) => telemetryLog('trace', msg, ctx),
};

View file

@ -41,16 +41,23 @@ const {
mockAddNotification: vi.fn(), mockAddNotification: vi.fn(),
})); }));
vi.mock('./adapters/agent-bridge', () => ({ vi.mock('./backend/backend', () => ({
onSidecarMessage: vi.fn(async (cb: (msg: any) => void) => { getBackend: vi.fn(() => ({
capturedCallbacks.msg = cb; onSidecarMessage: vi.fn((cb: (msg: any) => void) => {
return mockUnlistenMsg; capturedCallbacks.msg = cb;
}), return mockUnlistenMsg;
onSidecarExited: vi.fn(async (cb: () => void) => { }),
capturedCallbacks.exit = cb; onSidecarExited: vi.fn((cb: () => void) => {
return mockUnlistenExit; capturedCallbacks.exit = cb;
}), return mockUnlistenExit;
restartAgent: (...args: unknown[]) => mockRestartAgent(...args), }),
restartAgentSidecar: (...args: unknown[]) => mockRestartAgent(...args),
btmsgSetStatus: vi.fn().mockResolvedValue(undefined),
btmsgRecordHeartbeat: vi.fn().mockResolvedValue(undefined),
logAuditEvent: vi.fn().mockResolvedValue(undefined),
searchIndexMessage: vi.fn().mockResolvedValue(undefined),
telemetryLog: vi.fn(),
})),
})); }));
vi.mock('./providers/types', () => ({})); vi.mock('./providers/types', () => ({}));
@ -235,8 +242,9 @@ describe('agent-dispatcher', () => {
await startAgentDispatcher(); await startAgentDispatcher();
await startAgentDispatcher(); // second call should be no-op await startAgentDispatcher(); // second call should be no-op
const { onSidecarMessage } = await import('./adapters/agent-bridge'); // On second call, capturedCallbacks.msg should still be the same (not reset)
expect(onSidecarMessage).toHaveBeenCalledTimes(1); // The dispatcher guards against duplicate registration via `if (unlistenMsg) return;`
expect(capturedCallbacks.msg).not.toBeNull();
}); });
it('sets sidecarAlive to true on start', async () => { it('sets sidecarAlive to true on start', async () => {
@ -408,8 +416,8 @@ describe('agent-dispatcher', () => {
stopAgentDispatcher(); stopAgentDispatcher();
await startAgentDispatcher(); await startAgentDispatcher();
const { onSidecarMessage } = await import('./adapters/agent-bridge'); // After stop + restart, callbacks should be re-registered
expect(onSidecarMessage).toHaveBeenCalledTimes(2); expect(capturedCallbacks.msg).not.toBeNull();
}); });
}); });

View file

@ -2,7 +2,8 @@
// Thin coordinator that routes sidecar messages to specialized modules // Thin coordinator that routes sidecar messages to specialized modules
import { SessionId, type SessionId as SessionIdType } from './types/ids'; import { SessionId, type SessionId as SessionIdType } from './types/ids';
import { onSidecarMessage, onSidecarExited, restartAgent, type SidecarMessage } from './adapters/agent-bridge'; import { getBackend } from './backend/backend';
import type { SidecarMessagePayload } from '@agor/types';
import { adaptMessage } from './adapters/message-adapters'; import { adaptMessage } from './adapters/message-adapters';
import type { InitContent, CostContent, ToolCallContent } from './adapters/claude-messages'; import type { InitContent, CostContent, ToolCallContent } from './adapters/claude-messages';
import { import {
@ -16,7 +17,7 @@ import {
} from './stores/agents.svelte'; } from './stores/agents.svelte';
import { notify, addNotification } from './stores/notifications.svelte'; import { notify, addNotification } from './stores/notifications.svelte';
import { classifyError } from './utils/error-classifier'; import { classifyError } from './utils/error-classifier';
import { tel } from './adapters/telemetry-bridge'; import { tel } from './utils/telemetry';
import { handleInfraError } from './utils/handle-error'; import { handleInfraError } from './utils/handle-error';
import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte'; import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte';
import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte'; import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte';
@ -37,16 +38,13 @@ import {
spawnSubagentPane, spawnSubagentPane,
clearSubagentRoutes, clearSubagentRoutes,
} from './utils/subagent-router'; } from './utils/subagent-router';
import { indexMessage } from './adapters/search-bridge';
import { recordHeartbeat, setAgentStatus as setBtmsgAgentStatus } from './adapters/btmsg-bridge';
import { logAuditEvent } from './adapters/audit-bridge';
import type { AgentId } from './types/ids'; import type { AgentId } from './types/ids';
/** Sync btmsg agent status to 'stopped' when a session reaches terminal state */ /** Sync btmsg agent status to 'stopped' when a session reaches terminal state */
function syncBtmsgStopped(sessionId: SessionIdType): void { function syncBtmsgStopped(sessionId: SessionIdType): void {
const projectId = getSessionProjectId(sessionId); const projectId = getSessionProjectId(sessionId);
if (projectId) { if (projectId) {
setBtmsgAgentStatus(projectId as unknown as AgentId, 'stopped').catch(e => handleInfraError(e, 'dispatcher.syncBtmsgStopped')); getBackend().btmsgSetStatus(projectId as unknown as AgentId, 'stopped').catch(e => handleInfraError(e, 'dispatcher.syncBtmsgStopped'));
} }
} }
@ -75,7 +73,7 @@ export async function startAgentDispatcher(): Promise<void> {
sidecarAlive = true; sidecarAlive = true;
unlistenMsg = await onSidecarMessage((msg: SidecarMessage) => { unlistenMsg = getBackend().onSidecarMessage((msg: SidecarMessagePayload) => {
sidecarAlive = true; sidecarAlive = true;
// Reset restart counter on any successful message — sidecar recovered // Reset restart counter on any successful message — sidecar recovered
if (restartAttempts > 0) { if (restartAttempts > 0) {
@ -89,7 +87,7 @@ export async function startAgentDispatcher(): Promise<void> {
// Record heartbeat on any agent activity (best-effort, fire-and-forget) // Record heartbeat on any agent activity (best-effort, fire-and-forget)
const hbProjectId = getSessionProjectId(sessionId); const hbProjectId = getSessionProjectId(sessionId);
if (hbProjectId) { if (hbProjectId) {
recordHeartbeat(hbProjectId as unknown as AgentId).catch(e => handleInfraError(e, 'dispatcher.heartbeat')); getBackend().btmsgRecordHeartbeat(hbProjectId as unknown as AgentId).catch(e => handleInfraError(e, 'dispatcher.heartbeat'));
} }
switch (msg.type) { switch (msg.type) {
@ -98,7 +96,7 @@ export async function startAgentDispatcher(): Promise<void> {
recordSessionStart(sessionId); recordSessionStart(sessionId);
tel.info('agent_started', { sessionId }); tel.info('agent_started', { sessionId });
if (hbProjectId) { if (hbProjectId) {
logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent started (session ${sessionId.slice(0, 8)})`).catch(e => handleInfraError(e, 'dispatcher.auditLog')); getBackend().logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent started (session ${sessionId.slice(0, 8)})`).catch(e => handleInfraError(e, 'dispatcher.auditLog'));
} }
break; break;
@ -113,7 +111,7 @@ export async function startAgentDispatcher(): Promise<void> {
notify('success', `Agent ${sessionId.slice(0, 8)} completed`); notify('success', `Agent ${sessionId.slice(0, 8)} completed`);
addNotification('Agent complete', `Session ${sessionId.slice(0, 8)} finished`, 'agent_complete', getSessionProjectId(sessionId) ?? undefined); addNotification('Agent complete', `Session ${sessionId.slice(0, 8)} finished`, 'agent_complete', getSessionProjectId(sessionId) ?? undefined);
if (hbProjectId) { if (hbProjectId) {
logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent completed (session ${sessionId.slice(0, 8)})`).catch(e => handleInfraError(e, 'dispatcher.auditLog')); getBackend().logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent completed (session ${sessionId.slice(0, 8)})`).catch(e => handleInfraError(e, 'dispatcher.auditLog'));
} }
break; break;
@ -141,7 +139,7 @@ export async function startAgentDispatcher(): Promise<void> {
addNotification('Agent error', classified.message, 'agent_error', getSessionProjectId(sessionId) ?? undefined); addNotification('Agent error', classified.message, 'agent_error', getSessionProjectId(sessionId) ?? undefined);
if (hbProjectId) { if (hbProjectId) {
logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent error (${classified.type}): ${errorMsg} (session ${sessionId.slice(0, 8)})`).catch(e => handleInfraError(e, 'dispatcher.auditLog')); getBackend().logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent error (${classified.type}): ${errorMsg} (session ${sessionId.slice(0, 8)})`).catch(e => handleInfraError(e, 'dispatcher.auditLog'));
} }
break; break;
} }
@ -151,7 +149,7 @@ export async function startAgentDispatcher(): Promise<void> {
} }
}); });
unlistenExit = await onSidecarExited(async () => { unlistenExit = getBackend().onSidecarExited(async () => {
sidecarAlive = false; sidecarAlive = false;
tel.error('sidecar_crashed', { restartAttempts }); tel.error('sidecar_crashed', { restartAttempts });
@ -176,7 +174,7 @@ export async function startAgentDispatcher(): Promise<void> {
addNotification('Sidecar crashed', `Restarting (attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS})`, 'system'); addNotification('Sidecar crashed', `Restarting (attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS})`, 'system');
await new Promise((resolve) => setTimeout(resolve, delayMs)); await new Promise((resolve) => setTimeout(resolve, delayMs));
try { try {
await restartAgent(); await getBackend().restartAgentSidecar();
sidecarAlive = true; sidecarAlive = true;
// Note: restartAttempts is reset when next sidecar message arrives // Note: restartAttempts is reset when next sidecar message arrives
} catch { } catch {
@ -334,7 +332,7 @@ function handleAgentEvent(sessionId: SessionIdType, event: Record<string, unknow
// Index searchable text content into FTS5 search database // Index searchable text content into FTS5 search database
for (const msg of mainMessages) { for (const msg of mainMessages) {
if (msg.type === 'text' && typeof msg.content === 'string' && msg.content.trim()) { if (msg.type === 'text' && typeof msg.content === 'string' && msg.content.trim()) {
indexMessage(sessionId, 'assistant', msg.content).catch(e => handleInfraError(e, 'dispatcher.indexMessage')); getBackend().searchIndexMessage(sessionId, 'assistant', msg.content).catch(e => handleInfraError(e, 'dispatcher.indexMessage'));
} }
} }
} }

View file

@ -2,32 +2,40 @@
// Wraps the Electrobun RPC pattern (request/response + message listeners) // Wraps the Electrobun RPC pattern (request/response + message listeners)
import type { import type {
BackendAdapter, BackendAdapter, BackendCapabilities, UnsubscribeFn,
BackendCapabilities, AgentStartOptions, AgentMessage, AgentStatus,
UnsubscribeFn, FileEntry, FileContent, PtyCreateOptions,
AgentStartOptions, SettingsMap, GroupsFile, GroupConfig, GroupId, AgentId, SessionId, ProjectId,
AgentMessage, SearchResult,
AgentStatus, PersistedSession, PersistedLayout,
FileEntry, AgentMessageRecord, ProjectAgentState, SessionMetricRecord, MdFileEntry,
FileContent, BtmsgAgent, BtmsgMessage, BtmsgChannel, BtmsgChannelMessage, BtmsgFeedMessage,
PtyCreateOptions, DeadLetterEntry, AuditEntry, AuditEventType,
SettingsMap, Task, TaskComment,
GroupsFile, SessionAnchorRecord,
GroupConfig, NotificationUrgency, TelemetryLevel,
GroupId, FsWriteEvent, FsWatcherStatus,
CtxEntry, CtxSummary,
MemoraNode, MemoraSearchResult,
SshSessionRecord, PluginMeta,
ClaudeProfile, ClaudeSkill,
RemoteMachineConfig, RemoteMachineInfo,
RemoteSidecarMessage, RemotePtyData, RemotePtyExit,
RemoteMachineEvent, RemoteReconnectingEvent, RemoteSpkiTofuEvent,
FileChangedPayload, AgentQueryOptions, SidecarMessagePayload, PtySpawnOptions,
} from '@agor/types'; } from '@agor/types';
// ── Capabilities ───────────────────────────────────────────────────────────── // ── Capabilities ─────────────────────────────────────────────────────────────
const ELECTROBUN_CAPABILITIES: BackendCapabilities = { const ELECTROBUN_CAPABILITIES: BackendCapabilities = {
supportsPtyMultiplexing: true, // via agor-ptyd daemon supportsPtyMultiplexing: true,
supportsPluginSandbox: true, // Web Worker sandbox works in any webview supportsPluginSandbox: true,
supportsNativeMenus: false, // Electrobun has limited menu support supportsNativeMenus: false,
supportsOsKeychain: false, // No keyring crate — uses file-based secrets supportsOsKeychain: false,
supportsFileDialogs: false, // No native dialog plugin supportsFileDialogs: false,
supportsAutoUpdater: false, // Custom updater via GitHub Releases check supportsAutoUpdater: false,
supportsDesktopNotifications: false, // No notify-rust — uses in-app toasts only supportsDesktopNotifications: false,
supportsTelemetry: false, // No OTLP export in Electrobun backend supportsTelemetry: false,
}; };
// ── RPC handle type (matches ui-electrobun/src/mainview/rpc.ts) ────────────── // ── RPC handle type (matches ui-electrobun/src/mainview/rpc.ts) ──────────────
@ -67,7 +75,6 @@ export class ElectrobunAdapter implements BackendAdapter {
} }
async destroy(): Promise<void> { async destroy(): Promise<void> {
// Remove all registered message listeners
if (this.rpc?.removeMessageListener) { if (this.rpc?.removeMessageListener) {
for (const { event, handler } of this.messageHandlers) { for (const { event, handler } of this.messageHandlers) {
this.rpc.removeMessageListener(event, handler); this.rpc.removeMessageListener(event, handler);
@ -96,12 +103,10 @@ export class ElectrobunAdapter implements BackendAdapter {
// ── Groups ─────────────────────────────────────────────────────────────── // ── Groups ───────────────────────────────────────────────────────────────
async loadGroups(): Promise<GroupsFile> { async loadGroups(): Promise<GroupsFile> {
// Electrobun stores groups differently — reconstruct GroupsFile from flat list
const res = await this.r.request['groups.list']({}) as { const res = await this.r.request['groups.list']({}) as {
groups: Array<{ id: string; name: string; icon: string; position: number }>; groups: Array<{ id: string; name: string; icon: string; position: number }>;
}; };
// Load projects per group from settings
const projectsRes = await this.r.request['settings.getProjects']({}) as { const projectsRes = await this.r.request['settings.getProjects']({}) as {
projects: Array<{ id: string; config: string }>; projects: Array<{ id: string; config: string }>;
}; };
@ -130,42 +135,29 @@ export class ElectrobunAdapter implements BackendAdapter {
} }
async saveGroups(groupsFile: GroupsFile): Promise<void> { async saveGroups(groupsFile: GroupsFile): Promise<void> {
// Save groups list
for (const group of groupsFile.groups) { for (const group of groupsFile.groups) {
await this.r.request['groups.create']({ await this.r.request['groups.create']({
id: group.id, id: group.id, name: group.name, icon: '', position: groupsFile.groups.indexOf(group),
name: group.name,
icon: '',
position: groupsFile.groups.indexOf(group),
}); });
} }
// Save projects per group
for (const group of groupsFile.groups) { for (const group of groupsFile.groups) {
for (const project of group.projects) { for (const project of group.projects) {
await this.r.request['settings.setProject']({ await this.r.request['settings.setProject']({
id: project.id, id: project.id, config: JSON.stringify({ ...project, groupId: group.id }),
config: JSON.stringify({ ...project, groupId: group.id }),
}); });
} }
} }
} }
// ── Agent ──────────────────────────────────────────────────────────────── // ── Agent (simplified) ────────────────────────────────────────────────────
async startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }> { async startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }> {
const res = await this.r.request['agent.start']({ const res = await this.r.request['agent.start']({
sessionId: options.sessionId, sessionId: options.sessionId, provider: options.provider ?? 'claude',
provider: options.provider ?? 'claude', prompt: options.prompt, cwd: options.cwd, model: options.model,
prompt: options.prompt, systemPrompt: options.systemPrompt, maxTurns: options.maxTurns,
cwd: options.cwd, permissionMode: options.permissionMode, claudeConfigDir: options.claudeConfigDir,
model: options.model, extraEnv: options.extraEnv, additionalDirectories: options.additionalDirectories,
systemPrompt: options.systemPrompt,
maxTurns: options.maxTurns,
permissionMode: options.permissionMode,
claudeConfigDir: options.claudeConfigDir,
extraEnv: options.extraEnv,
additionalDirectories: options.additionalDirectories,
worktreeName: options.worktreeName, worktreeName: options.worktreeName,
}) as { ok: boolean; error?: string }; }) as { ok: boolean; error?: string };
return res; return res;
@ -181,16 +173,12 @@ export class ElectrobunAdapter implements BackendAdapter {
return res; return res;
} }
// ── PTY ────────────────────────────────────────────────────────────────── // ── PTY (simplified) ──────────────────────────────────────────────────────
async createPty(options: PtyCreateOptions): Promise<string> { async createPty(options: PtyCreateOptions): Promise<string> {
const res = await this.r.request['pty.create']({ const res = await this.r.request['pty.create']({
sessionId: options.sessionId, sessionId: options.sessionId, cols: options.cols, rows: options.rows,
cols: options.cols, cwd: options.cwd, shell: options.shell, args: options.args,
rows: options.rows,
cwd: options.cwd,
shell: options.shell,
args: options.args,
}) as { ok: boolean; error?: string }; }) as { ok: boolean; error?: string };
if (!res.ok) throw new Error(res.error ?? 'PTY creation failed'); if (!res.ok) throw new Error(res.error ?? 'PTY creation failed');
return options.sessionId; return options.sessionId;
@ -212,29 +200,20 @@ export class ElectrobunAdapter implements BackendAdapter {
async listDirectory(path: string): Promise<FileEntry[]> { async listDirectory(path: string): Promise<FileEntry[]> {
const res = await this.r.request['files.list']({ path }) as { const res = await this.r.request['files.list']({ path }) as {
entries: Array<{ name: string; type: string; size: number }>; entries: Array<{ name: string; type: string; size: number }>; error?: string;
error?: string;
}; };
if (res.error) throw new Error(res.error); if (res.error) throw new Error(res.error);
return res.entries.map((e) => ({ return res.entries.map((e) => ({
name: e.name, name: e.name, path: `${path}/${e.name}`, isDir: e.type === 'dir', size: e.size,
path: `${path}/${e.name}`,
isDir: e.type === 'dir',
size: e.size,
})); }));
} }
async readFile(path: string): Promise<FileContent> { async readFile(path: string): Promise<FileContent> {
const res = await this.r.request['files.read']({ path }) as { const res = await this.r.request['files.read']({ path }) as {
content?: string; content?: string; encoding: string; size: number; error?: string;
encoding: string;
size: number;
error?: string;
}; };
if (res.error) throw new Error(res.error); if (res.error) throw new Error(res.error);
if (res.encoding === 'base64') { if (res.encoding === 'base64') return { type: 'Binary', message: `Binary file (${res.size} bytes)` };
return { type: 'Binary', message: `Binary file (${res.size} bytes)` };
}
return { type: 'Text', content: res.content ?? '' }; return { type: 'Text', content: res.content ?? '' };
} }
@ -243,6 +222,307 @@ export class ElectrobunAdapter implements BackendAdapter {
if (!res.ok) throw new Error(res.error ?? 'Write failed'); if (!res.ok) throw new Error(res.error ?? 'Write failed');
} }
// ── Session persistence ─────────────────────────────────────────────────
async listSessions(): Promise<PersistedSession[]> {
// TODO: wire to Electrobun RPC
return [];
}
async saveSession(_session: PersistedSession): Promise<void> {
// TODO: wire to Electrobun RPC
}
async deleteSession(_id: string): Promise<void> {
// TODO: wire to Electrobun RPC
}
async updateSessionTitle(_id: string, _title: string): Promise<void> {
// TODO: wire to Electrobun RPC
}
async touchSession(_id: string): Promise<void> {
// TODO: wire to Electrobun RPC
}
async updateSessionGroup(_id: string, _groupName: string): Promise<void> {
// TODO: wire to Electrobun RPC
}
async saveLayout(_layout: PersistedLayout): Promise<void> {
// TODO: wire to Electrobun RPC
}
async loadLayout(): Promise<PersistedLayout> {
// TODO: wire to Electrobun RPC
return { preset: '1-col', pane_ids: [] };
}
// ── Agent persistence ───────────────────────────────────────────────────
async saveAgentMessages(
_sessionId: SessionId, _projectId: ProjectId, _sdkSessionId: string | undefined,
_messages: AgentMessageRecord[],
): Promise<void> {
// TODO: wire to Electrobun RPC
}
async loadAgentMessages(_projectId: ProjectId): Promise<AgentMessageRecord[]> {
// TODO: wire to Electrobun RPC
return [];
}
async saveProjectAgentState(_state: ProjectAgentState): Promise<void> {
// TODO: wire to Electrobun RPC
}
async loadProjectAgentState(_projectId: ProjectId): Promise<ProjectAgentState | null> {
// TODO: wire to Electrobun RPC
return null;
}
async saveSessionMetric(_metric: Omit<SessionMetricRecord, 'id'>): Promise<void> {
// TODO: wire to Electrobun RPC
}
async loadSessionMetrics(_projectId: ProjectId, _limit?: number): Promise<SessionMetricRecord[]> {
// TODO: wire to Electrobun RPC
return [];
}
async getCliGroup(): Promise<string | null> {
// TODO: wire to Electrobun RPC
return null;
}
async discoverMarkdownFiles(_cwd: string): Promise<MdFileEntry[]> {
// TODO: wire to Electrobun RPC
return [];
}
// ── Btmsg ───────────────────────────────────────────────────────────────
async btmsgGetAgents(_groupId: GroupId): Promise<BtmsgAgent[]> {
// TODO: wire to Electrobun RPC
return [];
}
async btmsgUnreadCount(_agentId: AgentId): Promise<number> { return 0; }
async btmsgUnreadMessages(_agentId: AgentId): Promise<BtmsgMessage[]> { return []; }
async btmsgHistory(_agentId: AgentId, _otherId: AgentId, _limit?: number): Promise<BtmsgMessage[]> { return []; }
async btmsgSend(_fromAgent: AgentId, _toAgent: AgentId, _content: string): Promise<string> { return ''; }
async btmsgSetStatus(_agentId: AgentId, _status: string): Promise<void> { }
async btmsgEnsureAdmin(_groupId: GroupId): Promise<void> { }
async btmsgAllFeed(_groupId: GroupId, _limit?: number): Promise<BtmsgFeedMessage[]> { return []; }
async btmsgMarkRead(_readerId: AgentId, _senderId: AgentId): Promise<void> { }
async btmsgGetChannels(_groupId: GroupId): Promise<BtmsgChannel[]> { return []; }
async btmsgChannelMessages(_channelId: string, _limit?: number): Promise<BtmsgChannelMessage[]> { return []; }
async btmsgChannelSend(_channelId: string, _fromAgent: AgentId, _content: string): Promise<string> { return ''; }
async btmsgCreateChannel(_name: string, _groupId: GroupId, _createdBy: AgentId): Promise<string> { return ''; }
async btmsgAddChannelMember(_channelId: string, _agentId: AgentId): Promise<void> { }
async btmsgRegisterAgents(_config: GroupsFile): Promise<void> { }
async btmsgUnseenMessages(_agentId: AgentId, _sessionId: string): Promise<BtmsgMessage[]> { return []; }
async btmsgMarkSeen(_sessionId: string, _messageIds: string[]): Promise<void> { }
async btmsgPruneSeen(): Promise<number> { return 0; }
async btmsgRecordHeartbeat(_agentId: AgentId): Promise<void> { }
async btmsgGetStaleAgents(_groupId: GroupId, _thresholdSecs?: number): Promise<string[]> { return []; }
async btmsgGetDeadLetters(_groupId: GroupId, _limit?: number): Promise<DeadLetterEntry[]> { return []; }
async btmsgClearDeadLetters(_groupId: GroupId): Promise<void> { }
async btmsgClearAllComms(_groupId: GroupId): Promise<void> { }
// ── Bttask ──────────────────────────────────────────────────────────────
async bttaskList(_groupId: GroupId): Promise<Task[]> { return []; }
async bttaskComments(_taskId: string): Promise<TaskComment[]> { return []; }
async bttaskUpdateStatus(_taskId: string, _status: string, _version: number): Promise<number> { return 0; }
async bttaskAddComment(_taskId: string, _agentId: AgentId, _content: string): Promise<string> { return ''; }
async bttaskCreate(
_title: string, _description: string, _priority: string,
_groupId: GroupId, _createdBy: AgentId, _assignedTo?: AgentId,
): Promise<string> { return ''; }
async bttaskDelete(_taskId: string): Promise<void> { }
async bttaskReviewQueueCount(_groupId: GroupId): Promise<number> { return 0; }
// ── Anchors ─────────────────────────────────────────────────────────────
async saveSessionAnchors(_anchors: SessionAnchorRecord[]): Promise<void> { }
async loadSessionAnchors(_projectId: string): Promise<SessionAnchorRecord[]> { return []; }
async deleteSessionAnchor(_id: string): Promise<void> { }
async clearProjectAnchors(_projectId: string): Promise<void> { }
async updateAnchorType(_id: string, _anchorType: string): Promise<void> { }
// ── Search ──────────────────────────────────────────────────────────────
async searchInit(): Promise<void> { }
async searchAll(_query: string, _limit?: number): Promise<SearchResult[]> { return []; }
async searchRebuild(): Promise<void> { }
async searchIndexMessage(_sessionId: string, _role: string, _content: string): Promise<void> { }
// ── Audit ───────────────────────────────────────────────────────────────
async logAuditEvent(_agentId: AgentId, _eventType: AuditEventType, _detail: string): Promise<void> { }
async getAuditLog(_groupId: GroupId, _limit?: number, _offset?: number): Promise<AuditEntry[]> { return []; }
async getAuditLogForAgent(_agentId: AgentId, _limit?: number): Promise<AuditEntry[]> { return []; }
// ── Notifications ───────────────────────────────────────────────────────
sendDesktopNotification(_title: string, _body: string, _urgency?: NotificationUrgency): void {
// Electrobun: in-app toasts only, no OS notifications
}
// ── Telemetry ───────────────────────────────────────────────────────────
telemetryLog(_level: TelemetryLevel, _message: string, _context?: Record<string, unknown>): void {
// Electrobun: no OTLP, console-only
}
// ── Secrets ─────────────────────────────────────────────────────────────
async storeSecret(_key: string, _value: string): Promise<void> { }
async getSecret(_key: string): Promise<string | null> { return null; }
async deleteSecret(_key: string): Promise<void> { }
async listSecrets(): Promise<string[]> { return []; }
async hasKeyring(): Promise<boolean> { return false; }
async knownSecretKeys(): Promise<string[]> { return []; }
// ── Filesystem watcher ──────────────────────────────────────────────────
async fsWatchProject(_projectId: string, _cwd: string): Promise<void> { }
async fsUnwatchProject(_projectId: string): Promise<void> { }
onFsWriteDetected(_callback: (event: FsWriteEvent) => void): UnsubscribeFn { return () => {}; }
async fsWatcherStatus(): Promise<FsWatcherStatus> {
return { max_watches: 0, estimated_watches: 0, usage_ratio: 0, active_projects: 0, warning: null };
}
// ── Ctx ─────────────────────────────────────────────────────────────────
async ctxInitDb(): Promise<void> { }
async ctxRegisterProject(_name: string, _description: string, _workDir?: string): Promise<void> { }
async ctxGetContext(_project: string): Promise<CtxEntry[]> { return []; }
async ctxGetShared(): Promise<CtxEntry[]> { return []; }
async ctxGetSummaries(_project: string, _limit?: number): Promise<CtxSummary[]> { return []; }
async ctxSearch(_query: string): Promise<CtxEntry[]> { return []; }
// ── Memora ──────────────────────────────────────────────────────────────
async memoraAvailable(): Promise<boolean> { return false; }
async memoraList(_options?: { tags?: string[]; limit?: number; offset?: number }): Promise<MemoraSearchResult> {
return { nodes: [], total: 0 };
}
async memoraSearch(_query: string, _options?: { tags?: string[]; limit?: number }): Promise<MemoraSearchResult> {
return { nodes: [], total: 0 };
}
async memoraGet(_id: number): Promise<MemoraNode | null> { return null; }
// ── SSH ─────────────────────────────────────────────────────────────────
async listSshSessions(): Promise<SshSessionRecord[]> { return []; }
async saveSshSession(_session: SshSessionRecord): Promise<void> { }
async deleteSshSession(_id: string): Promise<void> { }
// ── Plugins ─────────────────────────────────────────────────────────────
async discoverPlugins(): Promise<PluginMeta[]> { return []; }
async readPluginFile(_pluginId: string, _filename: string): Promise<string> { return ''; }
// ── Claude provider ─────────────────────────────────────────────────────
async listProfiles(): Promise<ClaudeProfile[]> { return []; }
async listSkills(): Promise<ClaudeSkill[]> { return []; }
async readSkill(_path: string): Promise<string> { return ''; }
// ── Remote machines ─────────────────────────────────────────────────────
async listRemoteMachines(): Promise<RemoteMachineInfo[]> { return []; }
async addRemoteMachine(_config: RemoteMachineConfig): Promise<string> { return ''; }
async removeRemoteMachine(_machineId: string): Promise<void> { }
async connectRemoteMachine(_machineId: string): Promise<void> { }
async disconnectRemoteMachine(_machineId: string): Promise<void> { }
async probeSpki(_url: string): Promise<string> { return ''; }
async addSpkiPin(_machineId: string, _pin: string): Promise<void> { }
async removeSpkiPin(_machineId: string, _pin: string): Promise<void> { }
onRemoteSidecarMessage(_callback: (msg: RemoteSidecarMessage) => void): UnsubscribeFn { return () => {}; }
onRemotePtyData(_callback: (msg: RemotePtyData) => void): UnsubscribeFn { return () => {}; }
onRemotePtyExit(_callback: (msg: RemotePtyExit) => void): UnsubscribeFn { return () => {}; }
onRemoteMachineReady(_callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn { return () => {}; }
onRemoteMachineDisconnected(_callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn { return () => {}; }
onRemoteStateSync(_callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn { return () => {}; }
onRemoteError(_callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn { return () => {}; }
onRemoteMachineReconnecting(_callback: (msg: RemoteReconnectingEvent) => void): UnsubscribeFn { return () => {}; }
onRemoteMachineReconnectReady(_callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn { return () => {}; }
onRemoteSpkiTofu(_callback: (msg: RemoteSpkiTofuEvent) => void): UnsubscribeFn { return () => {}; }
// ── File watcher ────────────────────────────────────────────────────────
async watchFile(_paneId: string, _path: string): Promise<string> { return ''; }
async unwatchFile(_paneId: string): Promise<void> { }
async readWatchedFile(_path: string): Promise<string> { return ''; }
onFileChanged(_callback: (payload: FileChangedPayload) => void): UnsubscribeFn { return () => {}; }
// ── Agent bridge (direct IPC) ───────────────────────────────────────────
async queryAgent(options: AgentQueryOptions): Promise<void> {
await this.r.request['agent.start']({
sessionId: options.session_id, provider: options.provider ?? 'claude',
prompt: options.prompt, cwd: options.cwd, model: options.model,
systemPrompt: options.system_prompt, maxTurns: options.max_turns,
permissionMode: options.permission_mode, extraEnv: options.extra_env,
resumeSessionId: options.resume_session_id,
});
}
async stopAgentDirect(sessionId: string, _remoteMachineId?: string): Promise<void> {
await this.r.request['agent.stop']({ sessionId });
}
async isAgentReady(): Promise<boolean> { return true; }
async restartAgentSidecar(): Promise<void> { }
async setSandbox(_projectCwds: string[], _worktreeRoots: string[], _enabled: boolean): Promise<void> { }
onSidecarMessage(callback: (msg: SidecarMessagePayload) => void): UnsubscribeFn {
return this.listenMsg<SidecarMessagePayload>('agent.sidecar', callback);
}
onSidecarExited(callback: () => void): UnsubscribeFn {
return this.listenMsg('agent.sidecar.exited', () => callback());
}
// ── PTY bridge (per-session) ────────────────────────────────────────────
async spawnPty(options: PtySpawnOptions): Promise<string> {
const res = await this.r.request['pty.create']({
sessionId: `pty-${Date.now()}`, cols: options.cols ?? 80, rows: options.rows ?? 24,
cwd: options.cwd, shell: options.shell, args: options.args,
}) as { ok: boolean; sessionId?: string; error?: string };
if (!res.ok) throw new Error(res.error ?? 'PTY creation failed');
return res.sessionId ?? '';
}
async writePtyDirect(id: string, data: string, _remoteMachineId?: string): Promise<void> {
await this.r.request['pty.write']({ sessionId: id, data });
}
async resizePtyDirect(id: string, cols: number, rows: number, _remoteMachineId?: string): Promise<void> {
await this.r.request['pty.resize']({ sessionId: id, cols, rows });
}
async killPty(id: string, _remoteMachineId?: string): Promise<void> {
await this.r.request['pty.close']({ sessionId: id });
}
onPtyData(id: string, callback: (data: string) => void): UnsubscribeFn {
return this.listenMsg<{ sessionId: string; data: string }>('pty.output', (p) => {
if (p.sessionId === id) callback(p.data);
});
}
onPtyExit(id: string, callback: () => void): UnsubscribeFn {
return this.listenMsg<{ sessionId: string }>('pty.closed', (p) => {
if (p.sessionId === id) callback();
});
}
// ── Events ─────────────────────────────────────────────────────────────── // ── Events ───────────────────────────────────────────────────────────────
/** Subscribe to an RPC message event; returns unsubscribe function. */ /** Subscribe to an RPC message event; returns unsubscribe function. */
@ -297,7 +577,6 @@ function convertWireMessage(raw: {
id: string; type: string; parentId?: string; content: unknown; timestamp: number; id: string; type: string; parentId?: string; content: unknown; timestamp: number;
}): AgentMessage | null { }): AgentMessage | null {
const c = raw.content as Record<string, unknown> | undefined; const c = raw.content as Record<string, unknown> | undefined;
switch (raw.type) { switch (raw.type) {
case 'text': case 'text':
return { id: raw.id, role: 'assistant', content: String(c?.['text'] ?? ''), timestamp: raw.timestamp }; return { id: raw.id, role: 'assistant', content: String(c?.['text'] ?? ''), timestamp: raw.timestamp };
@ -307,28 +586,22 @@ function convertWireMessage(raw: {
const name = String(c?.['name'] ?? 'Tool'); const name = String(c?.['name'] ?? 'Tool');
const input = c?.['input'] as Record<string, unknown> | undefined; const input = c?.['input'] as Record<string, unknown> | undefined;
return { return {
id: raw.id, role: 'tool-call', id: raw.id, role: 'tool-call', content: formatToolContent(name, input),
content: formatToolContent(name, input), toolName: name, toolInput: input ? JSON.stringify(input, null, 2) : undefined, timestamp: raw.timestamp,
toolName: name,
toolInput: input ? JSON.stringify(input, null, 2) : undefined,
timestamp: raw.timestamp,
}; };
} }
case 'tool_result': { case 'tool_result': {
const output = c?.['output']; const output = c?.['output'];
return { return {
id: raw.id, role: 'tool-result', id: raw.id, role: 'tool-result',
content: typeof output === 'string' ? output : JSON.stringify(output, null, 2), content: typeof output === 'string' ? output : JSON.stringify(output, null, 2), timestamp: raw.timestamp,
timestamp: raw.timestamp,
}; };
} }
case 'init': case 'init':
return { id: raw.id, role: 'system', content: `Session initialized`, timestamp: raw.timestamp }; return { id: raw.id, role: 'system', content: `Session initialized`, timestamp: raw.timestamp };
case 'error': case 'error':
return { id: raw.id, role: 'system', content: `Error: ${String(c?.['message'] ?? 'Unknown')}`, timestamp: raw.timestamp }; return { id: raw.id, role: 'system', content: `Error: ${String(c?.['message'] ?? 'Unknown')}`, timestamp: raw.timestamp };
case 'cost': case 'cost': case 'status': case 'compaction':
case 'status':
case 'compaction':
return null; return null;
default: default:
return null; return null;

View file

@ -6,7 +6,24 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import type { import type {
BackendAdapter, BackendCapabilities, UnsubscribeFn, AgentStartOptions, BackendAdapter, BackendCapabilities, UnsubscribeFn, AgentStartOptions,
AgentMessage, AgentStatus, FileEntry, FileContent, PtyCreateOptions, AgentMessage, AgentStatus, FileEntry, FileContent, PtyCreateOptions,
SettingsMap, GroupsFile, SettingsMap, GroupsFile, SearchResult,
PersistedSession, PersistedLayout,
AgentMessageRecord, ProjectAgentState, SessionMetricRecord, MdFileEntry,
BtmsgAgent, BtmsgMessage, BtmsgChannel, BtmsgChannelMessage, BtmsgFeedMessage,
DeadLetterEntry, AuditEntry, AuditEventType,
Task, TaskComment,
SessionAnchorRecord,
NotificationUrgency, TelemetryLevel,
FsWriteEvent, FsWatcherStatus,
CtxEntry, CtxSummary,
MemoraNode, MemoraSearchResult,
SshSessionRecord, PluginMeta,
ClaudeProfile, ClaudeSkill,
RemoteMachineConfig, RemoteMachineInfo,
RemoteSidecarMessage, RemotePtyData, RemotePtyExit,
RemoteMachineEvent, RemoteReconnectingEvent, RemoteSpkiTofuEvent,
FileChangedPayload, AgentQueryOptions, SidecarMessagePayload, PtySpawnOptions,
SessionId, ProjectId, GroupId, AgentId,
} from '@agor/types'; } from '@agor/types';
const TAURI_CAPABILITIES: BackendCapabilities = { const TAURI_CAPABILITIES: BackendCapabilities = {
@ -20,13 +37,6 @@ const TAURI_CAPABILITIES: BackendCapabilities = {
supportsTelemetry: true, supportsTelemetry: true,
}; };
interface SidecarMessage {
type: string;
sessionId?: string;
event?: Record<string, unknown>;
message?: string;
}
interface TauriDirEntry { interface TauriDirEntry {
name: string; path: string; is_dir: boolean; size: number; ext: string; name: string; path: string; is_dir: boolean; size: number; ext: string;
} }
@ -69,7 +79,7 @@ export class TauriAdapter implements BackendAdapter {
return invoke('groups_save', { config: groups }); return invoke('groups_save', { config: groups });
} }
// ── Agent ──────────────────────────────────────────────────────────────── // ── Agent (simplified) ────────────────────────────────────────────────────
async startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }> { async startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }> {
const tauriOpts = { const tauriOpts = {
@ -118,7 +128,7 @@ export class TauriAdapter implements BackendAdapter {
} }
} }
// ── PTY ────────────────────────────────────────────────────────────────── // ── PTY (simplified) ──────────────────────────────────────────────────────
async createPty(options: PtyCreateOptions): Promise<string> { async createPty(options: PtyCreateOptions): Promise<string> {
return invoke<string>('pty_spawn', { return invoke<string>('pty_spawn', {
@ -155,7 +165,584 @@ export class TauriAdapter implements BackendAdapter {
return invoke('write_file_content', { path, content }); return invoke('write_file_content', { path, content });
} }
// ── Events ─────────────────────────────────────────────────────────────── // ── Session persistence ─────────────────────────────────────────────────
async listSessions(): Promise<PersistedSession[]> {
return invoke('session_list');
}
async saveSession(session: PersistedSession): Promise<void> {
return invoke('session_save', { session });
}
async deleteSession(id: string): Promise<void> {
return invoke('session_delete', { id });
}
async updateSessionTitle(id: string, title: string): Promise<void> {
return invoke('session_update_title', { id, title });
}
async touchSession(id: string): Promise<void> {
return invoke('session_touch', { id });
}
async updateSessionGroup(id: string, groupName: string): Promise<void> {
return invoke('session_update_group', { id, group_name: groupName });
}
async saveLayout(layout: PersistedLayout): Promise<void> {
return invoke('layout_save', { layout });
}
async loadLayout(): Promise<PersistedLayout> {
return invoke('layout_load');
}
// ── Agent persistence ───────────────────────────────────────────────────
async saveAgentMessages(
sessionId: SessionId, projectId: ProjectId, sdkSessionId: string | undefined,
messages: AgentMessageRecord[],
): Promise<void> {
return invoke('agent_messages_save', {
sessionId, projectId, sdkSessionId: sdkSessionId ?? null, messages,
});
}
async loadAgentMessages(projectId: ProjectId): Promise<AgentMessageRecord[]> {
return invoke('agent_messages_load', { projectId });
}
async saveProjectAgentState(state: ProjectAgentState): Promise<void> {
return invoke('project_agent_state_save', { state });
}
async loadProjectAgentState(projectId: ProjectId): Promise<ProjectAgentState | null> {
return invoke('project_agent_state_load', { projectId });
}
async saveSessionMetric(metric: Omit<SessionMetricRecord, 'id'>): Promise<void> {
return invoke('session_metric_save', { metric: { id: 0, ...metric } });
}
async loadSessionMetrics(projectId: ProjectId, limit = 20): Promise<SessionMetricRecord[]> {
return invoke('session_metrics_load', { projectId, limit });
}
async getCliGroup(): Promise<string | null> {
return invoke('cli_get_group');
}
async discoverMarkdownFiles(cwd: string): Promise<MdFileEntry[]> {
return invoke('discover_markdown_files', { cwd });
}
// ── Btmsg ───────────────────────────────────────────────────────────────
async btmsgGetAgents(groupId: GroupId): Promise<BtmsgAgent[]> {
return invoke('btmsg_get_agents', { groupId });
}
async btmsgUnreadCount(agentId: AgentId): Promise<number> {
return invoke('btmsg_unread_count', { agentId });
}
async btmsgUnreadMessages(agentId: AgentId): Promise<BtmsgMessage[]> {
return invoke('btmsg_unread_messages', { agentId });
}
async btmsgHistory(agentId: AgentId, otherId: AgentId, limit = 20): Promise<BtmsgMessage[]> {
return invoke('btmsg_history', { agentId, otherId, limit });
}
async btmsgSend(fromAgent: AgentId, toAgent: AgentId, content: string): Promise<string> {
return invoke('btmsg_send', { fromAgent, toAgent, content });
}
async btmsgSetStatus(agentId: AgentId, status: string): Promise<void> {
return invoke('btmsg_set_status', { agentId, status });
}
async btmsgEnsureAdmin(groupId: GroupId): Promise<void> {
return invoke('btmsg_ensure_admin', { groupId });
}
async btmsgAllFeed(groupId: GroupId, limit = 100): Promise<BtmsgFeedMessage[]> {
return invoke('btmsg_all_feed', { groupId, limit });
}
async btmsgMarkRead(readerId: AgentId, senderId: AgentId): Promise<void> {
return invoke('btmsg_mark_read', { readerId, senderId });
}
async btmsgGetChannels(groupId: GroupId): Promise<BtmsgChannel[]> {
return invoke('btmsg_get_channels', { groupId });
}
async btmsgChannelMessages(channelId: string, limit = 100): Promise<BtmsgChannelMessage[]> {
return invoke('btmsg_channel_messages', { channelId, limit });
}
async btmsgChannelSend(channelId: string, fromAgent: AgentId, content: string): Promise<string> {
return invoke('btmsg_channel_send', { channelId, fromAgent, content });
}
async btmsgCreateChannel(name: string, groupId: GroupId, createdBy: AgentId): Promise<string> {
return invoke('btmsg_create_channel', { name, groupId, createdBy });
}
async btmsgAddChannelMember(channelId: string, agentId: AgentId): Promise<void> {
return invoke('btmsg_add_channel_member', { channelId, agentId });
}
async btmsgRegisterAgents(config: GroupsFile): Promise<void> {
return invoke('btmsg_register_agents', { config });
}
async btmsgUnseenMessages(agentId: AgentId, sessionId: string): Promise<BtmsgMessage[]> {
return invoke('btmsg_unseen_messages', { agentId, sessionId });
}
async btmsgMarkSeen(sessionId: string, messageIds: string[]): Promise<void> {
return invoke('btmsg_mark_seen', { sessionId, messageIds });
}
async btmsgPruneSeen(): Promise<number> {
return invoke('btmsg_prune_seen');
}
async btmsgRecordHeartbeat(agentId: AgentId): Promise<void> {
return invoke('btmsg_record_heartbeat', { agentId });
}
async btmsgGetStaleAgents(groupId: GroupId, thresholdSecs = 300): Promise<string[]> {
return invoke('btmsg_get_stale_agents', { groupId, thresholdSecs });
}
async btmsgGetDeadLetters(groupId: GroupId, limit = 50): Promise<DeadLetterEntry[]> {
return invoke('btmsg_get_dead_letters', { groupId, limit });
}
async btmsgClearDeadLetters(groupId: GroupId): Promise<void> {
return invoke('btmsg_clear_dead_letters', { groupId });
}
async btmsgClearAllComms(groupId: GroupId): Promise<void> {
return invoke('btmsg_clear_all_comms', { groupId });
}
// ── Bttask ──────────────────────────────────────────────────────────────
async bttaskList(groupId: GroupId): Promise<Task[]> {
return invoke<Task[]>('bttask_list', { groupId });
}
async bttaskComments(taskId: string): Promise<TaskComment[]> {
return invoke<TaskComment[]>('bttask_comments', { taskId });
}
async bttaskUpdateStatus(taskId: string, status: string, version: number): Promise<number> {
return invoke<number>('bttask_update_status', { taskId, status, version });
}
async bttaskAddComment(taskId: string, agentId: AgentId, content: string): Promise<string> {
return invoke<string>('bttask_add_comment', { taskId, agentId, content });
}
async bttaskCreate(
title: string, description: string, priority: string,
groupId: GroupId, createdBy: AgentId, assignedTo?: AgentId,
): Promise<string> {
return invoke<string>('bttask_create', { title, description, priority, groupId, createdBy, assignedTo });
}
async bttaskDelete(taskId: string): Promise<void> {
return invoke('bttask_delete', { taskId });
}
async bttaskReviewQueueCount(groupId: GroupId): Promise<number> {
return invoke<number>('bttask_review_queue_count', { groupId });
}
// ── Anchors ─────────────────────────────────────────────────────────────
async saveSessionAnchors(anchors: SessionAnchorRecord[]): Promise<void> {
return invoke('session_anchors_save', { anchors });
}
async loadSessionAnchors(projectId: string): Promise<SessionAnchorRecord[]> {
return invoke('session_anchors_load', { projectId });
}
async deleteSessionAnchor(id: string): Promise<void> {
return invoke('session_anchor_delete', { id });
}
async clearProjectAnchors(projectId: string): Promise<void> {
return invoke('session_anchors_clear', { projectId });
}
async updateAnchorType(id: string, anchorType: string): Promise<void> {
return invoke('session_anchor_update_type', { id, anchorType });
}
// ── Search ──────────────────────────────────────────────────────────────
async searchInit(): Promise<void> {
return invoke('search_init');
}
async searchAll(query: string, limit = 20): Promise<SearchResult[]> {
return invoke<SearchResult[]>('search_query', { query, limit });
}
async searchRebuild(): Promise<void> {
return invoke('search_rebuild');
}
async searchIndexMessage(sessionId: string, role: string, content: string): Promise<void> {
return invoke('search_index_message', { sessionId, role, content });
}
// ── Audit ───────────────────────────────────────────────────────────────
async logAuditEvent(agentId: AgentId, eventType: AuditEventType, detail: string): Promise<void> {
return invoke('audit_log_event', { agentId, eventType, detail });
}
async getAuditLog(groupId: GroupId, limit = 200, offset = 0): Promise<AuditEntry[]> {
return invoke('audit_log_list', { groupId, limit, offset });
}
async getAuditLogForAgent(agentId: AgentId, limit = 50): Promise<AuditEntry[]> {
return invoke('audit_log_for_agent', { agentId, limit });
}
// ── Notifications ───────────────────────────────────────────────────────
sendDesktopNotification(title: string, body: string, urgency: NotificationUrgency = 'normal'): void {
invoke('notify_desktop', { title, body, urgency }).catch((_e: unknown) => {
// eslint-disable-next-line no-console
console.warn('[TauriAdapter] Desktop notification failed:', _e);
});
}
// ── Telemetry ───────────────────────────────────────────────────────────
telemetryLog(level: TelemetryLevel, message: string, context?: Record<string, unknown>): void {
invoke('frontend_log', { level, message, context: context ?? null }).catch((_e: unknown) => {
// eslint-disable-next-line no-console
console.warn('[TauriAdapter] Telemetry IPC failed:', _e);
});
}
// ── Secrets ─────────────────────────────────────────────────────────────
async storeSecret(key: string, value: string): Promise<void> {
return invoke('secrets_store', { key, value });
}
async getSecret(key: string): Promise<string | null> {
return invoke('secrets_get', { key });
}
async deleteSecret(key: string): Promise<void> {
return invoke('secrets_delete', { key });
}
async listSecrets(): Promise<string[]> {
return invoke('secrets_list');
}
async hasKeyring(): Promise<boolean> {
return invoke('secrets_has_keyring');
}
async knownSecretKeys(): Promise<string[]> {
return invoke('secrets_known_keys');
}
// ── Filesystem watcher ──────────────────────────────────────────────────
async fsWatchProject(projectId: string, cwd: string): Promise<void> {
return invoke('fs_watch_project', { projectId, cwd });
}
async fsUnwatchProject(projectId: string): Promise<void> {
return invoke('fs_unwatch_project', { projectId });
}
onFsWriteDetected(callback: (event: FsWriteEvent) => void): UnsubscribeFn {
return this.listenTauri<FsWriteEvent>('fs-write-detected', callback);
}
async fsWatcherStatus(): Promise<FsWatcherStatus> {
return invoke('fs_watcher_status');
}
// ── Ctx ─────────────────────────────────────────────────────────────────
async ctxInitDb(): Promise<void> {
return invoke('ctx_init_db');
}
async ctxRegisterProject(name: string, description: string, workDir?: string): Promise<void> {
return invoke('ctx_register_project', { name, description, workDir: workDir ?? null });
}
async ctxGetContext(project: string): Promise<CtxEntry[]> {
return invoke('ctx_get_context', { project });
}
async ctxGetShared(): Promise<CtxEntry[]> {
return invoke('ctx_get_shared');
}
async ctxGetSummaries(project: string, limit = 5): Promise<CtxSummary[]> {
return invoke('ctx_get_summaries', { project, limit });
}
async ctxSearch(query: string): Promise<CtxEntry[]> {
return invoke('ctx_search', { query });
}
// ── Memora ──────────────────────────────────────────────────────────────
async memoraAvailable(): Promise<boolean> {
return invoke<boolean>('memora_available');
}
async memoraList(options?: { tags?: string[]; limit?: number; offset?: number }): Promise<MemoraSearchResult> {
return invoke<MemoraSearchResult>('memora_list', {
tags: options?.tags ?? null,
limit: options?.limit ?? 50,
offset: options?.offset ?? 0,
});
}
async memoraSearch(query: string, options?: { tags?: string[]; limit?: number }): Promise<MemoraSearchResult> {
return invoke<MemoraSearchResult>('memora_search', {
query,
tags: options?.tags ?? null,
limit: options?.limit ?? 50,
});
}
async memoraGet(id: number): Promise<MemoraNode | null> {
return invoke<MemoraNode | null>('memora_get', { id });
}
// ── SSH ─────────────────────────────────────────────────────────────────
async listSshSessions(): Promise<SshSessionRecord[]> {
return invoke('ssh_session_list');
}
async saveSshSession(session: SshSessionRecord): Promise<void> {
return invoke('ssh_session_save', { session });
}
async deleteSshSession(id: string): Promise<void> {
return invoke('ssh_session_delete', { id });
}
// ── Plugins ─────────────────────────────────────────────────────────────
async discoverPlugins(): Promise<PluginMeta[]> {
return invoke<PluginMeta[]>('plugins_discover');
}
async readPluginFile(pluginId: string, filename: string): Promise<string> {
return invoke<string>('plugin_read_file', { pluginId, filename });
}
// ── Claude provider ─────────────────────────────────────────────────────
async listProfiles(): Promise<ClaudeProfile[]> {
return invoke<ClaudeProfile[]>('claude_list_profiles');
}
async listSkills(): Promise<ClaudeSkill[]> {
return invoke<ClaudeSkill[]>('claude_list_skills');
}
async readSkill(path: string): Promise<string> {
return invoke<string>('claude_read_skill', { path });
}
// ── Remote machines ─────────────────────────────────────────────────────
async listRemoteMachines(): Promise<RemoteMachineInfo[]> {
return invoke('remote_list');
}
async addRemoteMachine(config: RemoteMachineConfig): Promise<string> {
return invoke('remote_add', { config });
}
async removeRemoteMachine(machineId: string): Promise<void> {
return invoke('remote_remove', { machineId });
}
async connectRemoteMachine(machineId: string): Promise<void> {
return invoke('remote_connect', { machineId });
}
async disconnectRemoteMachine(machineId: string): Promise<void> {
return invoke('remote_disconnect', { machineId });
}
async probeSpki(url: string): Promise<string> {
return invoke('remote_probe_spki', { url });
}
async addSpkiPin(machineId: string, pin: string): Promise<void> {
return invoke('remote_add_pin', { machineId, pin });
}
async removeSpkiPin(machineId: string, pin: string): Promise<void> {
return invoke('remote_remove_pin', { machineId, pin });
}
onRemoteSidecarMessage(callback: (msg: RemoteSidecarMessage) => void): UnsubscribeFn {
return this.listenTauri<RemoteSidecarMessage>('remote-sidecar-message', callback);
}
onRemotePtyData(callback: (msg: RemotePtyData) => void): UnsubscribeFn {
return this.listenTauri<RemotePtyData>('remote-pty-data', callback);
}
onRemotePtyExit(callback: (msg: RemotePtyExit) => void): UnsubscribeFn {
return this.listenTauri<RemotePtyExit>('remote-pty-exit', callback);
}
onRemoteMachineReady(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn {
return this.listenTauri<RemoteMachineEvent>('remote-machine-ready', callback);
}
onRemoteMachineDisconnected(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn {
return this.listenTauri<RemoteMachineEvent>('remote-machine-disconnected', callback);
}
onRemoteStateSync(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn {
return this.listenTauri<RemoteMachineEvent>('remote-state-sync', callback);
}
onRemoteError(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn {
return this.listenTauri<RemoteMachineEvent>('remote-error', callback);
}
onRemoteMachineReconnecting(callback: (msg: RemoteReconnectingEvent) => void): UnsubscribeFn {
return this.listenTauri<RemoteReconnectingEvent>('remote-machine-reconnecting', callback);
}
onRemoteMachineReconnectReady(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn {
return this.listenTauri<RemoteMachineEvent>('remote-machine-reconnect-ready', callback);
}
onRemoteSpkiTofu(callback: (msg: RemoteSpkiTofuEvent) => void): UnsubscribeFn {
return this.listenTauri<RemoteSpkiTofuEvent>('remote-spki-tofu', callback);
}
// ── File watcher ────────────────────────────────────────────────────────
async watchFile(paneId: string, path: string): Promise<string> {
return invoke('file_watch', { paneId, path });
}
async unwatchFile(paneId: string): Promise<void> {
return invoke('file_unwatch', { paneId });
}
async readWatchedFile(path: string): Promise<string> {
return invoke('file_read', { path });
}
onFileChanged(callback: (payload: FileChangedPayload) => void): UnsubscribeFn {
return this.listenTauri<FileChangedPayload>('file-changed', callback);
}
// ── Agent bridge (direct IPC) ───────────────────────────────────────────
async queryAgent(options: AgentQueryOptions): Promise<void> {
if (options.remote_machine_id) {
const { remote_machine_id: machineId, ...agentOptions } = options;
return invoke('remote_agent_query', { machineId, options: agentOptions });
}
return invoke('agent_query', { options });
}
async stopAgentDirect(sessionId: string, remoteMachineId?: string): Promise<void> {
if (remoteMachineId) {
return invoke('remote_agent_stop', { machineId: remoteMachineId, sessionId });
}
return invoke('agent_stop', { sessionId });
}
async isAgentReady(): Promise<boolean> {
return invoke<boolean>('agent_ready');
}
async restartAgentSidecar(): Promise<void> {
return invoke('agent_restart');
}
async setSandbox(projectCwds: string[], worktreeRoots: string[], enabled: boolean): Promise<void> {
return invoke('agent_set_sandbox', { projectCwds, worktreeRoots, enabled });
}
onSidecarMessage(callback: (msg: SidecarMessagePayload) => void): UnsubscribeFn {
return this.listenTauri<SidecarMessagePayload>('sidecar-message', (payload) => {
if (typeof payload !== 'object' || payload === null) return;
callback(payload);
});
}
onSidecarExited(callback: () => void): UnsubscribeFn {
return this.listenTauri('sidecar-exited', () => callback());
}
// ── PTY bridge (per-session) ────────────────────────────────────────────
async spawnPty(options: PtySpawnOptions): Promise<string> {
if (options.remote_machine_id) {
const { remote_machine_id: machineId, ...ptyOptions } = options;
return invoke<string>('remote_pty_spawn', { machineId, options: ptyOptions });
}
return invoke<string>('pty_spawn', { options });
}
async writePtyDirect(id: string, data: string, remoteMachineId?: string): Promise<void> {
if (remoteMachineId) {
return invoke('remote_pty_write', { machineId: remoteMachineId, id, data });
}
return invoke('pty_write', { id, data });
}
async resizePtyDirect(id: string, cols: number, rows: number, remoteMachineId?: string): Promise<void> {
if (remoteMachineId) {
return invoke('remote_pty_resize', { machineId: remoteMachineId, id, cols, rows });
}
return invoke('pty_resize', { id, cols, rows });
}
async killPty(id: string, remoteMachineId?: string): Promise<void> {
if (remoteMachineId) {
return invoke('remote_pty_kill', { machineId: remoteMachineId, id });
}
return invoke('pty_kill', { id });
}
onPtyData(id: string, callback: (data: string) => void): UnsubscribeFn {
return this.listenTauri<string>(`pty-data-${id}`, callback);
}
onPtyExit(id: string, callback: () => void): UnsubscribeFn {
return this.listenTauri(`pty-exit-${id}`, () => callback());
}
// ── Events (simplified — kept for backward compat) ──────────────────────
/** Subscribe to a Tauri event; tracks unlisten for cleanup. */ /** Subscribe to a Tauri event; tracks unlisten for cleanup. */
private listenTauri<T>(event: string, handler: (payload: T) => void): UnsubscribeFn { private listenTauri<T>(event: string, handler: (payload: T) => void): UnsubscribeFn {
@ -173,7 +760,7 @@ export class TauriAdapter implements BackendAdapter {
} }
onAgentMessage(callback: (sessionId: string, messages: AgentMessage[]) => void): UnsubscribeFn { onAgentMessage(callback: (sessionId: string, messages: AgentMessage[]) => void): UnsubscribeFn {
return this.listenTauri<SidecarMessage>('sidecar-message', (msg) => { return this.listenTauri<SidecarMessagePayload>('sidecar-message', (msg) => {
if (msg.type !== 'message' || !msg.sessionId || !msg.event) return; if (msg.type !== 'message' || !msg.sessionId || !msg.event) return;
const agentMsg: AgentMessage = { const agentMsg: AgentMessage = {
id: String(msg.event['id'] ?? Date.now()), id: String(msg.event['id'] ?? Date.now()),
@ -188,7 +775,7 @@ export class TauriAdapter implements BackendAdapter {
} }
onAgentStatus(callback: (sessionId: string, status: AgentStatus, error?: string) => void): UnsubscribeFn { onAgentStatus(callback: (sessionId: string, status: AgentStatus, error?: string) => void): UnsubscribeFn {
return this.listenTauri<SidecarMessage>('sidecar-message', (msg) => { return this.listenTauri<SidecarMessagePayload>('sidecar-message', (msg) => {
if (msg.type !== 'status' || !msg.sessionId) return; if (msg.type !== 'status' || !msg.sessionId) return;
callback(msg.sessionId, normalizeStatus(String(msg.event?.['status'] ?? 'idle')), msg.message); callback(msg.sessionId, normalizeStatus(String(msg.event?.['status'] ?? 'idle')), msg.message);
}); });
@ -197,7 +784,7 @@ export class TauriAdapter implements BackendAdapter {
onAgentCost( onAgentCost(
callback: (sessionId: string, costUsd: number, inputTokens: number, outputTokens: number) => void, callback: (sessionId: string, costUsd: number, inputTokens: number, outputTokens: number) => void,
): UnsubscribeFn { ): UnsubscribeFn {
return this.listenTauri<SidecarMessage>('sidecar-message', (msg) => { return this.listenTauri<SidecarMessagePayload>('sidecar-message', (msg) => {
if (msg.type !== 'cost' || !msg.sessionId || !msg.event) return; if (msg.type !== 'cost' || !msg.sessionId || !msg.event) return;
callback(msg.sessionId, Number(msg.event['totalCostUsd'] ?? 0), Number(msg.event['inputTokens'] ?? 0), Number(msg.event['outputTokens'] ?? 0)); callback(msg.sessionId, Number(msg.event['totalCostUsd'] ?? 0), Number(msg.event['inputTokens'] ?? 0), Number(msg.event['outputTokens'] ?? 0));
}); });
@ -205,7 +792,7 @@ export class TauriAdapter implements BackendAdapter {
onPtyOutput(_callback: (sessionId: string, data: string) => void): UnsubscribeFn { onPtyOutput(_callback: (sessionId: string, data: string) => void): UnsubscribeFn {
// Tauri PTY uses per-session event channels (pty-data-{id}). // Tauri PTY uses per-session event channels (pty-data-{id}).
// Phase 1: PTY event wiring remains in pty-bridge.ts per-session listeners. // Use onPtyData(id, callback) for per-session PTY output.
return () => {}; return () => {};
} }

View file

@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { marked, Renderer } from 'marked'; import { marked, Renderer } from 'marked';
import { queryAgent, stopAgent, isAgentReady, restartAgent } from '../../adapters/agent-bridge'; import { getBackend } from '../../backend/backend';
import type { ClaudeProfile, ClaudeSkill, AgentQueryOptions } from '@agor/types';
import { import {
getAgentSession, getAgentSession,
createAgentSession, createAgentSession,
@ -12,9 +13,7 @@
import { focusPane } from '../../stores/layout.svelte'; import { focusPane } from '../../stores/layout.svelte';
import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher'; import { isSidecarAlive, setSidecarAlive } from '../../agent-dispatcher';
import { getSessionProjectId } from '../../utils/session-persistence'; import { getSessionProjectId } from '../../utils/session-persistence';
import { setAgentStatus as setBtmsgAgentStatus } from '../../adapters/btmsg-bridge';
import type { AgentId } from '../../types/ids'; import type { AgentId } from '../../types/ids';
import { listProfiles, listSkills, readSkill, type ClaudeProfile, type ClaudeSkill } from '../../adapters/claude-bridge';
import { getInjectableAnchors, getProjectAnchors, addAnchors, removeAnchor } from '../../stores/anchors.svelte'; import { getInjectableAnchors, getProjectAnchors, addAnchors, removeAnchor } from '../../stores/anchors.svelte';
import { estimateTokens } from '../../utils/anchor-serializer'; import { estimateTokens } from '../../utils/anchor-serializer';
import type { SessionAnchor } from '../../types/anchors'; import type { SessionAnchor } from '../../types/anchors';
@ -146,8 +145,8 @@
await getHighlighter(); await getHighlighter();
// Only load profiles/skills for providers that support them // Only load profiles/skills for providers that support them
const [profileList, skillList] = await Promise.all([ const [profileList, skillList] = await Promise.all([
capabilities.hasProfiles ? listProfiles().catch(() => []) : Promise.resolve([]), capabilities.hasProfiles ? getBackend().listProfiles().catch(() => []) : Promise.resolve([]),
capabilities.hasSkills ? listSkills().catch(() => []) : Promise.resolve([]), capabilities.hasSkills ? getBackend().listSkills().catch(() => []) : Promise.resolve([]),
]); ]);
profiles = profileList; profiles = profileList;
skills = skillList; skills = skillList;
@ -175,7 +174,7 @@
async function startQuery(text: string, resume = false) { async function startQuery(text: string, resume = false) {
if (!text.trim()) return; if (!text.trim()) return;
const ready = await isAgentReady(); const ready = await getBackend().isAgentReady();
if (!ready) { if (!ready) {
if (!resume) createAgentSession(sessionId, text); if (!resume) createAgentSession(sessionId, text);
const { updateAgentStatus } = await import('../../stores/agents.svelte'); const { updateAgentStatus } = await import('../../stores/agents.svelte');
@ -207,7 +206,7 @@
} }
const systemPrompt = promptParts.length > 0 ? promptParts.join('\n\n') : undefined; const systemPrompt = promptParts.length > 0 ? promptParts.join('\n\n') : undefined;
await queryAgent({ await getBackend().queryAgent({
provider: providerId, provider: providerId,
session_id: sessionId, session_id: sessionId,
prompt: text, prompt: text,
@ -234,7 +233,7 @@
const skill = skills.find(s => s.name === skillName); const skill = skills.find(s => s.name === skillName);
if (!skill) return text; if (!skill) return text;
try { try {
const content = await readSkill(skill.source_path); const content = await getBackend().readSkill(skill.source_path);
const args = text.slice(1 + skillName.length).trim(); const args = text.slice(1 + skillName.length).trim();
return args ? `${content}\n\nUser input: ${args}` : content; return args ? `${content}\n\nUser input: ${args}` : content;
} catch { } catch {
@ -267,14 +266,14 @@
function handleStop() { function handleStop() {
updateAgentStatus(sessionId, 'done'); updateAgentStatus(sessionId, 'done');
const projId = getSessionProjectId(sessionId); const projId = getSessionProjectId(sessionId);
if (projId) setBtmsgAgentStatus(projId as unknown as AgentId, 'stopped').catch(() => {}); if (projId) getBackend().btmsgSetStatus(projId as unknown as AgentId, 'stopped').catch(() => {});
stopAgent(sessionId).catch(() => {}); getBackend().stopAgentDirect(sessionId).catch(() => {});
} }
async function handleRestart() { async function handleRestart() {
restarting = true; restarting = true;
try { try {
await restartAgent(); await getBackend().restartAgentSidecar();
setSidecarAlive(true); setSidecarAlive(true);
} catch { } catch {
// Still dead // Still dead

View file

@ -1,15 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { import { getBackend } from '../../backend/backend';
ctxInitDb, import type { CtxEntry, CtxSummary } from '@agor/types';
ctxRegisterProject,
ctxGetContext,
ctxGetShared,
ctxGetSummaries,
ctxSearch,
type CtxEntry,
type CtxSummary,
} from '../../adapters/ctx-bridge';
interface Props { interface Props {
projectName: string; projectName: string;
@ -32,12 +24,12 @@
loading = true; loading = true;
try { try {
// Register project if not already (INSERT OR IGNORE) // Register project if not already (INSERT OR IGNORE)
await ctxRegisterProject(projectName, `Agent Orchestrator project: ${projectName}`, projectCwd); await getBackend().ctxRegisterProject(projectName, `Agent Orchestrator project: ${projectName}`, projectCwd);
const [ctx, shared, sums] = await Promise.all([ const [ctx, shared, sums] = await Promise.all([
ctxGetContext(projectName), getBackend().ctxGetContext(projectName),
ctxGetShared(), getBackend().ctxGetShared(),
ctxGetSummaries(projectName, 5), getBackend().ctxGetSummaries(projectName, 5),
]); ]);
entries = ctx; entries = ctx;
sharedEntries = shared; sharedEntries = shared;
@ -55,7 +47,7 @@
async function handleInitDb() { async function handleInitDb() {
initializing = true; initializing = true;
try { try {
await ctxInitDb(); await getBackend().ctxInitDb();
await loadProjectContext(); await loadProjectContext();
} catch (e) { } catch (e) {
error = `Failed to initialize database: ${e}`; error = `Failed to initialize database: ${e}`;
@ -70,7 +62,7 @@
return; return;
} }
try { try {
searchResults = await ctxSearch(searchQuery); searchResults = await getBackend().ctxSearch(searchQuery);
} catch (e) { } catch (e) {
error = `Search failed: ${e}`; error = `Search failed: ${e}`;
} }

View file

@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { marked, Renderer } from 'marked'; import { marked, Renderer } from 'marked';
import { watchFile, unwatchFile, onFileChanged, type FileChangedPayload } from '../../adapters/file-bridge'; import { getBackend } from '../../backend/backend';
import type { FileChangedPayload } from '@agor/types';
import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight'; import { getHighlighter, highlightCode, escapeHtml } from '../../utils/highlight';
interface Props { interface Props {
@ -45,11 +46,11 @@
// Unwatch previous file // Unwatch previous file
if (currentWatchPath) { if (currentWatchPath) {
unwatchFile(paneId).catch(() => {}); getBackend().ungetBackend().watchFile(paneId).catch(() => {});
} }
currentWatchPath = path; currentWatchPath = path;
watchFile(paneId, path) getBackend().watchFile(paneId, path)
.then(content => renderMarkdown(content)) .then(content => renderMarkdown(content))
.catch(e => { error = `Failed to open file: ${e}`; }); .catch(e => { error = `Failed to open file: ${e}`; });
}); });
@ -59,7 +60,7 @@
await getHighlighter(); await getHighlighter();
highlighterReady = true; highlighterReady = true;
unlisten = await onFileChanged((payload: FileChangedPayload) => { unlisten = getBackend().onFileChanged((payload: FileChangedPayload) => {
if (payload.pane_id === paneId) { if (payload.pane_id === paneId) {
renderMarkdown(payload.content); renderMarkdown(payload.content);
} }
@ -71,7 +72,7 @@
onDestroy(() => { onDestroy(() => {
unlisten?.(); unlisten?.();
unwatchFile(paneId).catch(() => {}); getBackend().ungetBackend().watchFile(paneId).catch(() => {});
}); });
function handleLinkClick(event: MouseEvent) { function handleLinkClick(event: MouseEvent) {

View file

@ -4,8 +4,7 @@
import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte'; import { getHealthAggregates, getAttentionQueue, type ProjectHealth } from '../../stores/health.svelte';
import { getTotalConflictCount } from '../../stores/conflicts.svelte'; import { getTotalConflictCount } from '../../stores/conflicts.svelte';
import { clearWakeScheduler } from '../../stores/wake-scheduler.svelte'; import { clearWakeScheduler } from '../../stores/wake-scheduler.svelte';
import { stopAgent } from '../../adapters/agent-bridge'; import { getBackend } from '../../backend/backend';
import { setAgentStatus as setBtmsgAgentStatus } from '../../adapters/btmsg-bridge';
import { getSessionProjectId } from '../../utils/session-persistence'; import { getSessionProjectId } from '../../utils/session-persistence';
import type { AgentId } from '../../types/ids'; import type { AgentId } from '../../types/ids';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@ -42,9 +41,9 @@
for (const s of active) { for (const s of active) {
updateAgentStatus(s.id, 'done'); updateAgentStatus(s.id, 'done');
const projId = getSessionProjectId(s.id); const projId = getSessionProjectId(s.id);
if (projId) setBtmsgAgentStatus(projId as unknown as AgentId, 'stopped').catch(() => {}); if (projId) getBackend().btmsgSetStatus(projId as unknown as AgentId, 'stopped').catch(() => {});
} }
await Promise.all(active.map(s => stopAgent(s.id).catch(() => {}))); await Promise.all(active.map(s => getBackend().stopAgentDirect(s.id).catch(() => {})));
} finally { } finally {
stopping = false; stopping = false;
} }

View file

@ -3,9 +3,9 @@
import { Terminal } from '@xterm/xterm'; import { Terminal } from '@xterm/xterm';
import { CanvasAddon } from '@xterm/addon-canvas'; import { CanvasAddon } from '@xterm/addon-canvas';
import { FitAddon } from '@xterm/addon-fit'; import { FitAddon } from '@xterm/addon-fit';
import { spawnPty, writePty, resizePty, killPty, onPtyData, onPtyExit } from '../../adapters/pty-bridge'; import { getBackend } from '../../backend/backend';
import { getXtermTheme, onThemeChange } from '../../stores/theme.svelte'; import { getXtermTheme, onThemeChange } from '../../stores/theme.svelte';
import type { UnlistenFn } from '@tauri-apps/api/event'; import type { UnsubscribeFn } from '@agor/types';
import '@xterm/xterm/css/xterm.css'; import '@xterm/xterm/css/xterm.css';
interface Props { interface Props {
@ -21,8 +21,8 @@
let term: Terminal; let term: Terminal;
let fitAddon: FitAddon; let fitAddon: FitAddon;
let ptyId: string | null = null; let ptyId: string | null = null;
let unlistenData: UnlistenFn | null = null; let unlistenData: UnsubscribeFn | null = null;
let unlistenExit: UnlistenFn | null = null; let unlistenExit: UnsubscribeFn | null = null;
let resizeObserver: ResizeObserver | null = null; let resizeObserver: ResizeObserver | null = null;
let unsubTheme: (() => void) | null = null; let unsubTheme: (() => void) | null = null;
@ -48,14 +48,14 @@
// Spawn PTY // Spawn PTY
try { try {
ptyId = await spawnPty({ shell, cwd, args, cols, rows }); ptyId = await getBackend().spawnPty({ shell, cwd, args, cols, rows });
// Listen for PTY output // Listen for PTY output
unlistenData = await onPtyData(ptyId, (data) => { unlistenData = getBackend().onPtyData(ptyId, (data) => {
term.write(data); term.write(data);
}); });
unlistenExit = await onPtyExit(ptyId, () => { unlistenExit = getBackend().onPtyExit(ptyId, () => {
term.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n'); term.write('\r\n\x1b[90m[Process exited]\x1b[0m\r\n');
onExit?.(); onExit?.();
}); });
@ -70,7 +70,7 @@
} }
if (e.key === 'V') { if (e.key === 'V') {
navigator.clipboard.readText().then(text => { navigator.clipboard.readText().then(text => {
if (text && ptyId) writePty(ptyId, text); if (text && ptyId) getBackend().writePtyDirect(ptyId, text);
}); });
return false; return false;
} }
@ -80,7 +80,7 @@
// Forward keyboard input to PTY // Forward keyboard input to PTY
term.onData((data) => { term.onData((data) => {
if (ptyId) writePty(ptyId, data); if (ptyId) getBackend().writePtyDirect(ptyId, data);
}); });
} catch (e) { } catch (e) {
term.write(`\x1b[31mFailed to spawn terminal: ${e}\x1b[0m\r\n`); term.write(`\x1b[31mFailed to spawn terminal: ${e}\x1b[0m\r\n`);
@ -94,7 +94,7 @@
fitAddon.fit(); fitAddon.fit();
if (ptyId) { if (ptyId) {
const { cols, rows } = term; const { cols, rows } = term;
resizePty(ptyId, cols, rows); getBackend().resizePtyDirect(ptyId, cols, rows);
} }
}, 100); }, 100);
}); });
@ -112,7 +112,7 @@
unlistenData?.(); unlistenData?.();
unlistenExit?.(); unlistenExit?.();
if (ptyId) { if (ptyId) {
try { await killPty(ptyId); } catch { /* already dead */ } try { await getBackend().killPty(ptyId); } catch { /* already dead */ }
} }
term?.dispose(); term?.dispose();
}); });

View file

@ -3,17 +3,11 @@
import type { ProjectConfig, GroupAgentRole } from '../../types/groups'; import type { ProjectConfig, GroupAgentRole } from '../../types/groups';
import { generateAgentPrompt } from '../../utils/agent-prompts'; import { generateAgentPrompt } from '../../utils/agent-prompts';
import { getActiveGroup } from '../../stores/workspace.svelte'; import { getActiveGroup } from '../../stores/workspace.svelte';
import { logAuditEvent } from '../../adapters/audit-bridge'; import { getBackend } from '../../backend/backend';
import type { AgentId } from '../../types/ids'; import type { AgentId } from '../../types/ids';
import { import type { ProjectAgentState, AgentMessageRecord } from '@agor/types';
loadProjectAgentState,
loadAgentMessages,
type ProjectAgentState,
type AgentMessageRecord,
} from '../../adapters/groups-bridge';
import { registerSessionProject } from '../../agent-dispatcher'; import { registerSessionProject } from '../../agent-dispatcher';
import { onAgentStart, onAgentStop } from '../../stores/workspace.svelte'; import { onAgentStart, onAgentStop } from '../../stores/workspace.svelte';
import { stopAgent } from '../../adapters/agent-bridge';
import { trackProject, updateProjectSession } from '../../stores/health.svelte'; import { trackProject, updateProjectSession } from '../../stores/health.svelte';
import { import {
createAgentSession, createAgentSession,
@ -26,8 +20,6 @@
import type { AgentMessage } from '../../adapters/claude-messages'; import type { AgentMessage } from '../../adapters/claude-messages';
import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte'; import { getProvider, getDefaultProviderId } from '../../providers/registry.svelte';
import { loadAnchorsForProject } from '../../stores/anchors.svelte'; import { loadAnchorsForProject } from '../../stores/anchors.svelte';
import { getSecret } from '../../adapters/secrets-bridge';
import { getUnseenMessages, markMessagesSeen, setAgentStatus as setBtmsgAgentStatus } from '../../adapters/btmsg-bridge';
import { getWakeEvent, consumeWakeEvent, updateManagerSession } from '../../stores/wake-scheduler.svelte'; import { getWakeEvent, consumeWakeEvent, updateManagerSession } from '../../stores/wake-scheduler.svelte';
import { SessionId, ProjectId } from '../../types/ids'; import { SessionId, ProjectId } from '../../types/ids';
import AgentPane from '../Agent/AgentPane.svelte'; import AgentPane from '../Agent/AgentPane.svelte';
@ -95,7 +87,7 @@ bttask comment <task-id> "update" # Add a comment
$effect(() => { $effect(() => {
if (providerId === 'aider') { if (providerId === 'aider') {
getSecret('openrouter_api_key').then(key => { getBackend().getSecret('openrouter_api_key').then(key => {
openrouterKey = key; openrouterKey = key;
}).catch(() => {}); }).catch(() => {});
} else { } else {
@ -133,7 +125,7 @@ bttask comment <task-id> "update" # Add a comment
: '[Context Refresh] Review the instructions above and continue your work.'; : '[Context Refresh] Review the instructions above and continue your work.';
contextRefreshPrompt = refreshMsg; contextRefreshPrompt = refreshMsg;
// Audit: log prompt injection event // Audit: log prompt injection event
logAuditEvent( getBackend().logAuditEvent(
project.id as unknown as AgentId, project.id as unknown as AgentId,
'prompt_injection', 'prompt_injection',
`Context refresh triggered after ${Math.floor(elapsed / 60_000)} min idle`, `Context refresh triggered after ${Math.floor(elapsed / 60_000)} min idle`,
@ -159,8 +151,8 @@ bttask comment <task-id> "update" # Add a comment
const unsubAgentStop = onAgentStop((projectId) => { const unsubAgentStop = onAgentStop((projectId) => {
if (projectId !== project.id) return; if (projectId !== project.id) return;
updateAgentStatus(sessionId, 'done'); updateAgentStatus(sessionId, 'done');
setBtmsgAgentStatus(project.id as unknown as AgentId, 'stopped').catch(() => {}); getBackend().btmsgSetStatus(project.id as unknown as AgentId, 'stopped').catch(() => {});
stopAgent(sessionId).catch(() => {}); getBackend().stopAgentDirect(sessionId).catch(() => {});
}); });
// btmsg inbox polling — per-message acknowledgment wake mechanism // btmsg inbox polling — per-message acknowledgment wake mechanism
@ -173,7 +165,7 @@ bttask comment <task-id> "update" # Add a comment
msgPollTimer = setInterval(async () => { msgPollTimer = setInterval(async () => {
if (contextRefreshPrompt) return; // Don't queue if already has a pending prompt if (contextRefreshPrompt) return; // Don't queue if already has a pending prompt
try { try {
const unseen = await getUnseenMessages( const unseen = await getBackend().btmsgUnseenMessages(
project.id as unknown as AgentId, project.id as unknown as AgentId,
sessionId, sessionId,
); );
@ -185,9 +177,9 @@ bttask comment <task-id> "update" # Add a comment
contextRefreshPrompt = `[New Messages] You have ${unseen.length} unread message(s):\n\n${msgSummary}\n\nRespond appropriately using \`btmsg send <agent-id> "reply"\`.`; contextRefreshPrompt = `[New Messages] You have ${unseen.length} unread message(s):\n\n${msgSummary}\n\nRespond appropriately using \`btmsg send <agent-id> "reply"\`.`;
// Mark as seen immediately to prevent re-injection // Mark as seen immediately to prevent re-injection
await markMessagesSeen(sessionId, unseen.map(m => m.id)); await getBackend().btmsgMarkSeen(sessionId, unseen.map(m => m.id));
logAuditEvent( getBackend().logAuditEvent(
project.id as unknown as AgentId, project.id as unknown as AgentId,
'wake_event', 'wake_event',
`Agent woken by ${unseen.length} btmsg message(s)`, `Agent woken by ${unseen.length} btmsg message(s)`,
@ -277,13 +269,13 @@ bttask comment <task-id> "update" # Add a comment
loading = true; loading = true;
hasRestoredHistory = false; hasRestoredHistory = false;
try { try {
const state = await loadProjectAgentState(projectId); const state = await getBackend().loadProjectAgentState(projectId);
lastState = state; lastState = state;
if (state?.last_session_id) { if (state?.last_session_id) {
sessionId = SessionId(state.last_session_id); sessionId = SessionId(state.last_session_id);
// Restore cached messages into the agent store // Restore cached messages into the agent store
const records = await loadAgentMessages(projectId); const records = await getBackend().loadAgentMessages(projectId);
if (records.length > 0) { if (records.length > 0) {
restoreMessagesFromRecords(sessionId, state, records); restoreMessagesFromRecords(sessionId, state, records);
hasRestoredHistory = true; hasRestoredHistory = true;

View file

@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { listDirectoryChildren, readFileContent, writeFileContent, type DirEntry } from '../../adapters/files-bridge'; import { getBackend } from '../../backend/backend';
import type { FileEntry } from '@agor/types';
type DirEntry = FileEntry;
interface Props { interface Props {
cwd: string; cwd: string;
@ -90,7 +92,7 @@ package "Backend" {
async function loadDiagrams() { async function loadDiagrams() {
try { try {
const entries = await listDirectoryChildren(archPath); const entries = await getBackend().listDirectory(archPath);
diagrams = entries.filter(e => e.name.endsWith('.puml') || e.name.endsWith('.plantuml')); diagrams = entries.filter(e => e.name.endsWith('.puml') || e.name.endsWith('.plantuml'));
} catch { } catch {
// Directory might not exist yet // Directory might not exist yet
@ -108,7 +110,7 @@ package "Backend" {
error = null; error = null;
editing = false; editing = false;
try { try {
const content = await readFileContent(filePath); const content = await getBackend().readFile(filePath);
if (content.type === 'Text') { if (content.type === 'Text') {
pumlSource = content.content; pumlSource = content.content;
renderPlantUml(content.content); renderPlantUml(content.content);
@ -131,7 +133,7 @@ package "Backend" {
async function handleSave() { async function handleSave() {
if (!selectedFile) return; if (!selectedFile) return;
try { try {
await writeFileContent(selectedFile, pumlSource); await getBackend().writeFile(selectedFile, pumlSource);
renderPlantUml(pumlSource); renderPlantUml(pumlSource);
editing = false; editing = false;
} catch (e) { } catch (e) {
@ -144,7 +146,7 @@ package "Backend" {
const fileName = newName.trim().replace(/\s+/g, '-').toLowerCase(); const fileName = newName.trim().replace(/\s+/g, '-').toLowerCase();
const filePath = `${archPath}/${fileName}.puml`; const filePath = `${archPath}/${fileName}.puml`;
try { try {
await writeFileContent(filePath, template); await getBackend().writeFile(filePath, template);
showNewForm = false; showNewForm = false;
newName = ''; newName = '';
await loadDiagrams(); await loadDiagrams();

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { getAuditLog, type AuditEntry, type AuditEventType } from '../../adapters/audit-bridge'; import { getBackend } from '../../backend/backend';
import { getGroupAgents, type BtmsgAgent } from '../../adapters/btmsg-bridge'; import type { AuditEntry, AuditEventType, BtmsgAgent } from '@agor/types';
import type { GroupId, AgentId } from '../../types/ids'; import type { GroupId, AgentId } from '../../types/ids';
interface Props { interface Props {
@ -88,8 +88,8 @@
async function fetchData() { async function fetchData() {
try { try {
const [auditData, agentData] = await Promise.all([ const [auditData, agentData] = await Promise.all([
getAuditLog(groupId, 200, 0), getBackend().getAuditLog(groupId, 200, 0),
getGroupAgents(groupId), getBackend().btmsgGetAgents(groupId),
]); ]);
entries = auditData; entries = auditData;
agents = agentData; agents = agentData;

View file

@ -2,25 +2,11 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { confirm } from '@tauri-apps/plugin-dialog'; import { confirm } from '@tauri-apps/plugin-dialog';
import { getActiveGroup, emitAgentStart } from '../../stores/workspace.svelte'; import { getActiveGroup, emitAgentStart } from '../../stores/workspace.svelte';
import { import { getBackend } from '../../backend/backend';
type BtmsgAgent, import type {
type BtmsgMessage, BtmsgAgent, BtmsgMessage, BtmsgFeedMessage,
type BtmsgFeedMessage, BtmsgChannel, BtmsgChannelMessage,
type BtmsgChannel, } from '@agor/types';
type BtmsgChannelMessage,
getGroupAgents,
getHistory,
getAllFeed,
sendMessage,
markRead,
ensureAdmin,
getChannels,
getChannelMessages,
sendChannelMessage,
createChannel,
setAgentStatus,
clearAllComms,
} from '../../adapters/btmsg-bridge';
const ADMIN_ID = 'admin'; const ADMIN_ID = 'admin';
const ROLE_ICONS: Record<string, string> = { const ROLE_ICONS: Record<string, string> = {
@ -55,8 +41,8 @@
async function loadData() { async function loadData() {
if (!groupId) return; if (!groupId) return;
try { try {
agents = await getGroupAgents(groupId); agents = await getBackend().btmsgGetAgents(groupId);
channels = await getChannels(groupId); channels = await getBackend().btmsgGetChannels(groupId);
} catch (e) { } catch (e) {
console.error('[CommsTab] loadData failed:', e); console.error('[CommsTab] loadData failed:', e);
} }
@ -66,12 +52,12 @@
if (!groupId) return; if (!groupId) return;
try { try {
if (currentView.type === 'feed') { if (currentView.type === 'feed') {
feedMessages = await getAllFeed(groupId, 100); feedMessages = await getBackend().btmsgAllFeed(groupId, 100);
} else if (currentView.type === 'dm') { } else if (currentView.type === 'dm') {
dmMessages = await getHistory(ADMIN_ID, currentView.agentId, 100); dmMessages = await getBackend().btmsgHistory(ADMIN_ID, currentView.agentId, 100);
await markRead(ADMIN_ID, currentView.agentId); await getBackend().btmsgMarkRead(ADMIN_ID, currentView.agentId);
} else if (currentView.type === 'channel') { } else if (currentView.type === 'channel') {
channelMessages = await getChannelMessages(currentView.channelId, 100); channelMessages = await getBackend().btmsgChannelMessages(currentView.channelId, 100);
} }
} catch (e) { } catch (e) {
console.error('[CommsTab] loadMessages failed:', e); console.error('[CommsTab] loadMessages failed:', e);
@ -95,7 +81,7 @@
void groupId; void groupId;
if (groupId) { if (groupId) {
console.log('[CommsTab] groupId:', groupId); console.log('[CommsTab] groupId:', groupId);
ensureAdmin(groupId).catch((e) => console.error('[CommsTab] ensureAdmin failed:', e)); getBackend().btmsgEnsureAdmin(groupId).catch((e) => console.error('[CommsTab] ensureAdmin failed:', e));
loadData(); loadData();
} }
}); });
@ -129,16 +115,16 @@
try { try {
if (currentView.type === 'dm') { if (currentView.type === 'dm') {
await sendMessage(ADMIN_ID, currentView.agentId, text); await getBackend().btmsgSend(ADMIN_ID, currentView.agentId, text);
// Auto-wake agent if stopped // Auto-wake agent if stopped
const recipient = agents.find(a => a.id === currentView.agentId); const recipient = agents.find(a => a.id === currentView.agentId);
if (recipient && recipient.status !== 'active') { if (recipient && recipient.status !== 'active') {
await setAgentStatus(currentView.agentId, 'active'); await getBackend().btmsgSetStatus(currentView.agentId, 'active');
emitAgentStart(currentView.agentId); emitAgentStart(currentView.agentId);
await pollBtmsg(); await pollBtmsg();
} }
} else if (currentView.type === 'channel') { } else if (currentView.type === 'channel') {
await sendChannelMessage(currentView.channelId, ADMIN_ID, text); await getBackend().btmsgChannelSend(currentView.channelId, ADMIN_ID, text);
} else { } else {
return; // Can't send in feed view return; // Can't send in feed view
} }
@ -166,7 +152,7 @@
const name = newChannelName.trim(); const name = newChannelName.trim();
if (!name || !groupId) return; if (!name || !groupId) return;
try { try {
await createChannel(name, groupId, ADMIN_ID); await getBackend().btmsgCreateChannel(name, groupId, ADMIN_ID);
newChannelName = ''; newChannelName = '';
showNewChannel = false; showNewChannel = false;
await loadData(); await loadData();
@ -206,7 +192,7 @@
if (!confirmed) return; if (!confirmed) return;
clearing = true; clearing = true;
try { try {
await clearAllComms(groupId); await getBackend().btmsgClearAllComms(groupId);
feedMessages = []; feedMessages = [];
dmMessages = []; dmMessages = [];
channelMessages = []; channelMessages = [];

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getActiveProjectId, getActiveGroup } from '../../stores/workspace.svelte'; import { getActiveProjectId, getActiveGroup } from '../../stores/workspace.svelte';
import { discoverMarkdownFiles, type MdFileEntry } from '../../adapters/groups-bridge'; import { getBackend } from '../../backend/backend';
import type { MdFileEntry } from '@agor/types';
import MarkdownPane from '../Markdown/MarkdownPane.svelte'; import MarkdownPane from '../Markdown/MarkdownPane.svelte';
let files = $state<MdFileEntry[]>([]); let files = $state<MdFileEntry[]>([]);
@ -26,7 +27,7 @@
async function loadFiles(cwd: string) { async function loadFiles(cwd: string) {
loading = true; loading = true;
try { try {
files = await discoverMarkdownFiles(cwd); files = await getBackend().discoverMarkdownFiles(cwd);
// Auto-select first priority file // Auto-select first priority file
const priority = files.find(f => f.priority); const priority = files.find(f => f.priority);
selectedPath = priority?.path ?? files[0]?.path ?? null; selectedPath = priority?.path ?? files[0]?.path ?? null;

View file

@ -1,5 +1,7 @@
<script lang="ts"> <script lang="ts">
import { listDirectoryChildren, readFileContent, writeFileContent, type DirEntry, type FileContent } from '../../adapters/files-bridge'; import { getBackend } from '../../backend/backend';
import type { FileEntry, FileContent } from '@agor/types';
type DirEntry = FileEntry;
import { getSetting } from '../../stores/settings-store.svelte'; import { getSetting } from '../../stores/settings-store.svelte';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
import CodeEditor from './CodeEditor.svelte'; import CodeEditor from './CodeEditor.svelte';
@ -61,7 +63,7 @@
async function loadDirectory(path: string): Promise<DirEntry[]> { async function loadDirectory(path: string): Promise<DirEntry[]> {
try { try {
return await listDirectoryChildren(path); return await getBackend().listDirectory(path);
} catch (e) { } catch (e) {
console.warn('Failed to list directory:', e); console.warn('Failed to list directory:', e);
return []; return [];
@ -125,7 +127,7 @@
// Load content — must look up from reactive array, not local reference // Load content — must look up from reactive array, not local reference
fileLoading = true; fileLoading = true;
try { try {
const content = await readFileContent(node.path); const content = await getBackend().readFile(node.path);
const target = fileTabs.find(t => t.path === node.path); const target = fileTabs.find(t => t.path === node.path);
if (target) { if (target) {
target.content = content; target.content = content;
@ -229,7 +231,7 @@
async function saveTab(tab: FileTab) { async function saveTab(tab: FileTab) {
if (!tab.dirty || tab.content?.type !== 'Text') return; if (!tab.dirty || tab.content?.type !== 'Text') return;
try { try {
await writeFileContent(tab.path, tab.editContent); await getBackend().writeFile(tab.path, tab.editContent);
// Update the saved content reference // Update the saved content reference
tab.content = { type: 'Text', content: tab.editContent, lang: tab.content.lang }; tab.content = { type: 'Text', content: tab.editContent, lang: tab.content.lang };
tab.dirty = false; tab.dirty = false;

View file

@ -2,7 +2,8 @@
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { getActiveGroup, getEnabledProjects, setActiveProject, emitAgentStart, emitAgentStop } from '../../stores/workspace.svelte'; import { getActiveGroup, getEnabledProjects, setActiveProject, emitAgentStart, emitAgentStop } from '../../stores/workspace.svelte';
import type { GroupAgentConfig, GroupAgentStatus, ProjectConfig } from '../../types/groups'; import type { GroupAgentConfig, GroupAgentStatus, ProjectConfig } from '../../types/groups';
import { getGroupAgents, setAgentStatus, type BtmsgAgent } from '../../adapters/btmsg-bridge'; import { getBackend } from '../../backend/backend';
import type { BtmsgAgent } from '@agor/types';
import type { AgentId } from '../../types/ids'; import type { AgentId } from '../../types/ids';
/** Runtime agent status from btmsg database */ /** Runtime agent status from btmsg database */
@ -32,7 +33,7 @@
async function pollBtmsg() { async function pollBtmsg() {
if (!group) return; if (!group) return;
try { try {
btmsgAgents = await getGroupAgents(group.id); btmsgAgents = await getBackend().btmsgGetAgents(group.id);
} catch { } catch {
// btmsg.db might not exist yet // btmsg.db might not exist yet
} }
@ -61,7 +62,7 @@
const current = getStatus(agent.id); const current = getStatus(agent.id);
const newStatus = current === 'stopped' ? 'active' : 'stopped'; const newStatus = current === 'stopped' ? 'active' : 'stopped';
try { try {
await setAgentStatus(agent.id, newStatus); await getBackend().btmsgSetStatus(agent.id, newStatus);
await pollBtmsg(); // Refresh immediately await pollBtmsg(); // Refresh immediately
if (newStatus === 'active') { if (newStatus === 'active') {
emitAgentStart(agent.id); emitAgentStart(agent.id);

View file

@ -5,7 +5,8 @@
import type { GroupId, ProjectId as ProjectIdType } from '../../types/ids'; import type { GroupId, ProjectId as ProjectIdType } from '../../types/ids';
import { getProjectHealth, getAllProjectHealth, getHealthAggregates } from '../../stores/health.svelte'; import { getProjectHealth, getAllProjectHealth, getHealthAggregates } from '../../stores/health.svelte';
import { getAgentSession } from '../../stores/agents.svelte'; import { getAgentSession } from '../../stores/agents.svelte';
import { listTasks, type Task } from '../../adapters/bttask-bridge'; import { getBackend } from '../../backend/backend';
import type { Task } from '@agor/types';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
interface Props { interface Props {
@ -51,7 +52,7 @@
async function fetchTaskCounts() { async function fetchTaskCounts() {
if (!groupId) return; if (!groupId) return;
try { try {
const tasks = await listTasks(groupId); const tasks = await getBackend().bttaskList(groupId);
const counts: Record<string, number> = { todo: 0, progress: 0, review: 0, done: 0, blocked: 0 }; const counts: Record<string, number> = { todo: 0, progress: 0, review: 0, done: 0, blocked: 0 };
for (const t of tasks) { for (const t of tasks) {
if (counts[t.status] !== undefined) counts[t.status]++; if (counts[t.status] !== undefined) counts[t.status]++;

View file

@ -26,14 +26,12 @@
getFocusFlashProjectId, onProjectTabSwitch, onTerminalToggle, getFocusFlashProjectId, onProjectTabSwitch, onTerminalToggle,
} from '../../stores/workspace.svelte'; } from '../../stores/workspace.svelte';
import { getProjectHealth, setStallThreshold } from '../../stores/health.svelte'; import { getProjectHealth, setStallThreshold } from '../../stores/health.svelte';
import { fsWatchProject, fsUnwatchProject, onFsWriteDetected, fsWatcherStatus } from '../../adapters/fs-watcher-bridge'; import { getBackend } from '../../backend/backend';
import { recordExternalWrite } from '../../stores/conflicts.svelte'; import { recordExternalWrite } from '../../stores/conflicts.svelte';
import { ProjectId, type AgentId, type GroupId } from '../../types/ids'; import { ProjectId, type AgentId, type GroupId } from '../../types/ids';
import { notify, dismissNotification } from '../../stores/notifications.svelte'; import { notify, dismissNotification } from '../../stores/notifications.svelte';
import { registerManager, unregisterManager, updateManagerConfig } from '../../stores/wake-scheduler.svelte'; import { registerManager, unregisterManager, updateManagerConfig } from '../../stores/wake-scheduler.svelte';
import { setReviewQueueDepth } from '../../stores/health.svelte'; import { setReviewQueueDepth } from '../../stores/health.svelte';
import { reviewQueueCount } from '../../adapters/bttask-bridge';
import { getStaleAgents } from '../../adapters/btmsg-bridge';
interface Props { interface Props {
project: ProjectConfig; project: ProjectConfig;
@ -155,7 +153,7 @@
if (!groupId) return; if (!groupId) return;
const pollReviewQueue = () => { const pollReviewQueue = () => {
reviewQueueCount(groupId) getBackend().bttaskReviewQueueCount(groupId)
.then(count => setReviewQueueDepth(project.id, count)) .then(count => setReviewQueueDepth(project.id, count))
.catch(() => {}); // best-effort .catch(() => {}); // best-effort
}; };
@ -173,11 +171,11 @@
const pollHeartbeat = () => { const pollHeartbeat = () => {
// 300s = healthy threshold, 600s = dead threshold // 300s = healthy threshold, 600s = dead threshold
getStaleAgents(groupId as unknown as GroupId, 300) getBackend().btmsgGetStaleAgents(groupId as unknown as GroupId, 300)
.then(staleIds => { .then(staleIds => {
if (staleIds.includes(project.id)) { if (staleIds.includes(project.id)) {
// Check if truly dead (>10 min) // Check if truly dead (>10 min)
getStaleAgents(groupId as unknown as GroupId, 600) getBackend().btmsgGetStaleAgents(groupId as unknown as GroupId, 600)
.then(deadIds => { .then(deadIds => {
heartbeatStatus = deadIds.includes(project.id) ? 'dead' : 'stale'; heartbeatStatus = deadIds.includes(project.id) ? 'dead' : 'stale';
}) })
@ -207,8 +205,8 @@
scanToastId = notify('info', 'Scanning project directories…'); scanToastId = notify('info', 'Scanning project directories…');
}, 300); }, 300);
fsWatchProject(projectId, cwd) getBackend().fsWatchProject(projectId, cwd)
.then(() => fsWatcherStatus()) .then(() => getBackend().fsWatcherStatus())
.then((status) => { .then((status) => {
clearTimeout(scanTimer); clearTimeout(scanTimer);
if (scanToastId) dismissNotification(scanToastId); if (scanToastId) dismissNotification(scanToastId);
@ -224,7 +222,7 @@
// Listen for fs write events (filter to this project) // Listen for fs write events (filter to this project)
let unlisten: (() => void) | null = null; let unlisten: (() => void) | null = null;
onFsWriteDetected((event) => { getBackend().onFsWriteDetected((event) => {
if (event.project_id !== projectId) return; if (event.project_id !== projectId) return;
const isNew = recordExternalWrite(ProjectId(projectId), event.file_path, event.timestamp_ms); const isNew = recordExternalWrite(ProjectId(projectId), event.file_path, event.timestamp_ms);
if (isNew) { if (isNew) {
@ -235,7 +233,7 @@
return () => { return () => {
// Cleanup: stop watching on unmount or project change // Cleanup: stop watching on unmount or project change
fsUnwatchProject(projectId).catch(() => {}); getBackend().fsUnwatchProject(projectId).catch(() => {});
unlisten?.(); unlisten?.();
}; };
}); });

View file

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { discoverMarkdownFiles, type MdFileEntry } from '../../adapters/groups-bridge'; import { getBackend } from '../../backend/backend';
import type { MdFileEntry } from '@agor/types';
import MarkdownPane from '../Markdown/MarkdownPane.svelte'; import MarkdownPane from '../Markdown/MarkdownPane.svelte';
interface Props { interface Props {
@ -31,7 +32,7 @@
async function loadFiles(dir: string) { async function loadFiles(dir: string) {
loading = true; loading = true;
try { try {
files = await discoverMarkdownFiles(dir); files = await getBackend().discoverMarkdownFiles(dir);
const priority = files.find(f => f.priority); const priority = files.find(f => f.priority);
selectedPath = priority?.path ?? files[0]?.path ?? null; selectedPath = priority?.path ?? files[0]?.path ?? null;
} catch (e) { } catch (e) {

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { searchAll, type SearchResult } from '../../adapters/search-bridge'; import { getBackend } from '../../backend/backend';
import type { SearchResult } from '@agor/types';
import { setActiveProject } from '../../stores/workspace.svelte'; import { setActiveProject } from '../../stores/workspace.svelte';
interface Props { interface Props {
@ -64,7 +65,7 @@
loading = true; loading = true;
debounceTimer = setTimeout(async () => { debounceTimer = setTimeout(async () => {
try { try {
results = await searchAll(query, 30); results = await getBackend().searchAll(query, 30);
} catch { } catch {
results = []; results = [];
} finally { } finally {

View file

@ -21,16 +21,20 @@
import { getSetting, setSetting } from '../../stores/settings-store.svelte'; import { getSetting, setSetting } from '../../stores/settings-store.svelte';
import { getCurrentTheme, setTheme } from '../../stores/theme.svelte'; import { getCurrentTheme, setTheme } from '../../stores/theme.svelte';
import { THEME_LIST, getPalette, type ThemeId } from '../../styles/themes'; import { THEME_LIST, getPalette, type ThemeId } from '../../styles/themes';
import { listProfiles, type ClaudeProfile } from '../../adapters/claude-bridge'; import { getBackend } from '../../backend/backend';
import type { ClaudeProfile } from '@agor/types';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { getProviders } from '../../providers/registry.svelte'; import { getProviders } from '../../providers/registry.svelte';
import type { ProviderId, ProviderSettings } from '../../providers/types'; import type { ProviderId, ProviderSettings } from '../../providers/types';
import { ANCHOR_BUDGET_SCALES, ANCHOR_BUDGET_SCALE_LABELS, type AnchorBudgetScale } from '../../types/anchors'; import { ANCHOR_BUDGET_SCALES, ANCHOR_BUDGET_SCALE_LABELS, type AnchorBudgetScale } from '../../types/anchors';
import { WAKE_STRATEGIES, WAKE_STRATEGY_LABELS, WAKE_STRATEGY_DESCRIPTIONS, type WakeStrategy } from '../../types/wake'; import { WAKE_STRATEGIES, WAKE_STRATEGY_LABELS, WAKE_STRATEGY_DESCRIPTIONS, type WakeStrategy } from '../../types/wake';
import { const SECRET_KEY_LABELS: Record<string, string> = {
storeSecret, getSecret, deleteSecret, listSecrets, anthropic_api_key: 'Anthropic API Key',
hasKeyring, knownSecretKeys, SECRET_KEY_LABELS, openai_api_key: 'OpenAI API Key',
} from '../../adapters/secrets-bridge'; openrouter_api_key: 'OpenRouter API Key',
github_token: 'GitHub Token',
relay_token: 'Relay Token',
};
import { import {
checkForUpdates, checkForUpdates,
getCurrentVersion, getCurrentVersion,
@ -176,7 +180,7 @@
selectedTheme = getCurrentTheme(); selectedTheme = getCurrentTheme();
try { try {
profiles = await listProfiles(); profiles = await getBackend().listProfiles();
} catch { } catch {
profiles = []; profiles = [];
} }
@ -191,10 +195,10 @@
// Load secrets state // Load secrets state
try { try {
keyringAvailable = await hasKeyring(); keyringAvailable = await getBackend().hasKeyring();
if (keyringAvailable) { if (keyringAvailable) {
storedKeys = await listSecrets(); storedKeys = await getBackend().listSecrets();
knownKeys = await knownSecretKeys(); knownKeys = await getBackend().knownSecretKeys();
} }
} catch { } catch {
keyringAvailable = false; keyringAvailable = false;
@ -317,7 +321,7 @@
return; return;
} }
try { try {
const val = await getSecret(key); const val = await getBackend().getSecret(key);
revealedKey = key; revealedKey = key;
revealedValue = val ?? ''; revealedValue = val ?? '';
} catch (e) { } catch (e) {
@ -329,8 +333,8 @@
if (!newSecretKey || !newSecretValue) return; if (!newSecretKey || !newSecretValue) return;
secretsSaving = true; secretsSaving = true;
try { try {
await storeSecret(newSecretKey, newSecretValue); await getBackend().storeSecret(newSecretKey, newSecretValue);
storedKeys = await listSecrets(); storedKeys = await getBackend().listSecrets();
newSecretKey = ''; newSecretKey = '';
newSecretValue = ''; newSecretValue = '';
// If we just saved the currently revealed key, clear reveal // If we just saved the currently revealed key, clear reveal
@ -345,8 +349,8 @@
async function handleDeleteSecret(key: string) { async function handleDeleteSecret(key: string) {
try { try {
await deleteSecret(key); await getBackend().deleteSecret(key);
storedKeys = await listSecrets(); storedKeys = await getBackend().listSecrets();
if (revealedKey === key) { if (revealedKey === key) {
revealedKey = null; revealedKey = null;
revealedValue = ''; revealedValue = '';

View file

@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { listSshSessions, saveSshSession, deleteSshSession, type SshSession } from '../../adapters/ssh-bridge'; import { getBackend } from '../../backend/backend';
import type { SshSessionRecord } from '@agor/types';
type SshSession = SshSessionRecord;
import { addTerminalTab } from '../../stores/workspace.svelte'; import { addTerminalTab } from '../../stores/workspace.svelte';
interface Props { interface Props {
@ -27,7 +29,7 @@
async function loadSessions() { async function loadSessions() {
loading = true; loading = true;
try { try {
sessions = await listSshSessions(); sessions = await getBackend().listSshSessions();
} catch (e) { } catch (e) {
console.warn('Failed to load SSH sessions:', e); console.warn('Failed to load SSH sessions:', e);
} finally { } finally {
@ -79,7 +81,7 @@
}; };
try { try {
await saveSshSession(session); await getBackend().saveSshSession(session);
await loadSessions(); await loadSessions();
resetForm(); resetForm();
} catch (e) { } catch (e) {
@ -89,7 +91,7 @@
async function removeSession(id: string) { async function removeSession(id: string) {
try { try {
await deleteSshSession(id); await getBackend().deleteSshSession(id);
await loadSessions(); await loadSessions();
} catch (e) { } catch (e) {
console.warn('Failed to delete SSH session:', e); console.warn('Failed to delete SSH session:', e);

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { listTasks, updateTaskStatus, createTask, deleteTask, addTaskComment, type Task, type TaskComment, getTaskComments } from '../../adapters/bttask-bridge'; import { getBackend } from '../../backend/backend';
import type { Task, TaskComment } from '@agor/types';
import type { GroupId } from '../../types/ids'; import type { GroupId } from '../../types/ids';
import { AgentId } from '../../types/ids'; import { AgentId } from '../../types/ids';
@ -64,7 +65,7 @@
async function loadTasks() { async function loadTasks() {
try { try {
tasks = await listTasks(groupId); tasks = await getBackend().bttaskList(groupId);
error = null; error = null;
} catch (e) { } catch (e) {
error = String(e); error = String(e);
@ -86,7 +87,7 @@
try { try {
const task = tasks.find(t => t.id === taskId); const task = tasks.find(t => t.id === taskId);
const version = task?.version ?? 1; const version = task?.version ?? 1;
await updateTaskStatus(taskId, newStatus, version); await getBackend().bttaskUpdateStatus(taskId, newStatus, version);
await loadTasks(); await loadTasks();
} catch (e: any) { } catch (e: any) {
const msg = e?.message ?? String(e); const msg = e?.message ?? String(e);
@ -102,7 +103,7 @@
async function handleAddTask() { async function handleAddTask() {
if (!newTitle.trim()) return; if (!newTitle.trim()) return;
try { try {
await createTask(newTitle.trim(), newDesc.trim(), newPriority, groupId, AgentId('admin')); await getBackend().bttaskCreate(newTitle.trim(), newDesc.trim(), newPriority, groupId, AgentId('admin'));
newTitle = ''; newTitle = '';
newDesc = ''; newDesc = '';
newPriority = 'medium'; newPriority = 'medium';
@ -115,7 +116,7 @@
async function handleDelete(taskId: string) { async function handleDelete(taskId: string) {
try { try {
await deleteTask(taskId); await getBackend().bttaskDelete(taskId);
if (expandedTaskId === taskId) expandedTaskId = null; if (expandedTaskId === taskId) expandedTaskId = null;
await loadTasks(); await loadTasks();
} catch (e) { } catch (e) {
@ -130,7 +131,7 @@
} }
expandedTaskId = taskId; expandedTaskId = taskId;
try { try {
taskComments = await getTaskComments(taskId); taskComments = await getBackend().bttaskComments(taskId);
} catch { } catch {
taskComments = []; taskComments = [];
} }
@ -139,9 +140,9 @@
async function handleAddComment() { async function handleAddComment() {
if (!expandedTaskId || !newComment.trim()) return; if (!expandedTaskId || !newComment.trim()) return;
try { try {
await addTaskComment(expandedTaskId, AgentId('admin'), newComment.trim()); await getBackend().bttaskAddComment(expandedTaskId, AgentId('admin'), newComment.trim());
newComment = ''; newComment = '';
taskComments = await getTaskComments(expandedTaskId); taskComments = await getBackend().bttaskComments(expandedTaskId);
} catch (e) { } catch (e) {
console.warn('Failed to add comment:', e); console.warn('Failed to add comment:', e);
} }

View file

@ -7,7 +7,9 @@
removeTerminalTab, removeTerminalTab,
type TerminalTab, type TerminalTab,
} from '../../stores/workspace.svelte'; } from '../../stores/workspace.svelte';
import { listSshSessions, type SshSession } from '../../adapters/ssh-bridge'; import { getBackend } from '../../backend/backend';
import type { SshSessionRecord } from '@agor/types';
type SshSession = SshSessionRecord;
import TerminalPane from '../Terminal/TerminalPane.svelte'; import TerminalPane from '../Terminal/TerminalPane.svelte';
import AgentPreviewPane from '../Terminal/AgentPreviewPane.svelte'; import AgentPreviewPane from '../Terminal/AgentPreviewPane.svelte';
@ -15,7 +17,7 @@
let sshSessions = $state<SshSession[]>([]); let sshSessions = $state<SshSession[]>([]);
onMount(() => { onMount(() => {
listSshSessions().then(s => { sshSessions = s; }).catch(() => {}); getBackend().listSshSessions().then(s => { sshSessions = s; }).catch(() => {});
}); });
/** Resolved SSH args per tab, keyed by tab id */ /** Resolved SSH args per tab, keyed by tab id */

View file

@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
import { listDirectoryChildren, readFileContent, type DirEntry } from '../../adapters/files-bridge'; import { getBackend } from '../../backend/backend';
import type { FileEntry } from '@agor/types';
type DirEntry = FileEntry;
interface Props { interface Props {
cwd: string; cwd: string;
@ -23,7 +25,7 @@
async function loadSeleniumState() { async function loadSeleniumState() {
const screenshotPath = `${cwd}/${SCREENSHOTS_DIR}`; const screenshotPath = `${cwd}/${SCREENSHOTS_DIR}`;
try { try {
const entries = await listDirectoryChildren(screenshotPath); const entries = await getBackend().listDirectory(screenshotPath);
const imageFiles = entries const imageFiles = entries
.filter(e => /\.(png|jpg|jpeg|webp)$/i.test(e.name)) .filter(e => /\.(png|jpg|jpeg|webp)$/i.test(e.name))
.map(e => e.path) .map(e => e.path)
@ -43,7 +45,7 @@
// Load session log // Load session log
try { try {
const content = await readFileContent(`${cwd}/${SELENIUM_LOG}`); const content = await getBackend().readFile(`${cwd}/${SELENIUM_LOG}`);
if (content.type === 'Text') { if (content.type === 'Text') {
seleniumLog = content.content.split('\n').filter(Boolean).slice(-50); seleniumLog = content.content.split('\n').filter(Boolean).slice(-50);
} }
@ -64,7 +66,7 @@
async function loadTestFiles() { async function loadTestFiles() {
for (const dir of TEST_DIRS) { for (const dir of TEST_DIRS) {
try { try {
const entries = await listDirectoryChildren(`${cwd}/${dir}`); const entries = await getBackend().listDirectory(`${cwd}/${dir}`);
const tests = entries.filter(e => const tests = entries.filter(e =>
/\.(test|spec)\.(ts|js|py|rs)$/.test(e.name) || /\.(test|spec)\.(ts|js|py|rs)$/.test(e.name) ||
/test_.*\.py$/.test(e.name) /test_.*\.py$/.test(e.name)
@ -83,7 +85,7 @@
async function viewTestFile(filePath: string) { async function viewTestFile(filePath: string) {
selectedTestFile = filePath; selectedTestFile = filePath;
try { try {
const content = await readFileContent(filePath); const content = await getBackend().readFile(filePath);
if (content.type === 'Text') { if (content.type === 'Text') {
testOutput = content.content; testOutput = content.content;
} }

View file

@ -2,12 +2,18 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// --- Mocks --- // --- Mocks ---
const { mockInvoke } = vi.hoisted(() => ({ const { mockBackend } = vi.hoisted(() => ({
mockInvoke: vi.fn(), mockBackend: {
readPluginFile: vi.fn(),
bttaskList: vi.fn().mockResolvedValue([]),
bttaskComments: vi.fn().mockResolvedValue([]),
btmsgUnreadMessages: vi.fn().mockResolvedValue([]),
btmsgGetChannels: vi.fn().mockResolvedValue([]),
},
})); }));
vi.mock('@tauri-apps/api/core', () => ({ vi.mock('../backend/backend', () => ({
invoke: mockInvoke, getBackend: vi.fn(() => mockBackend),
})); }));
// Mock the plugins store to avoid Svelte 5 rune issues in test context // Mock the plugins store to avoid Svelte 5 rune issues in test context
@ -41,7 +47,7 @@ import {
unloadAllPlugins, unloadAllPlugins,
} from './plugin-host'; } from './plugin-host';
import { addPluginCommand, removePluginCommands, pluginEventBus } from '../stores/plugins.svelte'; import { addPluginCommand, removePluginCommands, pluginEventBus } from '../stores/plugins.svelte';
import type { PluginMeta } from '../adapters/plugins-bridge'; import type { PluginMeta } from '@agor/types';
import type { GroupId, AgentId } from '../types/ids'; import type { GroupId, AgentId } from '../types/ids';
// --- Mock Worker --- // --- Mock Worker ---
@ -208,10 +214,7 @@ function makeMeta(overrides: Partial<PluginMeta> = {}): PluginMeta {
} }
function mockPluginCode(code: string): void { function mockPluginCode(code: string): void {
mockInvoke.mockImplementation((cmd: string) => { mockBackend.readPluginFile.mockResolvedValue(code);
if (cmd === 'plugin_read_file') return Promise.resolve(code);
return Promise.reject(new Error(`Unexpected invoke: ${cmd}`));
});
} }
const GROUP_ID = 'test-group' as GroupId; const GROUP_ID = 'test-group' as GroupId;
@ -481,7 +484,7 @@ describe('plugin-host lifecycle', () => {
it('loadPlugin throws on file read failure', async () => { it('loadPlugin throws on file read failure', async () => {
const meta = makeMeta({ id: 'read-fail' }); const meta = makeMeta({ id: 'read-fail' });
mockInvoke.mockRejectedValue(new Error('file not found')); mockBackend.readPluginFile.mockRejectedValue(new Error('file not found'));
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow( await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).rejects.toThrow(
"Failed to read plugin 'read-fail'", "Failed to read plugin 'read-fail'",
@ -509,12 +512,7 @@ describe('plugin-host RPC routing', () => {
const meta = makeMeta({ id: 'rpc-tasks', permissions: ['bttask:read'] }); const meta = makeMeta({ id: 'rpc-tasks', permissions: ['bttask:read'] });
mockPluginCode(`agor.tasks.list();`); mockPluginCode(`agor.tasks.list();`);
// Mock the bttask bridge // bttaskList already mocked via mockBackend defaults
mockInvoke.mockImplementation((cmd: string) => {
if (cmd === 'plugin_read_file') return Promise.resolve('agor.tasks.list();');
if (cmd === 'bttask_list') return Promise.resolve([]);
return Promise.reject(new Error(`Unexpected: ${cmd}`));
});
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
}); });
@ -523,11 +521,7 @@ describe('plugin-host RPC routing', () => {
const meta = makeMeta({ id: 'rpc-messages', permissions: ['btmsg:read'] }); const meta = makeMeta({ id: 'rpc-messages', permissions: ['btmsg:read'] });
mockPluginCode(`agor.messages.inbox();`); mockPluginCode(`agor.messages.inbox();`);
mockInvoke.mockImplementation((cmd: string) => { // btmsgUnreadMessages already mocked via mockBackend defaults
if (cmd === 'plugin_read_file') return Promise.resolve('agor.messages.inbox();');
if (cmd === 'btmsg_get_unread') return Promise.resolve([]);
return Promise.reject(new Error(`Unexpected: ${cmd}`));
});
await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined(); await expect(loadPlugin(meta, GROUP_ID, AGENT_ID)).resolves.toBeUndefined();
}); });

View file

@ -12,13 +12,8 @@
* On unload, the Worker is terminated all plugin state is destroyed. * On unload, the Worker is terminated all plugin state is destroyed.
*/ */
import type { PluginMeta } from '../adapters/plugins-bridge'; import type { PluginMeta } from '@agor/types';
import { readPluginFile } from '../adapters/plugins-bridge'; import { getBackend } from '../backend/backend';
import { listTasks, getTaskComments } from '../adapters/bttask-bridge';
import {
getUnreadMessages,
getChannels,
} from '../adapters/btmsg-bridge';
import { import {
addPluginCommand, addPluginCommand,
removePluginCommands, removePluginCommands,
@ -189,7 +184,7 @@ export async function loadPlugin(
// Read the plugin's entry file // Read the plugin's entry file
let code: string; let code: string;
try { try {
code = await readPluginFile(meta.id, meta.main); code = await getBackend().readPluginFile(meta.id, meta.main);
} catch (e) { } catch (e) {
throw new Error(`Failed to read plugin '${meta.id}' entry file '${meta.main}': ${e}`); throw new Error(`Failed to read plugin '${meta.id}' entry file '${meta.main}': ${e}`);
} }
@ -253,16 +248,16 @@ export async function loadPlugin(
let result: unknown; let result: unknown;
switch (method) { switch (method) {
case 'tasks.list': case 'tasks.list':
result = await listTasks(groupId); result = await getBackend().bttaskList(groupId);
break; break;
case 'tasks.comments': case 'tasks.comments':
result = await getTaskComments(args.taskId); result = await getBackend().bttaskComments(args.taskId);
break; break;
case 'messages.inbox': case 'messages.inbox':
result = await getUnreadMessages(agentId); result = await getBackend().btmsgUnreadMessages(agentId);
break; break;
case 'messages.channels': case 'messages.channels':
result = await getChannels(groupId); result = await getBackend().btmsgGetChannels(groupId);
break; break;
default: default:
throw new Error(`Unknown RPC method: ${method}`); throw new Error(`Unknown RPC method: ${method}`);

View file

@ -1,7 +1,14 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { getSetting, setSetting } from '../../stores/settings-store.svelte'; import { getSetting, setSetting } from '../../stores/settings-store.svelte';
import { storeSecret, getSecret, deleteSecret, listSecrets, hasKeyring, knownSecretKeys, SECRET_KEY_LABELS } from '../../adapters/secrets-bridge'; import { getBackend } from '../../backend/backend';
const SECRET_KEY_LABELS: Record<string, string> = {
anthropic_api_key: 'Anthropic API Key',
openai_api_key: 'OpenAI API Key',
openrouter_api_key: 'OpenRouter API Key',
github_token: 'GitHub Token',
relay_token: 'Relay Token',
};
import { handleError, handleInfraError } from '../../utils/handle-error'; import { handleError, handleInfraError } from '../../utils/handle-error';
let keyringAvailable = $state(false); let keyringAvailable = $state(false);
@ -25,8 +32,8 @@
onMount(async () => { onMount(async () => {
try { try {
keyringAvailable = await hasKeyring(); keyringAvailable = await getBackend().hasKeyring();
if (keyringAvailable) { storedKeys = await listSecrets(); knownKeys = await knownSecretKeys(); } if (keyringAvailable) { storedKeys = await getBackend().listSecrets(); knownKeys = await getBackend().knownSecretKeys(); }
} catch (e) { } catch (e) {
// Keyring unavailable is expected on some systems — set state explicitly // Keyring unavailable is expected on some systems — set state explicitly
handleInfraError(e, 'settings.keyring.init'); handleInfraError(e, 'settings.keyring.init');
@ -52,18 +59,18 @@
async function handleRevealSecret(key: string) { async function handleRevealSecret(key: string) {
if (revealedKey === key) { revealedKey = null; revealedValue = ''; return; } if (revealedKey === key) { revealedKey = null; revealedValue = ''; return; }
try { const val = await getSecret(key); revealedKey = key; revealedValue = val ?? ''; } try { const val = await getBackend().getSecret(key); revealedKey = key; revealedValue = val ?? ''; }
catch (e) { handleError(e, `settings.secrets.reveal.${key}`, 'reveal the secret'); } catch (e) { handleError(e, `settings.secrets.reveal.${key}`, 'reveal the secret'); }
} }
async function handleSaveSecret() { async function handleSaveSecret() {
if (!newSecretKey || !newSecretValue) return; if (!newSecretKey || !newSecretValue) return;
secretsSaving = true; secretsSaving = true;
try { await storeSecret(newSecretKey, newSecretValue); storedKeys = await listSecrets(); newSecretKey = ''; newSecretValue = ''; revealedKey = null; revealedValue = ''; } try { await getBackend().storeSecret(newSecretKey, newSecretValue); storedKeys = await getBackend().listSecrets(); newSecretKey = ''; newSecretValue = ''; revealedKey = null; revealedValue = ''; }
catch (e) { handleError(e, 'settings.secrets.store', 'store the secret'); } catch (e) { handleError(e, 'settings.secrets.store', 'store the secret'); }
finally { secretsSaving = false; } finally { secretsSaving = false; }
} }
async function handleDeleteSecret(key: string) { async function handleDeleteSecret(key: string) {
try { await deleteSecret(key); storedKeys = await listSecrets(); if (revealedKey === key) { revealedKey = null; revealedValue = ''; } } try { await getBackend().deleteSecret(key); storedKeys = await getBackend().listSecrets(); if (revealedKey === key) { revealedKey = null; revealedValue = ''; } }
catch (e) { handleError(e, `settings.secrets.delete.${key}`, 'delete the secret'); } catch (e) { handleError(e, `settings.secrets.delete.${key}`, 'delete the secret'); }
} }
function addBranchPolicy() { function addBranchPolicy() {

View file

@ -3,12 +3,7 @@
import type { SessionAnchor, AnchorType, AnchorBudgetScale, SessionAnchorRecord } from '../types/anchors'; import type { SessionAnchor, AnchorType, AnchorBudgetScale, SessionAnchorRecord } from '../types/anchors';
import { DEFAULT_ANCHOR_SETTINGS, ANCHOR_BUDGET_SCALE_MAP } from '../types/anchors'; import { DEFAULT_ANCHOR_SETTINGS, ANCHOR_BUDGET_SCALE_MAP } from '../types/anchors';
import { import { getBackend } from '../backend/backend';
saveSessionAnchors,
loadSessionAnchors,
deleteSessionAnchor,
updateAnchorType as updateAnchorTypeBridge,
} from '../adapters/anchors-bridge';
import { handleInfraError } from '../utils/handle-error'; import { handleInfraError } from '../utils/handle-error';
// Per-project anchor state // Per-project anchor state
@ -61,7 +56,7 @@ export async function addAnchors(projectId: string, anchors: SessionAnchor[]): P
})); }));
try { try {
await saveSessionAnchors(records); await getBackend().saveSessionAnchors(records);
} catch (e) { } catch (e) {
handleInfraError(e, 'anchors.save'); handleInfraError(e, 'anchors.save');
} }
@ -73,7 +68,7 @@ export async function removeAnchor(projectId: string, anchorId: string): Promise
projectAnchors.set(projectId, existing.filter(a => a.id !== anchorId)); projectAnchors.set(projectId, existing.filter(a => a.id !== anchorId));
try { try {
await deleteSessionAnchor(anchorId); await getBackend().deleteSessionAnchor(anchorId);
} catch (e) { } catch (e) {
handleInfraError(e, 'anchors.delete'); handleInfraError(e, 'anchors.delete');
} }
@ -90,7 +85,7 @@ export async function changeAnchorType(projectId: string, anchorId: string, newT
projectAnchors.set(projectId, [...existing]); projectAnchors.set(projectId, [...existing]);
try { try {
await updateAnchorTypeBridge(anchorId, newType); await getBackend().updateAnchorType(anchorId, newType);
} catch (e) { } catch (e) {
handleInfraError(e, 'anchors.updateType'); handleInfraError(e, 'anchors.updateType');
} }
@ -99,7 +94,7 @@ export async function changeAnchorType(projectId: string, anchorId: string, newT
/** Load anchors from SQLite for a project */ /** Load anchors from SQLite for a project */
export async function loadAnchorsForProject(projectId: string): Promise<void> { export async function loadAnchorsForProject(projectId: string): Promise<void> {
try { try {
const records = await loadSessionAnchors(projectId); const records = await getBackend().loadSessionAnchors(projectId);
const anchors: SessionAnchor[] = records.map(r => ({ const anchors: SessionAnchor[] = records.map(r => ({
id: r.id, id: r.id,
projectId: r.project_id, projectId: r.project_id,

View file

@ -1,14 +1,5 @@
import { import { getBackend } from '../backend/backend';
listSessions, import type { PersistedSession } from '@agor/types';
saveSession,
deleteSession,
updateSessionTitle,
touchSession,
saveLayout,
loadLayout,
updateSessionGroup,
type PersistedSession,
} from '../adapters/session-bridge';
import { handleInfraError } from '../utils/handle-error'; import { handleInfraError } from '../utils/handle-error';
export type LayoutPreset = '1-col' | '2-col' | '3-col' | '2x2' | 'master-stack'; export type LayoutPreset = '1-col' | '2-col' | '3-col' | '2x2' | 'master-stack';
@ -47,11 +38,11 @@ function persistSession(pane: Pane): void {
created_at: now, created_at: now,
last_used_at: now, last_used_at: now,
}; };
saveSession(session).catch(e => handleInfraError(e, 'layout.persistSession')); getBackend().saveSession(session).catch(e => handleInfraError(e, 'layout.persistSession'));
} }
function persistLayout(): void { function persistLayout(): void {
saveLayout({ getBackend().saveLayout({
preset: activePreset, preset: activePreset,
pane_ids: panes.map(p => p.id), pane_ids: panes.map(p => p.id),
}).catch(e => handleInfraError(e, 'layout.persistLayout')); }).catch(e => handleInfraError(e, 'layout.persistLayout'));
@ -85,14 +76,14 @@ export function removePane(id: string): void {
focusedPaneId = panes.length > 0 ? panes[0].id : null; focusedPaneId = panes.length > 0 ? panes[0].id : null;
} }
autoPreset(); autoPreset();
deleteSession(id).catch(e => handleInfraError(e, 'layout.deleteSession')); getBackend().deleteSession(id).catch(e => handleInfraError(e, 'layout.deleteSession'));
persistLayout(); persistLayout();
} }
export function focusPane(id: string): void { export function focusPane(id: string): void {
focusedPaneId = id; focusedPaneId = id;
panes = panes.map(p => ({ ...p, focused: p.id === id })); panes = panes.map(p => ({ ...p, focused: p.id === id }));
touchSession(id).catch(e => handleInfraError(e, 'layout.touchSession')); getBackend().touchSession(id).catch(e => handleInfraError(e, 'layout.touchSession'));
} }
export function focusPaneByIndex(index: number): void { export function focusPaneByIndex(index: number): void {
@ -110,7 +101,7 @@ export function renamePaneTitle(id: string, title: string): void {
const pane = panes.find(p => p.id === id); const pane = panes.find(p => p.id === id);
if (pane) { if (pane) {
pane.title = title; pane.title = title;
updateSessionTitle(id, title).catch(e => handleInfraError(e, 'layout.updateTitle')); getBackend().updateSessionTitle(id, title).catch(e => handleInfraError(e, 'layout.updateTitle'));
} }
} }
@ -118,7 +109,7 @@ export function setPaneGroup(id: string, group: string): void {
const pane = panes.find(p => p.id === id); const pane = panes.find(p => p.id === id);
if (pane) { if (pane) {
pane.group = group || undefined; pane.group = group || undefined;
updateSessionGroup(id, group).catch(e => handleInfraError(e, 'layout.updateGroup')); getBackend().updateSessionGroup(id, group).catch(e => handleInfraError(e, 'layout.updateGroup'));
} }
} }
@ -128,7 +119,8 @@ export async function restoreFromDb(): Promise<void> {
initialized = true; initialized = true;
try { try {
const [sessions, layout] = await Promise.all([listSessions(), loadLayout()]); const backend = getBackend();
const [sessions, layout] = await Promise.all([backend.listSessions(), backend.loadLayout()]);
if (layout.preset) { if (layout.preset) {
activePreset = layout.preset as LayoutPreset; activePreset = layout.preset as LayoutPreset;

View file

@ -1,14 +1,17 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock session-bridge before importing the layout store // Mock backend before importing the layout store
vi.mock('../adapters/session-bridge', () => ({ vi.mock('../backend/backend', () => ({
listSessions: vi.fn().mockResolvedValue([]), getBackend: vi.fn(() => ({
saveSession: vi.fn().mockResolvedValue(undefined), listSessions: vi.fn().mockResolvedValue([]),
deleteSession: vi.fn().mockResolvedValue(undefined), saveSession: vi.fn().mockResolvedValue(undefined),
updateSessionTitle: vi.fn().mockResolvedValue(undefined), deleteSession: vi.fn().mockResolvedValue(undefined),
touchSession: vi.fn().mockResolvedValue(undefined), updateSessionTitle: vi.fn().mockResolvedValue(undefined),
saveLayout: vi.fn().mockResolvedValue(undefined), touchSession: vi.fn().mockResolvedValue(undefined),
loadLayout: vi.fn().mockResolvedValue({ preset: '1-col', pane_ids: [] }), updateSessionGroup: vi.fn().mockResolvedValue(undefined),
saveLayout: vi.fn().mockResolvedValue(undefined),
loadLayout: vi.fn().mockResolvedValue({ preset: '1-col', pane_ids: [] }),
})),
})); }));
import { import {

View file

@ -1,19 +1,7 @@
// Remote machines store — tracks connection state for multi-machine support // Remote machines store — tracks connection state for multi-machine support
import { import { getBackend } from '../backend/backend';
listRemoteMachines, import type { RemoteMachineConfig, RemoteMachineInfo } from '@agor/types';
addRemoteMachine,
removeRemoteMachine,
connectRemoteMachine,
disconnectRemoteMachine,
onRemoteMachineReady,
onRemoteMachineDisconnected,
onRemoteError,
onRemoteMachineReconnecting,
onRemoteMachineReconnectReady,
type RemoteMachineConfig,
type RemoteMachineInfo,
} from '../adapters/remote-bridge';
import { notify } from './notifications.svelte'; import { notify } from './notifications.svelte';
import { handleInfraError } from '../utils/handle-error'; import { handleInfraError } from '../utils/handle-error';
@ -31,26 +19,27 @@ export function getMachine(id: string): Machine | undefined {
export async function loadMachines(): Promise<void> { export async function loadMachines(): Promise<void> {
try { try {
machines = await listRemoteMachines(); machines = await getBackend().listRemoteMachines();
} catch (e) { } catch (e) {
handleInfraError(e, 'machines.load'); handleInfraError(e, 'machines.load');
} }
} }
export async function addMachine(config: RemoteMachineConfig): Promise<string> { export async function addMachine(config: RemoteMachineConfig): Promise<string> {
const id = await addRemoteMachine(config); const id = await getBackend().addRemoteMachine(config);
machines.push({ machines.push({
id, id,
label: config.label, label: config.label,
url: config.url, url: config.url,
status: 'disconnected', status: 'disconnected',
auto_connect: config.auto_connect, auto_connect: config.auto_connect,
spki_pins: config.spki_pins ?? [],
}); });
return id; return id;
} }
export async function removeMachine(id: string): Promise<void> { export async function removeMachine(id: string): Promise<void> {
await removeRemoteMachine(id); await getBackend().removeRemoteMachine(id);
machines = machines.filter(m => m.id !== id); machines = machines.filter(m => m.id !== id);
} }
@ -58,7 +47,7 @@ export async function connectMachine(id: string): Promise<void> {
const machine = machines.find(m => m.id === id); const machine = machines.find(m => m.id === id);
if (machine) machine.status = 'connecting'; if (machine) machine.status = 'connecting';
try { try {
await connectRemoteMachine(id); await getBackend().connectRemoteMachine(id);
if (machine) machine.status = 'connected'; if (machine) machine.status = 'connected';
} catch (e) { } catch (e) {
if (machine) machine.status = 'error'; if (machine) machine.status = 'error';
@ -67,7 +56,7 @@ export async function connectMachine(id: string): Promise<void> {
} }
export async function disconnectMachine(id: string): Promise<void> { export async function disconnectMachine(id: string): Promise<void> {
await disconnectRemoteMachine(id); await getBackend().disconnectRemoteMachine(id);
const machine = machines.find(m => m.id === id); const machine = machines.find(m => m.id === id);
if (machine) machine.status = 'disconnected'; if (machine) machine.status = 'disconnected';
} }
@ -76,11 +65,12 @@ export async function disconnectMachine(id: string): Promise<void> {
let unlistenFns: (() => void)[] = []; let unlistenFns: (() => void)[] = [];
// Initialize event listeners for machine status updates // Initialize event listeners for machine status updates
export async function initMachineListeners(): Promise<void> { export function initMachineListeners(): void {
// Clean up any existing listeners first // Clean up any existing listeners first
destroyMachineListeners(); destroyMachineListeners();
const backend = getBackend();
unlistenFns.push(await onRemoteMachineReady((msg) => { unlistenFns.push(backend.onRemoteMachineReady((msg) => {
const machine = machines.find(m => m.id === msg.machineId); const machine = machines.find(m => m.id === msg.machineId);
if (machine) { if (machine) {
machine.status = 'connected'; machine.status = 'connected';
@ -88,7 +78,7 @@ export async function initMachineListeners(): Promise<void> {
} }
})); }));
unlistenFns.push(await onRemoteMachineDisconnected((msg) => { unlistenFns.push(backend.onRemoteMachineDisconnected((msg) => {
const machine = machines.find(m => m.id === msg.machineId); const machine = machines.find(m => m.id === msg.machineId);
if (machine) { if (machine) {
machine.status = 'disconnected'; machine.status = 'disconnected';
@ -96,7 +86,7 @@ export async function initMachineListeners(): Promise<void> {
} }
})); }));
unlistenFns.push(await onRemoteError((msg) => { unlistenFns.push(backend.onRemoteError((msg) => {
const machine = machines.find(m => m.id === msg.machineId); const machine = machines.find(m => m.id === msg.machineId);
if (machine) { if (machine) {
machine.status = 'error'; machine.status = 'error';
@ -104,18 +94,18 @@ export async function initMachineListeners(): Promise<void> {
} }
})); }));
unlistenFns.push(await onRemoteMachineReconnecting((msg) => { unlistenFns.push(backend.onRemoteMachineReconnecting((msg) => {
const machine = machines.find(m => m.id === msg.machineId); const machine = machines.find(m => m.id === msg.machineId);
if (machine) { if (machine) {
machine.status = 'reconnecting'; machine.status = 'reconnecting';
notify('info', `Reconnecting to ${machine.label} in ${msg.backoffSecs}s`); notify('info', `Reconnecting to ${machine.label} in ${msg.backoffSecs}s...`);
} }
})); }));
unlistenFns.push(await onRemoteMachineReconnectReady((msg) => { unlistenFns.push(backend.onRemoteMachineReconnectReady((msg) => {
const machine = machines.find(m => m.id === msg.machineId); const machine = machines.find(m => m.id === msg.machineId);
if (machine) { if (machine) {
notify('info', `${machine.label} reachable — reconnecting…`); notify('info', `${machine.label} reachable - reconnecting...`);
connectMachine(msg.machineId).catch((e) => { connectMachine(msg.machineId).catch((e) => {
notify('error', `Auto-reconnect failed for ${machine.label}: ${e}`); notify('error', `Auto-reconnect failed for ${machine.label}: ${e}`);
}); });

View file

@ -1,6 +1,6 @@
// Notification store — ephemeral toasts + persistent notification history // Notification store — ephemeral toasts + persistent notification history
import { sendDesktopNotification } from '../adapters/notifications-bridge'; import { getBackend } from '../backend/backend';
// --- Toast types (existing) --- // --- Toast types (existing) ---
@ -141,7 +141,7 @@ export function addNotification(
notify(toastType, `${title}: ${body}`); notify(toastType, `${title}: ${body}`);
// Send OS desktop notification (fire-and-forget) // Send OS desktop notification (fire-and-forget)
sendDesktopNotification(title, body, notificationUrgency(type)); try { getBackend().sendDesktopNotification(title, body, notificationUrgency(type)); } catch { /* backend not ready */ }
return id; return id;
} }

View file

@ -3,8 +3,8 @@
* Uses Svelte 5 runes for reactivity. * Uses Svelte 5 runes for reactivity.
*/ */
import type { PluginMeta } from '../adapters/plugins-bridge'; import type { PluginMeta } from '@agor/types';
import { discoverPlugins } from '../adapters/plugins-bridge'; import { getBackend } from '../backend/backend';
import { getSetting, setSetting } from './settings-store.svelte'; import { getSetting, setSetting } from './settings-store.svelte';
import { loadPlugin, unloadPlugin, unloadAllPlugins, getLoadedPlugins } from '../plugins/plugin-host'; import { loadPlugin, unloadPlugin, unloadAllPlugins, getLoadedPlugins } from '../plugins/plugin-host';
import { handleInfraError } from '../utils/handle-error'; import { handleInfraError } from '../utils/handle-error';
@ -160,7 +160,7 @@ export async function loadAllPlugins(groupId?: GroupId, agentId?: AgentId): Prom
let discovered: PluginMeta[]; let discovered: PluginMeta[];
try { try {
discovered = await discoverPlugins(); discovered = await getBackend().discoverPlugins();
} catch (e) { } catch (e) {
handleInfraError(e, 'plugins.discover'); handleInfraError(e, 'plugins.discover');
pluginEntries = []; pluginEntries = [];

View file

@ -6,9 +6,8 @@ import type { AgentId } from '../types/ids';
import { evaluateWakeSignals, shouldWake } from '../utils/wake-scorer'; import { evaluateWakeSignals, shouldWake } from '../utils/wake-scorer';
import { getAllProjectHealth, getHealthAggregates } from './health.svelte'; import { getAllProjectHealth, getHealthAggregates } from './health.svelte';
import { getAllWorkItems } from './workspace.svelte'; import { getAllWorkItems } from './workspace.svelte';
import { listTasks } from '../adapters/bttask-bridge'; import { getBackend } from '../backend/backend';
import { getAgentSession } from './agents.svelte'; import { getAgentSession } from './agents.svelte';
import { logAuditEvent } from '../adapters/audit-bridge';
import { handleInfraError } from '../utils/handle-error'; import { handleInfraError } from '../utils/handle-error';
import type { GroupId } from '../types/ids'; import type { GroupId } from '../types/ids';
@ -210,7 +209,7 @@ async function evaluateAndEmit(reg: ManagerRegistration): Promise<void> {
// Fetch task summary (best-effort) // Fetch task summary (best-effort)
let taskSummary: WakeTaskSummary | undefined; let taskSummary: WakeTaskSummary | undefined;
try { try {
const tasks = await listTasks(reg.groupId); const tasks = await getBackend().bttaskList(reg.groupId);
taskSummary = { taskSummary = {
total: tasks.length, total: tasks.length,
todo: tasks.filter(t => t.status === 'todo').length, todo: tasks.filter(t => t.status === 'todo').length,
@ -262,7 +261,7 @@ async function evaluateAndEmit(reg: ManagerRegistration): Promise<void> {
}); });
// Audit: log wake event // Audit: log wake event
logAuditEvent( getBackend().logAuditEvent(
reg.agentId, reg.agentId,
'wake_event', 'wake_event',
`Auto-wake triggered (strategy=${reg.strategy}, mode=${mode}, score=${evaluation.totalScore.toFixed(2)})`, `Auto-wake triggered (strategy=${reg.strategy}, mode=${mode}, score=${evaluation.totalScore.toFixed(2)})`,

View file

@ -1,4 +1,4 @@
import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge'; import { getBackend } from '../backend/backend';
import { handleInfraError } from '../utils/handle-error'; import { handleInfraError } from '../utils/handle-error';
import type { GroupsFile, GroupConfig, ProjectConfig, GroupAgentConfig } from '../types/groups'; import type { GroupsFile, GroupConfig, ProjectConfig, GroupAgentConfig } from '../types/groups';
import { agentToProject } from '../types/groups'; import { agentToProject } from '../types/groups';
@ -7,7 +7,6 @@ import { clearHealthTracking } from '../stores/health.svelte';
import { clearAllConflicts } from '../stores/conflicts.svelte'; import { clearAllConflicts } from '../stores/conflicts.svelte';
import { clearWakeScheduler } from '../stores/wake-scheduler.svelte'; import { clearWakeScheduler } from '../stores/wake-scheduler.svelte';
import { waitForPendingPersistence } from '../agent-dispatcher'; import { waitForPendingPersistence } from '../agent-dispatcher';
import { registerAgents } from '../adapters/btmsg-bridge';
export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings' | 'comms'; export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings' | 'comms';
@ -201,7 +200,7 @@ export async function switchGroup(groupId: string): Promise<void> {
// Persist active group // Persist active group
if (groupsConfig) { if (groupsConfig) {
groupsConfig.activeGroupId = groupId; groupsConfig.activeGroupId = groupId;
saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups')); getBackend().saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
} }
} }
@ -225,18 +224,18 @@ export function removeTerminalTab(projectId: string, tabId: string): void {
export async function loadWorkspace(initialGroupId?: string): Promise<void> { export async function loadWorkspace(initialGroupId?: string): Promise<void> {
try { try {
const config = await loadGroups(); const config = await getBackend().loadGroups();
groupsConfig = config; groupsConfig = config;
projectTerminals = {}; projectTerminals = {};
// Register all agents from config into btmsg database // Register all agents from config into btmsg database
// (creates agent records, contact permissions, review channels) // (creates agent records, contact permissions, review channels)
registerAgents(config).catch(e => handleInfraError(e, 'workspace.registerAgents')); getBackend().btmsgRegisterAgents(config).catch(e => handleInfraError(e, 'workspace.registerAgents'));
// CLI --group flag takes priority, then explicit param, then persisted // CLI --group flag takes priority, then explicit param, then persisted
let cliGroup: string | null = null; let cliGroup: string | null = null;
if (!initialGroupId) { if (!initialGroupId) {
cliGroup = await getCliGroup(); cliGroup = await getBackend().getCliGroup();
} }
const targetId = initialGroupId || cliGroup || config.activeGroupId; const targetId = initialGroupId || cliGroup || config.activeGroupId;
// Match by ID or by name (CLI users may pass name) // Match by ID or by name (CLI users may pass name)
@ -263,9 +262,9 @@ export async function loadWorkspace(initialGroupId?: string): Promise<void> {
export async function saveWorkspace(): Promise<void> { export async function saveWorkspace(): Promise<void> {
if (!groupsConfig) return; if (!groupsConfig) return;
await saveGroups(groupsConfig); await getBackend().saveGroups(groupsConfig);
// Re-register agents after config changes (new agents, permission updates) // Re-register agents after config changes (new agents, permission updates)
registerAgents(groupsConfig).catch(e => handleInfraError(e, 'workspace.registerAgents')); getBackend().btmsgRegisterAgents(groupsConfig).catch(e => handleInfraError(e, 'workspace.registerAgents'));
} }
// --- Group/project mutation --- // --- Group/project mutation ---
@ -276,7 +275,7 @@ export function addGroup(group: GroupConfig): void {
...groupsConfig, ...groupsConfig,
groups: [...groupsConfig.groups, group], groups: [...groupsConfig.groups, group],
}; };
saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups')); getBackend().saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
} }
export function removeGroup(groupId: string): void { export function removeGroup(groupId: string): void {
@ -289,7 +288,7 @@ export function removeGroup(groupId: string): void {
activeGroupId = groupsConfig.groups[0]?.id ?? ''; activeGroupId = groupsConfig.groups[0]?.id ?? '';
activeProjectId = null; activeProjectId = null;
} }
saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups')); getBackend().saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
} }
export function updateProject(groupId: string, projectId: string, updates: Partial<ProjectConfig>): void { export function updateProject(groupId: string, projectId: string, updates: Partial<ProjectConfig>): void {
@ -307,7 +306,7 @@ export function updateProject(groupId: string, projectId: string, updates: Parti
}; };
}), }),
}; };
saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups')); getBackend().saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
} }
export function addProject(groupId: string, project: ProjectConfig): void { export function addProject(groupId: string, project: ProjectConfig): void {
@ -321,7 +320,7 @@ export function addProject(groupId: string, project: ProjectConfig): void {
return { ...g, projects: [...g.projects, project] }; return { ...g, projects: [...g.projects, project] };
}), }),
}; };
saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups')); getBackend().saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
} }
export function removeProject(groupId: string, projectId: string): void { export function removeProject(groupId: string, projectId: string): void {
@ -336,7 +335,7 @@ export function removeProject(groupId: string, projectId: string): void {
if (activeProjectId === projectId) { if (activeProjectId === projectId) {
activeProjectId = null; activeProjectId = null;
} }
saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups')); getBackend().saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
} }
export function updateAgent(groupId: string, agentId: string, updates: Partial<GroupAgentConfig>): void { export function updateAgent(groupId: string, agentId: string, updates: Partial<GroupAgentConfig>): void {
@ -354,5 +353,5 @@ export function updateAgent(groupId: string, agentId: string, updates: Partial<G
}; };
}), }),
}; };
saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups')); getBackend().saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
} }

View file

@ -38,10 +38,15 @@ vi.mock('../agent-dispatcher', () => ({
waitForPendingPersistence: vi.fn().mockResolvedValue(undefined), waitForPendingPersistence: vi.fn().mockResolvedValue(undefined),
})); }));
vi.mock('../adapters/groups-bridge', () => ({ const mockBackend = {
loadGroups: vi.fn().mockImplementation(() => Promise.resolve(mockGroupsData())), loadGroups: vi.fn().mockImplementation(() => Promise.resolve(mockGroupsData())),
saveGroups: vi.fn().mockResolvedValue(undefined), saveGroups: vi.fn().mockResolvedValue(undefined),
getCliGroup: vi.fn().mockResolvedValue(null), getCliGroup: vi.fn().mockResolvedValue(null),
btmsgRegisterAgents: vi.fn().mockResolvedValue(undefined),
};
vi.mock('../backend/backend', () => ({
getBackend: vi.fn(() => mockBackend),
})); }));
import { import {
@ -66,7 +71,7 @@ import {
removeProject, removeProject,
} from './workspace.svelte'; } from './workspace.svelte';
import { saveGroups, getCliGroup } from '../adapters/groups-bridge'; const { saveGroups, getCliGroup } = mockBackend;
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();

View file

@ -7,7 +7,7 @@ import type { SessionAnchor } from '../types/anchors';
import { getAnchorSettings, addAnchors } from '../stores/anchors.svelte'; import { getAnchorSettings, addAnchors } from '../stores/anchors.svelte';
import { selectAutoAnchors, serializeAnchorsForInjection } from '../utils/anchor-serializer'; import { selectAutoAnchors, serializeAnchorsForInjection } from '../utils/anchor-serializer';
import { getEnabledProjects } from '../stores/workspace.svelte'; import { getEnabledProjects } from '../stores/workspace.svelte';
import { tel } from '../adapters/telemetry-bridge'; import { tel } from './telemetry';
import { notify } from '../stores/notifications.svelte'; import { notify } from '../stores/notifications.svelte';
/** Auto-anchor first N turns on first compaction event for a project */ /** Auto-anchor first N turns on first compaction event for a project */

View file

@ -1,7 +1,7 @@
import { extractErrorMessage } from './extract-error-message'; import { extractErrorMessage } from './extract-error-message';
import { classifyError } from './error-classifier'; import { classifyError } from './error-classifier';
import { notify } from '../stores/notifications.svelte'; import { notify } from '../stores/notifications.svelte';
import { tel } from '../adapters/telemetry-bridge'; import { tel } from './telemetry';
let initialized = false; let initialized = false;

View file

@ -6,7 +6,7 @@
import { extractErrorMessage } from './extract-error-message'; import { extractErrorMessage } from './extract-error-message';
import { classifyError, type ClassifiedError } from './error-classifier'; import { classifyError, type ClassifiedError } from './error-classifier';
import { notify } from '../stores/notifications.svelte'; import { notify } from '../stores/notifications.svelte';
import { tel } from '../adapters/telemetry-bridge'; import { tel } from './telemetry';
/** User-facing error handler. Logs to telemetry AND shows a toast. */ /** User-facing error handler. Logs to telemetry AND shows a toast. */
export function handleError( export function handleError(

View file

@ -4,12 +4,8 @@
import type { SessionId as SessionIdType, ProjectId as ProjectIdType } from '../types/ids'; import type { SessionId as SessionIdType, ProjectId as ProjectIdType } from '../types/ids';
import type { ProviderId } from '../providers/types'; import type { ProviderId } from '../providers/types';
import { getAgentSession } from '../stores/agents.svelte'; import { getAgentSession } from '../stores/agents.svelte';
import { import { getBackend } from '../backend/backend';
saveProjectAgentState, import type { AgentMessageRecord } from '@agor/types';
saveAgentMessages,
saveSessionMetric,
type AgentMessageRecord,
} from '../adapters/groups-bridge';
import { handleInfraError } from './handle-error'; import { handleInfraError } from './handle-error';
// Map sessionId -> projectId for persistence routing // Map sessionId -> projectId for persistence routing
@ -58,8 +54,9 @@ export async function persistSessionForProject(sessionId: SessionIdType): Promis
pendingPersistCount++; pendingPersistCount++;
try { try {
const backend = getBackend();
// Save agent state // Save agent state
await saveProjectAgentState({ await backend.saveProjectAgentState({
project_id: projectId, project_id: projectId,
last_session_id: sessionId, last_session_id: sessionId,
sdk_session_id: session.sdkSessionId ?? null, sdk_session_id: session.sdkSessionId ?? null,
@ -85,13 +82,13 @@ export async function persistSessionForProject(sessionId: SessionIdType): Promis
})); }));
if (records.length > 0) { if (records.length > 0) {
await saveAgentMessages(sessionId, projectId, session.sdkSessionId, records); await backend.saveAgentMessages(sessionId, projectId, session.sdkSessionId, records);
} }
// Persist session metric for historical tracking // Persist session metric for historical tracking
const toolCallCount = session.messages.filter(m => m.type === 'tool_call').length; const toolCallCount = session.messages.filter(m => m.type === 'tool_call').length;
const startTime = sessionStartTimes.get(sessionId) ?? Math.floor(Date.now() / 1000); const startTime = sessionStartTimes.get(sessionId) ?? Math.floor(Date.now() / 1000);
await saveSessionMetric({ await backend.saveSessionMetric({
project_id: projectId, project_id: projectId,
session_id: sessionId, session_id: sessionId,
start_time: Math.floor(startTime / 1000), start_time: Math.floor(startTime / 1000),

View file

@ -0,0 +1,23 @@
// Telemetry utility — routes frontend events to backend via BackendAdapter
// Replaces telemetry-bridge.ts (which imported Tauri invoke directly)
import { getBackend } from '../backend/backend';
/** Convenience wrappers for structured logging */
export const tel = {
error: (msg: string, ctx?: Record<string, unknown>) => {
try { getBackend().telemetryLog('error', msg, ctx); } catch { /* backend not ready */ }
},
warn: (msg: string, ctx?: Record<string, unknown>) => {
try { getBackend().telemetryLog('warn', msg, ctx); } catch { /* backend not ready */ }
},
info: (msg: string, ctx?: Record<string, unknown>) => {
try { getBackend().telemetryLog('info', msg, ctx); } catch { /* backend not ready */ }
},
debug: (msg: string, ctx?: Record<string, unknown>) => {
try { getBackend().telemetryLog('debug', msg, ctx); } catch { /* backend not ready */ }
},
trace: (msg: string, ctx?: Record<string, unknown>) => {
try { getBackend().telemetryLog('trace', msg, ctx); } catch { /* backend not ready */ }
},
};