pi-feats 0.1.5 → 0.1.7
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.
- package/README.md +48 -5
- package/extensions/api-server/README.md +21 -1
- package/extensions/api-server/application-runtime.ts +65 -4
- package/extensions/api-server/application-store.ts +5 -5
- package/extensions/api-server/index.ts +6 -2
- package/extensions/api-server/profile-store.ts +81 -34
- package/extensions/api-server/server.ts +179 -42
- package/extensions/cli-resources.ts +125 -94
- package/extensions/cli-tui-mode.ts +7 -0
- package/extensions/context-memory/index.ts +6 -1
- package/extensions/guardrails/index.ts +2 -1
- package/extensions/lib/application-context.ts +2 -0
- package/extensions/lib/cli-tui-mode.ts +72 -0
- package/extensions/lib/context-memory.ts +6 -1
- package/extensions/lib/package-resources.ts +58 -0
- package/extensions/lib/pi-command.ts +11 -0
- package/extensions/lib/profile-sandbox.ts +11 -3
- package/extensions/lib/remote-hosts.ts +7 -5
- package/extensions/lib/workflow-evaluator.ts +113 -0
- package/extensions/lib/workflow-harness.ts +409 -43
- package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
- package/extensions/pi-console-webui/app/globals.css +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
- package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
- package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
- package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
- package/extensions/pi-console-webui/components/console-page.tsx +1 -0
- package/extensions/pi-console-webui/components/console-state.tsx +1 -1
- package/extensions/pi-console-webui/components/console.tsx +26 -9
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
- package/extensions/pi-console-webui/components/profile-model-form.tsx +2 -2
- package/extensions/pi-console-webui/components/pulse-resources.tsx +4 -3
- package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
- package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
- package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
- package/extensions/pi-console-webui/index.ts +2 -1
- package/extensions/pi-console-webui/lib/admin-models.ts +6 -4
- package/extensions/pi-console-webui/lib/page-metadata.ts +1 -0
- package/extensions/profile-ssh.ts +2 -2
- package/extensions/profiles.ts +17 -9
- package/extensions/pulse/index.ts +35 -17
- package/extensions/pulse/store.ts +29 -7
- package/extensions/sequential-workflow.ts +121 -79
- package/extensions/skill-sources/store.ts +29 -10
- package/package.json +19 -9
- package/scripts/build-console-webui.mjs +7 -0
- package/scripts/console-webui-cache.mjs +79 -0
- package/scripts/ensure-console-build.mjs +43 -0
- package/skills/sequential-workflow/SKILL.md +4 -3
- package/themes/legacy-dark.json +85 -0
|
@@ -14,7 +14,7 @@ type HistoryPulse = Pulse & { startedAt: string; finishedAt: string | null; stat
|
|
|
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
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:
|
|
17
|
+
const blank = (): Form => ({ name: "", description: "", schedule: "*/15 * * * *", prompt: "", thread_session_id: "", insertIntoApiSession: true, 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)}.` };
|
|
@@ -31,7 +31,7 @@ function scheduleDescription(schedule: string): { type: Pulse["type"]; text: str
|
|
|
31
31
|
async function request(path: string, init?: RequestInit) { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error?.message ?? body.error ?? "Request failed"); } return response; }
|
|
32
32
|
|
|
33
33
|
export function PulseResources({ profile, initial }: { profile: string; initial?: { pulses: Pulse[] } }) {
|
|
34
|
-
const { toast } = useToast(); const [items, setItems] = useState<Pulse[]>(initial?.pulses ?? []), [tickRunning, setTickRunning] = useState(false), [expanded, setExpanded] = useState<string>(), [showHistory, setShowHistory] = useState(false), [history, setHistory] = useState<HistoryPulse[] | undefined>(); const [saving, setSaving] = useState(false); const [dialog, setDialog] = useState<"create" | "edit">(), [editing, setEditing] = useState<Pulse>(), [form, setForm] = useState<Form>(blank()); const [logPulse, setLogPulse] = useState<Pulse>(), [runs, setRuns] = useState<PulseRun[]>(), [logsLoading, setLogsLoading] = useState(false), [logStart, setLogStart] = useState(() => localDateTime(new Date(Date.now() - 24 * 60 * 60 * 1_000))), [logEnd, setLogEnd] = useState(() => localDateTime(new Date()));
|
|
34
|
+
const { toast } = useToast(); const [items, setItems] = useState<Pulse[]>(initial?.pulses ?? []), [tickRunning, setTickRunning] = useState(false), [expanded, setExpanded] = useState<string>(), [showHistory, setShowHistory] = useState(false), [history, setHistory] = useState<HistoryPulse[] | undefined>(); const [saving, setSaving] = useState(false); const [dialog, setDialog] = useState<"create" | "edit">(), [editing, setEditing] = useState<Pulse>(), [form, setForm] = useState<Form>(blank()); const [logPulse, setLogPulse] = useState<Pulse>(), [runs, setRuns] = useState<PulseRun[]>(), [logsLoading, setLogsLoading] = useState(false), [finalizingRun, setFinalizingRun] = useState<string>(), [logStart, setLogStart] = useState(() => localDateTime(new Date(Date.now() - 24 * 60 * 60 * 1_000))), [logEnd, setLogEnd] = useState(() => localDateTime(new Date()));
|
|
35
35
|
const load = async () => { try { const [pulses, tick] = await Promise.all([request(`profiles/${profile}/pulses`).then((r) => r.json() as Promise<{ pulses: Pulse[] }>), request("pulse/tick/status").then((r) => r.json() as Promise<{ running: boolean }>)]); setItems(pulses.pulses); setTickRunning(tick.running); } catch (cause) { toast((cause as Error).message, "error"); } };
|
|
36
36
|
const loadRuns = async (pulse = logPulse) => { if (!pulse) return; try { setLogsLoading(true); const start = new Date(logStart), end = new Date(logEnd); if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime()) || start > end) throw new Error("Choose a valid chronological date range."); const response = await request(`profiles/${profile}/pulses/${encodeURIComponent(pulse.name)}/runs?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`); setRuns((await response.json() as { runs: PulseRun[] }).runs); } catch (cause) { toast((cause as Error).message, "error"); } finally { setLogsLoading(false); } };
|
|
37
37
|
const openLogs = (pulse: Pulse) => { setLogPulse(pulse); setRuns(undefined); const end = new Date(), start = new Date(end.getTime() - 24 * 60 * 60 * 1_000); setLogStart(localDateTime(start)); setLogEnd(localDateTime(end)); setTimeout(() => void loadRuns(pulse), 0); };
|
|
@@ -41,5 +41,6 @@ export function PulseResources({ profile, initial }: { profile: string; initial?
|
|
|
41
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.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>}</>;
|
|
44
|
+
const finalizeRun = async (run: PulseRun, status: "success" | "error") => { if (!logPulse || finalizingRun || !confirm(`Mark this running execution as ${status}?`)) return; try { setFinalizingRun(run.id); await request(`profiles/${profile}/pulses/${encodeURIComponent(logPulse.name)}/runs/${encodeURIComponent(run.id)}/finalize`, { method: "POST", body: JSON.stringify({ status }) }); await loadRuns(logPulse); await load(); toast(`Pulse execution marked as ${status}.`); } catch (cause) { toast((cause as Error).message, "error"); } finally { setFinalizingRun(undefined); } };
|
|
45
|
+
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><div className="flex items-center gap-2"><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>{run.status === "running" && <><button className="rounded p-2 text-emerald-600 hover:bg-emerald-50 disabled:opacity-50" title="Mark execution as successful" disabled={Boolean(finalizingRun)} onClick={() => void finalizeRun(run, "success")}>{finalizingRun === run.id ? <Loader2 className="animate-spin" size={17}/> : <Check size={17}/>}</button><button className="rounded p-2 text-red-600 hover:bg-red-50 disabled:opacity-50" title="Mark execution as failed" disabled={Boolean(finalizingRun)} onClick={() => void finalizeRun(run, "error")}><X size={17}/></button></>}</div></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 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
46
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useState } from "react";
|
|
3
|
+
import { Save } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { Card, CardTitle } from "@/components/ui/card";
|
|
6
|
+
import { Input } from "@/components/ui/input";
|
|
7
|
+
import { useToast } from "@/components/toast";
|
|
8
|
+
|
|
9
|
+
type Evaluation = { modelType: "llm" } | { modelType: "system_one"; systemOne: { provider: string; model: string; endpoint?: { baseUrl: string; path: string }; onUncertain: "block" } };
|
|
10
|
+
const defaultOpenRouterEndpoint = { baseUrl: "https://openrouter.ai/api/", path: "alpha/decisions" };
|
|
11
|
+
type Provider = { id: string; models: Array<{ id: string; name: string }> };
|
|
12
|
+
type Config = { evaluation: Evaluation };
|
|
13
|
+
type ModelCatalog = { providers: Provider[] };
|
|
14
|
+
type Props = { profile: string; initial?: Config };
|
|
15
|
+
const api = async <T,>(path: string, init?: RequestInit) => { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error?.message ?? body.error ?? "Request failed"); return body as T; };
|
|
16
|
+
|
|
17
|
+
export function SequentialWorkflowConfig({ profile, initial }: Props) {
|
|
18
|
+
const { toast } = useToast();
|
|
19
|
+
const [evaluation, setEvaluation] = useState<Evaluation>(initial?.evaluation ?? { modelType: "llm" });
|
|
20
|
+
const [credentialConfigured, setCredentialConfigured] = useState<boolean>();
|
|
21
|
+
const [catalog, setCatalog] = useState<ModelCatalog>({ providers: [] });
|
|
22
|
+
const [notice, setNotice] = useState("");
|
|
23
|
+
const load = async () => { try { const [config, status, models] = await Promise.all([api<Config>(`profiles/${profile}/sequential-workflows/config`), api<{ credentialConfigured: boolean }>(`profiles/${profile}/sequential-workflows/status`), api<ModelCatalog>(`profiles/${profile}/model`)]); setEvaluation(config.evaluation); setCredentialConfigured(status.credentialConfigured); setCatalog({ providers: models.providers }); setNotice(""); } catch (error) { setNotice((error as Error).message); } };
|
|
24
|
+
useEffect(() => { if (initial) { setEvaluation(initial.evaluation); } void load(); }, [profile, initial]);
|
|
25
|
+
const defaultProvider = catalog.providers[0];
|
|
26
|
+
const systemOne = evaluation.modelType === "system_one" ? evaluation.systemOne : { provider: defaultProvider?.id ?? "", model: defaultProvider?.models[0]?.id ?? "", endpoint: defaultOpenRouterEndpoint, onUncertain: "block" as const };
|
|
27
|
+
const endpoint = systemOne.endpoint ?? defaultOpenRouterEndpoint;
|
|
28
|
+
const selectedProvider = catalog.providers.find((provider) => provider.id === systemOne.provider) ?? defaultProvider;
|
|
29
|
+
const models = selectedProvider?.models ?? [];
|
|
30
|
+
const save = async () => { try { const result = await api<{ evaluation: Evaluation }>(`profiles/${profile}/sequential-workflows/config`, { method: "PUT", body: JSON.stringify({ evaluation }) }); setEvaluation(result.evaluation); await load(); toast("Sequential Workflow configuration saved."); } catch (error) { setNotice((error as Error).message); } };
|
|
31
|
+
return <div className="space-y-5"><Card><div className="flex flex-wrap items-start justify-between gap-4"><div><CardTitle>Sequential Workflows</CardTitle><p className="mt-1 text-sm text-zinc-500">Configure how this profile evaluates workflow requirements and criteria.</p></div><Button className="!bg-[#2bbb77] hover:!bg-[#249b63]" title="Save Sequential Workflow configuration" onClick={() => void save()}><Save size={17}/></Button></div>{notice && <p className="mt-4 text-sm text-red-700">{notice}</p>}<div className="mt-5"><label className="grid max-w-[calc(50%-0.5rem)] gap-1.5 text-sm font-medium text-zinc-700"><span>Evaluation model type</span><select className="rounded-md border border-zinc-200 bg-white px-3 py-2 font-normal" value={evaluation.modelType} onChange={(event) => setEvaluation(event.target.value === "system_one" ? { modelType: "system_one", systemOne } : { modelType: "llm" })}><option value="llm">LLM</option><option value="system_one">System One Model</option></select></label></div>{evaluation.modelType === "llm" ? <p className="mt-4 rounded-lg bg-sky-50 p-3 text-sm text-sky-900">Uses the active Pi session model for Sequential Workflow decisions.</p> : <div className="mt-4 space-y-3"><div className="grid gap-4 md:grid-cols-2"><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Provider</span><select className="rounded-md border border-zinc-200 bg-white px-3 py-2 font-normal" value={systemOne.provider} onChange={(event) => { const provider = event.target.value; const next = catalog.providers.find((item) => item.id === provider); setEvaluation({ modelType: "system_one", systemOne: { provider, model: next?.models[0]?.id ?? "", endpoint, onUncertain: "block" } }); }} disabled={catalog.providers.length === 0}>{catalog.providers.map((provider) => <option key={provider.id} value={provider.id}>{provider.id}</option>)}</select></label><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>System One model/version</span><Input list={`system-one-models-${profile}`} value={systemOne.model} onChange={(event) => setEvaluation({ modelType: "system_one", systemOne: { ...systemOne, model: event.target.value } })} placeholder="Enter a model or version"/><datalist id={`system-one-models-${profile}`}>{models.map((model) => <option key={model.id} value={model.id}>{model.name}</option>)}</datalist></label></div><div className="grid gap-4 md:grid-cols-2"><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Decision API base URL</span><Input value={endpoint.baseUrl} onChange={(event) => setEvaluation({ modelType: "system_one", systemOne: { ...systemOne, endpoint: { ...endpoint, baseUrl: event.target.value } } })} placeholder="https://openrouter.ai/api/"/></label><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Decision API path</span><Input value={endpoint.path} onChange={(event) => setEvaluation({ modelType: "system_one", systemOne: { ...systemOne, endpoint: { ...endpoint, path: event.target.value } } })} placeholder="alpha/decisions"/></label></div><label className="grid gap-1.5 text-sm font-medium text-zinc-700"><span>Uncertain decision policy</span><Input value="Block and require review" disabled/></label><p className={`rounded-lg p-3 text-sm ${credentialConfigured ? "bg-emerald-50 text-emerald-900" : "bg-amber-50 text-amber-900"}`}>{credentialConfigured ? "OpenRouter credential is configured for the running environment." : "OpenRouter credential is missing. System One decisions will remain blocked until it is configured."}</p></div>}</Card><Card><CardTitle>Active behavior</CardTitle><dl className="mt-4 grid gap-3 text-sm md:grid-cols-2"><div><dt className="font-medium text-zinc-700">Requirement and template routing</dt><dd className="text-zinc-500">{evaluation.modelType === "system_one" ? "TypeSafe System One" : "Active Pi LLM"}</dd></div><div><dt className="font-medium text-zinc-700">Collect and task criteria</dt><dd className="text-zinc-500">{evaluation.modelType === "system_one" ? "Automatic typed decisions" : "Current agent evaluation flow"}</dd></div><div><dt className="font-medium text-zinc-700">Uncertain or unavailable decisions</dt><dd className="text-zinc-500">Blocked and recorded by the harness.</dd></div><div><dt className="font-medium text-zinc-700">Credentials</dt><dd className="text-zinc-500">Never stored or displayed in settings.</dd></div></dl></Card></div>;
|
|
32
|
+
}
|
|
@@ -15,6 +15,7 @@ type Skill = { name: string; path?: string; source: Source; package?: string; en
|
|
|
15
15
|
type Document = { name: string; path?: string; frontmatter: Record<string, unknown>; content: string };
|
|
16
16
|
type Sources = { shared: boolean; profile: boolean };
|
|
17
17
|
type SkillSource = { identifier: string; name: string; enabled: boolean; hasAccessToken: boolean };
|
|
18
|
+
type Publication = { action: "created" | "updated" | "unchanged" | "confirmation_required"; relativePath: string; description?: string | null; commitHash?: string | null };
|
|
18
19
|
function formatFrontmatterValue(value: unknown): string { return typeof value === "string" ? value : JSON.stringify(value) ?? String(value); }
|
|
19
20
|
async function request(path: string, init?: RequestInit): Promise<Response> { const response = await fetch(`/api/pi/${path}`, { ...init, headers: { "content-type": "application/json", ...init?.headers } }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error?.message ?? body.error ?? "Request failed"); } return response; }
|
|
20
21
|
async function getJson<T>(path: string): Promise<T> { return (await request(path)).json() as Promise<T>; }
|
|
@@ -22,14 +23,14 @@ async function getJson<T>(path: string): Promise<T> { return (await request(path
|
|
|
22
23
|
export function SkillResourcesPanel({ profile, initial }: { profile: string; initial?: { resources: Array<Omit<Skill, "source"> & { source: string }> } }) {
|
|
23
24
|
const { toast } = useToast();
|
|
24
25
|
const [items, setItems] = useState<Skill[]>((initial?.resources ?? []) as Skill[]), [sources, setSources] = useState<Sources>({ shared: true, profile: false }), [document, setDocument] = useState<Document>();
|
|
25
|
-
const [publishing, setPublishing] = useState<Skill>(); const [syncing, setSyncing] = useState<Skill>(); const [targets, setTargets] = useState<SkillSource[]>([]), [target, setTarget] = useState(""), [busy, setBusy] = useState(false);
|
|
26
|
+
const [publishing, setPublishing] = useState<Skill>(); const [syncing, setSyncing] = useState<Skill>(); const [targets, setTargets] = useState<SkillSource[]>([]), [target, setTarget] = useState(""), [busy, setBusy] = useState(false), [overwrite, setOverwrite] = useState<Publication>();
|
|
26
27
|
const isDefault = profile === "default";
|
|
27
28
|
const load = async () => { const [resources, sourceState] = await Promise.all([getJson<{ resources: Skill[] }>(`profiles/${profile}/resources/skills`), getJson<{ sources: Sources }>(`profiles/${profile}/skills/sources`)]); setItems(resources.resources); setSources(sourceState.sources); };
|
|
28
29
|
const refresh = () => void load().catch((error) => toast((error as Error).message, "error"));
|
|
29
30
|
const setSource = async (source: Source, enabled: boolean) => { const next = { ...sources, [source]: enabled }; if (!next.shared && !next.profile) return toast("Enable at least one skill source.", "error"); try { const response = await request(`profiles/${profile}/skills/sources`, { method: "PUT", body: JSON.stringify(next) }); setSources((await response.json() as { sources: Sources }).sources); await load(); } catch (error) { toast((error as Error).message, "error"); } };
|
|
30
31
|
const openPublish = async (skill: Skill) => { if (skill.source === "shared" && profile !== "default") return toast("Shared Skills can only be published by the default profile.", "error"); try { const available = (await getJson<{ sources: SkillSource[] }>("skill-sources")).sources.filter((source) => source.enabled && source.hasAccessToken); setTargets(available); setTarget(available[0]?.identifier ?? ""); setPublishing(skill); } catch (error) { toast((error as Error).message, "error"); } };
|
|
31
|
-
const publish = async () => { if (!publishing || !target) return; setBusy(true); try { const data = await (await request(`profiles/${profile}/skills/${encodeURIComponent(publishing.name)}/publish?source=${publishing.source}`, { method: "POST", body: JSON.stringify({ identifier: target }) })).json() as { publication:
|
|
32
|
+
const publish = async (confirmOverwrite = false) => { if (!publishing || !target) return; setBusy(true); try { const data = await (await request(`profiles/${profile}/skills/${encodeURIComponent(publishing.name)}/publish?source=${publishing.source}`, { method: "POST", body: JSON.stringify({ identifier: target, confirmOverwrite }) })).json() as { publication: Publication }; if (data.publication.action === "confirmation_required") { setOverwrite(data.publication); return; } toast(`Skill ${data.publication.action} in the selected Skill Source.`, "success"); setOverwrite(undefined); setPublishing(undefined); await load(); } catch (error) { toast((error as Error).message, "error"); } finally { setBusy(false); } };
|
|
32
33
|
const syncFromSource = async () => { if (!syncing) return; setBusy(true); try { await request(`profiles/${profile}/skills/${encodeURIComponent(syncing.name)}/sync?source=${syncing.source}`, { method: "POST" }); toast("Skill updated from its Skill Source.", "success"); setSyncing(undefined); await load(); } catch (error) { toast((error as Error).message, "error"); } finally { setBusy(false); } };
|
|
33
34
|
useEffect(() => { refresh(); }, [profile]);
|
|
34
|
-
return <Card><div className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex flex-wrap items-center gap-3"><CardTitle>Skills</CardTitle>{isDefault ? <span className="rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium text-zinc-600">Shared Skills</span> : <div className="ml-8 flex items-center gap-4 rounded-lg border border-zinc-200 bg-white px-3 py-2 sm:ml-16"><label className="flex items-center gap-2 text-sm font-medium text-zinc-700"><Switch className="h-4 w-7 data-[state=checked]:bg-[#55d175] [&>span]:size-3 [&>span]:data-[state=checked]:translate-x-3" checked={sources.shared} onCheckedChange={(enabled) => void setSource("shared", enabled)}/><span>Shared Skills</span></label><label className="flex items-center gap-2 text-sm font-medium text-zinc-700"><Switch className="h-4 w-7 data-[state=checked]:bg-[#6d28d9] [&>span]:size-3 [&>span]:data-[state=checked]:translate-x-3" checked={sources.profile} onCheckedChange={(enabled) => void setSource("profile", enabled)}/><span>Profile Skills</span></label></div>}</div><p className="mt-2 text-sm text-zinc-500">{isDefault ? "Shared skills are editable by the default profile." : "Shared skills are read-only; Profile Skills can be edited by this profile."}</p></div><div className="flex gap-2"><SkillSourceImport profile={profile} onImported={refresh}/><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Reload skills" onClick={refresh}><RotateCw 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">Source</th><th className="p-3">Path</th><th className="p-3">Status</th><th className="p-3"/></tr></thead><tbody>{items.map((item) => <tr key={`${item.source}:${item.name}`} className="border-b border-zinc-100"><td className="p-3 font-medium">{item.name}</td><td className="p-3"><span className={item.source === "profile" ? "text-[#6d28d9]" : item.source === "package" ? "text-blue-600" : "text-[#55d175]"}>{item.source === "profile" ? "Profile" : item.source === "package" ? `Package: ${item.package ?? "unknown"}` : "Shared"}</span></td><td className="max-w-xs truncate p-3 text-zinc-500">{item.path ?? "—"}</td><td className="p-3"><label className="flex items-center gap-2"><Switch checked={item.enabled} onCheckedChange={async (enabled) => { try { await request(`profiles/${profile}/resources/skills/${encodeURIComponent(item.name)}?source=${item.source}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); } catch (error) { toast((error as Error).message, "error"); } }}/><span className={item.enabled ? "text-emerald-700/70" : "text-rose-700/70"}>{item.enabled ? "Enabled" : "Disabled"}</span></label></td><td className="p-3"><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="View skill" onClick={() => void getJson<Document>(`profiles/${profile}/skills/${encodeURIComponent(item.name)}/document?source=${item.source}`).then((value) => setDocument({ ...value, path: item.path })).catch((error) => toast(error.message, "error"))}><Eye size={17}/></button>{item.source !== "package" && <><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Apply version from Skill Source" onClick={() => setSyncing(item)}><RotateCw size={17}/></button><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Publish to Skill Source" onClick={() => void openPublish(item)}><Upload size={17}/></button><button className="rounded p-2 text-zinc-500 hover:bg-red-50 hover:text-red-700" title={item.source === "shared" && profile !== "default" ? "Remove from this profile" : "Delete skill"} onClick={() => { if (!confirm(item.source === "shared" && profile !== "default" ? `Remove '${item.name}' from this profile?` : `Delete '${item.name}'?`)) return; void request(`profiles/${profile}/resources/skills/${encodeURIComponent(item.name)}?source=${item.source}`, { method: "DELETE" }).then(refresh).catch((error) => toast(error.message, "error")); }}><Trash2 size={17}/></button></>}</td></tr>)}{items.length === 0 && <tr><td colSpan={5} className="p-5 text-zinc-500">No skills are available from the enabled sources.</td></tr>}</tbody></table></div>{syncing && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="sync-skill-title"><div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between gap-4"><div><h2 id="sync-skill-title" className="text-lg font-semibold">Apply Skill Source version</h2><p className="mt-1 text-sm text-zinc-500">Replace <b>{syncing.name}</b> with its current version from the installed Skill Source?</p></div><button type="button" className="rounded p-2 text-zinc-500 hover:bg-zinc-100" title="Close" disabled={busy} onClick={() => setSyncing(undefined)}><X size={18}/></button></div><p className="mt-4 rounded-lg bg-amber-50 p-3 text-sm text-amber-900">Local changes to this Skill, including auxiliary files, will be replaced.</p><div className="mt-6 flex justify-end gap-2"><Button type="button" className="border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100" disabled={busy} onClick={() => setSyncing(undefined)}>Cancel</Button><Button className="inline-flex items-center justify-center gap-2" disabled={busy} onClick={() => void syncFromSource()}>{busy ? <Loader2 className="animate-spin" size={16}/> : <RotateCw size={16}/>}<span>Apply version</span></Button></div></div></div>}{publishing && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="publish-skill-title"><form onSubmit={(event) => { event.preventDefault(); void publish(); }} className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between gap-4"><div><h2 id="publish-skill-title" className="text-lg font-semibold">Publish Skill</h2><p className="mt-1 text-sm text-zinc-500">Publish <b>{publishing.name}</b> to a Git-backed Skill Source.</p></div><button type="button" className="rounded p-2 text-zinc-500 hover:bg-zinc-100" title="Close" onClick={() => setPublishing(undefined)}><X size={18}/></button></div><label className="mt-5 block text-sm font-medium text-zinc-700">Skill Source<select className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm" value={target} required onChange={(event) => setTarget(event.target.value)}>{targets.map((source) => <option key={source.identifier} value={source.identifier}>{source.name} ({source.identifier})</option>)}</select></label>{targets.length === 0 && <p className="mt-2 text-sm text-zinc-500">No enabled Skill Source with a write credential is available.</p>}<div className="mt-6 flex justify-end gap-2"><Button type="button" className="border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100" onClick={() => setPublishing(undefined)}>Cancel</Button><Button type="submit" className="inline-flex items-center justify-center gap-2 bg-[#67c100] hover:bg-[#579f00]" disabled={!target || busy}>{busy ? <Loader2 className="animate-spin" size={16}/> : <Upload size={16}/>}<span>Publish</span></Button></div></form></div>}{document && <div className="fixed inset-0 z-50 bg-white" role="dialog" aria-modal="true"><div className="flex h-full flex-col"><header className="flex shrink-0 items-start justify-between gap-4 border-b border-zinc-200 px-6 py-4"><div className="min-w-0"><h2 className="truncate text-xl font-semibold text-slate-800">{document.name}</h2><p className="mt-0.5 truncate font-mono text-xs text-zinc-500">{document.path ?? `skills/${document.name}/SKILL.md`}</p></div><div className="flex shrink-0 items-center gap-2"><button className="rounded-lg p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Copy SKILL.md" onClick={() => navigator.clipboard.writeText(`---\n${Object.entries(document.frontmatter).map(([key, value]) => `${key}: ${value}`).join("\n")}\n---\n\n${document.content}`)}><Copy size={18}/></button><Button className="border border-zinc-300 bg-white text-zinc-700 shadow-sm hover:bg-zinc-100" onClick={() => setDocument(undefined)}>Close</Button></div></header><article className="overflow-y-auto px-4 py-6 sm:px-14"><div className="mx-auto max-w-5xl rounded-xl border border-zinc-200 bg-white p-6 shadow-sm"><section className="overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50"><div className="border-b border-zinc-200 bg-zinc-100 px-4 py-2 font-mono text-xs font-bold uppercase tracking-wide text-slate-500">Frontmatter</div><dl className="font-mono text-xs text-slate-800">{Object.entries(document.frontmatter).map(([key, value]) => <div key={key} className="grid grid-cols-[140px_1fr] border-b border-zinc-200 last:border-b-0"><dt className="bg-white/50 px-4 py-2 font-semibold text-slate-600">{key}</dt><dd className="whitespace-pre-wrap break-words px-4 py-2">{formatFrontmatterValue(value)}</dd></div>)}</dl></section><div className="skill-markdown prose mt-5 max-w-none prose-headings:text-slate-900 prose-p:text-slate-700"><ReactMarkdown remarkPlugins={[remarkGfm]}>{document.content}</ReactMarkdown></div></div></article></div></div>}</Card>;
|
|
35
|
+
return <Card><div className="flex flex-wrap items-start justify-between gap-4"><div><div className="flex flex-wrap items-center gap-3"><CardTitle>Skills</CardTitle>{isDefault ? <span className="rounded-full bg-zinc-100 px-2.5 py-1 text-xs font-medium text-zinc-600">Shared Skills</span> : <div className="ml-8 flex items-center gap-4 rounded-lg border border-zinc-200 bg-white px-3 py-2 sm:ml-16"><label className="flex items-center gap-2 text-sm font-medium text-zinc-700"><Switch className="h-4 w-7 data-[state=checked]:bg-[#55d175] [&>span]:size-3 [&>span]:data-[state=checked]:translate-x-3" checked={sources.shared} onCheckedChange={(enabled) => void setSource("shared", enabled)}/><span>Shared Skills</span></label><label className="flex items-center gap-2 text-sm font-medium text-zinc-700"><Switch className="h-4 w-7 data-[state=checked]:bg-[#6d28d9] [&>span]:size-3 [&>span]:data-[state=checked]:translate-x-3" checked={sources.profile} onCheckedChange={(enabled) => void setSource("profile", enabled)}/><span>Profile Skills</span></label></div>}</div><p className="mt-2 text-sm text-zinc-500">{isDefault ? "Shared skills are editable by the default profile." : "Shared skills are read-only; Profile Skills can be edited by this profile."}</p></div><div className="flex gap-2"><SkillSourceImport profile={profile} onImported={refresh}/><Button className="bg-zinc-100 text-zinc-700 hover:bg-zinc-200" title="Reload skills" onClick={refresh}><RotateCw 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">Source</th><th className="p-3">Path</th><th className="p-3">Status</th><th className="p-3"/></tr></thead><tbody>{items.map((item) => <tr key={`${item.source}:${item.name}`} className="border-b border-zinc-100"><td className="p-3 font-medium">{item.name}</td><td className="p-3"><span className={item.source === "profile" ? "text-[#6d28d9]" : item.source === "package" ? "text-blue-600" : "text-[#55d175]"}>{item.source === "profile" ? "Profile" : item.source === "package" ? `Package: ${item.package ?? "unknown"}` : "Shared"}</span></td><td className="max-w-xs truncate p-3 text-zinc-500">{item.path ?? "—"}</td><td className="p-3"><label className="flex items-center gap-2"><Switch checked={item.enabled} onCheckedChange={async (enabled) => { try { await request(`profiles/${profile}/resources/skills/${encodeURIComponent(item.name)}?source=${item.source}`, { method: "PATCH", body: JSON.stringify({ enabled }) }); await load(); } catch (error) { toast((error as Error).message, "error"); } }}/><span className={item.enabled ? "text-emerald-700/70" : "text-rose-700/70"}>{item.enabled ? "Enabled" : "Disabled"}</span></label></td><td className="p-3"><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="View skill" onClick={() => void getJson<Document>(`profiles/${profile}/skills/${encodeURIComponent(item.name)}/document?source=${item.source}`).then((value) => setDocument({ ...value, path: item.path })).catch((error) => toast(error.message, "error"))}><Eye size={17}/></button>{item.source !== "package" && <><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Apply version from Skill Source" onClick={() => setSyncing(item)}><RotateCw size={17}/></button><button className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Publish to Skill Source" onClick={() => void openPublish(item)}><Upload size={17}/></button><button className="rounded p-2 text-zinc-500 hover:bg-red-50 hover:text-red-700" title={item.source === "shared" && profile !== "default" ? "Remove from this profile" : "Delete skill"} onClick={() => { if (!confirm(item.source === "shared" && profile !== "default" ? `Remove '${item.name}' from this profile?` : `Delete '${item.name}'?`)) return; void request(`profiles/${profile}/resources/skills/${encodeURIComponent(item.name)}?source=${item.source}`, { method: "DELETE" }).then(refresh).catch((error) => toast(error.message, "error")); }}><Trash2 size={17}/></button></>}</td></tr>)}{items.length === 0 && <tr><td colSpan={5} className="p-5 text-zinc-500">No skills are available from the enabled sources.</td></tr>}</tbody></table></div>{syncing && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="sync-skill-title"><div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between gap-4"><div><h2 id="sync-skill-title" className="text-lg font-semibold">Apply Skill Source version</h2><p className="mt-1 text-sm text-zinc-500">Replace <b>{syncing.name}</b> with its current version from the installed Skill Source?</p></div><button type="button" className="rounded p-2 text-zinc-500 hover:bg-zinc-100" title="Close" disabled={busy} onClick={() => setSyncing(undefined)}><X size={18}/></button></div><p className="mt-4 rounded-lg bg-amber-50 p-3 text-sm text-amber-900">Local changes to this Skill, including auxiliary files, will be replaced.</p><div className="mt-6 flex justify-end gap-2"><Button type="button" className="border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100" disabled={busy} onClick={() => setSyncing(undefined)}>Cancel</Button><Button className="inline-flex items-center justify-center gap-2" disabled={busy} onClick={() => void syncFromSource()}>{busy ? <Loader2 className="animate-spin" size={16}/> : <RotateCw size={16}/>}<span>Apply version</span></Button></div></div></div>}{publishing && <div className="fixed inset-0 z-50 grid place-items-center bg-black/30 p-4" role="dialog" aria-modal="true" aria-labelledby="publish-skill-title"><form onSubmit={(event) => { event.preventDefault(); void publish(); }} className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl"><div className="flex items-start justify-between gap-4"><div><h2 id="publish-skill-title" className="text-lg font-semibold">Publish Skill</h2><p className="mt-1 text-sm text-zinc-500">Publish <b>{publishing.name}</b> to a Git-backed Skill Source.</p></div><button type="button" className="rounded p-2 text-zinc-500 hover:bg-zinc-100" title="Close" onClick={() => { setOverwrite(undefined); setPublishing(undefined); }} ><X size={18}/></button></div><label className="mt-5 block text-sm font-medium text-zinc-700">Skill Source<select className="mt-1 w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm" value={target} required onChange={(event) => setTarget(event.target.value)}>{targets.map((source) => <option key={source.identifier} value={source.identifier}>{source.name} ({source.identifier})</option>)}</select></label>{targets.length === 0 && <p className="mt-2 text-sm text-zinc-500">No enabled Skill Source with a write credential is available.</p>}<div className="mt-6 flex justify-end gap-2"><Button type="button" className="border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100" onClick={() => setPublishing(undefined)}>Cancel</Button><Button type="submit" className="inline-flex items-center justify-center gap-2 bg-[#67c100] hover:bg-[#579f00]" disabled={!target || busy}>{busy ? <Loader2 className="animate-spin" size={16}/> : <Upload size={16}/>}<span>Publish</span></Button></div></form></div>}{overwrite && <div className="fixed inset-0 z-[60] grid place-items-center bg-black/40 p-4" role="dialog" aria-modal="true" aria-labelledby="overwrite-skill-title"><div className="w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl"><h2 id="overwrite-skill-title" className="text-lg font-semibold">Overwrite Skill?</h2><p className="mt-2 text-sm text-zinc-600">This will replace the existing Skill at <code className="break-all">{overwrite.relativePath}</code> in the selected Skill Source with your local changes.</p>{overwrite.description && <p className="mt-2 text-sm text-zinc-500">Remote description: {overwrite.description}</p>}<p className="mt-3 rounded-lg bg-amber-50 p-3 text-sm text-amber-900">The remote Skill will be overwritten only after you confirm. Your local Skill is never replaced by this action.</p><div className="mt-6 flex justify-end gap-2"><Button type="button" className="border border-zinc-300 bg-white text-zinc-700 hover:bg-zinc-100" disabled={busy} onClick={() => setOverwrite(undefined)}>Cancel</Button><Button type="button" className="bg-red-600 hover:bg-red-700" disabled={busy} onClick={() => void publish(true)}>{busy ? <Loader2 className="animate-spin" size={16}/> : "Overwrite and publish"}</Button></div></div></div>}{document && <div className="fixed inset-0 z-50 bg-white" role="dialog" aria-modal="true"><div className="flex h-full flex-col"><header className="flex shrink-0 items-start justify-between gap-4 border-b border-zinc-200 px-6 py-4"><div className="min-w-0"><h2 className="truncate text-xl font-semibold text-slate-800">{document.name}</h2><p className="mt-0.5 truncate font-mono text-xs text-zinc-500">{document.path ?? `skills/${document.name}/SKILL.md`}</p></div><div className="flex shrink-0 items-center gap-2"><button className="rounded-lg p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900" title="Copy SKILL.md" onClick={() => navigator.clipboard.writeText(`---\n${Object.entries(document.frontmatter).map(([key, value]) => `${key}: ${value}`).join("\n")}\n---\n\n${document.content}`)}><Copy size={18}/></button><Button className="border border-zinc-300 bg-white text-zinc-700 shadow-sm hover:bg-zinc-100" onClick={() => setDocument(undefined)}>Close</Button></div></header><article className="overflow-y-auto px-4 py-6 sm:px-14"><div className="mx-auto max-w-5xl rounded-xl border border-zinc-200 bg-white p-6 shadow-sm"><section className="overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50"><div className="border-b border-zinc-200 bg-zinc-100 px-4 py-2 font-mono text-xs font-bold uppercase tracking-wide text-slate-500">Frontmatter</div><dl className="font-mono text-xs text-slate-800">{Object.entries(document.frontmatter).map(([key, value]) => <div key={key} className="grid grid-cols-[140px_1fr] border-b border-zinc-200 last:border-b-0"><dt className="bg-white/50 px-4 py-2 font-semibold text-slate-600">{key}</dt><dd className="whitespace-pre-wrap break-words px-4 py-2">{formatFrontmatterValue(value)}</dd></div>)}</dl></section><div className="skill-markdown prose mt-5 max-w-none prose-headings:text-slate-900 prose-p:text-slate-700"><ReactMarkdown remarkPlugins={[remarkGfm]}>{document.content}</ReactMarkdown></div></div></article></div></div>}</Card>;
|
|
35
36
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { type InputHTMLAttributes, forwardRef } from "react";
|
|
2
2
|
import { cn } from "@/lib/utils";
|
|
3
|
-
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(({ className, ...props }, ref) => <input ref={ref} className={cn("w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm outline-none focus:border-blue-500", className)} {...props} />);
|
|
3
|
+
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(({ className, ...props }, ref) => <input ref={ref} className={cn("w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm font-normal outline-none focus:border-blue-500", className)} {...props} />);
|
|
4
4
|
Input.displayName = "Input";
|
|
@@ -5,6 +5,7 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
|
+
import { withoutTuiModeArgs } from "../lib/cli-tui-mode.ts";
|
|
8
9
|
|
|
9
10
|
type ConsoleSettings = { host?: string; port?: number; username?: string; password?: string; secret?: string };
|
|
10
11
|
type LegacySettings = Record<string, unknown> & { console?: ConsoleSettings };
|
|
@@ -30,4 +31,4 @@ async function build(next: string) { const log = openSync(logPath(), "a"); const
|
|
|
30
31
|
async function start(rebuild = false) { const cfg = await config(), current = await state(), next = nextBinary(); if (current && alive(current.pid)) return console.log(`Pi Console is already running at http://${current.host}:${current.port}`); if (!existsSync(next)) throw new Error(`Dependencies are missing. Reinstall the Pi package or run npm install in ${dependencyRoot()}.`); if (rebuild || !existsSync(buildMarker())) await build(next); const log = openSync(logPath(), "a"); const child = spawn(process.execPath, [next, "start", "--hostname", cfg.host, "--port", String(cfg.port)], { cwd: root, detached: true, stdio: ["ignore", log, log], env: process.env }); child.unref(); await writeJson(statePath(), { pid: child.pid!, host: cfg.host, port: cfg.port, startedAt: new Date().toISOString() }); console.log(`Pi Console started at http://${cfg.host}:${cfg.port}`); console.log(`Console credentials are configured in ${configPath()}.`); }
|
|
31
32
|
async function stop() { const current = await state(); if (!current || !alive(current.pid)) { await unlink(statePath()).catch(() => {}); return console.log("Pi Console is not running."); } try { process.kill(-current.pid, "SIGTERM"); } catch { process.kill(current.pid, "SIGTERM"); } await unlink(statePath()).catch(() => {}); console.log("Pi Console stopped."); }
|
|
32
33
|
async function status() { const current = await state(); console.log(current && alive(current.pid) ? `Pi Console: running at http://${current.host}:${current.port}` : "Pi Console: stopped"); }
|
|
33
|
-
export default async function (_pi: ExtensionAPI) { const [command, action] = process.argv.slice(2); if (command !== "console") return; if (action === "start") await start(); else if (action === "stop") await stop(); else if (action === "restart") { await stop(); await start(); } else if (action === "rebuild") { await stop(); await start(true); } else if (action === "status") await status(); else { console.error("Usage: pi console start | stop | restart | rebuild | status"); process.exitCode = 1; } process.exit(); }
|
|
34
|
+
export default async function (_pi: ExtensionAPI) { const [command, action] = withoutTuiModeArgs(process.argv.slice(2)); if (command !== "console") return; if (action === "start") await start(); else if (action === "stop") await stop(); else if (action === "restart") { await stop(); await start(); } else if (action === "rebuild") { await stop(); await start(true); } else if (action === "status") await status(); else { console.error("Usage: pi console start | stop | restart | rebuild | status"); process.exitCode = 1; } process.exit(); }
|
|
@@ -2,7 +2,6 @@ import { chmod, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
|
|
6
5
|
|
|
7
6
|
const agentDir = () => process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
8
7
|
const modelsPath = () => join(agentDir(), "models.json");
|
|
@@ -22,11 +21,14 @@ export async function writeModelsConfig(content: unknown) {
|
|
|
22
21
|
const path = modelsPath();
|
|
23
22
|
const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
|
|
24
23
|
try {
|
|
24
|
+
let parsed: unknown;
|
|
25
|
+
try { parsed = JSON.parse(content); }
|
|
26
|
+
catch (error) { throw new Error(`Invalid models.json: ${error instanceof Error ? error.message : String(error)}`); }
|
|
27
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Invalid models.json: the root value must be an object.");
|
|
28
|
+
const providers = (parsed as { providers?: unknown }).providers;
|
|
29
|
+
if (!providers || typeof providers !== "object" || Array.isArray(providers)) throw new Error("Invalid models.json: providers must be an object.");
|
|
25
30
|
await writeFile(temporary, content.endsWith("\n") ? content : `${content}\n`, { encoding: "utf8", mode: 0o600 });
|
|
26
31
|
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
32
|
await rename(temporary, path);
|
|
31
33
|
await chmod(path, 0o600);
|
|
32
34
|
return readModelsConfig();
|
|
@@ -241,8 +241,8 @@ export function profileSshCommandArgs(raw: string[]): string[] {
|
|
|
241
241
|
const args: string[] = [];
|
|
242
242
|
for (let index = 0; index < raw.length; index += 1) {
|
|
243
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;
|
|
244
|
+
if (value === "--extension" || value === "--session-dir" || value === "--tui-mode") { index += 1; continue; }
|
|
245
|
+
if (value.startsWith("--extension=") || value.startsWith("--session-dir=") || value.startsWith("--tui-mode=")) continue;
|
|
246
246
|
args.push(value);
|
|
247
247
|
}
|
|
248
248
|
return args;
|
package/extensions/profiles.ts
CHANGED
|
@@ -8,7 +8,9 @@ import { basename, join, resolve } from "node:path";
|
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { ensureNonoAvailable, ensureProfileSandbox, isSandboxEnabled, migrateLegacySandboxRuntime, sandboxedCommand } from "./lib/profile-sandbox.ts";
|
|
10
10
|
import { profileEnvironment } from "./lib/profile-env.ts";
|
|
11
|
+
import { packageExtensions } from "./lib/package-resources.ts";
|
|
11
12
|
import { handleRemoteCli, REMOTE_COMMAND_NAMES } from "./lib/remote-hosts.ts";
|
|
13
|
+
import { withoutTuiModeArgs } from "./lib/cli-tui-mode.ts";
|
|
12
14
|
|
|
13
15
|
type ProfilePolicy = {
|
|
14
16
|
enabledTools?: string[];
|
|
@@ -22,7 +24,14 @@ type ProfilePolicy = {
|
|
|
22
24
|
type ProfileSettings = Record<string, unknown> & { profile?: ProfilePolicy };
|
|
23
25
|
|
|
24
26
|
const defaultAgentDir = join(homedir(), ".pi", "agent");
|
|
25
|
-
|
|
27
|
+
|
|
28
|
+
// PI_CODING_AGENT_DIR is set by the remote launcher before the profiles
|
|
29
|
+
// extension runs. PI_PROFILE_ROOT takes precedence only after a profile reexec.
|
|
30
|
+
export function resolveRootAgentDir(environment: NodeJS.ProcessEnv = process.env) {
|
|
31
|
+
return environment.PI_PROFILE_ROOT ?? environment.PI_CODING_AGENT_DIR ?? defaultAgentDir;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const rootAgentDir = () => resolveRootAgentDir();
|
|
26
35
|
const profilesDir = () => join(rootAgentDir(), "profiles");
|
|
27
36
|
const profileDir = (name: string) => join(profilesDir(), name);
|
|
28
37
|
const validProfileName = (name: string) => /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/.test(name);
|
|
@@ -157,13 +166,12 @@ export async function rootRuntimeSources(root: string, base: ProfileSettings): P
|
|
|
157
166
|
const sources = new Set<string>();
|
|
158
167
|
const enabled = base.profile?.enabledExtensions;
|
|
159
168
|
const extensionName = (path: string) => basename(path).replace(/\.(?:ts|js)$/, "");
|
|
160
|
-
const add = (path: string) => {
|
|
169
|
+
const add = (path: string, aliases = [extensionName(path)]) => {
|
|
161
170
|
if (!existsSync(path)) return;
|
|
162
|
-
const name = extensionName(path);
|
|
163
171
|
// Profiles are workspaces, but these extensions are core runtime
|
|
164
172
|
// capabilities and must cross the default-runtime boundary even when a
|
|
165
173
|
// profile limits optional extensions.
|
|
166
|
-
if (requiredRuntimeExtensions.has(name) || !enabled || enabled.includes("*") || enabled.includes(name)) sources.add(path);
|
|
174
|
+
if (aliases.some((name) => requiredRuntimeExtensions.has(name)) || !enabled || enabled.includes("*") || aliases.some((name) => enabled.includes(name))) sources.add(path);
|
|
167
175
|
};
|
|
168
176
|
if (Array.isArray(base.packages)) for (const entry of base.packages) {
|
|
169
177
|
const source = typeof entry === "object" && entry !== null ? (entry as { source?: unknown }).source : entry;
|
|
@@ -171,8 +179,8 @@ export async function rootRuntimeSources(root: string, base: ProfileSettings): P
|
|
|
171
179
|
const packagePath = packageInstallPath(root, source);
|
|
172
180
|
if (!packagePath) { add(source.startsWith(".") ? resolve(root, source) : source); continue; }
|
|
173
181
|
try {
|
|
174
|
-
const manifest = JSON.parse(await readFile(join(packagePath, "package.json"), "utf8")) as { pi?: { extensions?: unknown } };
|
|
175
|
-
|
|
182
|
+
const manifest = JSON.parse(await readFile(join(packagePath, "package.json"), "utf8")) as { name?: string; pi?: { extensions?: unknown } };
|
|
183
|
+
for (const extension of packageExtensions([{ base: packagePath, name: manifest.name ?? basename(packagePath), manifest }])) add(extension.path, extension.aliases);
|
|
176
184
|
} catch {}
|
|
177
185
|
}
|
|
178
186
|
if (Array.isArray(base.extensions)) for (const entry of base.extensions) if (typeof entry === "string") add(entry.startsWith(".") ? resolve(root, entry) : entry);
|
|
@@ -323,11 +331,11 @@ function commandArgs(raw: string[]): string[] {
|
|
|
323
331
|
const result: string[] = [];
|
|
324
332
|
for (let index = 0; index < raw.length; index += 1) {
|
|
325
333
|
const value = raw[index];
|
|
326
|
-
if (value === "--extension" || value === "--session-dir") { index += 1; continue; }
|
|
327
|
-
if (value.startsWith("--extension=") || value.startsWith("--session-dir=")) continue;
|
|
334
|
+
if (value === "--extension" || value === "--session-dir" || value === "--tui-mode") { index += 1; continue; }
|
|
335
|
+
if (value.startsWith("--extension=") || value.startsWith("--session-dir=") || value.startsWith("--tui-mode=")) continue;
|
|
328
336
|
result.push(value);
|
|
329
337
|
}
|
|
330
|
-
return result;
|
|
338
|
+
return withoutTuiModeArgs(result);
|
|
331
339
|
}
|
|
332
340
|
|
|
333
341
|
function extractResumeCommand(args: string[]) {
|
|
@@ -8,24 +8,30 @@ import { join } from "node:path";
|
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { PulseStore } from "./store.ts";
|
|
10
10
|
import { ApplicationStore } from "../api-server/application-store.ts";
|
|
11
|
+
import { withoutTuiModeArgs } from "../lib/cli-tui-mode.ts";
|
|
12
|
+
import { piCommand } from "../lib/pi-command.ts";
|
|
11
13
|
|
|
12
14
|
const root = () => process.env.PI_PROFILE_ROOT ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
|
|
13
15
|
const dbPath = () => join(root(), "pulse.db");
|
|
14
16
|
const statePath = () => join(root(), "pulse-tick.state.json");
|
|
15
17
|
const logPath = () => join(root(), "pulse-tick.log");
|
|
16
18
|
const TICK_INTERVAL_MS = 60_000;
|
|
17
|
-
type State = { pid: number; owner?: string; startedAt: string };
|
|
19
|
+
type State = { pid: number; owner?: string; startedAt: string; lastHeartbeatAt?: string };
|
|
18
20
|
const alive = (pid: number) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
19
21
|
async function json<T>(path: string): Promise<T | undefined> { try { return JSON.parse(await readFile(path, "utf8")) as T; } catch { return undefined; } }
|
|
20
22
|
async function write(path: string, value: unknown, exclusive = false) { await mkdir(root(), { recursive: true }); await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600, flag: exclusive ? "wx" : "w" }); }
|
|
23
|
+
function tickLog(message: string) { console.error(`[${new Date().toISOString()}] Pulse Tick: ${message}`); }
|
|
21
24
|
|
|
22
25
|
/** The SQLite lease is the singleton authority; the state file is diagnostic only. */
|
|
23
|
-
async function acquireTickLock(store: PulseStore): Promise<{ owner: string; release: () => Promise<void> } | undefined> {
|
|
26
|
+
async function acquireTickLock(store: PulseStore): Promise<{ owner: string; heartbeat: () => Promise<void>; release: () => Promise<void> } | undefined> {
|
|
24
27
|
const owner = randomUUID();
|
|
25
28
|
if (!store.claimTickLease(owner)) return undefined;
|
|
26
|
-
const state: State = { pid: process.pid, owner, startedAt: new Date().toISOString() };
|
|
29
|
+
const state: State = { pid: process.pid, owner, startedAt: new Date().toISOString(), lastHeartbeatAt: new Date().toISOString() };
|
|
27
30
|
await write(statePath(), state);
|
|
28
|
-
return { owner,
|
|
31
|
+
return { owner, heartbeat: async () => {
|
|
32
|
+
const current = await json<State>(statePath());
|
|
33
|
+
if (current?.owner === owner) await write(statePath(), { ...current, lastHeartbeatAt: new Date().toISOString() });
|
|
34
|
+
}, release: async () => {
|
|
29
35
|
store.releaseTickLease(owner);
|
|
30
36
|
const current = await json<State>(statePath());
|
|
31
37
|
if (current?.owner === owner) await unlink(statePath()).catch(() => {});
|
|
@@ -67,7 +73,10 @@ async function appendPulseResult(profile: string, sessionId: string, content: st
|
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
function table(profile?: string) { const rows = new PulseStore(dbPath()).list(profile); const columns: Array<[string, number]> = [["STATUS", 8], ["TYPE", 9], ["NAME", 20], ["SCHEDULE", 20], ["THREAD SESSION", 24], ["NEXT RUN", 20], ["LAST RUN", 20]]; const clip = (v: string, n: number) => v.length > n ? `${v.slice(0, n - 1)}…` : v; const row = (values: string[]) => `│ ${values.map((v, i) => clip(v, columns[i][1]).padEnd(columns[i][1])).join(" │ ")} │`; const line = `┼${columns.map(([, n]) => "─".repeat(n + 2)).join("┼")}┼`; console.log(line.replaceAll("┼", "┬").replace(/^┬/, "┌").replace(/┬$/, "┐")); console.log(row(columns.map(([n]) => n))); console.log(line); for (const item of rows) { const color = item.enabled ? "\x1b[32m" : "\x1b[38;2;245;194;215m"; console.log(`${color}${row([item.enabled ? "enabled" : "disabled", item.type, item.name, item.schedule, item.thread_session_id, item.nextRunAt ?? "—", item.lastRunAt ?? "—"])}\x1b[0m`); } if (!rows.length) console.log(row(["—", "—", "No pulses configured", "—", "—", "—", "—"])); console.log(line.replaceAll("┼", "┴").replace(/^┴/, "└").replace(/┴$/, "┘")); }
|
|
70
|
-
|
|
76
|
+
const persistentCommand = "dir=$(mktemp -d) || exit 1; fifo=$dir/stdin; mkfifo \"$fifo\" || { rmdir \"$dir\"; exit 1; }; cleanup() { kill \"$tail_pid\" 2>/dev/null || true; wait \"$tail_pid\" 2>/dev/null || true; rm -f \"$fifo\"; rmdir \"$dir\"; }; trap cleanup EXIT HUP INT TERM; tail -f /dev/null >\"$fifo\" & tail_pid=$!; \"$@\" <\"$fifo\"; exit $?";
|
|
77
|
+
const completionContract = "\n\nPulse completion contract (internal):\nAfter completing the requested work and any tool calls, always return a non-empty final response for the Pulse execution log. This final response is internal: do not send it to an external recipient unless the task explicitly asks you to do so. Instructions not to send text to a recipient restrict outbound communication only; they do not permit an empty final response. State the outcome concisely, including any relevant identifier or result.";
|
|
78
|
+
export function pulseMessage(name: string, prompt: string, handoff = "") { return `[Pulse: ${name}]\n${prompt}${handoff ? `\n\nPrevious heartbeat state (private):\n${handoff}` : ""}${completionContract}`; }
|
|
79
|
+
async function start() { const current = await json<State>(statePath()); if (current && alive(current.pid)) return console.log(`Pulse tick is already running (PID ${current.pid}).`); if (current) console.error(`Pulse tick found abandoned state for PID ${current.pid}; its last heartbeat was ${current.lastHeartbeatAt ?? "unknown"}.`); const fd = openSync(logPath(), "a"), pi = piCommand(); const child = spawn("sh", ["-c", persistentCommand, "pi-pulse-tick", pi.command, ...pi.args, "pulse", "tick"], { cwd: process.cwd(), detached: true, stdio: ["ignore", fd, fd], env: { ...process.env, PI_PULSE_TICK: "1" } }); child.unref(); console.log(`Pulse tick starting (PID ${child.pid}).`); }
|
|
71
80
|
async function stop() { const current = await json<State>(statePath()); if (!current || !alive(current.pid)) return console.log("Pulse tick is not running."); try { process.kill(-current.pid, "SIGTERM"); } catch { process.kill(current.pid, "SIGTERM"); } console.log(`Pulse tick stopping (PID ${current.pid}).`); }
|
|
72
81
|
async function restart() {
|
|
73
82
|
const current = await json<State>(statePath());
|
|
@@ -76,19 +85,21 @@ async function restart() {
|
|
|
76
85
|
if (current && alive(current.pid)) throw new Error(`Pulse tick (PID ${current.pid}) did not stop within 5 seconds.`);
|
|
77
86
|
await start();
|
|
78
87
|
}
|
|
79
|
-
async function status() { const state = await json<State>(statePath()); const store = new PulseStore(dbPath()); const enabled = store.list().filter((item) => item.enabled).length; console.log(
|
|
88
|
+
async function status() { const state = await json<State>(statePath()); const store = new PulseStore(dbPath()); const enabled = store.list().filter((item) => item.enabled).length; if (!state) return console.log(`Pulse tick: stopped (${enabled} enabled pulses)`); if (!alive(state.pid)) return console.log(`Pulse tick: abandoned (PID ${state.pid} is not running; last heartbeat ${state.lastHeartbeatAt ?? "unknown"})`); console.log(`Pulse tick: running (PID ${state.pid}, ${enabled} enabled pulses, since ${state.startedAt}; last heartbeat ${state.lastHeartbeatAt ?? "unknown"})`); }
|
|
80
89
|
async function executePulse(store: PulseStore, pulse: Awaited<ReturnType<PulseStore["claimDue"]>>[number], owner: string) {
|
|
90
|
+
const startedAt = Date.now(); tickLog(`Starting Pulse '${pulse.pulse.name}' (${pulse.runId}).`);
|
|
81
91
|
const renew = setInterval(() => { store.renewClaim(pulse.pulse.id, pulse.runId, owner); store.claimTickLease(owner); }, 30_000);
|
|
82
92
|
try {
|
|
83
93
|
const handoff = pulse.pulse.type === "heartbeat" ? store.handoff(pulse.pulse.id) : "";
|
|
84
|
-
const message =
|
|
94
|
+
const message = pulseMessage(pulse.pulse.name, pulse.pulse.prompt, handoff);
|
|
85
95
|
const source = pulse.pulse.thread_session_id ? await sessionFile(pulse.pulse.profile, pulse.pulse.thread_session_id) : undefined;
|
|
86
96
|
if (pulse.pulse.thread_session_id && !source) throw new Error(`Thread session '${pulse.pulse.thread_session_id}' was not found for profile '${pulse.pulse.profile}'.`);
|
|
87
97
|
const execution = await createPulseSession(pulse.pulse.profile, pulse.pulse.type, source?.cwd ?? process.cwd());
|
|
88
98
|
store.setRunSession(pulse.runId, execution.id);
|
|
89
99
|
const modelArgs = await profileModelArgs(pulse.pulse.profile);
|
|
90
100
|
const result = await new Promise<string>((resolve, reject) => {
|
|
91
|
-
const
|
|
101
|
+
const pi = piCommand();
|
|
102
|
+
const child = spawn(pi.command, [...pi.args, "profile", pulse.pulse.profile, "--session", execution.file, ...modelArgs, "--print", message], { cwd: source?.cwd ?? process.cwd(), env: { ...process.env, PI_PULSE_TICK: "1" }, stdio: ["ignore", "pipe", "pipe"] });
|
|
92
103
|
let output = "", error = "";
|
|
93
104
|
child.stdout.on("data", (data) => output += String(data)); child.stderr.on("data", (data) => error += String(data));
|
|
94
105
|
child.once("exit", (code) => code === 0 ? resolve(output.trim()) : reject(new Error(error.trim() || `Pi exited with ${code}`)));
|
|
@@ -105,14 +116,16 @@ async function executePulse(store: PulseStore, pulse: Awaited<ReturnType<PulseSt
|
|
|
105
116
|
}
|
|
106
117
|
}
|
|
107
118
|
store.complete(pulse.pulse, pulse.runId, result, pulse.pulse.type === "heartbeat" ? result.slice(-8000) : undefined, owner);
|
|
108
|
-
|
|
119
|
+
tickLog(`Pulse '${pulse.pulse.name}' (${pulse.runId}) completed in ${Date.now() - startedAt}ms.`);
|
|
120
|
+
} catch (error) { const message = error instanceof Error ? error.message : String(error); tickLog(`Pulse '${pulse.pulse.name}' (${pulse.runId}) failed after ${Date.now() - startedAt}ms: ${message}`); store.fail(pulse.pulse, pulse.runId, message, owner); }
|
|
109
121
|
finally { clearInterval(renew); }
|
|
110
122
|
}
|
|
111
123
|
async function tick() {
|
|
112
124
|
const store = new PulseStore(dbPath());
|
|
113
125
|
const lock = await acquireTickLock(store);
|
|
114
126
|
if (!lock) return console.log("Pulse tick is already running.");
|
|
115
|
-
const { owner, release } = lock;
|
|
127
|
+
const { owner, heartbeat, release } = lock;
|
|
128
|
+
tickLog(`Started (PID ${process.pid}, owner ${owner}).`);
|
|
116
129
|
const expiredClaims = store.expiredClaims();
|
|
117
130
|
if (expiredClaims) console.error(`Pulse tick found ${expiredClaims} expired active claim(s). Run 'pi pulse recover' only after confirming their workers are gone.`);
|
|
118
131
|
let stopped = false, wake: (() => void) | undefined;
|
|
@@ -122,11 +135,15 @@ async function tick() {
|
|
|
122
135
|
task = executePulse(store, pulse, owner).catch((error) => console.error(`Unexpected failure in Pulse '${pulse.pulse.name}': ${error instanceof Error ? error.message : String(error)}`)).finally(() => active.delete(task));
|
|
123
136
|
active.add(task);
|
|
124
137
|
};
|
|
125
|
-
const close = () => { stopped = true; wake?.(); };
|
|
126
|
-
process.once("SIGTERM", close); process.once("SIGINT", close);
|
|
138
|
+
const close = (signal: string) => { tickLog(`Received ${signal}; waiting for ${active.size} active Pulse(s).`); stopped = true; wake?.(); };
|
|
139
|
+
process.once("SIGTERM", () => close("SIGTERM")); process.once("SIGINT", () => close("SIGINT"));
|
|
140
|
+
process.once("unhandledRejection", (reason) => { tickLog(`Unhandled rejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}`); close("unhandled rejection"); });
|
|
141
|
+
process.once("uncaughtException", (error) => { tickLog(`Uncaught exception: ${error.stack ?? error.message}`); close("uncaught exception"); });
|
|
142
|
+
process.once("exit", (code) => tickLog(`Exiting with code ${code}.`));
|
|
127
143
|
try {
|
|
128
144
|
while (!stopped) {
|
|
129
|
-
if (!store.claimTickLease(owner)) {
|
|
145
|
+
if (!store.claimTickLease(owner)) { tickLog("Lost the SQLite lease; stopping."); break; }
|
|
146
|
+
await heartbeat();
|
|
130
147
|
// Claiming is atomic and exclusive per Pulse. Workers are intentionally
|
|
131
148
|
// launched independently so unrelated scheduled work can overlap.
|
|
132
149
|
for (const pulse of store.claimDue(undefined, owner)) { if (stopped) break; launch(pulse); }
|
|
@@ -136,23 +153,24 @@ async function tick() {
|
|
|
136
153
|
// Do not release the scheduler lease while this tick still owns work.
|
|
137
154
|
await Promise.allSettled([...active]);
|
|
138
155
|
await release();
|
|
156
|
+
tickLog("Stopped cleanly.");
|
|
139
157
|
}
|
|
140
158
|
}
|
|
141
159
|
export async function handlePulseCli(args: string[], selectedProfile?: string): Promise<boolean> { if (args[0] !== "pulse") return false; const profileIndex = args.indexOf("--profile"); const profile = selectedProfile ?? (profileIndex >= 0 ? args[profileIndex + 1] : undefined); if (args[1] === "tick" && process.env.PI_PULSE_TICK === "1") { await tick(); return true; } switch (args[1]) { case "start": await start(); break; case "stop": await stop(); break; case "restart": await restart(); break; case "status": await status(); break; case "list": table(profile); break; case "recover": { const recovered = new PulseStore(dbPath()).recoverExpiredClaims(profile); console.log(`Recovered ${recovered} expired Pulse claim(s).`); break; } case "enable": case "disable": { const name = args[2]; if (!name) throw new Error("Usage: pi pulse enable|disable <name>"); new PulseStore(dbPath()).setEnabled(name, args[1] === "enable", profile); console.log(`Pulse '${name}' ${args[1]}d.`); break; } default: console.error("Usage: pi pulse start | stop | restart | status | list | recover | enable <name> | disable <name>"); process.exitCode = 1; } return true; }
|
|
142
160
|
export default async function (pi: ExtensionAPI) {
|
|
143
|
-
if (await handlePulseCli(process.argv.slice(2))) process.exit();
|
|
161
|
+
if (await handlePulseCli(withoutTuiModeArgs(process.argv.slice(2)))) process.exit();
|
|
144
162
|
pi.on("before_agent_start", async (event) => ({ systemPrompt: `${event.systemPrompt}\n\nWhen the user asks to schedule, automate, remind, run future work, or manage an existing schedule, use the schedule tool. Do not ask for a thread or session ID: creation binds the schedule to this conversation automatically.` }));
|
|
145
163
|
pi.registerTool({
|
|
146
164
|
name: "schedule",
|
|
147
165
|
label: "Manage schedule",
|
|
148
|
-
description: "Create, list, edit, enable, disable, or delete cron jobs, heartbeats, reminders, and future schedules. Use this whenever the user asks to schedule or manage scheduled work.
|
|
166
|
+
description: "Create, list, edit, enable, disable, or delete cron jobs, heartbeats, reminders, and future schedules. Use this whenever the user asks to schedule or manage scheduled work. Results are recorded in active Application sessions by default; set insertIntoApiSession=false only when the user explicitly requests not to record them. This adds context only and does not send through an outbound handler.",
|
|
149
167
|
parameters: Type.Object({
|
|
150
168
|
action: Type.Union([Type.Literal("create"), Type.Literal("list"), Type.Literal("update"), Type.Literal("enable"), Type.Literal("disable"), Type.Literal("delete")]),
|
|
151
169
|
name: Type.Optional(Type.String({ description: "Schedule name. Required except when listing." })),
|
|
152
170
|
description: Type.Optional(Type.String({ description: "Human-readable purpose, required for create." })),
|
|
153
171
|
schedule: Type.Optional(Type.String({ description: "Five-field UTC cron expression or @once:<ISO-8601>. Required for create." })),
|
|
154
172
|
prompt: Type.Optional(Type.String({ description: "Markdown instructions, required for create." })),
|
|
155
|
-
insertIntoApiSession: Type.Optional(Type.Boolean({ description: "
|
|
173
|
+
insertIntoApiSession: Type.Optional(Type.Boolean({ description: "Defaults to true. Set false only when the user explicitly asks not to record the Pulse result in every active Application session for this profile. This records context only; it does not send through an outbound handler." })),
|
|
156
174
|
}),
|
|
157
175
|
async execute(_id, params, _signal, _update, ctx) {
|
|
158
176
|
try {
|
|
@@ -162,7 +180,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
162
180
|
if (params.action === "create") {
|
|
163
181
|
if (!params.description || !params.schedule || !params.prompt) throw new Error("Create requires name, description, schedule, and prompt.");
|
|
164
182
|
const threadSessionId = ctx.sessionManager.getSessionId(); if (!threadSessionId) throw new Error("A persistent conversation session is required to create a schedule.");
|
|
165
|
-
const pulse = store.create({ name: params.name, description: params.description, schedule: params.schedule, prompt: params.prompt, insertIntoApiSession: params.insertIntoApiSession
|
|
183
|
+
const pulse = store.create({ name: params.name, description: params.description, schedule: params.schedule, prompt: params.prompt, insertIntoApiSession: params.insertIntoApiSession !== false, profile, thread_session_id: threadSessionId }); await start();
|
|
166
184
|
return { content: [{ type: "text", text: JSON.stringify(pulse) }], details: pulse };
|
|
167
185
|
}
|
|
168
186
|
if (params.action === "enable" || params.action === "disable") { const pulse = store.setEnabled(params.name, params.action === "enable", profile); return { content: [{ type: "text", text: JSON.stringify(pulse) }], details: pulse }; }
|