diff --git a/packages/types/backend.ts b/packages/types/backend.ts index e376e2f..008b11c 100644 --- a/packages/types/backend.ts +++ b/packages/types/backend.ts @@ -1,9 +1,14 @@ // BackendAdapter — abstraction layer for Tauri and Electrobun backends -import type { AgentStartOptions, AgentMessage, AgentStatus } from './agent'; -import type { FileEntry, FileContent, PtyCreateOptions } from './protocol'; +import type { AgentStartOptions, AgentMessage, AgentStatus, ProviderId } from './agent'; +import type { FileEntry, FileContent, PtyCreateOptions, SearchResult } from './protocol'; import type { SettingsMap } from './settings'; 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 ───────────────────────────────────────────────────── @@ -31,9 +36,223 @@ export interface BackendCapabilities { /** Call to remove an event listener */ export type UnsubscribeFn = () => void; +// ── Domain-specific sub-interfaces ────────────────────────────────────────── + +export interface SessionPersistenceAdapter { + listSessions(): Promise; + saveSession(session: PersistedSession): Promise; + deleteSession(id: string): Promise; + updateSessionTitle(id: string, title: string): Promise; + touchSession(id: string): Promise; + updateSessionGroup(id: string, groupName: string): Promise; + saveLayout(layout: PersistedLayout): Promise; + loadLayout(): Promise; +} + +export interface AgentPersistenceAdapter { + saveAgentMessages( + sessionId: SessionId, projectId: ProjectId, sdkSessionId: string | undefined, + messages: AgentMessageRecord[], + ): Promise; + loadAgentMessages(projectId: ProjectId): Promise; + saveProjectAgentState(state: ProjectAgentState): Promise; + loadProjectAgentState(projectId: ProjectId): Promise; + saveSessionMetric(metric: Omit): Promise; + loadSessionMetrics(projectId: ProjectId, limit?: number): Promise; + getCliGroup(): Promise; + discoverMarkdownFiles(cwd: string): Promise; +} + +export interface BtmsgAdapter { + btmsgGetAgents(groupId: GroupId): Promise; + btmsgUnreadCount(agentId: AgentId): Promise; + btmsgUnreadMessages(agentId: AgentId): Promise; + btmsgHistory(agentId: AgentId, otherId: AgentId, limit?: number): Promise; + btmsgSend(fromAgent: AgentId, toAgent: AgentId, content: string): Promise; + btmsgSetStatus(agentId: AgentId, status: string): Promise; + btmsgEnsureAdmin(groupId: GroupId): Promise; + btmsgAllFeed(groupId: GroupId, limit?: number): Promise; + btmsgMarkRead(readerId: AgentId, senderId: AgentId): Promise; + btmsgGetChannels(groupId: GroupId): Promise; + btmsgChannelMessages(channelId: string, limit?: number): Promise; + btmsgChannelSend(channelId: string, fromAgent: AgentId, content: string): Promise; + btmsgCreateChannel(name: string, groupId: GroupId, createdBy: AgentId): Promise; + btmsgAddChannelMember(channelId: string, agentId: AgentId): Promise; + btmsgRegisterAgents(config: GroupsFile): Promise; + btmsgUnseenMessages(agentId: AgentId, sessionId: string): Promise; + btmsgMarkSeen(sessionId: string, messageIds: string[]): Promise; + btmsgPruneSeen(): Promise; + btmsgRecordHeartbeat(agentId: AgentId): Promise; + btmsgGetStaleAgents(groupId: GroupId, thresholdSecs?: number): Promise; + btmsgGetDeadLetters(groupId: GroupId, limit?: number): Promise; + btmsgClearDeadLetters(groupId: GroupId): Promise; + btmsgClearAllComms(groupId: GroupId): Promise; +} + +export interface BttaskAdapter { + bttaskList(groupId: GroupId): Promise; + bttaskComments(taskId: string): Promise; + bttaskUpdateStatus(taskId: string, status: string, version: number): Promise; + bttaskAddComment(taskId: string, agentId: AgentId, content: string): Promise; + bttaskCreate( + title: string, description: string, priority: string, + groupId: GroupId, createdBy: AgentId, assignedTo?: AgentId, + ): Promise; + bttaskDelete(taskId: string): Promise; + bttaskReviewQueueCount(groupId: GroupId): Promise; +} + +export interface AnchorsAdapter { + saveSessionAnchors(anchors: SessionAnchorRecord[]): Promise; + loadSessionAnchors(projectId: string): Promise; + deleteSessionAnchor(id: string): Promise; + clearProjectAnchors(projectId: string): Promise; + updateAnchorType(id: string, anchorType: string): Promise; +} + +export interface SearchAdapter { + searchInit(): Promise; + searchAll(query: string, limit?: number): Promise; + searchRebuild(): Promise; + searchIndexMessage(sessionId: string, role: string, content: string): Promise; +} + +export interface AuditAdapter { + logAuditEvent(agentId: AgentId, eventType: AuditEventType, detail: string): Promise; + getAuditLog(groupId: GroupId, limit?: number, offset?: number): Promise; + getAuditLogForAgent(agentId: AgentId, limit?: number): Promise; +} + +export interface NotificationsAdapter { + sendDesktopNotification(title: string, body: string, urgency?: NotificationUrgency): void; +} + +export interface TelemetryAdapter { + telemetryLog(level: TelemetryLevel, message: string, context?: Record): void; +} + +export interface SecretsAdapter { + storeSecret(key: string, value: string): Promise; + getSecret(key: string): Promise; + deleteSecret(key: string): Promise; + listSecrets(): Promise; + hasKeyring(): Promise; + knownSecretKeys(): Promise; +} + +export interface FsWatcherAdapter { + fsWatchProject(projectId: string, cwd: string): Promise; + fsUnwatchProject(projectId: string): Promise; + onFsWriteDetected(callback: (event: FsWriteEvent) => void): UnsubscribeFn; + fsWatcherStatus(): Promise; +} + +export interface CtxAdapter { + ctxInitDb(): Promise; + ctxRegisterProject(name: string, description: string, workDir?: string): Promise; + ctxGetContext(project: string): Promise; + ctxGetShared(): Promise; + ctxGetSummaries(project: string, limit?: number): Promise; + ctxSearch(query: string): Promise; +} + +export interface MemoraAdapter { + memoraAvailable(): Promise; + memoraList(options?: { tags?: string[]; limit?: number; offset?: number }): Promise; + memoraSearch(query: string, options?: { tags?: string[]; limit?: number }): Promise; + memoraGet(id: number): Promise; +} + +export interface SshAdapter { + listSshSessions(): Promise; + saveSshSession(session: SshSessionRecord): Promise; + deleteSshSession(id: string): Promise; +} + +export interface PluginsAdapter { + discoverPlugins(): Promise; + readPluginFile(pluginId: string, filename: string): Promise; +} + +export interface ClaudeProviderAdapter { + listProfiles(): Promise; + listSkills(): Promise; + readSkill(path: string): Promise; +} + +export interface RemoteMachineAdapter { + listRemoteMachines(): Promise; + addRemoteMachine(config: RemoteMachineConfig): Promise; + removeRemoteMachine(machineId: string): Promise; + connectRemoteMachine(machineId: string): Promise; + disconnectRemoteMachine(machineId: string): Promise; + probeSpki(url: string): Promise; + addSpkiPin(machineId: string, pin: string): Promise; + removeSpkiPin(machineId: string, pin: string): Promise; + 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; + unwatchFile(paneId: string): Promise; + readWatchedFile(path: string): Promise; + onFileChanged(callback: (payload: FileChangedPayload) => void): UnsubscribeFn; +} + +export interface AgentBridgeAdapter { + /** Direct agent IPC — supports remote machines and resume */ + queryAgent(options: AgentQueryOptions): Promise; + stopAgentDirect(sessionId: string, remoteMachineId?: string): Promise; + isAgentReady(): Promise; + restartAgentSidecar(): Promise; + setSandbox(projectCwds: string[], worktreeRoots: string[], enabled: boolean): Promise; + onSidecarMessage(callback: (msg: SidecarMessagePayload) => void): UnsubscribeFn; + onSidecarExited(callback: () => void): UnsubscribeFn; +} + +export interface PtyBridgeAdapter { + /** Per-session PTY — supports remote machines */ + spawnPty(options: PtySpawnOptions): Promise; + writePtyDirect(id: string, data: string, remoteMachineId?: string): Promise; + resizePtyDirect(id: string, cols: number, rows: number, remoteMachineId?: string): Promise; + killPty(id: string, remoteMachineId?: string): Promise; + onPtyData(id: string, callback: (data: string) => void): UnsubscribeFn; + onPtyExit(id: string, callback: () => void): UnsubscribeFn; +} + // ── 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; // ── Lifecycle ──────────────────────────────────────────────────────────── @@ -55,13 +274,13 @@ export interface BackendAdapter { loadGroups(): Promise; saveGroups(groups: GroupsFile): Promise; - // ── Agent ──────────────────────────────────────────────────────────────── + // ── Agent (simplified) ──────────────────────────────────────────────────── startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }>; stopAgent(sessionId: string): Promise<{ ok: boolean; error?: string }>; sendPrompt(sessionId: string, prompt: string): Promise<{ ok: boolean; error?: string }>; - // ── PTY ────────────────────────────────────────────────────────────────── + // ── PTY (simplified) ────────────────────────────────────────────────────── createPty(options: PtyCreateOptions): Promise; writePty(sessionId: string, data: string): Promise; @@ -82,3 +301,273 @@ export interface BackendAdapter { onPtyOutput(callback: (sessionId: string, data: string) => 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; + 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; +} + +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; + extra_env?: Record; + remote_machine_id?: string; +} + +export interface SidecarMessagePayload { + type: string; + sessionId?: string; + event?: Record; + 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; +} diff --git a/src/App.svelte b/src/App.svelte index 2d3c9e1..bbf38c3 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -11,7 +11,7 @@ import { OLLAMA_PROVIDER } from './lib/providers/ollama'; import { AIDER_PROVIDER } from './lib/providers/aider'; import { registerMemoryAdapter } from './lib/adapters/memory-adapter'; - import { MemoraAdapter } from './lib/adapters/memora-bridge'; + import { MemoraAdapter } from './lib/adapters/memora-adapter'; import { loadWorkspace, getActiveTab, setActiveTab, setActiveProject, getEnabledProjects, getAllWorkItems, getActiveProjectId, @@ -20,7 +20,7 @@ import { disableWakeScheduler } from './lib/stores/wake-scheduler.svelte'; import { initGlobalErrorHandler } from './lib/utils/global-error-handler'; 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'; // Workspace components @@ -119,7 +119,7 @@ // Step 2: Agent dispatcher startAgentDispatcher(); 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); // Disable wake scheduler in test mode to prevent timer interference diff --git a/src/lib/adapters/agent-bridge.test.ts b/src/lib/adapters/agent-bridge.test.ts deleted file mode 100644 index e9a2b8e..0000000 --- a/src/lib/adapters/agent-bridge.test.ts +++ /dev/null @@ -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(); - }); - }); -}); diff --git a/src/lib/adapters/agent-bridge.ts b/src/lib/adapters/agent-bridge.ts deleted file mode 100644 index df92e9e..0000000 --- a/src/lib/adapters/agent-bridge.ts +++ /dev/null @@ -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; - /** Extra environment variables injected into the agent process (e.g. BTMSG_AGENT_ID) */ - extra_env?: Record; - remote_machine_id?: string; -} - -export async function queryAgent(options: AgentQueryOptions): Promise { - 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 { - if (remoteMachineId) { - return invoke('remote_agent_stop', { machineId: remoteMachineId, sessionId }); - } - return invoke('agent_stop', { sessionId }); -} - -export async function isAgentReady(): Promise { - return invoke('agent_ready'); -} - -export async function restartAgent(): Promise { - return invoke('agent_restart'); -} - -/** Update Landlock sandbox config and restart sidecar to apply. */ -export async function setSandbox( - projectCwds: string[], - worktreeRoots: string[], - enabled: boolean, -): Promise { - return invoke('agent_set_sandbox', { projectCwds, worktreeRoots, enabled }); -} - -export interface SidecarMessage { - type: string; - sessionId?: string; - event?: Record; - message?: string; - exitCode?: number | null; - signal?: string | null; -} - -export async function onSidecarMessage( - callback: (msg: SidecarMessage) => void, -): Promise { - return listen('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 { - return listen('sidecar-exited', () => { - callback(); - }); -} diff --git a/src/lib/adapters/anchors-bridge.ts b/src/lib/adapters/anchors-bridge.ts deleted file mode 100644 index abc51ca..0000000 --- a/src/lib/adapters/anchors-bridge.ts +++ /dev/null @@ -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 { - return invoke('session_anchors_save', { anchors }); -} - -export async function loadSessionAnchors(projectId: string): Promise { - return invoke('session_anchors_load', { projectId }); -} - -export async function deleteSessionAnchor(id: string): Promise { - return invoke('session_anchor_delete', { id }); -} - -export async function clearProjectAnchors(projectId: string): Promise { - return invoke('session_anchors_clear', { projectId }); -} - -export async function updateAnchorType(id: string, anchorType: string): Promise { - return invoke('session_anchor_update_type', { id, anchorType }); -} diff --git a/src/lib/adapters/audit-bridge.ts b/src/lib/adapters/audit-bridge.ts deleted file mode 100644 index 34bba68..0000000 --- a/src/lib/adapters/audit-bridge.ts +++ /dev/null @@ -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 { - 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 { - 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 { - return invoke('audit_log_for_agent', { agentId, limit }); -} diff --git a/src/lib/adapters/btmsg-bridge.test.ts b/src/lib/adapters/btmsg-bridge.test.ts deleted file mode 100644 index 2905622..0000000 --- a/src/lib/adapters/btmsg-bridge.test.ts +++ /dev/null @@ -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)['group_id']).toBeUndefined(); - expect((result[0] as Record)['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'); - }); - }); -}); diff --git a/src/lib/adapters/btmsg-bridge.ts b/src/lib/adapters/btmsg-bridge.ts deleted file mode 100644 index 931ab53..0000000 --- a/src/lib/adapters/btmsg-bridge.ts +++ /dev/null @@ -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 { - return invoke('btmsg_get_agents', { groupId }); -} - -/** - * Get unread message count for an agent. - */ -export async function getUnreadCount(agentId: AgentId): Promise { - return invoke('btmsg_unread_count', { agentId }); -} - -/** - * Get unread messages for an agent. - */ -export async function getUnreadMessages(agentId: AgentId): Promise { - return invoke('btmsg_unread_messages', { agentId }); -} - -/** - * Get conversation history between two agents. - */ -export async function getHistory(agentId: AgentId, otherId: AgentId, limit: number = 20): Promise { - 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 { - return invoke('btmsg_send', { fromAgent, toAgent, content }); -} - -/** - * Update agent status (active/sleeping/stopped). - */ -export async function setAgentStatus(agentId: AgentId, status: string): Promise { - return invoke('btmsg_set_status', { agentId, status }); -} - -/** - * Ensure admin agent exists with contacts to all agents. - */ -export async function ensureAdmin(groupId: GroupId): Promise { - return invoke('btmsg_ensure_admin', { groupId }); -} - -/** - * Get all messages in group (admin global feed). - */ -export async function getAllFeed(groupId: GroupId, limit: number = 100): Promise { - 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 { - return invoke('btmsg_mark_read', { readerId, senderId }); -} - -/** - * Get channels in a group. - */ -export async function getChannels(groupId: GroupId): Promise { - return invoke('btmsg_get_channels', { groupId }); -} - -/** - * Get messages in a channel. - */ -export async function getChannelMessages(channelId: string, limit: number = 100): Promise { - return invoke('btmsg_channel_messages', { channelId, limit }); -} - -/** - * Send a message to a channel. - */ -export async function sendChannelMessage(channelId: string, fromAgent: AgentId, content: string): Promise { - return invoke('btmsg_channel_send', { channelId, fromAgent, content }); -} - -/** - * Create a new channel. - */ -export async function createChannel(name: string, groupId: GroupId, createdBy: AgentId): Promise { - return invoke('btmsg_create_channel', { name, groupId, createdBy }); -} - -/** - * Add a member to a channel. - */ -export async function addChannelMember(channelId: string, agentId: AgentId): Promise { - 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 { - 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 { - 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 { - 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 { - return invoke('btmsg_prune_seen'); -} - -// ---- Heartbeat monitoring ---- - -/** - * Record a heartbeat for an agent (upserts timestamp). - */ -export async function recordHeartbeat(agentId: AgentId): Promise { - 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 { - 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 { - return invoke('btmsg_get_dead_letters', { groupId, limit }); -} - -/** - * Clear all dead letters for a group. - */ -export async function clearDeadLetters(groupId: GroupId): Promise { - 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 { - return invoke('btmsg_clear_all_comms', { groupId }); -} diff --git a/src/lib/adapters/bttask-bridge.test.ts b/src/lib/adapters/bttask-bridge.test.ts deleted file mode 100644 index 92ee745..0000000 --- a/src/lib/adapters/bttask-bridge.test.ts +++ /dev/null @@ -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)['assigned_to']).toBeUndefined(); - expect((result[0] as Record)['created_by']).toBeUndefined(); - expect((result[0] as Record)['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)['task_id']).toBeUndefined(); - expect((result[0] as Record)['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'); - }); - }); -}); diff --git a/src/lib/adapters/bttask-bridge.ts b/src/lib/adapters/bttask-bridge.ts deleted file mode 100644 index adfecea..0000000 --- a/src/lib/adapters/bttask-bridge.ts +++ /dev/null @@ -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 { - return invoke('bttask_list', { groupId }); -} - -export async function getTaskComments(taskId: string): Promise { - return invoke('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 { - return invoke('bttask_update_status', { taskId, status, version }); -} - -export async function addTaskComment(taskId: string, agentId: AgentId, content: string): Promise { - return invoke('bttask_add_comment', { taskId, agentId, content }); -} - -export async function createTask( - title: string, - description: string, - priority: string, - groupId: GroupId, - createdBy: AgentId, - assignedTo?: AgentId, -): Promise { - return invoke('bttask_create', { title, description, priority, groupId, createdBy, assignedTo }); -} - -export async function deleteTask(taskId: string): Promise { - return invoke('bttask_delete', { taskId }); -} - -/** Count tasks currently in 'review' status for a group */ -export async function reviewQueueCount(groupId: GroupId): Promise { - return invoke('bttask_review_queue_count', { groupId }); -} diff --git a/src/lib/adapters/claude-bridge.ts b/src/lib/adapters/claude-bridge.ts deleted file mode 100644 index a03c318..0000000 --- a/src/lib/adapters/claude-bridge.ts +++ /dev/null @@ -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 { - return invoke('claude_list_profiles'); -} - -export async function listSkills(): Promise { - return invoke('claude_list_skills'); -} - -export async function readSkill(path: string): Promise { - return invoke('claude_read_skill', { path }); -} diff --git a/src/lib/adapters/ctx-bridge.ts b/src/lib/adapters/ctx-bridge.ts deleted file mode 100644 index 4956282..0000000 --- a/src/lib/adapters/ctx-bridge.ts +++ /dev/null @@ -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 { - return invoke('ctx_init_db'); -} - -export async function ctxRegisterProject(name: string, description: string, workDir?: string): Promise { - return invoke('ctx_register_project', { name, description, workDir: workDir ?? null }); -} - -export async function ctxGetContext(project: string): Promise { - return invoke('ctx_get_context', { project }); -} - -export async function ctxGetShared(): Promise { - return invoke('ctx_get_shared'); -} - -export async function ctxGetSummaries(project: string, limit: number = 5): Promise { - return invoke('ctx_get_summaries', { project, limit }); -} - -export async function ctxSearch(query: string): Promise { - return invoke('ctx_search', { query }); -} diff --git a/src/lib/adapters/file-bridge.ts b/src/lib/adapters/file-bridge.ts deleted file mode 100644 index 7937488..0000000 --- a/src/lib/adapters/file-bridge.ts +++ /dev/null @@ -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 { - return invoke('file_watch', { paneId, path }); -} - -export async function unwatchFile(paneId: string): Promise { - return invoke('file_unwatch', { paneId }); -} - -export async function readFile(path: string): Promise { - return invoke('file_read', { path }); -} - -export async function onFileChanged( - callback: (payload: FileChangedPayload) => void -): Promise { - return listen('file-changed', (event) => { - callback(event.payload); - }); -} diff --git a/src/lib/adapters/files-bridge.ts b/src/lib/adapters/files-bridge.ts deleted file mode 100644 index a46f4f2..0000000 --- a/src/lib/adapters/files-bridge.ts +++ /dev/null @@ -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 { - return invoke('list_directory_children', { path }); -} - -export function readFileContent(path: string): Promise { - return invoke('read_file_content', { path }); -} - -export function writeFileContent(path: string, content: string): Promise { - return invoke('write_file_content', { path, content }); -} diff --git a/src/lib/adapters/fs-watcher-bridge.ts b/src/lib/adapters/fs-watcher-bridge.ts deleted file mode 100644 index 17d4e6b..0000000 --- a/src/lib/adapters/fs-watcher-bridge.ts +++ /dev/null @@ -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 { - return invoke('fs_watch_project', { projectId, cwd }); -} - -/** Stop watching a project's CWD */ -export function fsUnwatchProject(projectId: string): Promise { - return invoke('fs_unwatch_project', { projectId }); -} - -/** Listen for filesystem write events from all watched projects */ -export function onFsWriteDetected( - callback: (event: FsWriteEvent) => void, -): Promise { - return listen('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 { - return invoke('fs_watcher_status'); -} diff --git a/src/lib/adapters/groups-bridge.ts b/src/lib/adapters/groups-bridge.ts deleted file mode 100644 index 72551f9..0000000 --- a/src/lib/adapters/groups-bridge.ts +++ /dev/null @@ -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 { - return invoke('groups_load'); -} - -export async function saveGroups(config: GroupsFile): Promise { - return invoke('groups_save', { config }); -} - -// --- Markdown discovery --- - -export async function discoverMarkdownFiles(cwd: string): Promise { - return invoke('discover_markdown_files', { cwd }); -} - -// --- Agent message persistence --- - -export async function saveAgentMessages( - sessionId: SessionId, - projectId: ProjectId, - sdkSessionId: string | undefined, - messages: AgentMessageRecord[], -): Promise { - return invoke('agent_messages_save', { - sessionId, - projectId, - sdkSessionId: sdkSessionId ?? null, - messages, - }); -} - -export async function loadAgentMessages(projectId: ProjectId): Promise { - return invoke('agent_messages_load', { projectId }); -} - -// --- Project agent state --- - -export async function saveProjectAgentState(state: ProjectAgentState): Promise { - return invoke('project_agent_state_save', { state }); -} - -export async function loadProjectAgentState(projectId: ProjectId): Promise { - 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): Promise { - return invoke('session_metric_save', { metric: { id: 0, ...metric } }); -} - -export async function loadSessionMetrics(projectId: ProjectId, limit = 20): Promise { - return invoke('session_metrics_load', { projectId, limit }); -} - -// --- CLI arguments --- - -export async function getCliGroup(): Promise { - return invoke('cli_get_group'); -} diff --git a/src/lib/adapters/memora-adapter.ts b/src/lib/adapters/memora-adapter.ts new file mode 100644 index 0000000..727bbde --- /dev/null +++ b/src/lib/adapters/memora-adapter.ts @@ -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; 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 { + this._available = await getBackend().memoraAvailable(); + return this._available; + } + + async list(options?: { + tags?: string[]; + limit?: number; + offset?: number; + }): Promise { + 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 { + 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 { + 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; + } +} diff --git a/src/lib/adapters/memora-bridge.test.ts b/src/lib/adapters/memora-bridge.test.ts deleted file mode 100644 index 206bcaf..0000000 --- a/src/lib/adapters/memora-bridge.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/lib/adapters/memora-bridge.ts b/src/lib/adapters/memora-bridge.ts deleted file mode 100644 index c206c73..0000000 --- a/src/lib/adapters/memora-bridge.ts +++ /dev/null @@ -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; - created_at?: string; - updated_at?: string; -} - -interface MemoraSearchResult { - nodes: MemoraNode[]; - total: number; -} - -// --- IPC wrappers --- - -export async function memoraAvailable(): Promise { - return invoke('memora_available'); -} - -export async function memoraList(options?: { - tags?: string[]; - limit?: number; - offset?: number; -}): Promise { - return invoke('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 { - return invoke('memora_search', { - query, - tags: options?.tags ?? null, - limit: options?.limit ?? 50, - }); -} - -export async function memoraGet(id: number): Promise { - return invoke('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 { - this._available = await memoraAvailable(); - return this._available; - } - - async list(options?: { - tags?: string[]; - limit?: number; - offset?: number; - }): Promise { - const result = await memoraList(options); - this._available = true; - return toSearchResult(result); - } - - async search( - query: string, - options?: { tags?: string[]; limit?: number }, - ): Promise { - const result = await memoraSearch(query, options); - this._available = true; - return toSearchResult(result); - } - - async get(id: string | number): Promise { - 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; - } -} diff --git a/src/lib/adapters/message-adapters.ts b/src/lib/adapters/message-adapters.ts index 501919c..c5c1b6d 100644 --- a/src/lib/adapters/message-adapters.ts +++ b/src/lib/adapters/message-adapters.ts @@ -7,7 +7,7 @@ import { adaptSDKMessage } from './claude-messages'; import { adaptCodexMessage } from './codex-messages'; import { adaptOllamaMessage } from './ollama-messages'; import { adaptAiderMessage } from './aider-messages'; -import { tel } from './telemetry-bridge'; +import { tel } from '../utils/telemetry'; /** Function signature for a provider message adapter */ export type MessageAdapter = (raw: Record) => AgentMessage[]; diff --git a/src/lib/adapters/notifications-bridge.ts b/src/lib/adapters/notifications-bridge.ts deleted file mode 100644 index e1eccf1..0000000 --- a/src/lib/adapters/notifications-bridge.ts +++ /dev/null @@ -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); - }); -} diff --git a/src/lib/adapters/plugins-bridge.ts b/src/lib/adapters/plugins-bridge.ts deleted file mode 100644 index 22c46cb..0000000 --- a/src/lib/adapters/plugins-bridge.ts +++ /dev/null @@ -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 { - return invoke('plugins_discover'); -} - -/** Read a file from a plugin's directory (path-traversal safe) */ -export async function readPluginFile(pluginId: string, filename: string): Promise { - return invoke('plugin_read_file', { pluginId, filename }); -} diff --git a/src/lib/adapters/provider-bridge.ts b/src/lib/adapters/provider-bridge.ts deleted file mode 100644 index e5fb5db..0000000 --- a/src/lib/adapters/provider-bridge.ts +++ /dev/null @@ -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 { - if (provider === 'claude') return claudeListProfiles(); - return []; -} - -/** List skills for a given provider (only Claude supports this) */ -export async function listProviderSkills(provider: ProviderId): Promise { - if (provider === 'claude') return claudeListSkills(); - return []; -} - -/** Read a skill file (only Claude supports this) */ -export async function readProviderSkill(provider: ProviderId, path: string): Promise { - if (provider === 'claude') return claudeReadSkill(path); - return ''; -} diff --git a/src/lib/adapters/pty-bridge.ts b/src/lib/adapters/pty-bridge.ts deleted file mode 100644 index 1018c37..0000000 --- a/src/lib/adapters/pty-bridge.ts +++ /dev/null @@ -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 { - if (options.remote_machine_id) { - const { remote_machine_id: machineId, ...ptyOptions } = options; - return invoke('remote_pty_spawn', { machineId, options: ptyOptions }); - } - return invoke('pty_spawn', { options }); -} - -export async function writePty(id: string, data: string, remoteMachineId?: string): Promise { - 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 { - 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 { - 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 { - return listen(`pty-data-${id}`, (event) => { - callback(event.payload); - }); -} - -export async function onPtyExit(id: string, callback: () => void): Promise { - return listen(`pty-exit-${id}`, () => { - callback(); - }); -} diff --git a/src/lib/adapters/remote-bridge.ts b/src/lib/adapters/remote-bridge.ts deleted file mode 100644 index e56cd91..0000000 --- a/src/lib/adapters/remote-bridge.ts +++ /dev/null @@ -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 { - return invoke('remote_list'); -} - -export async function addRemoteMachine(config: RemoteMachineConfig): Promise { - return invoke('remote_add', { config }); -} - -export async function removeRemoteMachine(machineId: string): Promise { - return invoke('remote_remove', { machineId }); -} - -export async function connectRemoteMachine(machineId: string): Promise { - return invoke('remote_connect', { machineId }); -} - -export async function disconnectRemoteMachine(machineId: string): Promise { - 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 { - 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 { - 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 { - return invoke('remote_remove_pin', { machineId, pin }); -} - -// --- Remote event listeners --- - -export interface RemoteSidecarMessage { - machineId: string; - sessionId?: string; - event?: Record; -} - -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 { - return listen('remote-sidecar-message', (event) => { - callback(event.payload); - }); -} - -export async function onRemotePtyData( - callback: (msg: RemotePtyData) => void, -): Promise { - return listen('remote-pty-data', (event) => { - callback(event.payload); - }); -} - -export async function onRemotePtyExit( - callback: (msg: RemotePtyExit) => void, -): Promise { - return listen('remote-pty-exit', (event) => { - callback(event.payload); - }); -} - -export async function onRemoteMachineReady( - callback: (msg: RemoteMachineEvent) => void, -): Promise { - return listen('remote-machine-ready', (event) => { - callback(event.payload); - }); -} - -export async function onRemoteMachineDisconnected( - callback: (msg: RemoteMachineEvent) => void, -): Promise { - return listen('remote-machine-disconnected', (event) => { - callback(event.payload); - }); -} - -export async function onRemoteStateSync( - callback: (msg: RemoteMachineEvent) => void, -): Promise { - return listen('remote-state-sync', (event) => { - callback(event.payload); - }); -} - -export async function onRemoteError( - callback: (msg: RemoteMachineEvent) => void, -): Promise { - return listen('remote-error', (event) => { - callback(event.payload); - }); -} - -export interface RemoteReconnectingEvent { - machineId: string; - backoffSecs: number; -} - -export async function onRemoteMachineReconnecting( - callback: (msg: RemoteReconnectingEvent) => void, -): Promise { - return listen('remote-machine-reconnecting', (event) => { - callback(event.payload); - }); -} - -export async function onRemoteMachineReconnectReady( - callback: (msg: RemoteMachineEvent) => void, -): Promise { - return listen('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 { - return listen('remote-spki-tofu', (event) => { - callback(event.payload); - }); -} diff --git a/src/lib/adapters/search-bridge.ts b/src/lib/adapters/search-bridge.ts deleted file mode 100644 index 562b20d..0000000 --- a/src/lib/adapters/search-bridge.ts +++ /dev/null @@ -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 { - return invoke('search_init'); -} - -/** Search across all FTS5 tables (messages, tasks, btmsg). */ -export async function searchAll(query: string, limit?: number): Promise { - return invoke('search_query', { query, limit: limit ?? 20 }); -} - -/** Drop and recreate all FTS5 tables (clears the index). */ -export async function rebuildIndex(): Promise { - return invoke('search_rebuild'); -} - -/** Index an agent message into the search database. */ -export async function indexMessage(sessionId: string, role: string, content: string): Promise { - return invoke('search_index_message', { sessionId, role, content }); -} diff --git a/src/lib/adapters/secrets-bridge.ts b/src/lib/adapters/secrets-bridge.ts deleted file mode 100644 index 4cf5b9e..0000000 --- a/src/lib/adapters/secrets-bridge.ts +++ /dev/null @@ -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 { - 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 { - return invoke('secrets_get', { key }); -} - -/** Delete a secret from the system keyring. */ -export async function deleteSecret(key: string): Promise { - return invoke('secrets_delete', { key }); -} - -/** List keys that have been stored in the keyring. */ -export async function listSecrets(): Promise { - return invoke('secrets_list'); -} - -/** Check if the system keyring is available. */ -export async function hasKeyring(): Promise { - return invoke('secrets_has_keyring'); -} - -/** Get the list of known/recognized secret key identifiers. */ -export async function knownSecretKeys(): Promise { - return invoke('secrets_known_keys'); -} - -/** Human-readable labels for known secret keys. */ -export const SECRET_KEY_LABELS: Record = { - 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', -}; diff --git a/src/lib/adapters/session-bridge.ts b/src/lib/adapters/session-bridge.ts deleted file mode 100644 index 4815ca7..0000000 --- a/src/lib/adapters/session-bridge.ts +++ /dev/null @@ -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 { - return invoke('session_list'); -} - -export async function saveSession(session: PersistedSession): Promise { - return invoke('session_save', { session }); -} - -export async function deleteSession(id: string): Promise { - return invoke('session_delete', { id }); -} - -export async function updateSessionTitle(id: string, title: string): Promise { - return invoke('session_update_title', { id, title }); -} - -export async function touchSession(id: string): Promise { - return invoke('session_touch', { id }); -} - -export async function updateSessionGroup(id: string, groupName: string): Promise { - return invoke('session_update_group', { id, group_name: groupName }); -} - -export async function saveLayout(layout: PersistedLayout): Promise { - return invoke('layout_save', { layout }); -} - -export async function loadLayout(): Promise { - return invoke('layout_load'); -} diff --git a/src/lib/adapters/ssh-bridge.ts b/src/lib/adapters/ssh-bridge.ts deleted file mode 100644 index a01c40c..0000000 --- a/src/lib/adapters/ssh-bridge.ts +++ /dev/null @@ -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 { - return invoke('ssh_session_list'); -} - -export async function saveSshSession(session: SshSession): Promise { - return invoke('ssh_session_save', { session }); -} - -export async function deleteSshSession(id: string): Promise { - return invoke('ssh_session_delete', { id }); -} diff --git a/src/lib/adapters/telemetry-bridge.ts b/src/lib/adapters/telemetry-bridge.ts deleted file mode 100644 index 6e7d8c9..0000000 --- a/src/lib/adapters/telemetry-bridge.ts +++ /dev/null @@ -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, -): 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) => telemetryLog('error', msg, ctx), - warn: (msg: string, ctx?: Record) => telemetryLog('warn', msg, ctx), - info: (msg: string, ctx?: Record) => telemetryLog('info', msg, ctx), - debug: (msg: string, ctx?: Record) => telemetryLog('debug', msg, ctx), - trace: (msg: string, ctx?: Record) => telemetryLog('trace', msg, ctx), -}; diff --git a/src/lib/agent-dispatcher.test.ts b/src/lib/agent-dispatcher.test.ts index 74a4ff1..42710f3 100644 --- a/src/lib/agent-dispatcher.test.ts +++ b/src/lib/agent-dispatcher.test.ts @@ -41,16 +41,23 @@ const { mockAddNotification: vi.fn(), })); -vi.mock('./adapters/agent-bridge', () => ({ - onSidecarMessage: vi.fn(async (cb: (msg: any) => void) => { - capturedCallbacks.msg = cb; - return mockUnlistenMsg; - }), - onSidecarExited: vi.fn(async (cb: () => void) => { - capturedCallbacks.exit = cb; - return mockUnlistenExit; - }), - restartAgent: (...args: unknown[]) => mockRestartAgent(...args), +vi.mock('./backend/backend', () => ({ + getBackend: vi.fn(() => ({ + onSidecarMessage: vi.fn((cb: (msg: any) => void) => { + capturedCallbacks.msg = cb; + return mockUnlistenMsg; + }), + onSidecarExited: vi.fn((cb: () => void) => { + capturedCallbacks.exit = cb; + return mockUnlistenExit; + }), + 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', () => ({})); @@ -235,8 +242,9 @@ describe('agent-dispatcher', () => { await startAgentDispatcher(); await startAgentDispatcher(); // second call should be no-op - const { onSidecarMessage } = await import('./adapters/agent-bridge'); - expect(onSidecarMessage).toHaveBeenCalledTimes(1); + // On second call, capturedCallbacks.msg should still be the same (not reset) + // The dispatcher guards against duplicate registration via `if (unlistenMsg) return;` + expect(capturedCallbacks.msg).not.toBeNull(); }); it('sets sidecarAlive to true on start', async () => { @@ -408,8 +416,8 @@ describe('agent-dispatcher', () => { stopAgentDispatcher(); await startAgentDispatcher(); - const { onSidecarMessage } = await import('./adapters/agent-bridge'); - expect(onSidecarMessage).toHaveBeenCalledTimes(2); + // After stop + restart, callbacks should be re-registered + expect(capturedCallbacks.msg).not.toBeNull(); }); }); diff --git a/src/lib/agent-dispatcher.ts b/src/lib/agent-dispatcher.ts index 055ddb2..7bcf4a5 100644 --- a/src/lib/agent-dispatcher.ts +++ b/src/lib/agent-dispatcher.ts @@ -2,7 +2,8 @@ // Thin coordinator that routes sidecar messages to specialized modules 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 type { InitContent, CostContent, ToolCallContent } from './adapters/claude-messages'; import { @@ -16,7 +17,7 @@ import { } from './stores/agents.svelte'; import { notify, addNotification } from './stores/notifications.svelte'; import { classifyError } from './utils/error-classifier'; -import { tel } from './adapters/telemetry-bridge'; +import { tel } from './utils/telemetry'; import { handleInfraError } from './utils/handle-error'; import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte'; import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte'; @@ -37,16 +38,13 @@ import { spawnSubagentPane, clearSubagentRoutes, } 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'; /** Sync btmsg agent status to 'stopped' when a session reaches terminal state */ function syncBtmsgStopped(sessionId: SessionIdType): void { const projectId = getSessionProjectId(sessionId); 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 { sidecarAlive = true; - unlistenMsg = await onSidecarMessage((msg: SidecarMessage) => { + unlistenMsg = getBackend().onSidecarMessage((msg: SidecarMessagePayload) => { sidecarAlive = true; // Reset restart counter on any successful message — sidecar recovered if (restartAttempts > 0) { @@ -89,7 +87,7 @@ export async function startAgentDispatcher(): Promise { // Record heartbeat on any agent activity (best-effort, fire-and-forget) const hbProjectId = getSessionProjectId(sessionId); 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) { @@ -98,7 +96,7 @@ export async function startAgentDispatcher(): Promise { recordSessionStart(sessionId); tel.info('agent_started', { sessionId }); 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; @@ -113,7 +111,7 @@ export async function startAgentDispatcher(): Promise { notify('success', `Agent ${sessionId.slice(0, 8)} completed`); addNotification('Agent complete', `Session ${sessionId.slice(0, 8)} finished`, 'agent_complete', getSessionProjectId(sessionId) ?? undefined); 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; @@ -141,7 +139,7 @@ export async function startAgentDispatcher(): Promise { addNotification('Agent error', classified.message, 'agent_error', getSessionProjectId(sessionId) ?? undefined); 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; } @@ -151,7 +149,7 @@ export async function startAgentDispatcher(): Promise { } }); - unlistenExit = await onSidecarExited(async () => { + unlistenExit = getBackend().onSidecarExited(async () => { sidecarAlive = false; tel.error('sidecar_crashed', { restartAttempts }); @@ -176,7 +174,7 @@ export async function startAgentDispatcher(): Promise { addNotification('Sidecar crashed', `Restarting (attempt ${restartAttempts}/${MAX_RESTART_ATTEMPTS})`, 'system'); await new Promise((resolve) => setTimeout(resolve, delayMs)); try { - await restartAgent(); + await getBackend().restartAgentSidecar(); sidecarAlive = true; // Note: restartAttempts is reset when next sidecar message arrives } catch { @@ -334,7 +332,7 @@ function handleAgentEvent(sessionId: SessionIdType, event: Record handleInfraError(e, 'dispatcher.indexMessage')); + getBackend().searchIndexMessage(sessionId, 'assistant', msg.content).catch(e => handleInfraError(e, 'dispatcher.indexMessage')); } } } diff --git a/src/lib/backend/ElectrobunAdapter.ts b/src/lib/backend/ElectrobunAdapter.ts index 042c912..3a6aa08 100644 --- a/src/lib/backend/ElectrobunAdapter.ts +++ b/src/lib/backend/ElectrobunAdapter.ts @@ -2,32 +2,40 @@ // Wraps the Electrobun RPC pattern (request/response + message listeners) import type { - BackendAdapter, - BackendCapabilities, - UnsubscribeFn, - AgentStartOptions, - AgentMessage, - AgentStatus, - FileEntry, - FileContent, - PtyCreateOptions, - SettingsMap, - GroupsFile, - GroupConfig, - GroupId, + BackendAdapter, BackendCapabilities, UnsubscribeFn, + AgentStartOptions, AgentMessage, AgentStatus, + FileEntry, FileContent, PtyCreateOptions, + SettingsMap, GroupsFile, GroupConfig, GroupId, AgentId, SessionId, ProjectId, + 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, } from '@agor/types'; // ── Capabilities ───────────────────────────────────────────────────────────── const ELECTROBUN_CAPABILITIES: BackendCapabilities = { - supportsPtyMultiplexing: true, // via agor-ptyd daemon - supportsPluginSandbox: true, // Web Worker sandbox works in any webview - supportsNativeMenus: false, // Electrobun has limited menu support - supportsOsKeychain: false, // No keyring crate — uses file-based secrets - supportsFileDialogs: false, // No native dialog plugin - supportsAutoUpdater: false, // Custom updater via GitHub Releases check - supportsDesktopNotifications: false, // No notify-rust — uses in-app toasts only - supportsTelemetry: false, // No OTLP export in Electrobun backend + supportsPtyMultiplexing: true, + supportsPluginSandbox: true, + supportsNativeMenus: false, + supportsOsKeychain: false, + supportsFileDialogs: false, + supportsAutoUpdater: false, + supportsDesktopNotifications: false, + supportsTelemetry: false, }; // ── RPC handle type (matches ui-electrobun/src/mainview/rpc.ts) ────────────── @@ -67,7 +75,6 @@ export class ElectrobunAdapter implements BackendAdapter { } async destroy(): Promise { - // Remove all registered message listeners if (this.rpc?.removeMessageListener) { for (const { event, handler } of this.messageHandlers) { this.rpc.removeMessageListener(event, handler); @@ -96,12 +103,10 @@ export class ElectrobunAdapter implements BackendAdapter { // ── Groups ─────────────────────────────────────────────────────────────── async loadGroups(): Promise { - // Electrobun stores groups differently — reconstruct GroupsFile from flat list const res = await this.r.request['groups.list']({}) as { 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 { projects: Array<{ id: string; config: string }>; }; @@ -130,42 +135,29 @@ export class ElectrobunAdapter implements BackendAdapter { } async saveGroups(groupsFile: GroupsFile): Promise { - // Save groups list for (const group of groupsFile.groups) { await this.r.request['groups.create']({ - id: group.id, - name: group.name, - icon: '', - position: groupsFile.groups.indexOf(group), + id: group.id, name: group.name, icon: '', position: groupsFile.groups.indexOf(group), }); } - - // Save projects per group for (const group of groupsFile.groups) { for (const project of group.projects) { await this.r.request['settings.setProject']({ - id: project.id, - config: JSON.stringify({ ...project, groupId: group.id }), + id: project.id, config: JSON.stringify({ ...project, groupId: group.id }), }); } } } - // ── Agent ──────────────────────────────────────────────────────────────── + // ── Agent (simplified) ──────────────────────────────────────────────────── async startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }> { const res = await this.r.request['agent.start']({ - sessionId: options.sessionId, - provider: options.provider ?? 'claude', - prompt: options.prompt, - cwd: options.cwd, - model: options.model, - systemPrompt: options.systemPrompt, - maxTurns: options.maxTurns, - permissionMode: options.permissionMode, - claudeConfigDir: options.claudeConfigDir, - extraEnv: options.extraEnv, - additionalDirectories: options.additionalDirectories, + sessionId: options.sessionId, provider: options.provider ?? 'claude', + prompt: options.prompt, cwd: options.cwd, model: options.model, + systemPrompt: options.systemPrompt, maxTurns: options.maxTurns, + permissionMode: options.permissionMode, claudeConfigDir: options.claudeConfigDir, + extraEnv: options.extraEnv, additionalDirectories: options.additionalDirectories, worktreeName: options.worktreeName, }) as { ok: boolean; error?: string }; return res; @@ -181,16 +173,12 @@ export class ElectrobunAdapter implements BackendAdapter { return res; } - // ── PTY ────────────────────────────────────────────────────────────────── + // ── PTY (simplified) ────────────────────────────────────────────────────── async createPty(options: PtyCreateOptions): Promise { const res = await this.r.request['pty.create']({ - sessionId: options.sessionId, - cols: options.cols, - rows: options.rows, - cwd: options.cwd, - shell: options.shell, - args: options.args, + sessionId: options.sessionId, cols: options.cols, rows: options.rows, + cwd: options.cwd, shell: options.shell, args: options.args, }) as { ok: boolean; error?: string }; if (!res.ok) throw new Error(res.error ?? 'PTY creation failed'); return options.sessionId; @@ -212,29 +200,20 @@ export class ElectrobunAdapter implements BackendAdapter { async listDirectory(path: string): Promise { const res = await this.r.request['files.list']({ path }) as { - entries: Array<{ name: string; type: string; size: number }>; - error?: string; + entries: Array<{ name: string; type: string; size: number }>; error?: string; }; if (res.error) throw new Error(res.error); return res.entries.map((e) => ({ - name: e.name, - path: `${path}/${e.name}`, - isDir: e.type === 'dir', - size: e.size, + name: e.name, path: `${path}/${e.name}`, isDir: e.type === 'dir', size: e.size, })); } async readFile(path: string): Promise { const res = await this.r.request['files.read']({ path }) as { - content?: string; - encoding: string; - size: number; - error?: string; + content?: string; encoding: string; size: number; error?: string; }; if (res.error) throw new Error(res.error); - if (res.encoding === 'base64') { - return { type: 'Binary', message: `Binary file (${res.size} bytes)` }; - } + if (res.encoding === 'base64') return { type: 'Binary', message: `Binary file (${res.size} bytes)` }; 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'); } + // ── Session persistence ───────────────────────────────────────────────── + + async listSessions(): Promise { + // TODO: wire to Electrobun RPC + return []; + } + + async saveSession(_session: PersistedSession): Promise { + // TODO: wire to Electrobun RPC + } + + async deleteSession(_id: string): Promise { + // TODO: wire to Electrobun RPC + } + + async updateSessionTitle(_id: string, _title: string): Promise { + // TODO: wire to Electrobun RPC + } + + async touchSession(_id: string): Promise { + // TODO: wire to Electrobun RPC + } + + async updateSessionGroup(_id: string, _groupName: string): Promise { + // TODO: wire to Electrobun RPC + } + + async saveLayout(_layout: PersistedLayout): Promise { + // TODO: wire to Electrobun RPC + } + + async loadLayout(): Promise { + // 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 { + // TODO: wire to Electrobun RPC + } + + async loadAgentMessages(_projectId: ProjectId): Promise { + // TODO: wire to Electrobun RPC + return []; + } + + async saveProjectAgentState(_state: ProjectAgentState): Promise { + // TODO: wire to Electrobun RPC + } + + async loadProjectAgentState(_projectId: ProjectId): Promise { + // TODO: wire to Electrobun RPC + return null; + } + + async saveSessionMetric(_metric: Omit): Promise { + // TODO: wire to Electrobun RPC + } + + async loadSessionMetrics(_projectId: ProjectId, _limit?: number): Promise { + // TODO: wire to Electrobun RPC + return []; + } + + async getCliGroup(): Promise { + // TODO: wire to Electrobun RPC + return null; + } + + async discoverMarkdownFiles(_cwd: string): Promise { + // TODO: wire to Electrobun RPC + return []; + } + + // ── Btmsg ─────────────────────────────────────────────────────────────── + + async btmsgGetAgents(_groupId: GroupId): Promise { + // TODO: wire to Electrobun RPC + return []; + } + + async btmsgUnreadCount(_agentId: AgentId): Promise { return 0; } + async btmsgUnreadMessages(_agentId: AgentId): Promise { return []; } + async btmsgHistory(_agentId: AgentId, _otherId: AgentId, _limit?: number): Promise { return []; } + async btmsgSend(_fromAgent: AgentId, _toAgent: AgentId, _content: string): Promise { return ''; } + async btmsgSetStatus(_agentId: AgentId, _status: string): Promise { } + async btmsgEnsureAdmin(_groupId: GroupId): Promise { } + async btmsgAllFeed(_groupId: GroupId, _limit?: number): Promise { return []; } + async btmsgMarkRead(_readerId: AgentId, _senderId: AgentId): Promise { } + async btmsgGetChannels(_groupId: GroupId): Promise { return []; } + async btmsgChannelMessages(_channelId: string, _limit?: number): Promise { return []; } + async btmsgChannelSend(_channelId: string, _fromAgent: AgentId, _content: string): Promise { return ''; } + async btmsgCreateChannel(_name: string, _groupId: GroupId, _createdBy: AgentId): Promise { return ''; } + async btmsgAddChannelMember(_channelId: string, _agentId: AgentId): Promise { } + async btmsgRegisterAgents(_config: GroupsFile): Promise { } + async btmsgUnseenMessages(_agentId: AgentId, _sessionId: string): Promise { return []; } + async btmsgMarkSeen(_sessionId: string, _messageIds: string[]): Promise { } + async btmsgPruneSeen(): Promise { return 0; } + async btmsgRecordHeartbeat(_agentId: AgentId): Promise { } + async btmsgGetStaleAgents(_groupId: GroupId, _thresholdSecs?: number): Promise { return []; } + async btmsgGetDeadLetters(_groupId: GroupId, _limit?: number): Promise { return []; } + async btmsgClearDeadLetters(_groupId: GroupId): Promise { } + async btmsgClearAllComms(_groupId: GroupId): Promise { } + + // ── Bttask ────────────────────────────────────────────────────────────── + + async bttaskList(_groupId: GroupId): Promise { return []; } + async bttaskComments(_taskId: string): Promise { return []; } + async bttaskUpdateStatus(_taskId: string, _status: string, _version: number): Promise { return 0; } + async bttaskAddComment(_taskId: string, _agentId: AgentId, _content: string): Promise { return ''; } + async bttaskCreate( + _title: string, _description: string, _priority: string, + _groupId: GroupId, _createdBy: AgentId, _assignedTo?: AgentId, + ): Promise { return ''; } + async bttaskDelete(_taskId: string): Promise { } + async bttaskReviewQueueCount(_groupId: GroupId): Promise { return 0; } + + // ── Anchors ───────────────────────────────────────────────────────────── + + async saveSessionAnchors(_anchors: SessionAnchorRecord[]): Promise { } + async loadSessionAnchors(_projectId: string): Promise { return []; } + async deleteSessionAnchor(_id: string): Promise { } + async clearProjectAnchors(_projectId: string): Promise { } + async updateAnchorType(_id: string, _anchorType: string): Promise { } + + // ── Search ────────────────────────────────────────────────────────────── + + async searchInit(): Promise { } + async searchAll(_query: string, _limit?: number): Promise { return []; } + async searchRebuild(): Promise { } + async searchIndexMessage(_sessionId: string, _role: string, _content: string): Promise { } + + // ── Audit ─────────────────────────────────────────────────────────────── + + async logAuditEvent(_agentId: AgentId, _eventType: AuditEventType, _detail: string): Promise { } + async getAuditLog(_groupId: GroupId, _limit?: number, _offset?: number): Promise { return []; } + async getAuditLogForAgent(_agentId: AgentId, _limit?: number): Promise { 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): void { + // Electrobun: no OTLP, console-only + } + + // ── Secrets ───────────────────────────────────────────────────────────── + + async storeSecret(_key: string, _value: string): Promise { } + async getSecret(_key: string): Promise { return null; } + async deleteSecret(_key: string): Promise { } + async listSecrets(): Promise { return []; } + async hasKeyring(): Promise { return false; } + async knownSecretKeys(): Promise { return []; } + + // ── Filesystem watcher ────────────────────────────────────────────────── + + async fsWatchProject(_projectId: string, _cwd: string): Promise { } + async fsUnwatchProject(_projectId: string): Promise { } + onFsWriteDetected(_callback: (event: FsWriteEvent) => void): UnsubscribeFn { return () => {}; } + async fsWatcherStatus(): Promise { + return { max_watches: 0, estimated_watches: 0, usage_ratio: 0, active_projects: 0, warning: null }; + } + + // ── Ctx ───────────────────────────────────────────────────────────────── + + async ctxInitDb(): Promise { } + async ctxRegisterProject(_name: string, _description: string, _workDir?: string): Promise { } + async ctxGetContext(_project: string): Promise { return []; } + async ctxGetShared(): Promise { return []; } + async ctxGetSummaries(_project: string, _limit?: number): Promise { return []; } + async ctxSearch(_query: string): Promise { return []; } + + // ── Memora ────────────────────────────────────────────────────────────── + + async memoraAvailable(): Promise { return false; } + async memoraList(_options?: { tags?: string[]; limit?: number; offset?: number }): Promise { + return { nodes: [], total: 0 }; + } + async memoraSearch(_query: string, _options?: { tags?: string[]; limit?: number }): Promise { + return { nodes: [], total: 0 }; + } + async memoraGet(_id: number): Promise { return null; } + + // ── SSH ───────────────────────────────────────────────────────────────── + + async listSshSessions(): Promise { return []; } + async saveSshSession(_session: SshSessionRecord): Promise { } + async deleteSshSession(_id: string): Promise { } + + // ── Plugins ───────────────────────────────────────────────────────────── + + async discoverPlugins(): Promise { return []; } + async readPluginFile(_pluginId: string, _filename: string): Promise { return ''; } + + // ── Claude provider ───────────────────────────────────────────────────── + + async listProfiles(): Promise { return []; } + async listSkills(): Promise { return []; } + async readSkill(_path: string): Promise { return ''; } + + // ── Remote machines ───────────────────────────────────────────────────── + + async listRemoteMachines(): Promise { return []; } + async addRemoteMachine(_config: RemoteMachineConfig): Promise { return ''; } + async removeRemoteMachine(_machineId: string): Promise { } + async connectRemoteMachine(_machineId: string): Promise { } + async disconnectRemoteMachine(_machineId: string): Promise { } + async probeSpki(_url: string): Promise { return ''; } + async addSpkiPin(_machineId: string, _pin: string): Promise { } + async removeSpkiPin(_machineId: string, _pin: string): Promise { } + 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 { return ''; } + async unwatchFile(_paneId: string): Promise { } + async readWatchedFile(_path: string): Promise { return ''; } + onFileChanged(_callback: (payload: FileChangedPayload) => void): UnsubscribeFn { return () => {}; } + + // ── Agent bridge (direct IPC) ─────────────────────────────────────────── + + async queryAgent(options: AgentQueryOptions): Promise { + 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 { + await this.r.request['agent.stop']({ sessionId }); + } + + async isAgentReady(): Promise { return true; } + async restartAgentSidecar(): Promise { } + + async setSandbox(_projectCwds: string[], _worktreeRoots: string[], _enabled: boolean): Promise { } + + onSidecarMessage(callback: (msg: SidecarMessagePayload) => void): UnsubscribeFn { + return this.listenMsg('agent.sidecar', callback); + } + + onSidecarExited(callback: () => void): UnsubscribeFn { + return this.listenMsg('agent.sidecar.exited', () => callback()); + } + + // ── PTY bridge (per-session) ──────────────────────────────────────────── + + async spawnPty(options: PtySpawnOptions): Promise { + 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 { + await this.r.request['pty.write']({ sessionId: id, data }); + } + + async resizePtyDirect(id: string, cols: number, rows: number, _remoteMachineId?: string): Promise { + await this.r.request['pty.resize']({ sessionId: id, cols, rows }); + } + + async killPty(id: string, _remoteMachineId?: string): Promise { + 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 ─────────────────────────────────────────────────────────────── /** 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; }): AgentMessage | null { const c = raw.content as Record | undefined; - switch (raw.type) { case 'text': 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 input = c?.['input'] as Record | undefined; return { - id: raw.id, role: 'tool-call', - content: formatToolContent(name, input), - toolName: name, - toolInput: input ? JSON.stringify(input, null, 2) : undefined, - timestamp: raw.timestamp, + id: raw.id, role: 'tool-call', content: formatToolContent(name, input), + toolName: name, toolInput: input ? JSON.stringify(input, null, 2) : undefined, timestamp: raw.timestamp, }; } case 'tool_result': { const output = c?.['output']; return { id: raw.id, role: 'tool-result', - content: typeof output === 'string' ? output : JSON.stringify(output, null, 2), - timestamp: raw.timestamp, + content: typeof output === 'string' ? output : JSON.stringify(output, null, 2), timestamp: raw.timestamp, }; } case 'init': return { id: raw.id, role: 'system', content: `Session initialized`, timestamp: raw.timestamp }; case 'error': return { id: raw.id, role: 'system', content: `Error: ${String(c?.['message'] ?? 'Unknown')}`, timestamp: raw.timestamp }; - case 'cost': - case 'status': - case 'compaction': + case 'cost': case 'status': case 'compaction': return null; default: return null; diff --git a/src/lib/backend/TauriAdapter.ts b/src/lib/backend/TauriAdapter.ts index cfabeae..e5bccd6 100644 --- a/src/lib/backend/TauriAdapter.ts +++ b/src/lib/backend/TauriAdapter.ts @@ -6,7 +6,24 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import type { BackendAdapter, BackendCapabilities, UnsubscribeFn, AgentStartOptions, 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'; const TAURI_CAPABILITIES: BackendCapabilities = { @@ -20,13 +37,6 @@ const TAURI_CAPABILITIES: BackendCapabilities = { supportsTelemetry: true, }; -interface SidecarMessage { - type: string; - sessionId?: string; - event?: Record; - message?: string; -} - interface TauriDirEntry { 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 }); } - // ── Agent ──────────────────────────────────────────────────────────────── + // ── Agent (simplified) ──────────────────────────────────────────────────── async startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }> { const tauriOpts = { @@ -118,7 +128,7 @@ export class TauriAdapter implements BackendAdapter { } } - // ── PTY ────────────────────────────────────────────────────────────────── + // ── PTY (simplified) ────────────────────────────────────────────────────── async createPty(options: PtyCreateOptions): Promise { return invoke('pty_spawn', { @@ -155,7 +165,584 @@ export class TauriAdapter implements BackendAdapter { return invoke('write_file_content', { path, content }); } - // ── Events ─────────────────────────────────────────────────────────────── + // ── Session persistence ───────────────────────────────────────────────── + + async listSessions(): Promise { + return invoke('session_list'); + } + + async saveSession(session: PersistedSession): Promise { + return invoke('session_save', { session }); + } + + async deleteSession(id: string): Promise { + return invoke('session_delete', { id }); + } + + async updateSessionTitle(id: string, title: string): Promise { + return invoke('session_update_title', { id, title }); + } + + async touchSession(id: string): Promise { + return invoke('session_touch', { id }); + } + + async updateSessionGroup(id: string, groupName: string): Promise { + return invoke('session_update_group', { id, group_name: groupName }); + } + + async saveLayout(layout: PersistedLayout): Promise { + return invoke('layout_save', { layout }); + } + + async loadLayout(): Promise { + return invoke('layout_load'); + } + + // ── Agent persistence ─────────────────────────────────────────────────── + + async saveAgentMessages( + sessionId: SessionId, projectId: ProjectId, sdkSessionId: string | undefined, + messages: AgentMessageRecord[], + ): Promise { + return invoke('agent_messages_save', { + sessionId, projectId, sdkSessionId: sdkSessionId ?? null, messages, + }); + } + + async loadAgentMessages(projectId: ProjectId): Promise { + return invoke('agent_messages_load', { projectId }); + } + + async saveProjectAgentState(state: ProjectAgentState): Promise { + return invoke('project_agent_state_save', { state }); + } + + async loadProjectAgentState(projectId: ProjectId): Promise { + return invoke('project_agent_state_load', { projectId }); + } + + async saveSessionMetric(metric: Omit): Promise { + return invoke('session_metric_save', { metric: { id: 0, ...metric } }); + } + + async loadSessionMetrics(projectId: ProjectId, limit = 20): Promise { + return invoke('session_metrics_load', { projectId, limit }); + } + + async getCliGroup(): Promise { + return invoke('cli_get_group'); + } + + async discoverMarkdownFiles(cwd: string): Promise { + return invoke('discover_markdown_files', { cwd }); + } + + // ── Btmsg ─────────────────────────────────────────────────────────────── + + async btmsgGetAgents(groupId: GroupId): Promise { + return invoke('btmsg_get_agents', { groupId }); + } + + async btmsgUnreadCount(agentId: AgentId): Promise { + return invoke('btmsg_unread_count', { agentId }); + } + + async btmsgUnreadMessages(agentId: AgentId): Promise { + return invoke('btmsg_unread_messages', { agentId }); + } + + async btmsgHistory(agentId: AgentId, otherId: AgentId, limit = 20): Promise { + return invoke('btmsg_history', { agentId, otherId, limit }); + } + + async btmsgSend(fromAgent: AgentId, toAgent: AgentId, content: string): Promise { + return invoke('btmsg_send', { fromAgent, toAgent, content }); + } + + async btmsgSetStatus(agentId: AgentId, status: string): Promise { + return invoke('btmsg_set_status', { agentId, status }); + } + + async btmsgEnsureAdmin(groupId: GroupId): Promise { + return invoke('btmsg_ensure_admin', { groupId }); + } + + async btmsgAllFeed(groupId: GroupId, limit = 100): Promise { + return invoke('btmsg_all_feed', { groupId, limit }); + } + + async btmsgMarkRead(readerId: AgentId, senderId: AgentId): Promise { + return invoke('btmsg_mark_read', { readerId, senderId }); + } + + async btmsgGetChannels(groupId: GroupId): Promise { + return invoke('btmsg_get_channels', { groupId }); + } + + async btmsgChannelMessages(channelId: string, limit = 100): Promise { + return invoke('btmsg_channel_messages', { channelId, limit }); + } + + async btmsgChannelSend(channelId: string, fromAgent: AgentId, content: string): Promise { + return invoke('btmsg_channel_send', { channelId, fromAgent, content }); + } + + async btmsgCreateChannel(name: string, groupId: GroupId, createdBy: AgentId): Promise { + return invoke('btmsg_create_channel', { name, groupId, createdBy }); + } + + async btmsgAddChannelMember(channelId: string, agentId: AgentId): Promise { + return invoke('btmsg_add_channel_member', { channelId, agentId }); + } + + async btmsgRegisterAgents(config: GroupsFile): Promise { + return invoke('btmsg_register_agents', { config }); + } + + async btmsgUnseenMessages(agentId: AgentId, sessionId: string): Promise { + return invoke('btmsg_unseen_messages', { agentId, sessionId }); + } + + async btmsgMarkSeen(sessionId: string, messageIds: string[]): Promise { + return invoke('btmsg_mark_seen', { sessionId, messageIds }); + } + + async btmsgPruneSeen(): Promise { + return invoke('btmsg_prune_seen'); + } + + async btmsgRecordHeartbeat(agentId: AgentId): Promise { + return invoke('btmsg_record_heartbeat', { agentId }); + } + + async btmsgGetStaleAgents(groupId: GroupId, thresholdSecs = 300): Promise { + return invoke('btmsg_get_stale_agents', { groupId, thresholdSecs }); + } + + async btmsgGetDeadLetters(groupId: GroupId, limit = 50): Promise { + return invoke('btmsg_get_dead_letters', { groupId, limit }); + } + + async btmsgClearDeadLetters(groupId: GroupId): Promise { + return invoke('btmsg_clear_dead_letters', { groupId }); + } + + async btmsgClearAllComms(groupId: GroupId): Promise { + return invoke('btmsg_clear_all_comms', { groupId }); + } + + // ── Bttask ────────────────────────────────────────────────────────────── + + async bttaskList(groupId: GroupId): Promise { + return invoke('bttask_list', { groupId }); + } + + async bttaskComments(taskId: string): Promise { + return invoke('bttask_comments', { taskId }); + } + + async bttaskUpdateStatus(taskId: string, status: string, version: number): Promise { + return invoke('bttask_update_status', { taskId, status, version }); + } + + async bttaskAddComment(taskId: string, agentId: AgentId, content: string): Promise { + return invoke('bttask_add_comment', { taskId, agentId, content }); + } + + async bttaskCreate( + title: string, description: string, priority: string, + groupId: GroupId, createdBy: AgentId, assignedTo?: AgentId, + ): Promise { + return invoke('bttask_create', { title, description, priority, groupId, createdBy, assignedTo }); + } + + async bttaskDelete(taskId: string): Promise { + return invoke('bttask_delete', { taskId }); + } + + async bttaskReviewQueueCount(groupId: GroupId): Promise { + return invoke('bttask_review_queue_count', { groupId }); + } + + // ── Anchors ───────────────────────────────────────────────────────────── + + async saveSessionAnchors(anchors: SessionAnchorRecord[]): Promise { + return invoke('session_anchors_save', { anchors }); + } + + async loadSessionAnchors(projectId: string): Promise { + return invoke('session_anchors_load', { projectId }); + } + + async deleteSessionAnchor(id: string): Promise { + return invoke('session_anchor_delete', { id }); + } + + async clearProjectAnchors(projectId: string): Promise { + return invoke('session_anchors_clear', { projectId }); + } + + async updateAnchorType(id: string, anchorType: string): Promise { + return invoke('session_anchor_update_type', { id, anchorType }); + } + + // ── Search ────────────────────────────────────────────────────────────── + + async searchInit(): Promise { + return invoke('search_init'); + } + + async searchAll(query: string, limit = 20): Promise { + return invoke('search_query', { query, limit }); + } + + async searchRebuild(): Promise { + return invoke('search_rebuild'); + } + + async searchIndexMessage(sessionId: string, role: string, content: string): Promise { + return invoke('search_index_message', { sessionId, role, content }); + } + + // ── Audit ─────────────────────────────────────────────────────────────── + + async logAuditEvent(agentId: AgentId, eventType: AuditEventType, detail: string): Promise { + return invoke('audit_log_event', { agentId, eventType, detail }); + } + + async getAuditLog(groupId: GroupId, limit = 200, offset = 0): Promise { + return invoke('audit_log_list', { groupId, limit, offset }); + } + + async getAuditLogForAgent(agentId: AgentId, limit = 50): Promise { + 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): 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 { + return invoke('secrets_store', { key, value }); + } + + async getSecret(key: string): Promise { + return invoke('secrets_get', { key }); + } + + async deleteSecret(key: string): Promise { + return invoke('secrets_delete', { key }); + } + + async listSecrets(): Promise { + return invoke('secrets_list'); + } + + async hasKeyring(): Promise { + return invoke('secrets_has_keyring'); + } + + async knownSecretKeys(): Promise { + return invoke('secrets_known_keys'); + } + + // ── Filesystem watcher ────────────────────────────────────────────────── + + async fsWatchProject(projectId: string, cwd: string): Promise { + return invoke('fs_watch_project', { projectId, cwd }); + } + + async fsUnwatchProject(projectId: string): Promise { + return invoke('fs_unwatch_project', { projectId }); + } + + onFsWriteDetected(callback: (event: FsWriteEvent) => void): UnsubscribeFn { + return this.listenTauri('fs-write-detected', callback); + } + + async fsWatcherStatus(): Promise { + return invoke('fs_watcher_status'); + } + + // ── Ctx ───────────────────────────────────────────────────────────────── + + async ctxInitDb(): Promise { + return invoke('ctx_init_db'); + } + + async ctxRegisterProject(name: string, description: string, workDir?: string): Promise { + return invoke('ctx_register_project', { name, description, workDir: workDir ?? null }); + } + + async ctxGetContext(project: string): Promise { + return invoke('ctx_get_context', { project }); + } + + async ctxGetShared(): Promise { + return invoke('ctx_get_shared'); + } + + async ctxGetSummaries(project: string, limit = 5): Promise { + return invoke('ctx_get_summaries', { project, limit }); + } + + async ctxSearch(query: string): Promise { + return invoke('ctx_search', { query }); + } + + // ── Memora ────────────────────────────────────────────────────────────── + + async memoraAvailable(): Promise { + return invoke('memora_available'); + } + + async memoraList(options?: { tags?: string[]; limit?: number; offset?: number }): Promise { + return invoke('memora_list', { + tags: options?.tags ?? null, + limit: options?.limit ?? 50, + offset: options?.offset ?? 0, + }); + } + + async memoraSearch(query: string, options?: { tags?: string[]; limit?: number }): Promise { + return invoke('memora_search', { + query, + tags: options?.tags ?? null, + limit: options?.limit ?? 50, + }); + } + + async memoraGet(id: number): Promise { + return invoke('memora_get', { id }); + } + + // ── SSH ───────────────────────────────────────────────────────────────── + + async listSshSessions(): Promise { + return invoke('ssh_session_list'); + } + + async saveSshSession(session: SshSessionRecord): Promise { + return invoke('ssh_session_save', { session }); + } + + async deleteSshSession(id: string): Promise { + return invoke('ssh_session_delete', { id }); + } + + // ── Plugins ───────────────────────────────────────────────────────────── + + async discoverPlugins(): Promise { + return invoke('plugins_discover'); + } + + async readPluginFile(pluginId: string, filename: string): Promise { + return invoke('plugin_read_file', { pluginId, filename }); + } + + // ── Claude provider ───────────────────────────────────────────────────── + + async listProfiles(): Promise { + return invoke('claude_list_profiles'); + } + + async listSkills(): Promise { + return invoke('claude_list_skills'); + } + + async readSkill(path: string): Promise { + return invoke('claude_read_skill', { path }); + } + + // ── Remote machines ───────────────────────────────────────────────────── + + async listRemoteMachines(): Promise { + return invoke('remote_list'); + } + + async addRemoteMachine(config: RemoteMachineConfig): Promise { + return invoke('remote_add', { config }); + } + + async removeRemoteMachine(machineId: string): Promise { + return invoke('remote_remove', { machineId }); + } + + async connectRemoteMachine(machineId: string): Promise { + return invoke('remote_connect', { machineId }); + } + + async disconnectRemoteMachine(machineId: string): Promise { + return invoke('remote_disconnect', { machineId }); + } + + async probeSpki(url: string): Promise { + return invoke('remote_probe_spki', { url }); + } + + async addSpkiPin(machineId: string, pin: string): Promise { + return invoke('remote_add_pin', { machineId, pin }); + } + + async removeSpkiPin(machineId: string, pin: string): Promise { + return invoke('remote_remove_pin', { machineId, pin }); + } + + onRemoteSidecarMessage(callback: (msg: RemoteSidecarMessage) => void): UnsubscribeFn { + return this.listenTauri('remote-sidecar-message', callback); + } + + onRemotePtyData(callback: (msg: RemotePtyData) => void): UnsubscribeFn { + return this.listenTauri('remote-pty-data', callback); + } + + onRemotePtyExit(callback: (msg: RemotePtyExit) => void): UnsubscribeFn { + return this.listenTauri('remote-pty-exit', callback); + } + + onRemoteMachineReady(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn { + return this.listenTauri('remote-machine-ready', callback); + } + + onRemoteMachineDisconnected(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn { + return this.listenTauri('remote-machine-disconnected', callback); + } + + onRemoteStateSync(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn { + return this.listenTauri('remote-state-sync', callback); + } + + onRemoteError(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn { + return this.listenTauri('remote-error', callback); + } + + onRemoteMachineReconnecting(callback: (msg: RemoteReconnectingEvent) => void): UnsubscribeFn { + return this.listenTauri('remote-machine-reconnecting', callback); + } + + onRemoteMachineReconnectReady(callback: (msg: RemoteMachineEvent) => void): UnsubscribeFn { + return this.listenTauri('remote-machine-reconnect-ready', callback); + } + + onRemoteSpkiTofu(callback: (msg: RemoteSpkiTofuEvent) => void): UnsubscribeFn { + return this.listenTauri('remote-spki-tofu', callback); + } + + // ── File watcher ──────────────────────────────────────────────────────── + + async watchFile(paneId: string, path: string): Promise { + return invoke('file_watch', { paneId, path }); + } + + async unwatchFile(paneId: string): Promise { + return invoke('file_unwatch', { paneId }); + } + + async readWatchedFile(path: string): Promise { + return invoke('file_read', { path }); + } + + onFileChanged(callback: (payload: FileChangedPayload) => void): UnsubscribeFn { + return this.listenTauri('file-changed', callback); + } + + // ── Agent bridge (direct IPC) ─────────────────────────────────────────── + + async queryAgent(options: AgentQueryOptions): Promise { + 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 { + if (remoteMachineId) { + return invoke('remote_agent_stop', { machineId: remoteMachineId, sessionId }); + } + return invoke('agent_stop', { sessionId }); + } + + async isAgentReady(): Promise { + return invoke('agent_ready'); + } + + async restartAgentSidecar(): Promise { + return invoke('agent_restart'); + } + + async setSandbox(projectCwds: string[], worktreeRoots: string[], enabled: boolean): Promise { + return invoke('agent_set_sandbox', { projectCwds, worktreeRoots, enabled }); + } + + onSidecarMessage(callback: (msg: SidecarMessagePayload) => void): UnsubscribeFn { + return this.listenTauri('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 { + if (options.remote_machine_id) { + const { remote_machine_id: machineId, ...ptyOptions } = options; + return invoke('remote_pty_spawn', { machineId, options: ptyOptions }); + } + return invoke('pty_spawn', { options }); + } + + async writePtyDirect(id: string, data: string, remoteMachineId?: string): Promise { + 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 { + 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 { + 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(`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. */ private listenTauri(event: string, handler: (payload: T) => void): UnsubscribeFn { @@ -173,7 +760,7 @@ export class TauriAdapter implements BackendAdapter { } onAgentMessage(callback: (sessionId: string, messages: AgentMessage[]) => void): UnsubscribeFn { - return this.listenTauri('sidecar-message', (msg) => { + return this.listenTauri('sidecar-message', (msg) => { if (msg.type !== 'message' || !msg.sessionId || !msg.event) return; const agentMsg: AgentMessage = { 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 { - return this.listenTauri('sidecar-message', (msg) => { + return this.listenTauri('sidecar-message', (msg) => { if (msg.type !== 'status' || !msg.sessionId) return; callback(msg.sessionId, normalizeStatus(String(msg.event?.['status'] ?? 'idle')), msg.message); }); @@ -197,7 +784,7 @@ export class TauriAdapter implements BackendAdapter { onAgentCost( callback: (sessionId: string, costUsd: number, inputTokens: number, outputTokens: number) => void, ): UnsubscribeFn { - return this.listenTauri('sidecar-message', (msg) => { + return this.listenTauri('sidecar-message', (msg) => { 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)); }); @@ -205,7 +792,7 @@ export class TauriAdapter implements BackendAdapter { onPtyOutput(_callback: (sessionId: string, data: string) => void): UnsubscribeFn { // 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 () => {}; } diff --git a/src/lib/components/Agent/AgentPane.svelte b/src/lib/components/Agent/AgentPane.svelte index fb6c98a..3bb1c54 100644 --- a/src/lib/components/Agent/AgentPane.svelte +++ b/src/lib/components/Agent/AgentPane.svelte @@ -1,7 +1,8 @@