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
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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue