feat: @agor/types package + BackendAdapter + TauriAdapter + ElectrobunAdapter
- packages/types/: shared type definitions (agent, project, btmsg, bttask, health, settings, protocol, backend interface) - BackendAdapter: capability-flagged interface, compile-time selected - TauriAdapter: wraps Tauri invoke/listen - ElectrobunAdapter: wraps Electrobun RPC - src/lib/backend/backend.ts: adapter singleton + setBackendForTesting() - pnpm-workspace.yaml: workspace setup
This commit is contained in:
parent
631fc2efc8
commit
c86f669f96
19 changed files with 1383 additions and 2 deletions
|
|
@ -3,6 +3,9 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"workspaces": [
|
||||||
|
"packages/*"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"prebuild": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
|
"prebuild": "cp node_modules/pdfjs-dist/build/pdf.worker.min.mjs public/pdf.worker.min.mjs",
|
||||||
|
|
|
||||||
119
packages/types/agent.ts
Normal file
119
packages/types/agent.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<string, unknown>;
|
||||||
|
extraEnv?: Record<string, string>;
|
||||||
|
}
|
||||||
84
packages/types/backend.ts
Normal file
84
packages/types/backend.ts
Normal file
|
|
@ -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<void>;
|
||||||
|
|
||||||
|
/** Tear down the backend (disconnect, clean up listeners) */
|
||||||
|
destroy(): Promise<void>;
|
||||||
|
|
||||||
|
// ── Settings ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getSetting(key: string): Promise<string | null>;
|
||||||
|
setSetting(key: string, value: string): Promise<void>;
|
||||||
|
getAllSettings(): Promise<SettingsMap>;
|
||||||
|
|
||||||
|
// ── Groups ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
loadGroups(): Promise<GroupsFile>;
|
||||||
|
saveGroups(groups: GroupsFile): Promise<void>;
|
||||||
|
|
||||||
|
// ── 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<string>;
|
||||||
|
writePty(sessionId: string, data: string): Promise<void>;
|
||||||
|
resizePty(sessionId: string, cols: number, rows: number): Promise<void>;
|
||||||
|
closePty(sessionId: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── Files ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
listDirectory(path: string): Promise<FileEntry[]>;
|
||||||
|
readFile(path: string): Promise<FileContent>;
|
||||||
|
writeFile(path: string, content: string): Promise<void>;
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
67
packages/types/btmsg.ts
Normal file
67
packages/types/btmsg.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
30
packages/types/bttask.ts
Normal file
30
packages/types/bttask.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
33
packages/types/health.ts
Normal file
33
packages/types/health.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
34
packages/types/ids.ts
Normal file
34
packages/types/ids.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
11
packages/types/index.ts
Normal file
11
packages/types/index.ts
Normal file
|
|
@ -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';
|
||||||
13
packages/types/package.json
Normal file
13
packages/types/package.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
88
packages/types/project.ts
Normal file
88
packages/types/project.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
127
packages/types/protocol.ts
Normal file
127
packages/types/protocol.ts
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
27
packages/types/settings.ts
Normal file
27
packages/types/settings.ts
Normal file
|
|
@ -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<string, string>;
|
||||||
16
packages/types/tsconfig.json
Normal file
16
packages/types/tsconfig.json
Normal file
|
|
@ -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"]
|
||||||
|
}
|
||||||
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
packages:
|
||||||
|
- 'packages/*'
|
||||||
350
src/lib/backend/ElectrobunAdapter.ts
Normal file
350
src/lib/backend/ElectrobunAdapter.ts
Normal file
|
|
@ -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<string, unknown>) => Promise<Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
// RPC handle is set externally via setRpc()
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy(): Promise<void> {
|
||||||
|
// 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<string | null> {
|
||||||
|
const res = await this.r.request['settings.get']({ key }) as { value: string | null };
|
||||||
|
return res.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSetting(key: string, value: string): Promise<void> {
|
||||||
|
await this.r.request['settings.set']({ key, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllSettings(): Promise<SettingsMap> {
|
||||||
|
const res = await this.r.request['settings.getAll']({}) as { settings: SettingsMap };
|
||||||
|
return res.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Groups ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async loadGroups(): Promise<GroupsFile> {
|
||||||
|
// 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<string, Array<Record<string, unknown>>>();
|
||||||
|
for (const p of projectsRes.projects) {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(p.config) as Record<string, unknown>;
|
||||||
|
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<void> {
|
||||||
|
// 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<string> {
|
||||||
|
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<void> {
|
||||||
|
await this.r.request['pty.write']({ sessionId, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async resizePty(sessionId: string, cols: number, rows: number): Promise<void> {
|
||||||
|
await this.r.request['pty.resize']({ sessionId, cols, rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
async closePty(sessionId: string): Promise<void> {
|
||||||
|
await this.r.request['pty.close']({ sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Files ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async listDirectory(path: string): Promise<FileEntry[]> {
|
||||||
|
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<FileContent> {
|
||||||
|
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<void> {
|
||||||
|
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<T>(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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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';
|
||||||
|
}
|
||||||
318
src/lib/backend/TauriAdapter.ts
Normal file
318
src/lib/backend/TauriAdapter.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||||
|
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<void> {
|
||||||
|
// Tauri IPC is ready as soon as the webview loads — no setup needed.
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroy(): Promise<void> {
|
||||||
|
for (const unlisten of this.unlisteners) {
|
||||||
|
unlisten();
|
||||||
|
}
|
||||||
|
this.unlisteners = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getSetting(key: string): Promise<string | null> {
|
||||||
|
return invoke<string | null>('settings_get', { key });
|
||||||
|
}
|
||||||
|
|
||||||
|
async setSetting(key: string, value: string): Promise<void> {
|
||||||
|
return invoke('settings_set', { key, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllSettings(): Promise<SettingsMap> {
|
||||||
|
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<GroupsFile> {
|
||||||
|
return invoke<GroupsFile>('groups_load');
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveGroups(groups: GroupsFile): Promise<void> {
|
||||||
|
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<string> {
|
||||||
|
return invoke<string>('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<void> {
|
||||||
|
return invoke('pty_write', { id: sessionId, data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async resizePty(sessionId: string, cols: number, rows: number): Promise<void> {
|
||||||
|
return invoke('pty_resize', { id: sessionId, cols, rows });
|
||||||
|
}
|
||||||
|
|
||||||
|
async closePty(sessionId: string): Promise<void> {
|
||||||
|
return invoke('pty_kill', { id: sessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Files ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async listDirectory(path: string): Promise<FileEntry[]> {
|
||||||
|
const entries = await invoke<TauriDirEntry[]>('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<FileContent> {
|
||||||
|
return invoke<FileContent>('read_file_content', { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(path: string, content: string): Promise<void> {
|
||||||
|
return invoke('write_file_content', { path, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Events ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
onAgentMessage(callback: (sessionId: string, messages: AgentMessage[]) => void): UnsubscribeFn {
|
||||||
|
let unlisten: UnlistenFn | null = null;
|
||||||
|
|
||||||
|
listen<SidecarMessage>('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<SidecarMessage>('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<SidecarMessage>('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';
|
||||||
|
}
|
||||||
49
src/lib/backend/backend.ts
Normal file
49
src/lib/backend/backend.ts
Normal file
|
|
@ -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<BackendAdapter>): 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;
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,11 @@
|
||||||
*/
|
*/
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"checkJs": true,
|
"checkJs": true,
|
||||||
"moduleDetection": "force"
|
"moduleDetection": "force",
|
||||||
},
|
"paths": {
|
||||||
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"]
|
"@agor/types": ["./packages/types/index.ts"],
|
||||||
|
"@agor/types/*": ["./packages/types/*.ts"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte", "packages/types/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,14 @@
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
plugins: [svelte()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@agor/types': path.resolve(__dirname, 'packages/types/index.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 9710,
|
port: 9710,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue