pi-feats 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/README.md +43 -5
  2. package/extensions/api-server/application-log-store.ts +4 -2
  3. package/extensions/api-server/application-runtime.ts +16 -6
  4. package/extensions/api-server/application-store.ts +21 -8
  5. package/extensions/api-server/profile-store.ts +33 -10
  6. package/extensions/api-server/server.ts +43 -4
  7. package/extensions/cli-resources.ts +20 -20
  8. package/extensions/conversation-search/index.ts +42 -0
  9. package/extensions/lib/builtin-tools.ts +24 -0
  10. package/extensions/lib/conversation-search.ts +33 -0
  11. package/extensions/lib/profile-sandbox.ts +3 -0
  12. package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
  13. package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
  14. package/extensions/pi-console-webui/components/application-logs.tsx +4 -4
  15. package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
  16. package/extensions/pi-console-webui/components/console-page.tsx +3 -0
  17. package/extensions/pi-console-webui/components/console.tsx +9 -5
  18. package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
  19. package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
  20. package/extensions/pi-console-webui/components/pulse-resources.tsx +5 -5
  21. package/extensions/pi-console-webui/lib/admin-models.ts +36 -0
  22. package/extensions/pi-console-webui/lib/page-metadata.ts +3 -1
  23. package/extensions/pi-console-webui/next.config.js +1 -0
  24. package/extensions/profile-ssh.ts +274 -0
  25. package/extensions/profiles.ts +53 -17
  26. package/extensions/pulse/index.ts +128 -14
  27. package/extensions/pulse/store.ts +108 -16
  28. package/package.json +3 -1
  29. package/skills/profile-ssh/SKILL.md +65 -0
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import { Loader2, RefreshCw, Save } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Card, CardTitle } from "@/components/ui/card";
7
+ import { useToast } from "@/components/toast";
8
+
9
+ type Provider = { id: string; models: Array<{ id: string; name: string }> };
10
+ type ModelCatalog = { providers: Provider[] };
11
+ type ApplicationSession = { application: string; sessionPrefix: string; sessionId: string };
12
+
13
+ export function ProfileModelForm({ profile, initial }: { profile: string; initial?: ModelCatalog & { settings?: Record<string, unknown> } }) {
14
+ const { toast } = useToast();
15
+ const [catalog, setCatalog] = useState<ModelCatalog>({ providers: initial?.providers ?? [] });
16
+ const [provider, setProvider] = useState(String(initial?.settings?.defaultProvider ?? initial?.providers[0]?.id ?? ""));
17
+ const models = useMemo(() => catalog.providers.find((item) => item.id === provider)?.models ?? [], [catalog, provider]);
18
+ const [model, setModel] = useState(String(initial?.settings?.defaultModel ?? ""));
19
+ const [loading, setLoading] = useState(!initial), [saving, setSaving] = useState(false), [rollingOver, setRollingOver] = useState(false);
20
+ const [rolloverDialog, setRolloverDialog] = useState(false), [activeSessions, setActiveSessions] = useState<ApplicationSession[]>([]);
21
+ const load = async () => { try { setLoading(true); const response = await fetch(`/api/pi/profiles/${encodeURIComponent(profile)}/model`); const body = await response.json(); if (!response.ok) throw new Error(body.error ?? "Unable to load models"); setCatalog(body); setProvider((current) => current && body.providers.some((item: Provider) => item.id === current) ? current : body.providers[0]?.id ?? ""); } catch (error) { toast((error as Error).message, "error"); } finally { setLoading(false); } };
22
+ useEffect(() => { if (!initial) { void load(); return; } setCatalog({ providers: initial.providers }); setProvider(String(initial.settings?.defaultProvider ?? initial.providers[0]?.id ?? "")); setModel(String(initial.settings?.defaultModel ?? "")); setLoading(false); }, [profile, initial]);
23
+ useEffect(() => { if (!models.some((item) => item.id === model)) setModel(models[0]?.id ?? ""); }, [models, model]);
24
+ const save = async () => {
25
+ if (!provider || !model || saving) return;
26
+ try {
27
+ setSaving(true);
28
+ const response = await fetch(`/api/pi/profiles/${encodeURIComponent(profile)}/model`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ provider, model }) });
29
+ const body = await response.json();
30
+ if (!response.ok) throw new Error(body.error ?? "Unable to save model");
31
+ const sessionsResponse = await fetch(`/api/pi/application-sessions?${new URLSearchParams({ profile })}`);
32
+ const sessionsBody = await sessionsResponse.json();
33
+ if (!sessionsResponse.ok) throw new Error(sessionsBody.error ?? "Model saved, but active sessions could not be loaded.");
34
+ const unique = new Map<string, ApplicationSession>();
35
+ for (const session of sessionsBody.sessions as ApplicationSession[]) unique.set(`${session.application}:${session.sessionPrefix}`, session);
36
+ setActiveSessions([...unique.values()]);
37
+ setRolloverDialog(true);
38
+ } catch (error) { toast((error as Error).message, "error"); } finally { setSaving(false); }
39
+ };
40
+ const later = () => { setRolloverDialog(false); toast("Model saved. It will be used by new sessions."); };
41
+ const rollover = async () => {
42
+ try {
43
+ setRollingOver(true);
44
+ const outcomes = await Promise.allSettled(activeSessions.map(async (session) => {
45
+ const response = await fetch(`/api/pi/applications/${encodeURIComponent(session.application)}/session-rollover`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ profile, sessionPrefix: session.sessionPrefix }) });
46
+ if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error ?? "Unable to roll over session"); }
47
+ }));
48
+ const failed = outcomes.filter((outcome) => outcome.status === "rejected").length;
49
+ setRolloverDialog(false);
50
+ toast(failed ? `Model saved. ${activeSessions.length - failed} session(s) rolled over; ${failed} failed.` : "Model saved and active sessions rolled over.", failed ? "error" : "success");
51
+ } finally { setRollingOver(false); }
52
+ };
53
+ return <><Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>Model</CardTitle><p className="text-sm text-zinc-500">Choose the default provider and model for this profile.</p></div><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save model" disabled={loading || saving || !provider || !model} onClick={save}>{saving ? <Loader2 className="animate-spin" size={17}/> : <Save size={17}/>}</Button></div>{loading ? <div className="flex min-h-40 items-center justify-center gap-3 text-sm text-zinc-500"><Loader2 className="animate-spin" size={18}/>Loading models…</div> : <div className="mt-5 grid gap-4 md:grid-cols-2"><label className="grid gap-1.5 text-sm font-medium text-zinc-700">Provider<select className="rounded-md border border-zinc-200 px-3 py-2 font-normal" value={provider} onChange={(event) => setProvider(event.target.value)} disabled={catalog.providers.length === 0}>{catalog.providers.map((item) => <option key={item.id} value={item.id}>{item.id}</option>)}</select></label><label className="grid gap-1.5 text-sm font-medium text-zinc-700">Model<select className="rounded-md border border-zinc-200 px-3 py-2 font-normal" value={model} onChange={(event) => setModel(event.target.value)} disabled={models.length === 0}>{models.map((item) => <option key={item.id} value={item.id}>{item.name === item.id ? item.id : `${item.name} (${item.id})`}</option>)}</select></label>{catalog.providers.length === 0 && <p className="text-sm text-zinc-500 md:col-span-2">No models are available for this profile.</p>}</div>}</Card>{rolloverDialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="rollover-title"><Card className="w-full max-w-lg"><h2 id="rollover-title" className="text-lg font-semibold">Roll over active sessions?</h2><p className="mt-2 text-sm text-zinc-600">The new model is saved and will be used by new sessions. Roll over active Application sessions now so they start using this model.</p>{activeSessions.length > 0 ? <ul className="mt-4 max-h-40 space-y-1 overflow-auto rounded-md border border-zinc-200 bg-zinc-50 p-3 text-sm text-zinc-600">{activeSessions.map((session) => <li key={`${session.application}:${session.sessionPrefix}`}><span className="font-medium text-zinc-800">{session.application}</span> · {session.sessionPrefix}</li>)}</ul> : <p className="mt-4 text-sm text-zinc-500">There are no active Application sessions for this profile.</p>}<div className="mt-6 flex justify-end gap-2">{activeSessions.length > 0 && <Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Roll over active sessions" disabled={rollingOver} onClick={() => void rollover()}>{rollingOver ? <Loader2 className="animate-spin" size={17}/> : <RefreshCw size={17}/>}<span className="ml-2">Roll over now</span></Button>}<Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title={activeSessions.length > 0 ? "Roll over later" : "Close"} disabled={rollingOver} onClick={later}>{activeSessions.length > 0 ? "Later" : "Close"}</Button></div></Card></div>}</>;
54
+ }
@@ -9,12 +9,12 @@ import { Input } from "@/components/ui/input";
9
9
  import { Switch } from "@/components/ui/switch";
10
10
  import { useToast } from "@/components/toast";
11
11
 
12
- type Pulse = { id: string; name: string; description: string; type: "cron" | "heartbeat"; schedule: string; prompt: string; thread_session_id: string; result: string | null; enabled: boolean; nextRunAt: string | null; lastRunAt: string | null };
12
+ type Pulse = { id: string; name: string; description: string; type: "cron" | "heartbeat"; schedule: string; prompt: string; thread_session_id: string; insertIntoApiSession: boolean; result: string | null; enabled: boolean; nextRunAt: string | null; lastRunAt: string | null };
13
13
  type HistoryPulse = Pulse & { startedAt: string; finishedAt: string | null; status: string; error: string | null };
14
14
  type PulseRun = { id: string; startedAt: string; finishedAt: string | null; status: "running" | "success" | "error"; response: string | null; error: string | null };
15
15
  type Session = { id: string; name?: string | null };
16
- type Form = { name: string; description: string; schedule: string; prompt: string; thread_session_id: string; enabled: boolean };
17
- const blank = (): Form => ({ name: "", description: "", schedule: "*/15 * * * *", prompt: "", thread_session_id: "", enabled: true });
16
+ type Form = { name: string; description: string; schedule: string; prompt: string; thread_session_id: string; insertIntoApiSession: boolean; enabled: boolean };
17
+ const blank = (): Form => ({ name: "", description: "", schedule: "*/15 * * * *", prompt: "", thread_session_id: "", insertIntoApiSession: false, enabled: true });
18
18
  function localDateTime(value: Date): string { const pad = (part: number) => String(part).padStart(2, "0"); return `${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}T${pad(value.getHours())}:${pad(value.getMinutes())}`; }
19
19
  function scheduleDescription(schedule: string): { type: Pulse["type"]; text: string } {
20
20
  if (schedule.startsWith("@once:")) return { type: "cron", text: `Runs once at ${schedule.slice(6)}.` };
@@ -38,8 +38,8 @@ export function PulseResources({ profile, initial }: { profile: string; initial?
38
38
  useEffect(() => { void load(); }, [profile]);
39
39
  const close = () => { setDialog(undefined); setEditing(undefined); };
40
40
  const create = () => { setForm(blank()); setEditing(undefined); setDialog("create"); };
41
- const edit = (pulse: Pulse) => { setEditing(pulse); setForm({ name: pulse.name, description: pulse.description, schedule: pulse.schedule, prompt: pulse.prompt, thread_session_id: pulse.thread_session_id, enabled: pulse.enabled }); setDialog("edit"); };
41
+ const edit = (pulse: Pulse) => { setEditing(pulse); setForm({ name: pulse.name, description: pulse.description, schedule: pulse.schedule, prompt: pulse.prompt, thread_session_id: pulse.thread_session_id, insertIntoApiSession: pulse.insertIntoApiSession, enabled: pulse.enabled }); setDialog("edit"); };
42
42
  const valid = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(form.name) && Boolean(form.description.trim() && form.schedule.trim() && form.prompt.trim());
43
43
  const save = async () => { if (!valid || saving) return; try { setSaving(true); const { enabled, ...payload } = form; const response = await request(`profiles/${profile}/pulses${editing ? `/${encodeURIComponent(editing.name)}` : ""}`, { method: editing ? "PUT" : "POST", body: JSON.stringify(payload) }); const saved = await response.json() as { pulse: Pulse }; if (saved.pulse.enabled !== enabled) await request(`profiles/${profile}/pulses/${encodeURIComponent(saved.pulse.name)}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); toast(editing ? "Pulse saved." : `Pulse created and ${enabled ? "enabled" : "disabled"}.`); close(); } catch (cause) { toast((cause as Error).message, "error"); } finally { setSaving(false); } };
44
- return <><Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>Pulses</CardTitle><p className="mt-1 text-sm text-zinc-500">Scheduled cron jobs and stateful heartbeats, executed by the Pulse Tick service.</p></div><div className="flex items-center gap-3"><label className="mr-6 flex items-center gap-2 text-sm font-medium text-zinc-600"><Switch className="data-[state=checked]:!bg-[#00b9ed]" checked={tickRunning} onCheckedChange={async (enabled) => { try { const response = await request("pulse/tick", { method: "POST", body: JSON.stringify({ enabled }) }); const data = await response.json() as { running: boolean }; setTickRunning(data.running); toast(`Pulse Tick ${data.running ? "started" : "stopped"}.`); } catch (cause) { toast((cause as Error).message, "error"); } }}/><span>Pulse Service</span></label><label className="flex items-center gap-2 text-sm font-medium text-zinc-600"><Switch className="data-[state=checked]:!bg-[#00b9ed]" checked={showHistory} onCheckedChange={setShowHistory}/><span>Show History</span></label><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Reload pulses" onClick={() => void load()}><RotateCw size={17}/></Button><Button title="Add pulse" onClick={create}><Plus size={17}/></Button></div></div><div className="mt-5 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b border-zinc-200 text-zinc-500"><tr><th className="p-3">Name</th><th className="p-3">Type</th><th className="p-3">Schedule</th><th className="p-3">Thread session</th><th className="p-3">Next run</th><th className="p-3">Enabled</th><th className="p-3"/></tr></thead><tbody>{items.filter((item) => showHistory || !(item.schedule.startsWith("@once:") && item.lastRunAt)).map((item) => <Fragment key={item.id}><tr className="border-b border-zinc-100"><td className="p-3"><p className="font-medium">{item.name}</p><p className="max-w-sm truncate text-xs text-zinc-500">{item.description}</p></td><td className="p-3"><span className={`inline-flex rounded-md border px-2.5 py-1 text-xs font-medium ${item.type === "heartbeat" ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-blue-200 bg-blue-50 text-blue-800"}`}>{item.type === "heartbeat" ? "Heartbeat" : "Cron"}</span></td><td className="p-3 font-mono text-xs">{item.schedule}</td><td className="p-3 font-mono text-xs">{item.thread_session_id}</td><td className="p-3 text-xs text-zinc-500">{item.nextRunAt ?? "—"}</td><td className="p-3"><Switch checked={item.enabled} onCheckedChange={async (enabled) => { try { await request(`profiles/${profile}/pulses/${encodeURIComponent(item.name)}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); toast(`Pulse ${enabled ? "enabled" : "disabled"}.`); } catch (cause) { toast((cause as Error).message, "error"); } }}/></td><td className="whitespace-nowrap p-3"><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={expanded === item.id ? "Hide last result" : "Show last result"} onClick={() => setExpanded((current) => current === item.id ? undefined : item.id)}>{expanded === item.id ? <ChevronDown size={17}/> : <ChevronRight size={17}/>}</button><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`View execution logs for ${item.name}`} onClick={() => openLogs(item)}><ScrollText size={17}/></button><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`Edit ${item.name}`} onClick={() => edit(item)}><FilePenLine size={17}/></button><button className="rounded p-2 text-zinc-400 hover:bg-red-50 hover:text-red-600" title={`Delete ${item.name}`} onClick={async () => { if (!confirm(`Delete pulse '${item.name}'?`)) return; try { await request(`profiles/${profile}/pulses/${encodeURIComponent(item.name)}`, { method: "DELETE" }); await load(); toast("Pulse deleted."); } catch (cause) { toast((cause as Error).message, "error"); } }}><Trash2 size={17}/></button></td></tr>{expanded === item.id && <tr className="border-b border-zinc-100 bg-zinc-50"><td colSpan={7} className="p-4"><p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">Last result</p>{item.result ? <pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-zinc-200 bg-white p-3 font-mono text-xs text-zinc-800">{item.result}</pre> : <p className="text-sm text-zinc-500">No result available yet.</p>}</td></tr>}</Fragment>)}{!items.filter((item) => showHistory || !(item.schedule.startsWith("@once:") && item.lastRunAt)).length && <tr><td colSpan={7} className="p-5 text-zinc-500">No pulses are configured for this view.</td></tr>}</tbody></table></div></Card>{logPulse && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="pulse-logs-title"><div className="max-h-[90vh] w-full max-w-5xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-5 flex items-start justify-between gap-4"><div><h2 id="pulse-logs-title" className="text-xl font-semibold">Execution logs — {logPulse.name}</h2><p className="mt-1 text-sm text-zinc-500">Results recorded by the Pulse Tick service.</p></div><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close execution logs" onClick={() => setLogPulse(undefined)}><X size={17}/></Button></header><div className="mb-5 grid gap-3 md:grid-cols-[1fr_1fr_auto_auto]"><label className="grid gap-1 text-sm font-medium text-zinc-700">Start<Input type="datetime-local" value={logStart} onChange={(event) => setLogStart(event.target.value)}/></label><label className="grid gap-1 text-sm font-medium text-zinc-700">End<Input type="datetime-local" value={logEnd} onChange={(event) => setLogEnd(event.target.value)}/></label><Button className="self-end" disabled={logsLoading} onClick={() => void loadRuns()}>{logsLoading ? <Loader2 className="animate-spin" size={17}/> : "Apply filters"}</Button><Button className="self-end bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Refresh execution logs" disabled={logsLoading} onClick={() => void loadRuns()}><RotateCw className={logsLoading ? "animate-spin" : ""} size={17}/></Button></div>{logsLoading && !runs ? <div className="py-12 text-center text-sm text-zinc-500">Loading execution logs…</div> : runs?.length ? <div className="space-y-3">{runs.map((run) => <article key={run.id} className="rounded-lg border border-zinc-200 p-4"><div className="flex flex-wrap items-center justify-between gap-3"><div><p className="font-medium">{new Date(run.startedAt).toLocaleString()}</p><p className="text-xs text-zinc-500">{run.finishedAt ? `Duration: ${Math.max(0, new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000}s` : "Still running"}</p></div><span className={`rounded-md px-2 py-1 text-xs font-medium ${run.status === "success" ? "bg-emerald-50 text-emerald-800" : run.status === "error" ? "bg-red-50 text-red-800" : "bg-amber-50 text-amber-800"}`}>{run.status}</span></div>{run.response && <pre className="mt-3 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-zinc-200 bg-zinc-50 p-3 font-mono text-xs text-zinc-800">{run.response}</pre>}{run.error && <pre className="mt-3 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-red-200 bg-red-50 p-3 font-mono text-xs text-red-800">{run.error}</pre>}</article>)}</div> : <div className="py-12 text-center text-sm text-zinc-500">No executions were recorded in this period.</div>}</div></div>}{history && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-5 flex items-start justify-between gap-4"><div><h2 className="text-xl font-semibold">Completed one-time jobs</h2><p className="mt-1 text-sm text-zinc-500">Executed jobs with an <code>@once</code> schedule.</p></div><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close history" onClick={() => setHistory(undefined)}><X size={17}/></Button></header>{history.length ? <div className="space-y-3">{history.map((item) => <article key={item.id} className="rounded-lg border border-zinc-200 p-4"><div className="flex flex-wrap items-center justify-between gap-2"><div><p className="font-medium">{item.name}</p><p className="text-xs text-zinc-500">{item.description}</p></div><span className={`rounded-md px-2 py-1 text-xs font-medium ${item.status === "success" ? "bg-emerald-50 text-emerald-800" : "bg-red-50 text-red-800"}`}>{item.status}</span></div><p className="mt-3 text-xs text-zinc-500">Executed: {item.finishedAt ?? item.startedAt}</p>{item.result && <pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-zinc-50 p-3 font-mono text-xs">{item.result}</pre>}{item.error && <pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-red-50 p-3 font-mono text-xs text-red-800">{item.error}</pre>}</article>)}</div> : <p className="py-10 text-center text-sm text-zinc-500">No completed one-time jobs yet.</p>}</div></div>}{dialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="max-h-[94vh] w-full max-w-3xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-6 flex items-start justify-between gap-4"><div><h2 className="text-xl font-semibold">{dialog === "create" ? "Create pulse" : `Edit ${editing?.name}`}</h2><p className="mt-1 text-sm text-zinc-500">Heartbeats retain state and must repeat in less than four hours. Cron jobs do not retain state.</p></div><div className="flex gap-2"><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save pulse" disabled={!valid || saving} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" size={17}/> : <Check size={17}/>}</Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close" onClick={close}><X size={17}/></Button></div></header><div className="grid gap-5 md:grid-cols-2"><label className="grid content-start gap-1.5 text-sm font-semibold md:col-span-2">Name<Input disabled={dialog === "edit"} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="e.g. daily-report"/></label><label className="grid gap-1.5 text-sm font-semibold md:col-span-2">Description<Input value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="What this pulse is responsible for"/></label><label className="grid content-start gap-1.5 text-sm font-semibold">Schedule<Input value={form.schedule} onChange={(event) => setForm({ ...form, schedule: event.target.value })} placeholder="*/15 * * * * or @once:2026-03-12T14:30:00Z"/><span className="text-xs font-normal text-zinc-500">{scheduleDescription(form.schedule).text}</span></label><label className="grid content-start gap-1.5 text-sm font-semibold">Type<Input readOnly value={scheduleDescription(form.schedule).type === "heartbeat" ? "Heartbeat" : "Cron"} className={scheduleDescription(form.schedule).type === "heartbeat" ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-blue-200 bg-blue-50 text-blue-800"}/><span className="text-xs font-normal text-zinc-500">Calculated automatically from the schedule.</span></label><label className="flex items-center gap-3 text-sm font-semibold md:col-span-2"><Switch checked={form.enabled} onCheckedChange={(enabled) => setForm({ ...form, enabled })}/><span>Enabled</span><span className="font-normal text-zinc-500">Run this pulse when the Pulse Tick service is active.</span></label>{editing?.thread_session_id && <label className="grid gap-1.5 text-sm font-semibold md:col-span-2">Thread session<Input readOnly value={editing.thread_session_id}/><span className="text-xs font-normal text-zinc-500">This pulse was registered from this conversation and receives its results there.</span></label>}<section className="grid gap-1.5 md:col-span-2"><label className="text-sm font-semibold">Prompt</label><p className="text-xs text-zinc-500">Markdown instructions executed on every pulse.</p><div className="overflow-hidden rounded-md border border-zinc-200"><CodeMirror value={form.prompt} height="300px" extensions={[markdown()]} onChange={(prompt) => setForm({ ...form, prompt })}/></div></section></div></div></div>}</>;
44
+ return <><Card><div className="flex flex-wrap items-center justify-between gap-3"><div><CardTitle>Pulses</CardTitle><p className="mt-1 text-sm text-zinc-500">Scheduled cron jobs and stateful heartbeats, executed by the Pulse Tick service.</p></div><div className="flex items-center gap-3"><label className="mr-6 flex items-center gap-2 text-sm font-medium text-zinc-600"><Switch className="data-[state=checked]:!bg-[#00b9ed]" checked={tickRunning} onCheckedChange={async (enabled) => { try { const response = await request("pulse/tick", { method: "POST", body: JSON.stringify({ enabled }) }); const data = await response.json() as { running: boolean }; setTickRunning(data.running); toast(`Pulse Tick ${data.running ? "started" : "stopped"}.`); } catch (cause) { toast((cause as Error).message, "error"); } }}/><span>Pulse Service</span></label><label className="flex items-center gap-2 text-sm font-medium text-zinc-600"><Switch className="data-[state=checked]:!bg-[#00b9ed]" checked={showHistory} onCheckedChange={setShowHistory}/><span>Show History</span></label><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Reload pulses" onClick={() => void load()}><RotateCw size={17}/></Button><Button title="Add pulse" onClick={create}><Plus size={17}/></Button></div></div><div className="mt-5 overflow-x-auto"><table className="w-full text-left text-sm"><thead className="border-b border-zinc-200 text-zinc-500"><tr><th className="p-3">Name</th><th className="p-3">Type</th><th className="p-3">Schedule</th><th className="p-3">Thread session</th><th className="p-3">Next run</th><th className="p-3">Enabled</th><th className="p-3"/></tr></thead><tbody>{items.filter((item) => showHistory || !(item.schedule.startsWith("@once:") && item.lastRunAt)).map((item) => <Fragment key={item.id}><tr className="border-b border-zinc-100"><td className="p-3"><p className="font-medium">{item.name}</p><p className="max-w-sm truncate text-xs text-zinc-500">{item.description}</p></td><td className="p-3"><span className={`inline-flex rounded-md border px-2.5 py-1 text-xs font-medium ${item.type === "heartbeat" ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-blue-200 bg-blue-50 text-blue-800"}`}>{item.type === "heartbeat" ? "Heartbeat" : "Cron"}</span></td><td className="p-3 font-mono text-xs">{item.schedule}</td><td className="p-3 font-mono text-xs">{item.thread_session_id}</td><td className="p-3 text-xs text-zinc-500">{item.nextRunAt ?? "—"}</td><td className="p-3"><Switch checked={item.enabled} onCheckedChange={async (enabled) => { try { await request(`profiles/${profile}/pulses/${encodeURIComponent(item.name)}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); toast(`Pulse ${enabled ? "enabled" : "disabled"}.`); } catch (cause) { toast((cause as Error).message, "error"); } }}/></td><td className="whitespace-nowrap p-3"><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={expanded === item.id ? "Hide last result" : "Show last result"} onClick={() => setExpanded((current) => current === item.id ? undefined : item.id)}>{expanded === item.id ? <ChevronDown size={17}/> : <ChevronRight size={17}/>}</button><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`View execution logs for ${item.name}`} onClick={() => openLogs(item)}><ScrollText size={17}/></button><button className="rounded p-2 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900" title={`Edit ${item.name}`} onClick={() => edit(item)}><FilePenLine size={17}/></button><button className="rounded p-2 text-zinc-400 hover:bg-red-50 hover:text-red-600" title={`Delete ${item.name}`} onClick={async () => { if (!confirm(`Delete pulse '${item.name}'?`)) return; try { await request(`profiles/${profile}/pulses/${encodeURIComponent(item.name)}`, { method: "DELETE" }); await load(); toast("Pulse deleted."); } catch (cause) { toast((cause as Error).message, "error"); } }}><Trash2 size={17}/></button></td></tr>{expanded === item.id && <tr className="border-b border-zinc-100 bg-zinc-50"><td colSpan={7} className="p-4"><p className="mb-2 text-xs font-semibold uppercase tracking-wide text-zinc-500">Last result</p>{item.result ? <pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-zinc-200 bg-white p-3 font-mono text-xs text-zinc-800">{item.result}</pre> : <p className="text-sm text-zinc-500">No result available yet.</p>}</td></tr>}</Fragment>)}{!items.filter((item) => showHistory || !(item.schedule.startsWith("@once:") && item.lastRunAt)).length && <tr><td colSpan={7} className="p-5 text-zinc-500">No pulses are configured for this view.</td></tr>}</tbody></table></div></Card>{logPulse && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="pulse-logs-title"><div className="max-h-[90vh] w-full max-w-5xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-5 flex items-start justify-between gap-4"><div><h2 id="pulse-logs-title" className="text-xl font-semibold">Execution logs — {logPulse.name}</h2><p className="mt-1 text-sm text-zinc-500">Results recorded by the Pulse Tick service.</p></div><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close execution logs" onClick={() => setLogPulse(undefined)}><X size={17}/></Button></header><div className="mb-5 grid gap-3 md:grid-cols-[1fr_1fr_auto_auto]"><label className="grid gap-1 text-sm font-medium text-zinc-700">Start<Input type="datetime-local" value={logStart} onChange={(event) => setLogStart(event.target.value)}/></label><label className="grid gap-1 text-sm font-medium text-zinc-700">End<Input type="datetime-local" value={logEnd} onChange={(event) => setLogEnd(event.target.value)}/></label><Button className="self-end" disabled={logsLoading} onClick={() => void loadRuns()}>{logsLoading ? <Loader2 className="animate-spin" size={17}/> : "Apply filters"}</Button><Button className="self-end bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Refresh execution logs" disabled={logsLoading} onClick={() => void loadRuns()}><RotateCw className={logsLoading ? "animate-spin" : ""} size={17}/></Button></div>{logsLoading && !runs ? <div className="py-12 text-center text-sm text-zinc-500">Loading execution logs…</div> : runs?.length ? <div className="space-y-3">{runs.map((run) => <article key={run.id} className="rounded-lg border border-zinc-200 p-4"><div className="flex flex-wrap items-center justify-between gap-3"><div><p className="font-medium">{new Date(run.startedAt).toLocaleString()}</p><p className="text-xs text-zinc-500">{run.finishedAt ? `Duration: ${Math.max(0, new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000}s` : "Still running"}</p></div><span className={`rounded-md px-2 py-1 text-xs font-medium ${run.status === "success" ? "bg-emerald-50 text-emerald-800" : run.status === "error" ? "bg-red-50 text-red-800" : "bg-amber-50 text-amber-800"}`}>{run.status}</span></div>{run.response && <pre className="mt-3 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-zinc-200 bg-zinc-50 p-3 font-mono text-xs text-zinc-800">{run.response}</pre>}{run.error && <pre className="mt-3 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-red-200 bg-red-50 p-3 font-mono text-xs text-red-800">{run.error}</pre>}</article>)}</div> : <div className="py-12 text-center text-sm text-zinc-500">No executions were recorded in this period.</div>}</div></div>}{history && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-5 flex items-start justify-between gap-4"><div><h2 className="text-xl font-semibold">Completed one-time jobs</h2><p className="mt-1 text-sm text-zinc-500">Executed jobs with an <code>@once</code> schedule.</p></div><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close history" onClick={() => setHistory(undefined)}><X size={17}/></Button></header>{history.length ? <div className="space-y-3">{history.map((item) => <article key={item.id} className="rounded-lg border border-zinc-200 p-4"><div className="flex flex-wrap items-center justify-between gap-2"><div><p className="font-medium">{item.name}</p><p className="text-xs text-zinc-500">{item.description}</p></div><span className={`rounded-md px-2 py-1 text-xs font-medium ${item.status === "success" ? "bg-emerald-50 text-emerald-800" : "bg-red-50 text-red-800"}`}>{item.status}</span></div><p className="mt-3 text-xs text-zinc-500">Executed: {item.finishedAt ?? item.startedAt}</p>{item.result && <pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-zinc-50 p-3 font-mono text-xs">{item.result}</pre>}{item.error && <pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-red-50 p-3 font-mono text-xs text-red-800">{item.error}</pre>}</article>)}</div> : <p className="py-10 text-center text-sm text-zinc-500">No completed one-time jobs yet.</p>}</div></div>}{dialog && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true"><div className="max-h-[94vh] w-full max-w-3xl overflow-y-auto rounded-xl bg-white p-6 shadow-2xl"><header className="mb-6 flex items-start justify-between gap-4"><div><h2 className="text-xl font-semibold">{dialog === "create" ? "Create pulse" : `Edit ${editing?.name}`}</h2><p className="mt-1 text-sm text-zinc-500">Heartbeats retain state and must repeat in less than four hours. Cron jobs do not retain state.</p></div><div className="flex gap-2"><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save pulse" disabled={!valid || saving} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" size={17}/> : <Check size={17}/>}</Button><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Close" onClick={close}><X size={17}/></Button></div></header><div className="grid gap-5 md:grid-cols-2"><label className="grid content-start gap-1.5 text-sm font-semibold md:col-span-2">Name<Input disabled={dialog === "edit"} value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="e.g. daily-report"/></label><label className="grid gap-1.5 text-sm font-semibold md:col-span-2">Description<Input value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="What this pulse is responsible for"/></label><label className="grid content-start gap-1.5 text-sm font-semibold">Schedule<Input value={form.schedule} onChange={(event) => setForm({ ...form, schedule: event.target.value })} placeholder="*/15 * * * * or @once:2026-03-12T14:30:00Z"/><span className="text-xs font-normal text-zinc-500">{scheduleDescription(form.schedule).text}</span></label><label className="grid content-start gap-1.5 text-sm font-semibold">Type<Input readOnly value={scheduleDescription(form.schedule).type === "heartbeat" ? "Heartbeat" : "Cron"} className={scheduleDescription(form.schedule).type === "heartbeat" ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-blue-200 bg-blue-50 text-blue-800"}/><span className="text-xs font-normal text-zinc-500">Calculated automatically from the schedule.</span></label><label className="flex items-center gap-3 text-sm font-semibold md:col-span-2"><Switch checked={form.insertIntoApiSession} onCheckedChange={(insertIntoApiSession) => setForm({ ...form, insertIntoApiSession })}/><span>Insert result into active API sessions</span><span className="font-normal text-zinc-500">Records the result in every active Application session for this profile; it does not send through an outbound handler.</span></label><label className="flex items-center gap-3 text-sm font-semibold md:col-span-2"><Switch checked={form.enabled} onCheckedChange={(enabled) => setForm({ ...form, enabled })}/><span>Enabled</span><span className="font-normal text-zinc-500">Run this pulse when the Pulse Tick service is active.</span></label>{editing?.thread_session_id && <label className="grid gap-1.5 text-sm font-semibold md:col-span-2">Thread session<Input readOnly value={editing.thread_session_id}/><span className="text-xs font-normal text-zinc-500">This pulse was registered from this conversation and receives its results there.</span></label>}<section className="grid gap-1.5 md:col-span-2"><label className="text-sm font-semibold">Prompt</label><p className="text-xs text-zinc-500">Markdown instructions executed on every pulse.</p><div className="overflow-hidden rounded-md border border-zinc-200"><CodeMirror value={form.prompt} height="300px" extensions={[markdown()]} onChange={(prompt) => setForm({ ...form, prompt })}/></div></section></div></div></div>}</>;
45
45
  }
@@ -0,0 +1,36 @@
1
+ import { chmod, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ModelRuntime } from "@earendil-works/pi-coding-agent";
6
+
7
+ const agentDir = () => process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
8
+ const modelsPath = () => join(agentDir(), "models.json");
9
+ const emptyModels = "{\n \"providers\": {}\n}\n";
10
+
11
+ export async function readModelsConfig() {
12
+ try {
13
+ return await readFile(modelsPath(), "utf8");
14
+ } catch (error) {
15
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyModels;
16
+ throw error;
17
+ }
18
+ }
19
+
20
+ export async function writeModelsConfig(content: unknown) {
21
+ if (typeof content !== "string") throw new Error("models.json content must be a string.");
22
+ const path = modelsPath();
23
+ const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
24
+ try {
25
+ await writeFile(temporary, content.endsWith("\n") ? content : `${content}\n`, { encoding: "utf8", mode: 0o600 });
26
+ await chmod(temporary, 0o600);
27
+ const runtime = await ModelRuntime.create({ modelsPath: temporary, refreshOnCreate: false });
28
+ const error = runtime.getError();
29
+ if (error) throw new Error(error.replace(/\n\nFile: .*$/, ""));
30
+ await rename(temporary, path);
31
+ await chmod(path, 0o600);
32
+ return readModelsConfig();
33
+ } finally {
34
+ await rm(temporary, { force: true }).catch(() => {});
35
+ }
36
+ }
@@ -5,6 +5,7 @@ const sectionLabels: Record<string, string> = {
5
5
  chat: "Chat",
6
6
  profiles: "Profiles",
7
7
  settings: "Settings",
8
+ model: "Model",
8
9
  env: "Environment",
9
10
  soul: "SOUL",
10
11
  "context-memory": "Context Memory",
@@ -15,12 +16,13 @@ const sectionLabels: Record<string, string> = {
15
16
  pulses: "Pulses",
16
17
  extensions: "Extensions",
17
18
  applications: "Applications",
19
+ models: "Models",
18
20
  "api-server": "API Server",
19
21
  "pi-console-webui": "Console WebUI",
20
22
  "skill-sources": "Skill Sources",
21
23
  };
22
24
 
23
- const administrativeSections = new Set(["applications", "api-server", "pi-console-webui", "skill-sources"]);
25
+ const administrativeSections = new Set(["applications", "models", "api-server", "pi-console-webui", "skill-sources"]);
24
26
 
25
27
  export function sectionMetadata(section: string, profile?: string): Metadata {
26
28
  const label = sectionLabels[section] ?? "Pi Console";
@@ -3,6 +3,7 @@ const appRoot = __dirname;
3
3
 
4
4
  module.exports = {
5
5
  outputFileTracingRoot: join(__dirname, "../.."),
6
+ serverExternalPackages: ["@earendil-works/pi-coding-agent"],
6
7
  // npm installs Pi Feats below node_modules. Ask Next to transpile the
7
8
  // package itself when the Console app is built from that location.
8
9
  transpilePackages: ["pi-feats"],
@@ -0,0 +1,274 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Box, render, Text } from "ink";
3
+ import React from "react";
4
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5
+ import { existsSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { isIP } from "node:net";
8
+ import { join } from "node:path";
9
+ import { spawn } from "node:child_process";
10
+ import { createInterface } from "node:readline/promises";
11
+
12
+ const profileDirectory = () => process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
13
+ const validAlias = (value: string) => /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(value);
14
+ const validHostname = (value: string) => /^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$/.test(value);
15
+ const validUser = (value: string) => /^[A-Za-z_][A-Za-z0-9_.-]*$/.test(value);
16
+
17
+ type CommandResult = { code: number; stdout: string; stderr: string };
18
+ export type ProfileSshKeyType = "ed25519" | "rsa";
19
+ type SshHost = { alias: string; hostname?: string; ip?: string; port: number; user: string; keyType?: ProfileSshKeyType };
20
+
21
+ export function profileSshKeyTypeForBanner(banner: string): ProfileSshKeyType {
22
+ const version = banner.match(/OpenSSH_(\d+)\.(\d+)/i);
23
+ if (!version) return "ed25519";
24
+ const major = Number(version[1]), minor = Number(version[2]);
25
+ return major < 6 || (major === 6 && minor < 5) ? "rsa" : "ed25519";
26
+ }
27
+
28
+ async function command(command: string, args: string[], options: { env?: NodeJS.ProcessEnv; input?: string } = {}): Promise<CommandResult> {
29
+ return await new Promise((resolve, reject) => {
30
+ const child = spawn(command, args, { stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"], env: options.env });
31
+ let stdout = "", stderr = "";
32
+ child.stdout.on("data", (chunk) => { stdout += String(chunk); });
33
+ child.stderr.on("data", (chunk) => { stderr += String(chunk); });
34
+ child.once("error", reject);
35
+ child.once("exit", (code) => resolve({ code: code ?? 1, stdout, stderr }));
36
+ if (options.input !== undefined) child.stdin.end(options.input);
37
+ });
38
+ }
39
+
40
+ async function ask(label: string, defaultValue?: string): Promise<string> {
41
+ const prompt = createInterface({ input: process.stdin, output: process.stdout });
42
+ try { return (await prompt.question(defaultValue === undefined ? `${label}: ` : `${label} [${defaultValue}]: `)).trim() || defaultValue || ""; }
43
+ finally { prompt.close(); }
44
+ }
45
+
46
+ async function interactiveCommand(command: string, args: string[], env: NodeJS.ProcessEnv = process.env): Promise<number> {
47
+ return await new Promise((resolve, reject) => {
48
+ const child = spawn(command, args, { stdio: "inherit", env });
49
+ child.once("error", reject);
50
+ child.once("exit", (code) => resolve(code ?? 1));
51
+ });
52
+ }
53
+
54
+ export function profileSshCopyEnvironment(profileDir: string, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
55
+ // ssh-copy-id creates temporary files below ~/.ssh. Named-profile sandboxes
56
+ // may read but cannot write the host user's home directory, so keep those
57
+ // files in the profile workspace alongside the profile key/config instead.
58
+ return { ...base, HOME: profileDir };
59
+ }
60
+
61
+ export function profileSshVerificationArgs(config: string, alias: string): string[] {
62
+ return [
63
+ "-F", config,
64
+ "-o", "BatchMode=yes",
65
+ "-o", "PasswordAuthentication=no",
66
+ "-o", "KbdInteractiveAuthentication=no",
67
+ "-o", "ConnectTimeout=15",
68
+ alias,
69
+ "true",
70
+ ];
71
+ }
72
+
73
+ function hostTokens(host: Pick<SshHost, "alias" | "hostname" | "ip">): string[] {
74
+ return [...new Set([host.alias, host.hostname, host.ip].filter((value): value is string => Boolean(value)))];
75
+ }
76
+
77
+ export function profileSshConfigBlock(host: SshHost, privateKey: string, knownHosts: string): string {
78
+ const target = host.ip ?? host.hostname;
79
+ if (!target) throw new Error("SSH host requires a hostname or IP address.");
80
+ const keyType = host.keyType ?? "ed25519";
81
+ return `# pi-profile-ssh: ${host.alias}\nHost ${hostTokens(host).join(" ")}\n HostName ${target}\n User ${host.user}\n Port ${host.port}\n IdentityFile ${privateKey}\n IdentitiesOnly yes\n${keyType === "rsa" ? " PubkeyAcceptedAlgorithms +ssh-rsa\n" : ""} UserKnownHostsFile ${knownHosts}\n StrictHostKeyChecking yes\n`;
82
+ }
83
+
84
+ async function ensureKey(directory: string, keyType: ProfileSshKeyType): Promise<{ privateKey: string; publicKey: string }> {
85
+ const privateKey = join(directory, keyType === "rsa" ? "id_rsa" : "id_ed25519"), publicKey = `${privateKey}.pub`;
86
+ if (existsSync(privateKey) !== existsSync(publicKey)) throw new Error(`The profile ${keyType} SSH key pair is incomplete. Restore it or remove both files before retrying.`);
87
+ if (!existsSync(privateKey)) {
88
+ const generated = await command("ssh-keygen", keyType === "rsa"
89
+ ? ["-q", "-t", "rsa", "-b", "4096", "-N", "", "-f", privateKey, "-C", `pi-profile@${profileDirectory()}`]
90
+ : ["-q", "-t", "ed25519", "-N", "", "-f", privateKey, "-C", `pi-profile@${profileDirectory()}`]);
91
+ if (generated.code !== 0) throw new Error(`ssh-keygen failed: ${generated.stderr.trim() || "unknown error"}`);
92
+ }
93
+ await chmod(privateKey, 0o600); await chmod(publicKey, 0o644);
94
+ return { privateKey, publicKey };
95
+ }
96
+
97
+ async function hostFingerprint(host: string, port: number): Promise<{ key: string; fingerprint: string; banner: string }> {
98
+ const scanned = await command("ssh-keyscan", ["-p", String(port), "-T", "10", host]);
99
+ if (!scanned.stdout.trim()) throw new Error(`Could not retrieve an SSH host key: ${scanned.stderr.trim() || "connection failed"}`);
100
+ const fingerprint = await command("ssh-keygen", ["-lf", "-"], { input: scanned.stdout });
101
+ if (fingerprint.code !== 0) throw new Error(`Could not read the SSH host fingerprint: ${fingerprint.stderr.trim() || "invalid key"}`);
102
+ return { key: scanned.stdout, fingerprint: fingerprint.stdout.trim(), banner: scanned.stderr };
103
+ }
104
+
105
+ function configuredHostTokens(source: string): Set<string> {
106
+ return new Set([...source.matchAll(/^\s*Host\s+(.+)$/gim)].flatMap((match) => match[1].trim().split(/\s+/)));
107
+ }
108
+
109
+ function assertHostTokensAvailable(source: string, tokens: string[]) {
110
+ const existing = configuredHostTokens(source);
111
+ const conflict = tokens.find((token) => existing.has(token));
112
+ if (conflict) throw new Error(`SSH host '${conflict}' already exists in the profile SSH config.`);
113
+ }
114
+
115
+ async function replaceConfig(path: string, content: string): Promise<void> {
116
+ const temporary = `${path}.${process.pid}.tmp`;
117
+ await writeFile(temporary, content, { mode: 0o600 });
118
+ await rename(temporary, path); await chmod(path, 0o600);
119
+ }
120
+
121
+ async function appendConfig(path: string, block: string): Promise<void> {
122
+ const current = existsSync(path) ? await readFile(path, "utf8") : "";
123
+ await replaceConfig(path, `${current.trimEnd()}${current.trim() ? "\n\n" : ""}${block}`);
124
+ }
125
+
126
+ async function writeKnownHosts(path: string, key: string): Promise<void> {
127
+ const current = existsSync(path) ? await readFile(path, "utf8") : "";
128
+ await writeFile(path, `${current}${current && !current.endsWith("\n") ? "\n" : ""}${key}`, { mode: 0o644 });
129
+ await chmod(path, 0o644);
130
+ }
131
+
132
+ type SshHostRow = { alias: string; hosts: string; user: string; port: string; keyType: ProfileSshKeyType };
133
+
134
+ function SshHostTable({ hosts }: { hosts: SshHostRow[] }) {
135
+ const width = Math.max(88, process.stdout.columns ?? 88);
136
+ const available = width - 16;
137
+ const columns: Array<[string, number]> = [["ALIAS", Math.max(14, Math.floor(available * 0.18))], ["HOSTS", 0], ["USER", Math.max(12, Math.floor(available * 0.16))], ["PORT", 6], ["KEY TYPE", 10]];
138
+ columns[1][1] = Math.max(16, available - columns[0][1] - columns[2][1] - columns[3][1] - columns[4][1]);
139
+ const clip = (value: string, columnWidth: number) => value.length <= columnWidth ? value : `${value.slice(0, Math.max(0, columnWidth - 1))}…`;
140
+ const cell = (value: string, columnWidth: number) => clip(value, columnWidth).padEnd(columnWidth);
141
+ const line = `┼${columns.map(([, columnWidth]) => "─".repeat(columnWidth + 2)).join("┼")}┼`;
142
+ const top = line.replaceAll("┼", "┬").replace(/^┬/, "┌").replace(/┬$/, "┐");
143
+ const bottom = line.replaceAll("┼", "┴").replace(/^┴/, "└").replace(/┴$/, "┘");
144
+ const row = (values: string[]) => `│ ${values.map((value, index) => cell(value, columns[index][1])).join(" │ ")} │`;
145
+ return React.createElement(Box, { flexDirection: "column" },
146
+ React.createElement(Text, { color: "cyan", bold: true }, "PROFILE SSH HOSTS"),
147
+ React.createElement(Text, { color: "gray" }, top),
148
+ React.createElement(Text, { color: "cyan", bold: true }, row(columns.map(([name]) => name))),
149
+ React.createElement(Text, { color: "gray" }, line),
150
+ ...(hosts.length
151
+ ? hosts.map((host) => React.createElement(Text, { color: "white", key: host.alias }, row([host.alias, host.hosts, host.user, host.port, host.keyType])))
152
+ : [React.createElement(Text, { color: "gray", key: "empty" }, row(["—", "No Pi-managed SSH hosts configured", "—", "—", "—"]))]),
153
+ React.createElement(Text, { color: "gray" }, bottom),
154
+ );
155
+ }
156
+
157
+ export function removeProfileSshConfigBlock(config: string, alias: string): { config: string; hostName: string } {
158
+ const lines = config.split(/\r?\n/);
159
+ const marker = `# pi-profile-ssh: ${alias}`;
160
+ const start = lines.findIndex((line) => line === marker);
161
+ if (start < 0) throw new Error(`Pi-managed SSH host '${alias}' was not found.`);
162
+ const end = lines.findIndex((line, index) => index > start && line === " StrictHostKeyChecking yes");
163
+ if (end < 0) throw new Error(`Pi-managed SSH host '${alias}' has an incomplete config block.`);
164
+ const hostLine = lines.slice(start, end + 1).find((line) => line.startsWith(" HostName "));
165
+ if (!hostLine) throw new Error(`Pi-managed SSH host '${alias}' has no HostName.`);
166
+ const remaining = [...lines.slice(0, start), ...lines.slice(end + 1)].join("\n").replace(/\n{3,}/g, "\n\n").trim();
167
+ return { config: remaining ? `${remaining}\n` : "", hostName: hostLine.slice(" HostName ".length) };
168
+ }
169
+
170
+ async function deleteProfileSshHost(alias: string): Promise<void> {
171
+ if (!validAlias(alias)) throw new Error("Invalid host name. Use letters, numbers, hyphens, or underscores (max. 64 characters).");
172
+ const directory = join(profileDirectory(), ".ssh"), configPath = join(directory, "config"), knownHosts = join(directory, "known_hosts");
173
+ if (!existsSync(configPath)) throw new Error(`Pi-managed SSH host '${alias}' was not found.`);
174
+ const removed = removeProfileSshConfigBlock(await readFile(configPath, "utf8"), alias);
175
+ await replaceConfig(configPath, removed.config);
176
+ if (existsSync(knownHosts)) await command("ssh-keygen", ["-R", removed.hostName, "-f", knownHosts]);
177
+ process.stdout.write(`SSH host '${alias}' deleted from ${configPath}.\n`);
178
+ }
179
+
180
+ async function listProfileSshHosts(): Promise<void> {
181
+ const path = join(profileDirectory(), ".ssh", "config");
182
+ const config = existsSync(path) ? await readFile(path, "utf8") : "";
183
+ const hosts = [...config.matchAll(/^# pi-profile-ssh: ([^\r\n]+)\r?\nHost ([^\r\n]+)[\s\S]*?^ User ([^\r\n]+)\r?\n Port (\d+)\r?\n IdentityFile ([^\r\n]+)$/gim)]
184
+ .map((match) => ({ alias: match[1], hosts: match[2], user: match[3], port: match[4], keyType: match[5].endsWith("id_rsa") ? "rsa" as const : "ed25519" as const }));
185
+ const app = render(React.createElement(SshHostTable, { hosts }), { stdout: process.stdout, stdin: process.stdin, exitOnCtrlC: false, patchConsole: false });
186
+ await new Promise((resolveRender) => setTimeout(resolveRender, 25));
187
+ app.unmount();
188
+ }
189
+
190
+ export async function addProfileSshHost(alias: string): Promise<void> {
191
+ if (!validAlias(alias)) throw new Error("Invalid host name. Use letters, numbers, hyphens, or underscores (max. 64 characters).");
192
+ const hostname = await ask("Hostname", alias);
193
+ if (!validHostname(hostname)) throw new Error("Invalid hostname.");
194
+ const ip = await ask("IP address");
195
+ if (!isIP(ip)) throw new Error("Invalid IP address.");
196
+ const portText = await ask("Port", "22"), port = Number(portText);
197
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port must be an integer between 1 and 65535.");
198
+ const user = await ask("Remote user");
199
+ if (!validUser(user)) throw new Error("Invalid remote user.");
200
+ const connection = { alias, hostname, ip, port, user };
201
+ const directory = join(profileDirectory(), ".ssh"), config = join(directory, "config"), knownHosts = join(directory, "known_hosts");
202
+ await mkdir(directory, { recursive: true, mode: 0o700 }); await chmod(directory, 0o700);
203
+ const currentConfig = existsSync(config) ? await readFile(config, "utf8") : "";
204
+ assertHostTokensAvailable(currentConfig, hostTokens(connection));
205
+ const fingerprint = await hostFingerprint(ip, port);
206
+ const keyType = profileSshKeyTypeForBanner(fingerprint.banner);
207
+ const host: SshHost = { ...connection, keyType };
208
+ const { privateKey, publicKey } = await ensureKey(directory, keyType);
209
+ process.stdout.write(`\nSSH host fingerprint:\n${fingerprint.fingerprint}\nUsing profile ${keyType === "rsa" ? "RSA-4096 (legacy server compatibility)" : "Ed25519"} key.\n`);
210
+ const confirmation = await ask("Trust this host key? [y/N]", "N");
211
+ if (!/^(y|yes)$/i.test(confirmation)) throw new Error("SSH host key was not trusted.");
212
+
213
+ const temporaryKnownHosts = join(directory, `known_hosts.${process.pid}.check`);
214
+ const block = profileSshConfigBlock(host, privateKey, temporaryKnownHosts);
215
+ const temporaryConfig = join(directory, `config.${process.pid}.check`);
216
+ await writeKnownHosts(temporaryKnownHosts, fingerprint.key);
217
+ await writeFile(temporaryConfig, block, { mode: 0o600 });
218
+ try {
219
+ let verified = await command("ssh", profileSshVerificationArgs(temporaryConfig, alias));
220
+ if (verified.code !== 0) {
221
+ if (!process.stdin.isTTY) throw new Error(`Profile key is not authorized and a password cannot be requested without a terminal: ${verified.stderr.trim() || "connection failed"}`);
222
+ process.stdout.write("The profile key is not authorized yet. ssh-copy-id will request the remote password once.\n");
223
+ const copied = await interactiveCommand("ssh-copy-id", ["-i", publicKey, "-F", temporaryConfig, alias], profileSshCopyEnvironment(profileDirectory()));
224
+ if (copied !== 0) throw new Error("ssh-copy-id failed.");
225
+ verified = await command("ssh", profileSshVerificationArgs(temporaryConfig, alias));
226
+ }
227
+ if (verified.code !== 0) throw new Error(`SSH key validation failed: ${verified.stderr.trim() || "connection failed"}`);
228
+ } finally {
229
+ await rm(temporaryConfig, { force: true });
230
+ await rm(temporaryKnownHosts, { force: true });
231
+ }
232
+
233
+ await writeKnownHosts(knownHosts, fingerprint.key);
234
+ await appendConfig(config, profileSshConfigBlock(host, privateKey, knownHosts));
235
+ process.stdout.write(`SSH host '${alias}' added to ${config}.\n`);
236
+ }
237
+
238
+ // Named profiles are re-executed with runtime-only extension/session flags
239
+ // before the actual command. Strip those pairs before dispatching SSH actions.
240
+ export function profileSshCommandArgs(raw: string[]): string[] {
241
+ const args: string[] = [];
242
+ for (let index = 0; index < raw.length; index += 1) {
243
+ const value = raw[index];
244
+ if (value === "--extension" || value === "--session-dir") { index += 1; continue; }
245
+ if (value.startsWith("--extension=") || value.startsWith("--session-dir=")) continue;
246
+ args.push(value);
247
+ }
248
+ return args;
249
+ }
250
+
251
+ export async function handleProfileSshCli(args: string[]): Promise<boolean> {
252
+ if (args[0] !== "ssh") return false;
253
+ if (args[1] === "add" && args[2] && !args[3]) {
254
+ try { await addProfileSshHost(args[2]); }
255
+ catch (error) { process.stderr.write(`SSH setup failed: ${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }
256
+ return true;
257
+ }
258
+ if (args[1] === "list" && !args[2]) {
259
+ try { await listProfileSshHosts(); }
260
+ catch (error) { process.stderr.write(`SSH setup failed: ${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }
261
+ return true;
262
+ }
263
+ if ((args[1] === "delete" || args[1] === "remove") && args[2] && !args[3]) {
264
+ try { await deleteProfileSshHost(args[2]); }
265
+ catch (error) { process.stderr.write(`SSH setup failed: ${error instanceof Error ? error.message : String(error)}\n`); process.exitCode = 1; }
266
+ return true;
267
+ }
268
+ process.stderr.write("Usage: pi ssh add <host-name> | pi ssh list | pi ssh delete <host-name>\n"); process.exitCode = 1;
269
+ return true;
270
+ }
271
+
272
+ export default async function (_pi: ExtensionAPI) {
273
+ if (await handleProfileSshCli(profileSshCommandArgs(process.argv.slice(2)))) process.exit();
274
+ }