diff --git a/package.json b/package.json index f55910d..5cc531d 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "private": true, "version": "0.1.0", "type": "module", + "workspaces": [ + "packages/*" + ], "scripts": { "dev": "vite", "prebuild": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs", diff --git a/packages/types/agent.ts b/packages/types/agent.ts new file mode 100644 index 0000000..0c2c9d1 --- /dev/null +++ b/packages/types/agent.ts @@ -0,0 +1,119 @@ +// Agent types — shared between Tauri and Electrobun frontends + +// ── Provider ───────────────────────────────────────────────────────────────── + +export type ProviderId = 'claude' | 'codex' | 'ollama' | 'aider'; + +/** What a provider can do — UI gates features on these flags */ +export interface ProviderCapabilities { + hasProfiles: boolean; + hasSkills: boolean; + hasModelSelection: boolean; + hasSandbox: boolean; + supportsSubagents: boolean; + supportsCost: boolean; + supportsResume: boolean; +} + +/** Static metadata about a provider */ +export interface ProviderMeta { + id: ProviderId; + name: string; + description: string; + capabilities: ProviderCapabilities; + /** Name of the sidecar runner file (e.g. 'claude-runner.mjs') */ + sidecarRunner: string; + /** Default model identifier, if applicable */ + defaultModel?: string; + /** Available model presets for dropdown selection */ + models?: { id: string; label: string }[]; +} + +/** Per-provider configuration (stored in settings) */ +export interface ProviderSettings { + enabled: boolean; + defaultModel?: string; + /** Provider-specific config blob */ + config: Record; +} + +// ── Agent status ───────────────────────────────────────────────────────────── + +export type AgentStatus = 'idle' | 'starting' | 'running' | 'done' | 'error'; + +// ── Agent message (internal display format) ────────────────────────────────── + +export type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result' | 'thinking' | 'system'; + +export interface AgentMessage { + id: string; + role: MsgRole; + content: string; + toolName?: string; + toolInput?: string; + toolPath?: string; + timestamp: number; +} + +// ── Agent message (wire format from sidecar) ───────────────────────────────── + +export type AgentMessageType = + | 'init' + | 'text' + | 'thinking' + | 'tool_call' + | 'tool_result' + | 'status' + | 'compaction' + | 'cost' + | 'error' + | 'unknown'; + +export interface AgentWireMessage { + id: string; + type: AgentMessageType; + parentId?: string; + content: unknown; + timestamp: number; +} + +// ── Agent session state ────────────────────────────────────────────────────── + +export interface AgentSession { + id: string; + sdkSessionId?: string; + status: AgentStatus; + model?: string; + prompt: string; + messages: AgentMessage[]; + costUsd: number; + inputTokens: number; + outputTokens: number; + numTurns: number; + durationMs: number; + error?: string; + // Agent Teams: parent/child hierarchy + parentSessionId?: string; + parentToolUseId?: string; + childSessionIds: string[]; +} + +// ── Agent start options (backend-agnostic) ─────────────────────────────────── + +export interface AgentStartOptions { + provider?: ProviderId; + sessionId: string; + prompt: string; + cwd?: string; + model?: string; + systemPrompt?: string; + maxTurns?: number; + maxBudgetUsd?: number; + permissionMode?: string; + settingSources?: string[]; + claudeConfigDir?: string; + additionalDirectories?: string[]; + worktreeName?: string; + providerConfig?: Record; + extraEnv?: Record; +} diff --git a/packages/types/backend.ts b/packages/types/backend.ts new file mode 100644 index 0000000..94165ad --- /dev/null +++ b/packages/types/backend.ts @@ -0,0 +1,84 @@ +// BackendAdapter — abstraction layer for Tauri and Electrobun backends + +import type { AgentStartOptions, AgentMessage, AgentStatus } from './agent.ts'; +import type { FileEntry, FileContent, PtyCreateOptions } from './protocol.ts'; +import type { SettingsMap } from './settings.ts'; +import type { GroupsFile } from './project.ts'; + +// ── Backend capabilities ───────────────────────────────────────────────────── + +export interface BackendCapabilities { + /** Can multiplex multiple PTY sessions (Tauri: yes, Electrobun: via daemon) */ + readonly supportsPtyMultiplexing: boolean; + /** Web Worker plugin sandbox available */ + readonly supportsPluginSandbox: boolean; + /** Native OS menu bar integration */ + readonly supportsNativeMenus: boolean; + /** OS keychain for secrets (libsecret on Linux) */ + readonly supportsOsKeychain: boolean; + /** Native file open/save dialogs */ + readonly supportsFileDialogs: boolean; + /** In-app auto-updater (Tauri updater / GitHub Releases) */ + readonly supportsAutoUpdater: boolean; + /** Desktop notifications (notify-rust / OS notification daemon) */ + readonly supportsDesktopNotifications: boolean; + /** OpenTelemetry tracing export */ + readonly supportsTelemetry: boolean; +} + +// ── Unsubscribe function ───────────────────────────────────────────────────── + +/** Call to remove an event listener */ +export type UnsubscribeFn = () => void; + +// ── Backend adapter interface ──────────────────────────────────────────────── + +export interface BackendAdapter { + readonly capabilities: BackendCapabilities; + + // ── Lifecycle ──────────────────────────────────────────────────────────── + + /** Initialize the backend (connect to IPC, register listeners, etc.) */ + init(): Promise; + + /** Tear down the backend (disconnect, clean up listeners) */ + destroy(): Promise; + + // ── Settings ───────────────────────────────────────────────────────────── + + getSetting(key: string): Promise; + setSetting(key: string, value: string): Promise; + getAllSettings(): Promise; + + // ── Groups ─────────────────────────────────────────────────────────────── + + loadGroups(): Promise; + saveGroups(groups: GroupsFile): Promise; + + // ── Agent ──────────────────────────────────────────────────────────────── + + 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 ────────────────────────────────────────────────────────────────── + + createPty(options: PtyCreateOptions): Promise; + writePty(sessionId: string, data: string): Promise; + resizePty(sessionId: string, cols: number, rows: number): Promise; + closePty(sessionId: string): Promise; + + // ── Files ──────────────────────────────────────────────────────────────── + + listDirectory(path: string): Promise; + readFile(path: string): Promise; + writeFile(path: string, content: string): Promise; + + // ── Events (backend -> frontend) ───────────────────────────────────────── + + onAgentMessage(callback: (sessionId: string, messages: AgentMessage[]) => void): UnsubscribeFn; + onAgentStatus(callback: (sessionId: string, status: AgentStatus, error?: string) => void): UnsubscribeFn; + onAgentCost(callback: (sessionId: string, costUsd: number, inputTokens: number, outputTokens: number) => void): UnsubscribeFn; + onPtyOutput(callback: (sessionId: string, data: string) => void): UnsubscribeFn; + onPtyClosed(callback: (sessionId: string, exitCode: number | null) => void): UnsubscribeFn; +} diff --git a/packages/types/btmsg.ts b/packages/types/btmsg.ts new file mode 100644 index 0000000..c7c12db --- /dev/null +++ b/packages/types/btmsg.ts @@ -0,0 +1,67 @@ +// btmsg types — agent messaging system + +import type { AgentId, GroupId } from './ids.ts'; + +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 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; +} + +export interface AgentHeartbeat { + agentId: AgentId; + timestamp: number; +} + +export interface DeadLetterEntry { + id: number; + fromAgent: string; + toAgent: string; + content: string; + error: string; + createdAt: string; +} + +export interface AuditEntry { + id: number; + agentId: string; + eventType: string; + detail: string; + createdAt: string; +} diff --git a/packages/types/bttask.ts b/packages/types/bttask.ts new file mode 100644 index 0000000..a17a189 --- /dev/null +++ b/packages/types/bttask.ts @@ -0,0 +1,30 @@ +// bttask types — task board system + +import type { AgentId, GroupId } from './ids.ts'; + +export type TaskStatus = 'todo' | 'progress' | 'review' | 'done' | 'blocked'; +export type TaskPriority = 'low' | 'medium' | 'high' | 'critical'; + +export interface Task { + id: string; + title: string; + description: string; + status: TaskStatus; + priority: TaskPriority; + 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; +} diff --git a/packages/types/health.ts b/packages/types/health.ts new file mode 100644 index 0000000..a306e0f --- /dev/null +++ b/packages/types/health.ts @@ -0,0 +1,33 @@ +// Health tracking types + +import type { ProjectId, SessionId } from './ids.ts'; + +export type ActivityState = 'inactive' | 'running' | 'idle' | 'stalled'; + +export interface ProjectHealth { + projectId: ProjectId; + sessionId: SessionId | null; + /** Current activity state */ + activityState: ActivityState; + /** Name of currently running tool (if any) */ + activeTool: string | null; + /** Duration in ms since last activity (0 if running a tool) */ + idleDurationMs: number; + /** Burn rate in USD per hour (0 if no data) */ + burnRatePerHour: number; + /** Context pressure as fraction 0..1 (null if unknown) */ + contextPressure: number | null; + /** Number of file conflicts (2+ agents writing same file) */ + fileConflictCount: number; + /** Number of external write conflicts */ + externalConflictCount: number; + /** Attention urgency score (higher = more urgent, 0 = no attention needed) */ + attentionScore: number; + /** Human-readable attention reason */ + attentionReason: string | null; +} + +export interface AttentionItem extends ProjectHealth { + projectName: string; + projectIcon: string; +} diff --git a/packages/types/ids.ts b/packages/types/ids.ts new file mode 100644 index 0000000..f704e91 --- /dev/null +++ b/packages/types/ids.ts @@ -0,0 +1,34 @@ +// Branded types for domain identifiers — prevents accidental swapping of IDs across domains. +// These are compile-time only; at runtime they are plain strings. + +/** Unique identifier for an agent session */ +export type SessionId = string & { readonly __brand: 'SessionId' }; + +/** Unique identifier for a project */ +export type ProjectId = string & { readonly __brand: 'ProjectId' }; + +/** Unique identifier for a project group */ +export type GroupId = string & { readonly __brand: 'GroupId' }; + +/** Unique identifier for an agent in the btmsg/bttask system */ +export type AgentId = string & { readonly __brand: 'AgentId' }; + +/** Create a SessionId from a raw string */ +export function SessionId(value: string): SessionId { + return value as SessionId; +} + +/** Create a ProjectId from a raw string */ +export function ProjectId(value: string): ProjectId { + return value as ProjectId; +} + +/** Create a GroupId from a raw string */ +export function GroupId(value: string): GroupId { + return value as GroupId; +} + +/** Create an AgentId from a raw string */ +export function AgentId(value: string): AgentId { + return value as AgentId; +} diff --git a/packages/types/index.ts b/packages/types/index.ts new file mode 100644 index 0000000..41fd765 --- /dev/null +++ b/packages/types/index.ts @@ -0,0 +1,11 @@ +// @agor/types — shared type definitions for Tauri and Electrobun frontends + +export * from './ids.ts'; +export * from './agent.ts'; +export * from './project.ts'; +export * from './settings.ts'; +export * from './btmsg.ts'; +export * from './bttask.ts'; +export * from './health.ts'; +export * from './protocol.ts'; +export * from './backend.ts'; diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 0000000..eca0b9d --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,13 @@ +{ + "name": "@agor/types", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "index.ts", + "types": "index.ts", + "exports": { + ".": "./index.ts", + "./*": "./*.ts" + }, + "files": ["*.ts"] +} diff --git a/packages/types/project.ts b/packages/types/project.ts new file mode 100644 index 0000000..ebe3e8b --- /dev/null +++ b/packages/types/project.ts @@ -0,0 +1,88 @@ +// Project and Group configuration types + +import type { ProviderId } from './agent.ts'; +import type { ProjectId, GroupId, AgentId } from './ids.ts'; + +// ── Anchor budget ──────────────────────────────────────────────────────────── + +export type AnchorBudgetScale = 'small' | 'medium' | 'large' | 'full'; + +// ── Wake strategy ──────────────────────────────────────────────────────────── + +export type WakeStrategy = 'persistent' | 'on-demand' | 'smart'; + +// ── Agent roles ────────────────────────────────────────────────────────────── + +export type GroupAgentRole = 'manager' | 'architect' | 'tester' | 'reviewer'; + +// ── Project configuration ──────────────────────────────────────────────────── + +export interface ProjectConfig { + id: ProjectId; + name: string; + identifier: string; + description: string; + icon: string; + cwd: string; + profile: string; + enabled: boolean; + /** Agent provider for this project (defaults to 'claude') */ + provider?: ProviderId; + /** Model override. Falls back to provider default. */ + model?: string; + /** When true, agents for this project use git worktrees for isolation */ + useWorktrees?: boolean; + /** When true, sidecar process is sandboxed via Landlock */ + sandboxEnabled?: boolean; + /** Shell execution mode for AI agents */ + autonomousMode?: 'restricted' | 'autonomous'; + /** Anchor token budget scale (defaults to 'medium' = 6K tokens) */ + anchorBudgetScale?: AnchorBudgetScale; + /** Stall detection threshold in minutes (defaults to 15) */ + stallThresholdMin?: number; + /** True for Tier 1 management agents rendered as project boxes */ + isAgent?: boolean; + /** Agent role (manager/architect/tester/reviewer) -- only when isAgent */ + agentRole?: GroupAgentRole; + /** System prompt injected at session start -- only when isAgent */ + systemPrompt?: string; +} + +// ── Group-level agent configuration ────────────────────────────────────────── + +export interface GroupAgentConfig { + id: AgentId; + name: string; + role: GroupAgentRole; + provider?: ProviderId; + model?: string; + cwd?: string; + systemPrompt?: string; + enabled: boolean; + /** Auto-wake interval in minutes (Manager only, default 3) */ + wakeIntervalMin?: number; + /** Wake strategy */ + wakeStrategy?: WakeStrategy; + /** Wake threshold 0..1 for smart strategy (default 0.5) */ + wakeThreshold?: number; + /** Shell execution mode */ + autonomousMode?: 'restricted' | 'autonomous'; +} + +// ── Group configuration ────────────────────────────────────────────────────── + +export interface GroupConfig { + id: GroupId; + name: string; + projects: ProjectConfig[]; + /** Group-level orchestration agents (Tier 1) */ + agents?: GroupAgentConfig[]; +} + +// ── Groups file (persisted to disk) ────────────────────────────────────────── + +export interface GroupsFile { + version: number; + groups: GroupConfig[]; + activeGroupId: GroupId; +} diff --git a/packages/types/protocol.ts b/packages/types/protocol.ts new file mode 100644 index 0000000..20cf1ae --- /dev/null +++ b/packages/types/protocol.ts @@ -0,0 +1,127 @@ +// Unified RPC protocol types — covers both Tauri commands and Electrobun RPC schema +// These define the request/response shapes for all backend operations. + +import type { AgentStartOptions, AgentStatus, AgentWireMessage } from './agent.ts'; + +// ── PTY ────────────────────────────────────────────────────────────────────── + +export interface PtyCreateOptions { + sessionId: string; + cols: number; + rows: number; + cwd?: string; + shell?: string; + args?: string[]; +} + +export interface PtyCreateResponse { + ok: boolean; + sessionId?: string; + error?: string; +} + +// ── Files ──────────────────────────────────────────────────────────────────── + +export interface FileEntry { + name: string; + path: string; + isDir: boolean; + size: number; + ext?: string; +} + +export type FileContent = + | { type: 'Text'; content: string; lang?: string } + | { type: 'Binary'; message: string } + | { type: 'TooLarge'; size: number }; + +// ── Settings ───────────────────────────────────────────────────────────────── + +export interface SettingEntry { + key: string; + value: string; +} + +// ── Session persistence ────────────────────────────────────────────────────── + +export interface SessionRecord { + projectId: string; + sessionId: string; + provider: string; + status: string; + costUsd: number; + inputTokens: number; + outputTokens: number; + model: string; + error?: string; + createdAt: number; + updatedAt: number; +} + +export interface SessionMessageRecord { + sessionId: string; + msgId: string; + role: string; + content: string; + toolName?: string; + toolInput?: string; + timestamp: number; + costUsd?: number; + inputTokens?: number; + outputTokens?: number; +} + +// ── Agent events (backend -> frontend) ─────────────────────────────────────── + +export interface AgentMessageEvent { + sessionId: string; + messages: AgentWireMessage[]; +} + +export interface AgentStatusEvent { + sessionId: string; + status: string; + error?: string; +} + +export interface AgentCostEvent { + sessionId: string; + costUsd: number; + inputTokens: number; + outputTokens: number; +} + +// ── PTY events (backend -> frontend) ───────────────────────────────────────── + +export interface PtyOutputEvent { + sessionId: string; + /** Base64-encoded or raw UTF-8 data depending on backend */ + data: string; +} + +export interface PtyClosedEvent { + sessionId: string; + exitCode: number | null; +} + +// ── Search ─────────────────────────────────────────────────────────────────── + +export interface SearchResult { + resultType: string; + id: string; + title: string; + snippet: string; + score: number; +} + +// ── Remote machine ─────────────────────────────────────────────────────────── + +export type RemoteMachineStatus = 'connecting' | 'connected' | 'disconnected' | 'error'; + +export interface RemoteMachine { + machineId: string; + label: string; + url: string; + status: RemoteMachineStatus; + latencyMs: number | null; +} diff --git a/packages/types/settings.ts b/packages/types/settings.ts new file mode 100644 index 0000000..f982742 --- /dev/null +++ b/packages/types/settings.ts @@ -0,0 +1,27 @@ +// Settings types — shared key definitions and map type + +/** Well-known setting keys used across the application */ +export enum SettingKey { + // Appearance + Theme = 'theme', + UiFontFamily = 'ui_font_family', + UiFontSize = 'ui_font_size', + TermFontFamily = 'term_font_family', + TermFontSize = 'term_font_size', + + // Defaults + DefaultShell = 'default_shell', + DefaultCwd = 'default_cwd', + + // Agent + PermissionMode = 'permission_mode', + SystemPromptTemplate = 'system_prompt_template', + ProviderSettings = 'provider_settings', + + // Layout + SidebarWidth = 'sidebar_width', + SidebarCollapsed = 'sidebar_collapsed', +} + +/** Flat key-value settings map (all values are strings) */ +export type SettingsMap = Record; diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json new file mode 100644 index 0000000..ab3dab7 --- /dev/null +++ b/packages/types/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "include": ["*.ts"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..18ec407 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/src/lib/backend/ElectrobunAdapter.ts b/src/lib/backend/ElectrobunAdapter.ts new file mode 100644 index 0000000..042c912 --- /dev/null +++ b/src/lib/backend/ElectrobunAdapter.ts @@ -0,0 +1,350 @@ +// ElectrobunAdapter — implements BackendAdapter for Electrobun backend +// Wraps the Electrobun RPC pattern (request/response + message listeners) + +import type { + BackendAdapter, + BackendCapabilities, + UnsubscribeFn, + AgentStartOptions, + AgentMessage, + AgentStatus, + FileEntry, + FileContent, + PtyCreateOptions, + SettingsMap, + GroupsFile, + GroupConfig, + GroupId, +} 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 +}; + +// ── RPC handle type (matches ui-electrobun/src/mainview/rpc.ts) ────────────── + +interface RpcRequestFns { + [method: string]: (params: Record) => Promise>; +} + +interface RpcHandle { + request: RpcRequestFns; + addMessageListener: (event: string, handler: (payload: unknown) => void) => void; + removeMessageListener?: (event: string, handler: (payload: unknown) => void) => void; +} + +// ── Adapter ────────────────────────────────────────────────────────────────── + +export class ElectrobunAdapter implements BackendAdapter { + readonly capabilities = ELECTROBUN_CAPABILITIES; + + private rpc: RpcHandle | null = null; + private messageHandlers: Array<{ event: string; handler: (payload: unknown) => void }> = []; + + /** Inject the Electrobun RPC handle (set from main.ts after Electroview.defineRPC()) */ + setRpc(rpc: RpcHandle): void { + this.rpc = rpc; + } + + private get r(): RpcHandle { + if (!this.rpc) { + throw new Error('[ElectrobunAdapter] RPC not initialized. Call setRpc() first.'); + } + return this.rpc; + } + + async init(): Promise { + // RPC handle is set externally via setRpc() + } + + async destroy(): Promise { + // Remove all registered message listeners + if (this.rpc?.removeMessageListener) { + for (const { event, handler } of this.messageHandlers) { + this.rpc.removeMessageListener(event, handler); + } + } + this.messageHandlers = []; + this.rpc = null; + } + + // ── Settings ───────────────────────────────────────────────────────────── + + async getSetting(key: string): Promise { + const res = await this.r.request['settings.get']({ key }) as { value: string | null }; + return res.value; + } + + async setSetting(key: string, value: string): Promise { + await this.r.request['settings.set']({ key, value }); + } + + async getAllSettings(): Promise { + const res = await this.r.request['settings.getAll']({}) as { settings: SettingsMap }; + return res.settings; + } + + // ── 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 }>; + }; + + const projectsByGroup = new Map>>(); + for (const p of projectsRes.projects) { + try { + const config = JSON.parse(p.config) as Record; + const groupId = String(config['groupId'] ?? 'default'); + if (!projectsByGroup.has(groupId)) projectsByGroup.set(groupId, []); + projectsByGroup.get(groupId)!.push(config); + } catch { /* skip invalid */ } + } + + const groups: GroupConfig[] = res.groups.map((g) => ({ + id: g.id as GroupId, + name: g.name, + projects: (projectsByGroup.get(g.id) ?? []) as unknown as GroupConfig['projects'], + })); + + return { + version: 1, + groups, + activeGroupId: (groups[0]?.id ?? 'default') as GroupId, + }; + } + + 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), + }); + } + + // 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 }), + }); + } + } + } + + // ── Agent ──────────────────────────────────────────────────────────────── + + 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, + worktreeName: options.worktreeName, + }) as { ok: boolean; error?: string }; + return res; + } + + async stopAgent(sessionId: string): Promise<{ ok: boolean; error?: string }> { + const res = await this.r.request['agent.stop']({ sessionId }) as { ok: boolean; error?: string }; + return res; + } + + async sendPrompt(sessionId: string, prompt: string): Promise<{ ok: boolean; error?: string }> { + const res = await this.r.request['agent.prompt']({ sessionId, prompt }) as { ok: boolean; error?: string }; + return res; + } + + // ── PTY ────────────────────────────────────────────────────────────────── + + 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, + }) as { ok: boolean; error?: string }; + if (!res.ok) throw new Error(res.error ?? 'PTY creation failed'); + return options.sessionId; + } + + async writePty(sessionId: string, data: string): Promise { + await this.r.request['pty.write']({ sessionId, data }); + } + + async resizePty(sessionId: string, cols: number, rows: number): Promise { + await this.r.request['pty.resize']({ sessionId, cols, rows }); + } + + async closePty(sessionId: string): Promise { + await this.r.request['pty.close']({ sessionId }); + } + + // ── Files ──────────────────────────────────────────────────────────────── + + 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; + }; + 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, + })); + } + + async readFile(path: string): Promise { + const res = await this.r.request['files.read']({ path }) as { + 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)` }; + } + return { type: 'Text', content: res.content ?? '' }; + } + + async writeFile(path: string, content: string): Promise { + const res = await this.r.request['files.write']({ path, content }) as { ok: boolean; error?: string }; + if (!res.ok) throw new Error(res.error ?? 'Write failed'); + } + + // ── Events ─────────────────────────────────────────────────────────────── + + /** Subscribe to an RPC message event; returns unsubscribe function. */ + private listenMsg(event: string, handler: (payload: T) => void): UnsubscribeFn { + const wrapped = handler as (payload: unknown) => void; + this.r.addMessageListener(event, wrapped); + this.messageHandlers.push({ event, handler: wrapped }); + return () => { + this.r.removeMessageListener?.(event, wrapped); + this.messageHandlers = this.messageHandlers.filter((h) => h.handler !== wrapped); + }; + } + + onAgentMessage(callback: (sessionId: string, messages: AgentMessage[]) => void): UnsubscribeFn { + return this.listenMsg<{ + sessionId: string; + messages: Array<{ id: string; type: string; parentId?: string; content: unknown; timestamp: number }>; + }>('agent.message', (p) => { + const converted = p.messages.map(convertWireMessage).filter((m): m is AgentMessage => m !== null); + if (converted.length > 0) callback(p.sessionId, converted); + }); + } + + onAgentStatus(callback: (sessionId: string, status: AgentStatus, error?: string) => void): UnsubscribeFn { + return this.listenMsg<{ sessionId: string; status: string; error?: string }>('agent.status', (p) => { + callback(p.sessionId, normalizeStatus(p.status), p.error); + }); + } + + onAgentCost( + callback: (sessionId: string, costUsd: number, inputTokens: number, outputTokens: number) => void, + ): UnsubscribeFn { + return this.listenMsg<{ sessionId: string; costUsd: number; inputTokens: number; outputTokens: number }>( + 'agent.cost', (p) => callback(p.sessionId, p.costUsd, p.inputTokens, p.outputTokens), + ); + } + + onPtyOutput(callback: (sessionId: string, data: string) => void): UnsubscribeFn { + return this.listenMsg<{ sessionId: string; data: string }>('pty.output', (p) => callback(p.sessionId, p.data)); + } + + onPtyClosed(callback: (sessionId: string, exitCode: number | null) => void): UnsubscribeFn { + return this.listenMsg<{ sessionId: string; exitCode: number | null }>('pty.closed', (p) => callback(p.sessionId, p.exitCode)); + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result' | 'thinking' | 'system'; + +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 }; + case 'thinking': + return { id: raw.id, role: 'thinking', content: String(c?.['text'] ?? ''), timestamp: raw.timestamp }; + case 'tool_call': { + 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, + }; + } + 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, + }; + } + 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': + return null; + default: + return null; + } +} + +function formatToolContent(name: string, input: Record | undefined): string { + if (!input) return ''; + if (name === 'Bash' && typeof input['command'] === 'string') return input['command'] as string; + if (typeof input['file_path'] === 'string') return input['file_path'] as string; + return JSON.stringify(input, null, 2); +} + +function normalizeStatus(status: string): AgentStatus { + if (status === 'idle' || status === 'starting' || status === 'running' || status === 'done' || status === 'error') { + return status; + } + return 'idle'; +} diff --git a/src/lib/backend/TauriAdapter.ts b/src/lib/backend/TauriAdapter.ts new file mode 100644 index 0000000..a823658 --- /dev/null +++ b/src/lib/backend/TauriAdapter.ts @@ -0,0 +1,318 @@ +// TauriAdapter — implements BackendAdapter for Tauri 2.x backend +// Wraps existing invoke() and listen() calls from @tauri-apps/api + +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import type { + BackendAdapter, + BackendCapabilities, + UnsubscribeFn, + AgentStartOptions, + AgentMessage, + AgentStatus, + FileEntry, + FileContent, + PtyCreateOptions, + SettingsMap, + GroupsFile, +} from '@agor/types'; + +// ── Capabilities ───────────────────────────────────────────────────────────── + +const TAURI_CAPABILITIES: BackendCapabilities = { + supportsPtyMultiplexing: true, + supportsPluginSandbox: true, + supportsNativeMenus: true, + supportsOsKeychain: true, + supportsFileDialogs: true, + supportsAutoUpdater: true, + supportsDesktopNotifications: true, + supportsTelemetry: true, +}; + +// ── Wire format types (Tauri event payloads) ───────────────────────────────── + +interface SidecarMessage { + type: string; + sessionId?: string; + event?: Record; + message?: string; + exitCode?: number | null; + signal?: string | null; +} + +interface TauriDirEntry { + name: string; + path: string; + is_dir: boolean; + size: number; + ext: string; +} + +// ── Adapter ────────────────────────────────────────────────────────────────── + +export class TauriAdapter implements BackendAdapter { + readonly capabilities = TAURI_CAPABILITIES; + + private unlisteners: UnlistenFn[] = []; + + async init(): Promise { + // Tauri IPC is ready as soon as the webview loads — no setup needed. + } + + async destroy(): Promise { + for (const unlisten of this.unlisteners) { + unlisten(); + } + this.unlisteners = []; + } + + // ── Settings ───────────────────────────────────────────────────────────── + + async getSetting(key: string): Promise { + return invoke('settings_get', { key }); + } + + async setSetting(key: string, value: string): Promise { + return invoke('settings_set', { key, value }); + } + + async getAllSettings(): Promise { + const pairs = await invoke<[string, string][]>('settings_list'); + const map: SettingsMap = {}; + for (const [k, v] of pairs) { + map[k] = v; + } + return map; + } + + // ── Groups ─────────────────────────────────────────────────────────────── + + async loadGroups(): Promise { + return invoke('groups_load'); + } + + async saveGroups(groups: GroupsFile): Promise { + return invoke('groups_save', { config: groups }); + } + + // ── Agent ──────────────────────────────────────────────────────────────── + + async startAgent(options: AgentStartOptions): Promise<{ ok: boolean; error?: string }> { + // Map camelCase options to snake_case for Tauri command + const tauriOptions = { + provider: options.provider ?? 'claude', + session_id: options.sessionId, + prompt: options.prompt, + cwd: options.cwd, + max_turns: options.maxTurns, + max_budget_usd: options.maxBudgetUsd, + resume_session_id: undefined, + permission_mode: options.permissionMode, + setting_sources: options.settingSources, + system_prompt: options.systemPrompt, + model: options.model, + claude_config_dir: options.claudeConfigDir, + additional_directories: options.additionalDirectories, + worktree_name: options.worktreeName, + provider_config: options.providerConfig, + extra_env: options.extraEnv, + }; + try { + await invoke('agent_query', { options: tauriOptions }); + return { ok: true }; + } catch (err: unknown) { + return { ok: false, error: String(err) }; + } + } + + async stopAgent(sessionId: string): Promise<{ ok: boolean; error?: string }> { + try { + await invoke('agent_stop', { sessionId }); + return { ok: true }; + } catch (err: unknown) { + return { ok: false, error: String(err) }; + } + } + + async sendPrompt(sessionId: string, prompt: string): Promise<{ ok: boolean; error?: string }> { + // Tauri agent_query supports resume via resume_session_id + try { + await invoke('agent_query', { + options: { + session_id: sessionId, + prompt, + resume_session_id: sessionId, + }, + }); + return { ok: true }; + } catch (err: unknown) { + return { ok: false, error: String(err) }; + } + } + + // ── PTY ────────────────────────────────────────────────────────────────── + + async createPty(options: PtyCreateOptions): Promise { + return invoke('pty_spawn', { + options: { + shell: options.shell, + cwd: options.cwd, + args: options.args, + cols: options.cols, + rows: options.rows, + }, + }); + } + + async writePty(sessionId: string, data: string): Promise { + return invoke('pty_write', { id: sessionId, data }); + } + + async resizePty(sessionId: string, cols: number, rows: number): Promise { + return invoke('pty_resize', { id: sessionId, cols, rows }); + } + + async closePty(sessionId: string): Promise { + return invoke('pty_kill', { id: sessionId }); + } + + // ── Files ──────────────────────────────────────────────────────────────── + + async listDirectory(path: string): Promise { + const entries = await invoke('list_directory_children', { path }); + return entries.map((e) => ({ + name: e.name, + path: e.path, + isDir: e.is_dir, + size: e.size, + ext: e.ext || undefined, + })); + } + + async readFile(path: string): Promise { + return invoke('read_file_content', { path }); + } + + async writeFile(path: string, content: string): Promise { + return invoke('write_file_content', { path, content }); + } + + // ── Events ─────────────────────────────────────────────────────────────── + + onAgentMessage(callback: (sessionId: string, messages: AgentMessage[]) => void): UnsubscribeFn { + let unlisten: UnlistenFn | null = null; + + listen('sidecar-message', (event) => { + const msg = event.payload; + if (msg.type !== 'message' || !msg.sessionId || !msg.event) return; + // The sidecar emits individual messages; wrap in array for uniform API + const agentMsg: AgentMessage = { + id: String(msg.event['id'] ?? Date.now()), + role: mapSidecarRole(String(msg.event['type'] ?? '')), + content: String(msg.event['content'] ?? ''), + toolName: msg.event['toolName'] as string | undefined, + toolInput: msg.event['toolInput'] as string | undefined, + timestamp: Number(msg.event['timestamp'] ?? Date.now()), + }; + callback(msg.sessionId, [agentMsg]); + }).then((fn) => { + unlisten = fn; + this.unlisteners.push(fn); + }); + + return () => { + if (unlisten) { + unlisten(); + this.unlisteners = this.unlisteners.filter((fn) => fn !== unlisten); + } + }; + } + + onAgentStatus(callback: (sessionId: string, status: AgentStatus, error?: string) => void): UnsubscribeFn { + let unlisten: UnlistenFn | null = null; + + listen('sidecar-message', (event) => { + const msg = event.payload; + if (msg.type !== 'status' || !msg.sessionId) return; + const status = normalizeAgentStatus(String(msg.event?.['status'] ?? 'idle')); + callback(msg.sessionId, status, msg.message); + }).then((fn) => { + unlisten = fn; + this.unlisteners.push(fn); + }); + + return () => { + if (unlisten) { + unlisten(); + this.unlisteners = this.unlisteners.filter((fn) => fn !== unlisten); + } + }; + } + + onAgentCost( + callback: (sessionId: string, costUsd: number, inputTokens: number, outputTokens: number) => void, + ): UnsubscribeFn { + let unlisten: UnlistenFn | null = null; + + listen('sidecar-message', (event) => { + const msg = event.payload; + 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), + ); + }).then((fn) => { + unlisten = fn; + this.unlisteners.push(fn); + }); + + return () => { + if (unlisten) { + unlisten(); + this.unlisteners = this.unlisteners.filter((fn) => fn !== unlisten); + } + }; + } + + onPtyOutput(callback: (sessionId: string, data: string) => void): UnsubscribeFn { + // Tauri PTY uses per-session event channels: pty-data-{id} + // This adapter requires the caller to register per-session; for the generic + // interface we listen to a global pty-data event if available, or this becomes + // a no-op that individual TerminalPane components handle directly. + // For Phase 1, PTY event wiring remains in pty-bridge.ts. + return () => {}; + } + + onPtyClosed(callback: (sessionId: string, exitCode: number | null) => void): UnsubscribeFn { + // Same as onPtyOutput — Tauri uses per-session events. + return () => {}; + } +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result' | 'thinking' | 'system'; + +function mapSidecarRole(type: string): MsgRole { + switch (type) { + case 'text': return 'assistant'; + case 'thinking': return 'thinking'; + case 'tool_call': return 'tool-call'; + case 'tool_result': return 'tool-result'; + case 'init': + case 'error': + case 'status': + return 'system'; + default: return 'assistant'; + } +} + +function normalizeAgentStatus(status: string): AgentStatus { + if (status === 'idle' || status === 'starting' || status === 'running' || status === 'done' || status === 'error') { + return status; + } + return 'idle'; +} diff --git a/src/lib/backend/backend.ts b/src/lib/backend/backend.ts new file mode 100644 index 0000000..101ba1b --- /dev/null +++ b/src/lib/backend/backend.ts @@ -0,0 +1,49 @@ +// Backend singleton — provides a single access point for the active backend adapter. +// Set once at app startup (App.svelte), consumed by all stores and adapters. + +import type { BackendAdapter } from '@agor/types'; + +let _adapter: BackendAdapter | null = null; + +/** + * Get the active backend adapter. + * Throws if called before setBackend() — this is intentional to catch + * initialization ordering bugs early. + */ +export function getBackend(): BackendAdapter { + if (!_adapter) { + throw new Error( + '[backend] Adapter not initialized. Call setBackend() before accessing the backend.' + ); + } + return _adapter; +} + +/** + * Set the active backend adapter. Call once at app startup. + * Calling twice throws to prevent accidental re-initialization. + */ +export function setBackend(adapter: BackendAdapter): void { + if (_adapter) { + throw new Error( + '[backend] Adapter already set. setBackend() must be called exactly once.' + ); + } + _adapter = adapter; +} + +/** + * Replace the backend with a partial mock for testing. + * Only available in test environments — does NOT enforce single-set. + */ +export function setBackendForTesting(adapter: Partial): void { + _adapter = adapter as BackendAdapter; +} + +/** + * Reset the backend to uninitialized state. + * Only for testing — production code should never need this. + */ +export function resetBackendForTesting(): void { + _adapter = null; +} diff --git a/tsconfig.app.json b/tsconfig.app.json index 31c18cf..89b876c 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -15,7 +15,11 @@ */ "allowJs": true, "checkJs": true, - "moduleDetection": "force" + "moduleDetection": "force", + "paths": { + "@agor/types": ["./packages/types/index.ts"], + "@agor/types/*": ["./packages/types/*.ts"] + } }, - "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] + "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "packages/types/**/*.ts"] } diff --git a/vite.config.ts b/vite.config.ts index f4cd8ca..90de7e1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,14 @@ import { defineConfig } from 'vite' import { svelte } from '@sveltejs/vite-plugin-svelte' +import path from 'node:path' export default defineConfig({ plugins: [svelte()], + resolve: { + alias: { + '@agor/types': path.resolve(__dirname, 'packages/types/index.ts'), + }, + }, server: { port: 9710, strictPort: true,