Files
Termix/plugins/ai/src/frontend/agents/AgentPanel.tsx
T
ZacharyZcR bf817d8bd1 feat(ai): add remote coding agent sessions for SSH hosts (#1545)
* feat(ai): add host coding agent sessions with configured providers

* Wait for agent initialization and shutdown before resuming sessions

* Keep Pi transcripts outside its configuration migration directory

* Cover Pi session directory separation

* Update core permission catalog expectations for remote agents

* Install remote agent runtimes from verified official sources

* Wait for the identity panel credentials request in its test

* Add scoped SSH forwarding setup for remote agents

* Verify forwarding setup through a fresh SSH connection

* Bound the SSH forwarding verification timeout

* Return the forwarding verification callback result

* fix(ai): support agent send and interrupt keyboard shortcuts

* feat(ai): add agent queues attachments worktrees and session recovery

* fix(ai): handle deleted workspace paths and duplicate status events

* fix(ai): accept file references in root workspaces

* fix(ai): refresh paused queues after agent errors

* fix(ai): store agent sessions in plugin tables and lock down OpenCode

Agent sessions lived in a single ctx.kv value holding up to 2000 events,
which passed the 256 KB kv limit within minutes of a normal run. Every
save after that failed, so history, nativeId, queue and archive changes
were lost and every event wrote a kv audit row.

Sessions, events and queued prompts now have their own tables. A running
agent appends events in batches at most once a second with a lazy save;
session and queue changes save immediately. Prompts, drafts and stored
event text are capped in UTF-8 bytes to fit MySQL TEXT. kv:own is no
longer requested.

The OpenCode server now requires a per-session password, so other local
accounts on the host cannot drive the agent through its loopback port.

* test(upgrade): include agent session tables in the reviewed upgrade SQL
2026-10-03 16:01:47 -05:00

488 lines
17 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation, type TabProps } from "@termix/plugin-sdk/frontend";
import { Button, Input } from "@termix/plugin-sdk/ui";
import { Bot, Plus, Square, Play } from "lucide-react";
import { useAgentStream } from "./useAgentStream";
import { SessionTools } from "./SessionTools";
import { AgentComposer } from "./AgentComposer";
import { InstallRuntime } from "./InstallRuntime";
import { aiApp } from "../app-ref";
import { AiMessage } from "../AiMessage";
import { getAiProviders, type AiProvider } from "../ai-api";
import {
AGENTS,
compatible,
type AgentEvent,
type AgentKind,
type AgentSession,
} from "../../backend/agents/types";
const names: Record<AgentKind, string> = {
pi: "Pi",
opencode: "OpenCode",
claude: "Claude Code",
codex: "Codex",
};
function message(error: unknown): string {
const e = error as {
response?: { data?: { error?: string } };
message?: string;
};
return e.response?.data?.error || e.message || "Agent request failed";
}
export function AgentPanel({ host, sshHost }: TabProps) {
const { t } = useTranslation();
const target = host ?? sshHost;
const hostId = Number(target?.id);
const [providers, setProviders] = useState<AiProvider[]>([]),
[sessions, setSessions] = useState<AgentSession[]>([]);
const [agent, setAgent] = useState<AgentKind>("pi"),
[providerId, setProviderId] = useState(0),
[model, setModel] = useState("");
const [cwd, setCwd] = useState("/tmp"),
[executable, setExecutable] = useState("");
const [active, setActive] = useState<AgentSession | null>(null),
[events, setEvents] = useState<AgentEvent[]>([]);
const [error, setError] = useState(""),
[busy, setBusy] = useState(false);
const [answers, setAnswers] = useState<Record<string, string>>({}),
[answered, setAnswered] = useState<Set<string>>(new Set());
const [generation, setGeneration] = useState(0);
const bottom = useRef<HTMLDivElement>(null);
const composing = useRef(false);
const [search, setSearch] = useState("");
const [showArchived, setShowArchived] = useState(false);
const refresh = useCallback(async () => {
const [p, s] = await Promise.all([
getAiProviders(),
aiApp().api.get<{ sessions: AgentSession[] }>("/agents"),
]);
setProviders(p.filter((x) => x.enabled));
setSessions(s.data.sessions.filter((x) => x.hostId === hostId));
}, [hostId]);
useEffect(() => {
void refresh().catch((e) => setError(message(e)));
}, [refresh]);
useEffect(() => {
bottom.current?.scrollIntoView({ block: "end" });
}, [events]);
const activeId = active?.id;
const connection = useAgentStream(
activeId,
generation,
(s) => {
setActive((old) => (old?.id === s.id ? s : old));
setEvents((old) =>
[
...s.events,
...old.filter((e) => e.seq > (s.events.at(-1)?.seq ?? 0)),
].slice(-2000),
);
},
(e) => {
setEvents((old) =>
old.some((x) => x.seq === e.seq) ? old : [...old, e].slice(-2000),
);
if (e.kind === "status")
setActive((old) =>
old ? { ...old, status: e.text as AgentSession["status"] } : old,
);
if (e.kind === "error") setError(e.text);
},
);
async function action(fn: () => Promise<void>) {
setBusy(true);
setError("");
try {
await fn();
} catch (e) {
setError(message(e));
} finally {
setBusy(false);
}
}
async function select(id: string) {
await action(async () => {
const r = await aiApp().api.get<AgentSession>(`/agents/${id}`);
setActive(r.data);
setEvents(r.data.events);
setAnswered(new Set());
});
}
async function input(body: unknown) {
if (active) await aiApp().api.post(`/agents/${active.id}/input`, body);
}
const transcript: AgentEvent[] = [];
for (const e of events) {
const last = transcript.at(-1);
if (e.kind === "text" && last?.kind === "text") last.text += e.text;
else transcript.push({ ...e });
}
return (
<div
className="flex h-full min-h-0 flex-col bg-background text-foreground"
onKeyDown={(e) => {
if (
e.key !== "Escape" ||
e.defaultPrevented ||
e.repeat ||
e.nativeEvent.isComposing ||
e.nativeEvent.keyCode === 229 ||
composing.current ||
busy ||
active?.status !== "running"
)
return;
e.preventDefault();
e.stopPropagation();
void action(() => input({ type: "cancel" }));
}}
>
<header className="flex items-center gap-2 border-b p-3">
<Bot size={18} />
<strong>{t("agents.title")}</strong>
<span className="text-muted-foreground">
{String(target?.name ?? target?.ip ?? "")}
</span>
<Button
size="sm"
variant="outline"
className="ml-auto"
onClick={() => {
setActive(null);
setEvents([]);
void refresh();
}}
>
<Plus size={14} />
{t("agents.new")}
</Button>
</header>
{error && (
<div
role="alert"
className="m-3 whitespace-pre-wrap border border-destructive p-3 text-sm text-destructive"
>
{error}
</div>
)}
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<aside className="max-h-40 overflow-auto border-b p-2 md:max-h-none md:w-56 md:border-r">
<Input
aria-label={t("agents.search")}
placeholder={t("agents.search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<label className="my-2 flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={showArchived}
onChange={(e) => setShowArchived(e.target.checked)}
/>
{t("agents.showArchived")}
</label>
{sessions
.filter(
(s) =>
!!s.archived === showArchived &&
[s.title, s.model, s.cwd]
.join(" ")
.toLowerCase()
.includes(search.toLowerCase()),
)
.map((s) => (
<button
key={s.id}
className={`mb-1 block w-full rounded p-2 text-left text-sm hover:bg-muted ${active?.id === s.id ? "bg-muted" : ""}`}
onClick={() => void select(s.id)}
>
<div>{s.title || `${names[s.agent]} · ${s.model}`}</div>
<div className="truncate text-xs text-muted-foreground">
{s.cwd}
</div>
</button>
))}
{!sessions.length && (
<p className="p-2 text-sm text-muted-foreground">
{t("agents.noSessions")}
</p>
)}
</aside>
{!active ? (
<form
className="mx-auto w-full max-w-xl space-y-4 overflow-auto p-5"
onSubmit={(e) => {
e.preventDefault();
void action(async () => {
const r = await aiApp().api.post<{ id: string }>("/agents", {
hostId,
agent,
providerId,
model,
cwd,
executable: executable || undefined,
});
const s = await aiApp().api.get<AgentSession>(
`/agents/${r.data.id}`,
);
setActive(s.data);
setEvents(s.data.events);
await refresh();
});
}}
>
<p className="text-sm text-muted-foreground">
{t("agents.description")}
</p>
<label className="block space-y-1">
<span>{t("agents.runtime")}</span>
<select
className="w-full rounded border bg-background p-2"
value={agent}
onChange={(e) => {
setAgent(e.target.value as AgentKind);
setProviderId(0);
setModel("");
}}
>
{AGENTS.map((a) => (
<option key={a} value={a}>
{names[a]}
</option>
))}
</select>
</label>
<label className="block space-y-1">
<span>{t("agents.provider")}</span>
<select
required
className="w-full rounded border bg-background p-2"
value={providerId}
onChange={(e) => {
const id = Number(e.target.value);
setProviderId(id);
setModel(
providers.find((p) => p.id === id)?.defaultModel ?? "",
);
}}
>
<option value={0}>{t("agents.chooseProvider")}</option>
{providers
.filter((p) => compatible(agent, p.providerType))
.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
</option>
))}
</select>
</label>
<label className="block space-y-1">
<span>{t("agents.model")}</span>
<Input
required
value={model}
onChange={(e) => setModel(e.target.value)}
/>
</label>
<label className="block space-y-1">
<span>{t("agents.directory")}</span>
<Input
required
value={cwd}
onChange={(e) => setCwd(e.target.value)}
/>
</label>
<label className="block space-y-1">
<span>{t("agents.executable")}</span>
<Input
placeholder={agent}
value={executable}
onChange={(e) => setExecutable(e.target.value)}
/>
</label>
<p className="text-xs text-muted-foreground">
{t("agents.requirements")}
</p>
{agent === "codex" && (
<p className="text-sm">{t("agents.responsesRequired")}</p>
)}
{agent === "pi" && (
<p className="text-sm">{t("agents.piPermissions")}</p>
)}
<InstallRuntime
hostId={hostId}
agent={agent}
busy={busy}
setBusy={setBusy}
/>
<Button disabled={busy || !providerId || !model} type="submit">
<Play size={14} />
{t("agents.start")}
</Button>
</form>
) : (
<section className="flex min-h-0 flex-1 flex-col">
{connection !== "connected" && (
<p role="status" className="p-2 text-sm">
{t(`agents.${connection}`)}
</p>
)}
<SessionTools
onWorktree={(path) => {
setCwd(path);
setAgent(active.agent);
setProviderId(active.providerId);
setModel(active.model);
setExecutable(active.executable);
setActive(null);
setEvents([]);
}}
key={`tools-${active.id}`}
session={active}
busy={busy}
action={action}
onUpdated={async (s) => {
setActive(s);
await refresh();
}}
/>
<div className="flex flex-wrap items-center gap-2 border-b p-2 text-sm">
<span>
{names[active.agent]} · {active.model} · {active.cwd} ·{" "}
{t(`agents.status.${active.status}`)}
</span>
<Button
size="sm"
variant="outline"
disabled={busy || active.archived}
onClick={() =>
void action(async () => {
await aiApp().api.post(`/agents/${active.id}/resume`);
setActive({ ...active, status: "starting" });
setGeneration((x) => x + 1);
})
}
>
{t("agents.resume")}
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
void action(async () => {
await aiApp().api.post(`/agents/${active.id}/stop`);
setActive({ ...active, status: "stopped" });
await refresh();
})
}
>
<Square size={12} />
{t("agents.stop")}
</Button>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-auto p-4">
{transcript
.filter((e) => e.kind !== "state")
.map((e) => (
<div key={e.seq}>
{e.kind === "text" || e.kind === "user" ? (
<AiMessage
role={e.kind === "user" ? "user" : "assistant"}
content={e.text}
/>
) : e.kind === "permission" ? (
<div className="space-y-2 rounded border p-3">
<strong>{t("agents.approval")}</strong>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap text-xs">
{e.text}
</pre>
{e.choices?.length ? (
<select
className="w-full border bg-background p-2"
value={answers[e.requestId!] ?? ""}
onChange={(v) =>
setAnswers((a) => ({
...a,
[e.requestId!]: v.target.value,
}))
}
>
<option value="">{t("agents.answer")}</option>
{e.choices.map((c) => (
<option key={c}>{c}</option>
))}
</select>
) : (
<Input
placeholder={t("agents.answer")}
value={answers[e.requestId!] ?? ""}
onChange={(v) =>
setAnswers((a) => ({
...a,
[e.requestId!]: v.target.value,
}))
}
/>
)}
{[true, false].map((allow) => (
<Button
key={String(allow)}
size="sm"
variant={allow ? "default" : "outline"}
disabled={answered.has(e.requestId!)}
onClick={() =>
void action(async () => {
await input({
type: "answer",
requestId: e.requestId,
allow,
value: answers[e.requestId!],
});
setAnswered(
(a) => new Set([...a, e.requestId!]),
);
})
}
>
{t(allow ? "agents.allow" : "agents.deny")}
</Button>
))}
</div>
) : e.kind === "tool" ? (
<details className="rounded border p-2 text-xs">
<summary className="cursor-pointer">
{t("agents.tool")}
</summary>
<pre className="max-h-72 overflow-auto whitespace-pre-wrap">
{e.text}
</pre>
</details>
) : (
<p className="text-xs text-muted-foreground">
{e.kind === "status"
? t(`agents.status.${e.text}`)
: e.text}
</p>
)}
</div>
))}
<div ref={bottom} />
</div>
<AgentComposer
key={`composer-${active.id}`}
session={active}
busy={busy}
action={action}
onComposition={(value) => {
composing.current = value;
}}
onUpdated={setActive}
/>
</section>
)}
</div>
</div>
);
}