feat(electrobun): agent execution layer — sidecar manager + message adapters + store
- SidecarManager: spawns claude/codex/ollama runners via Bun.spawn(), NDJSON stdio protocol, Claude CLI auto-detection, env stripping, AbortController stop, Deno/Node runtime detection - MessageAdapter: parses Claude stream-json, Codex ThreadEvent, Ollama chunks into common AgentMessage format - agent-store.svelte.ts: per-project reactive session state, RPC event listeners for agent.message/status/cost - AgentPane: wired to real sessions (start/stop/prompt), stop button, thinking/system message rendering - ProjectCard: status dot from real agent status, cost/tokens from store - 5 new RPC types (agent.start/stop/prompt/list + events)
This commit is contained in:
parent
95f1f8208f
commit
ef0183de7f
8 changed files with 1566 additions and 61 deletions
|
|
@ -1,20 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import ChatInput from './ChatInput.svelte';
|
||||
|
||||
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result';
|
||||
|
||||
interface AgentMessage {
|
||||
id: number;
|
||||
role: MsgRole;
|
||||
content: string;
|
||||
toolName?: string;
|
||||
toolPath?: string;
|
||||
}
|
||||
import type { AgentMessage, AgentStatus } from './agent-store.svelte.ts';
|
||||
|
||||
interface Props {
|
||||
messages: AgentMessage[];
|
||||
status: 'running' | 'idle' | 'stalled';
|
||||
status: AgentStatus;
|
||||
costUsd: number;
|
||||
tokens: number;
|
||||
model?: string;
|
||||
|
|
@ -23,6 +14,7 @@
|
|||
contextPct?: number;
|
||||
burnRate?: number;
|
||||
onSend?: (text: string) => void;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -34,11 +26,12 @@
|
|||
provider = 'claude',
|
||||
contextPct = 0,
|
||||
onSend,
|
||||
onStop,
|
||||
}: Props = $props();
|
||||
|
||||
let scrollEl: HTMLDivElement;
|
||||
let promptText = $state('');
|
||||
let expandedTools = $state<Set<number>>(new Set());
|
||||
let expandedTools = $state<Set<string>>(new Set());
|
||||
|
||||
// Drag-resize state
|
||||
let agentPaneEl: HTMLDivElement;
|
||||
|
|
@ -58,7 +51,7 @@
|
|||
onSend?.(text);
|
||||
}
|
||||
|
||||
function toggleTool(id: number) {
|
||||
function toggleTool(id: string) {
|
||||
const next = new Set(expandedTools);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
expandedTools = next;
|
||||
|
|
@ -67,10 +60,18 @@
|
|||
function fmtTokens(n: number) { return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); }
|
||||
function fmtCost(n: number) { return `$${n.toFixed(3)}`; }
|
||||
|
||||
function dotClass(s: string) {
|
||||
function dotClass(s: AgentStatus) {
|
||||
if (s === 'running') return 'dot-progress';
|
||||
if (s === 'stalled') return 'dot-error';
|
||||
return 'dot-success';
|
||||
if (s === 'error') return 'dot-error';
|
||||
if (s === 'done') return 'dot-success';
|
||||
return 'dot-idle';
|
||||
}
|
||||
|
||||
function statusLabel(s: AgentStatus) {
|
||||
if (s === 'running') return 'Running';
|
||||
if (s === 'error') return 'Error';
|
||||
if (s === 'done') return 'Done';
|
||||
return 'Idle';
|
||||
}
|
||||
|
||||
function onResizeMouseDown(e: MouseEvent) {
|
||||
|
|
@ -98,12 +99,19 @@
|
|||
<!-- Status strip (top) -->
|
||||
<div class="status-strip">
|
||||
<span class="strip-dot {dotClass(status)}"></span>
|
||||
<span class="strip-label">{status === 'running' ? 'Running' : status === 'stalled' ? 'Stalled' : 'Done'}</span>
|
||||
<span class="strip-label">{statusLabel(status)}</span>
|
||||
<span class="strip-model">{model}</span>
|
||||
<span class="strip-spacer"></span>
|
||||
<span class="strip-tokens">{fmtTokens(tokens)} tok</span>
|
||||
<span class="strip-sep" aria-hidden="true"></span>
|
||||
<span class="strip-cost">{fmtCost(costUsd)}</span>
|
||||
{#if status === 'running' && onStop}
|
||||
<button class="strip-stop-btn" onclick={onStop} title="Stop agent" aria-label="Stop agent">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<rect x="3" y="3" width="10" height="10" rx="1" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Main pane with floating input -->
|
||||
|
|
@ -126,6 +134,22 @@
|
|||
<div class="timeline-content">{msg.content}</div>
|
||||
</div>
|
||||
|
||||
{:else if msg.role === 'thinking'}
|
||||
<div class="timeline-row">
|
||||
{#if !isFirst}<div class="timeline-line-up"></div>{/if}
|
||||
<div class="timeline-diamond dot-thinking"></div>
|
||||
{#if !isLast}<div class="timeline-line-down"></div>{/if}
|
||||
<div class="thinking-content">{msg.content}</div>
|
||||
</div>
|
||||
|
||||
{:else if msg.role === 'system'}
|
||||
<div class="timeline-row">
|
||||
{#if !isFirst}<div class="timeline-line-up"></div>{/if}
|
||||
<div class="timeline-diamond dot-system"></div>
|
||||
{#if !isLast}<div class="timeline-line-down"></div>{/if}
|
||||
<div class="system-content">{msg.content}</div>
|
||||
</div>
|
||||
|
||||
{:else if msg.role === 'tool-call'}
|
||||
<div class="timeline-row">
|
||||
{#if !isFirst}<div class="timeline-line-up"></div>{/if}
|
||||
|
|
@ -222,6 +246,9 @@
|
|||
.dot-success { background: var(--ctp-green); }
|
||||
.dot-progress { background: var(--ctp-peach); }
|
||||
.dot-error { background: var(--ctp-red); }
|
||||
.dot-idle { background: var(--ctp-overlay1); }
|
||||
.dot-thinking { background: var(--ctp-mauve); }
|
||||
.dot-system { background: var(--ctp-overlay0); }
|
||||
|
||||
.strip-label { color: var(--ctp-subtext1); font-weight: 500; }
|
||||
.strip-model { color: var(--ctp-overlay1); margin-left: 0.25rem; }
|
||||
|
|
@ -234,6 +261,32 @@
|
|||
}
|
||||
.strip-cost { color: var(--ctp-text); font-weight: 500; }
|
||||
|
||||
.strip-stop-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
margin-left: 0.25rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--ctp-red);
|
||||
border-radius: 0.2rem;
|
||||
color: var(--ctp-red);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.strip-stop-btn:hover {
|
||||
background: color-mix(in srgb, var(--ctp-red) 20%, transparent);
|
||||
}
|
||||
|
||||
.strip-stop-btn svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* ── Main pane ────────────────────────────────────────────── */
|
||||
.agent-pane {
|
||||
display: flex;
|
||||
|
|
@ -424,6 +477,27 @@
|
|||
|
||||
.tool-result-content { color: var(--ctp-teal); }
|
||||
|
||||
/* ── Thinking content ──────────────────────────────────────── */
|
||||
.thinking-content {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--ctp-overlay1);
|
||||
font-style: italic;
|
||||
padding: 0.5rem 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── System content ────────────────────────────────────────── */
|
||||
.system-content {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
color: var(--ctp-overlay0);
|
||||
padding: 0.25rem 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tool-fade-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
|
|
|
|||
|
|
@ -383,10 +383,6 @@
|
|||
name={project.name}
|
||||
cwd={project.cwd}
|
||||
accent={project.accent}
|
||||
status={project.status}
|
||||
costUsd={project.costUsd}
|
||||
tokens={project.tokens}
|
||||
messages={project.messages}
|
||||
provider={project.provider}
|
||||
profile={project.profile}
|
||||
model={project.model}
|
||||
|
|
|
|||
|
|
@ -3,26 +3,18 @@
|
|||
import TerminalTabs from './TerminalTabs.svelte';
|
||||
import FileBrowser from './FileBrowser.svelte';
|
||||
import MemoryTab from './MemoryTab.svelte';
|
||||
import {
|
||||
startAgent, stopAgent, sendPrompt, getSession, hasSession,
|
||||
type AgentStatus, type AgentMessage,
|
||||
} from './agent-store.svelte.ts';
|
||||
|
||||
type AgentStatus = 'running' | 'idle' | 'stalled';
|
||||
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result';
|
||||
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memory';
|
||||
|
||||
interface AgentMessage {
|
||||
id: number;
|
||||
role: MsgRole;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
name: string;
|
||||
cwd: string;
|
||||
accent: string;
|
||||
status: AgentStatus;
|
||||
costUsd: number;
|
||||
tokens: number;
|
||||
messages: AgentMessage[];
|
||||
provider?: string;
|
||||
profile?: string;
|
||||
model?: string;
|
||||
|
|
@ -44,10 +36,6 @@
|
|||
name,
|
||||
cwd,
|
||||
accent,
|
||||
status,
|
||||
costUsd,
|
||||
tokens,
|
||||
messages: initialMessages,
|
||||
provider = 'claude',
|
||||
profile,
|
||||
model = 'claude-opus-4-5',
|
||||
|
|
@ -60,6 +48,18 @@
|
|||
onClone,
|
||||
}: Props = $props();
|
||||
|
||||
// ── Agent session (reactive from store) ──────────────────────────
|
||||
let session = $derived(getSession(id));
|
||||
let agentStatus: AgentStatus = $derived(session?.status ?? 'idle');
|
||||
let agentMessages: AgentMessage[] = $derived(session?.messages ?? []);
|
||||
let agentCost = $derived(session?.costUsd ?? 0);
|
||||
let agentTokens = $derived((session?.inputTokens ?? 0) + (session?.outputTokens ?? 0));
|
||||
let agentModel = $derived(session?.model ?? model);
|
||||
|
||||
// ── Demo messages (fallback when no real session) ────────────────
|
||||
const demoMessages: AgentMessage[] = [];
|
||||
let displayMessages = $derived(agentMessages.length > 0 ? agentMessages : demoMessages);
|
||||
|
||||
// ── Clone dialog state ──────────────────────────────────────────
|
||||
let showCloneDialog = $state(false);
|
||||
let cloneBranchName = $state('');
|
||||
|
|
@ -83,9 +83,6 @@
|
|||
}
|
||||
|
||||
let activeTab = $state<ProjectTab>('model');
|
||||
// svelte-ignore state_referenced_locally
|
||||
const seedMessages = initialMessages.slice();
|
||||
let messages = $state(seedMessages);
|
||||
// Track which project tabs have been activated (PERSISTED-LAZY pattern)
|
||||
let activatedTabs = $state<Set<ProjectTab>>(new Set(['model']));
|
||||
|
||||
|
|
@ -97,15 +94,23 @@
|
|||
}
|
||||
|
||||
function handleSend(text: string) {
|
||||
const newMsg: AgentMessage = { id: messages.length + 1, role: 'user', content: text };
|
||||
messages = [...messages, newMsg];
|
||||
setTimeout(() => {
|
||||
messages = [...messages, {
|
||||
id: messages.length + 1,
|
||||
role: 'assistant',
|
||||
content: `(demo) Received: "${text}"`,
|
||||
}];
|
||||
}, 400);
|
||||
if (hasSession(id)) {
|
||||
// Session exists — send follow-up prompt
|
||||
sendPrompt(id, text).catch((err) => {
|
||||
console.error('[agent.prompt] error:', err);
|
||||
});
|
||||
} else {
|
||||
// No session — start a new agent
|
||||
startAgent(id, provider, text, { cwd, model }).catch((err) => {
|
||||
console.error('[agent.start] error:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleStop() {
|
||||
stopAgent(id).catch((err) => {
|
||||
console.error('[agent.stop] error:', err);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
@ -117,12 +122,12 @@
|
|||
>
|
||||
<!-- Header -->
|
||||
<header class="project-header">
|
||||
<div class="status-dot-wrap" aria-label="Status: {status}">
|
||||
<div class="status-dot-wrap" aria-label="Status: {agentStatus}">
|
||||
<div
|
||||
class="status-dot {status}"
|
||||
class:blink-off={status === 'running' && !blinkVisible}
|
||||
class="status-dot {agentStatus}"
|
||||
class:blink-off={agentStatus === 'running' && !blinkVisible}
|
||||
role="img"
|
||||
aria-label={status}
|
||||
aria-label={agentStatus}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
|
|
@ -230,16 +235,17 @@
|
|||
aria-label="Model"
|
||||
>
|
||||
<AgentPane
|
||||
{messages}
|
||||
{status}
|
||||
{costUsd}
|
||||
{tokens}
|
||||
{model}
|
||||
messages={displayMessages}
|
||||
status={agentStatus}
|
||||
costUsd={agentCost}
|
||||
tokens={agentTokens}
|
||||
model={agentModel}
|
||||
{provider}
|
||||
{profile}
|
||||
{contextPct}
|
||||
{burnRate}
|
||||
onSend={handleSend}
|
||||
onStop={handleStop}
|
||||
/>
|
||||
<TerminalTabs projectId={id} {accent} />
|
||||
</div>
|
||||
|
|
@ -270,7 +276,7 @@
|
|||
<div class="ctx-stats-row">
|
||||
<div class="ctx-stat">
|
||||
<span class="ctx-stat-label">Tokens used</span>
|
||||
<span class="ctx-stat-value">{tokens.toLocaleString()}</span>
|
||||
<span class="ctx-stat-value">{agentTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="ctx-stat">
|
||||
<span class="ctx-stat-label">Context %</span>
|
||||
|
|
@ -278,7 +284,7 @@
|
|||
</div>
|
||||
<div class="ctx-stat">
|
||||
<span class="ctx-stat-label">Model</span>
|
||||
<span class="ctx-stat-value">{model}</span>
|
||||
<span class="ctx-stat-value">{agentModel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctx-meter-wrap" title="{contextPct}% context used">
|
||||
|
|
@ -289,7 +295,7 @@
|
|||
</div>
|
||||
<div class="ctx-turn-list">
|
||||
<div class="ctx-section-label">Turn breakdown</div>
|
||||
{#each messages.slice(0, 5) as msg}
|
||||
{#each displayMessages.slice(0, 5) as msg}
|
||||
<div class="ctx-turn-row">
|
||||
<span class="ctx-turn-role ctx-role-{msg.role}">{msg.role}</span>
|
||||
<span class="ctx-turn-preview">{msg.content.slice(0, 60)}{msg.content.length > 60 ? '…' : ''}</span>
|
||||
|
|
@ -403,6 +409,8 @@
|
|||
|
||||
.status-dot.running { background: var(--ctp-green); }
|
||||
.status-dot.idle { background: var(--ctp-overlay1); }
|
||||
.status-dot.done { background: var(--ctp-green); }
|
||||
.status-dot.error { background: var(--ctp-red); }
|
||||
.status-dot.stalled { background: var(--ctp-peach); }
|
||||
.status-dot.blink-off { opacity: 0.3; }
|
||||
|
||||
|
|
|
|||
345
ui-electrobun/src/mainview/agent-store.svelte.ts
Normal file
345
ui-electrobun/src/mainview/agent-store.svelte.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
/**
|
||||
* Agent session store — manages per-project agent state and RPC communication.
|
||||
*
|
||||
* Listens for agent.message, agent.status, agent.cost events from Bun process.
|
||||
* Exposes reactive Svelte 5 rune state per project.
|
||||
*/
|
||||
|
||||
import { electrobun, appRpc } from './main.ts';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type AgentStatus = 'idle' | 'running' | 'done' | 'error';
|
||||
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;
|
||||
}
|
||||
|
||||
export interface AgentSession {
|
||||
sessionId: string;
|
||||
projectId: string;
|
||||
provider: string;
|
||||
status: AgentStatus;
|
||||
messages: AgentMessage[];
|
||||
costUsd: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
model: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface StartOptions {
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
systemPrompt?: string;
|
||||
maxTurns?: number;
|
||||
permissionMode?: string;
|
||||
claudeConfigDir?: string;
|
||||
extraEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
// ── Internal state ───────────────────────────────────────────────────────────
|
||||
|
||||
// Map projectId -> sessionId for lookup
|
||||
const projectSessionMap = new Map<string, string>();
|
||||
|
||||
// Map sessionId -> reactive session state
|
||||
let sessions = $state<Record<string, AgentSession>>({});
|
||||
|
||||
// ── RPC event listeners (registered once) ────────────────────────────────────
|
||||
|
||||
let listenersRegistered = false;
|
||||
|
||||
function ensureListeners() {
|
||||
if (listenersRegistered) return;
|
||||
listenersRegistered = true;
|
||||
|
||||
// agent.message — raw messages from sidecar, converted to display format
|
||||
electrobun.rpc?.addMessageListener('agent.message', (payload: {
|
||||
sessionId: string;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
parentId?: string;
|
||||
content: unknown;
|
||||
timestamp: number;
|
||||
}>;
|
||||
}) => {
|
||||
const session = sessions[payload.sessionId];
|
||||
if (!session) return;
|
||||
|
||||
const converted: AgentMessage[] = [];
|
||||
for (const raw of payload.messages) {
|
||||
const msg = convertRawMessage(raw);
|
||||
if (msg) converted.push(msg);
|
||||
}
|
||||
|
||||
if (converted.length > 0) {
|
||||
session.messages = [...session.messages, ...converted];
|
||||
}
|
||||
});
|
||||
|
||||
// agent.status — session status changes
|
||||
electrobun.rpc?.addMessageListener('agent.status', (payload: {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
}) => {
|
||||
const session = sessions[payload.sessionId];
|
||||
if (!session) return;
|
||||
|
||||
session.status = normalizeStatus(payload.status);
|
||||
if (payload.error) session.error = payload.error;
|
||||
});
|
||||
|
||||
// agent.cost — token/cost updates
|
||||
electrobun.rpc?.addMessageListener('agent.cost', (payload: {
|
||||
sessionId: string;
|
||||
costUsd: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
}) => {
|
||||
const session = sessions[payload.sessionId];
|
||||
if (!session) return;
|
||||
|
||||
session.costUsd = payload.costUsd;
|
||||
session.inputTokens = payload.inputTokens;
|
||||
session.outputTokens = payload.outputTokens;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Message conversion ───────────────────────────────────────────────────────
|
||||
|
||||
function convertRawMessage(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;
|
||||
// Extract file path from common tool input patterns
|
||||
const path = extractToolPath(name, input);
|
||||
return {
|
||||
id: raw.id,
|
||||
role: 'tool-call',
|
||||
content: formatToolInput(name, input),
|
||||
toolName: name,
|
||||
toolInput: JSON.stringify(input, null, 2),
|
||||
toolPath: path,
|
||||
timestamp: raw.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
case 'tool_result': {
|
||||
const output = c?.output;
|
||||
const text = typeof output === 'string'
|
||||
? output
|
||||
: JSON.stringify(output, null, 2);
|
||||
return {
|
||||
id: raw.id,
|
||||
role: 'tool-result',
|
||||
content: truncateOutput(text, 500),
|
||||
timestamp: raw.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
case 'init': {
|
||||
const model = String(c?.model ?? '');
|
||||
// Update session model from init message
|
||||
const sid = String(c?.sessionId ?? '');
|
||||
for (const s of Object.values(sessions)) {
|
||||
if (s.sessionId === raw.id || (sid && s.sessionId.includes(sid.slice(0, 8)))) {
|
||||
if (model) s.model = model;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: raw.id,
|
||||
role: 'system',
|
||||
content: `Session initialized${model ? ` (${model})` : ''}`,
|
||||
timestamp: raw.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
case 'error':
|
||||
return {
|
||||
id: raw.id,
|
||||
role: 'system',
|
||||
content: `Error: ${String(c?.message ?? 'Unknown error')}`,
|
||||
timestamp: raw.timestamp,
|
||||
};
|
||||
|
||||
case 'cost':
|
||||
case 'status':
|
||||
case 'compaction':
|
||||
case 'unknown':
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractToolPath(name: string, input: Record<string, unknown> | undefined): string | undefined {
|
||||
if (!input) return undefined;
|
||||
// Common patterns: file_path, path, command (for Bash)
|
||||
if (typeof input.file_path === 'string') return input.file_path;
|
||||
if (typeof input.path === 'string') return input.path;
|
||||
if (name === 'Bash' && typeof input.command === 'string') {
|
||||
return input.command.length > 80 ? input.command.slice(0, 80) + '...' : input.command;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatToolInput(name: string, input: Record<string, unknown> | undefined): string {
|
||||
if (!input) return '';
|
||||
if (name === 'Bash' && typeof input.command === 'string') return input.command;
|
||||
if (typeof input.file_path === 'string') return input.file_path;
|
||||
return JSON.stringify(input, null, 2);
|
||||
}
|
||||
|
||||
function truncateOutput(text: string, maxLines: number): string {
|
||||
const lines = text.split('\n');
|
||||
if (lines.length <= maxLines) return text;
|
||||
return lines.slice(0, maxLines).join('\n') + `\n... (${lines.length - maxLines} more lines)`;
|
||||
}
|
||||
|
||||
function normalizeStatus(status: string): AgentStatus {
|
||||
if (status === 'running' || status === 'idle' || status === 'done' || status === 'error') {
|
||||
return status;
|
||||
}
|
||||
return 'idle';
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Start an agent session for a project. */
|
||||
export async function startAgent(
|
||||
projectId: string,
|
||||
provider: string,
|
||||
prompt: string,
|
||||
options: StartOptions = {},
|
||||
): Promise<{ ok: boolean; error?: string }> {
|
||||
ensureListeners();
|
||||
|
||||
const sessionId = `${projectId}-${Date.now()}`;
|
||||
|
||||
// Create reactive session state
|
||||
sessions[sessionId] = {
|
||||
sessionId,
|
||||
projectId,
|
||||
provider,
|
||||
status: 'running',
|
||||
messages: [{
|
||||
id: `${sessionId}-user-0`,
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
timestamp: Date.now(),
|
||||
}],
|
||||
costUsd: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
model: options.model ?? 'claude-opus-4-5',
|
||||
};
|
||||
|
||||
projectSessionMap.set(projectId, sessionId);
|
||||
|
||||
const result = await appRpc.request['agent.start']({
|
||||
sessionId,
|
||||
provider: provider as 'claude' | 'codex' | 'ollama',
|
||||
prompt,
|
||||
cwd: options.cwd,
|
||||
model: options.model,
|
||||
systemPrompt: options.systemPrompt,
|
||||
maxTurns: options.maxTurns,
|
||||
permissionMode: options.permissionMode,
|
||||
claudeConfigDir: options.claudeConfigDir,
|
||||
extraEnv: options.extraEnv,
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
sessions[sessionId].status = 'error';
|
||||
sessions[sessionId].error = result.error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Stop a running agent session for a project. */
|
||||
export async function stopAgent(projectId: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const sessionId = projectSessionMap.get(projectId);
|
||||
if (!sessionId) return { ok: false, error: 'No session for project' };
|
||||
|
||||
const result = await appRpc.request['agent.stop']({ sessionId });
|
||||
|
||||
if (result.ok) {
|
||||
const session = sessions[sessionId];
|
||||
if (session) session.status = 'done';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Send a follow-up prompt to a running session. */
|
||||
export async function sendPrompt(projectId: string, prompt: string): Promise<{ ok: boolean; error?: string }> {
|
||||
const sessionId = projectSessionMap.get(projectId);
|
||||
if (!sessionId) return { ok: false, error: 'No session for project' };
|
||||
|
||||
const session = sessions[sessionId];
|
||||
if (!session) return { ok: false, error: 'Session not found' };
|
||||
|
||||
// Add user message immediately
|
||||
session.messages = [...session.messages, {
|
||||
id: `${sessionId}-user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
timestamp: Date.now(),
|
||||
}];
|
||||
|
||||
session.status = 'running';
|
||||
|
||||
return appRpc.request['agent.prompt']({ sessionId, prompt });
|
||||
}
|
||||
|
||||
/** Get the current session for a project (reactive). */
|
||||
export function getSession(projectId: string): AgentSession | undefined {
|
||||
const sessionId = projectSessionMap.get(projectId);
|
||||
if (!sessionId) return undefined;
|
||||
return sessions[sessionId];
|
||||
}
|
||||
|
||||
/** Check if a project has an active session. */
|
||||
export function hasSession(projectId: string): boolean {
|
||||
return projectSessionMap.has(projectId);
|
||||
}
|
||||
|
||||
/** Initialize listeners on module load. */
|
||||
ensureListeners();
|
||||
Loading…
Add table
Add a link
Reference in a new issue