Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions components/evy/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,14 @@ import {
type ProfileName,
type ProfilesFile,
} from "./profiles";
// ── v2.8.1 operator preferences (HTTP surface mounted W6 row ②) ──
import {
loadPreferences,
setPreference,
deletePreference,
resetPreferences,
} from "./preferences";
import { preferencesTools } from "./tools/preferences";
import {
registerWatchdog,
touchWatchdog,
Expand Down Expand Up @@ -752,6 +760,15 @@ export const toolRegistry: Record<string, InternalTool> = {
v as unknown as InternalTool,
]),
),
// v2.8.1 bilateral preferences — keys already prefixed
// (evy_get_preferences, evy_set_preference). Registered W6 row ②
// alongside the /preferences HTTP mount; both shipped unmounted.
...Object.fromEntries(
Object.entries(preferencesTools).map(([k, v]) => [
k,
v as unknown as InternalTool,
]),
),
...Object.fromEntries(
Object.entries(specforgeTools).map(([k, v]) => [
k, // specforge
Expand Down Expand Up @@ -6198,6 +6215,87 @@ async function main() {
});
}

// ── Operator preferences (v2.8.1; HTTP surface mounted W6 row ②) ────
// Bilateral-maintenance config at ~/.config/subctl/preferences.toml.
// The dashboard's Preferences tab reaches these via its /api/preferences
// proxy (dashboard/server.ts rewrites /api/preferences/* → /preferences/*).
//
// GET /preferences → { ok, preferences }
// POST /preferences/<cat>/<key> → { ok, entry } body: { value, by?, reason? }
// DELETE /preferences/<cat>/<key> → { ok, removed }
// POST /preferences/reset → { ok, preferences } gated on { confirm: true }
if (url.pathname === "/preferences" && req.method === "GET") {
try {
return Response.json({ ok: true, preferences: loadPreferences() });
} catch (err) {
return Response.json(
{ ok: false, error: (err as Error).message },
{ status: 500 },
);
}
}
if (url.pathname === "/preferences/reset" && req.method === "POST") {
let body: { confirm?: boolean };
try {
body = await req.json();
} catch {
return Response.json({ ok: false, error: "invalid JSON" }, { status: 400 });
}
if (body.confirm !== true) {
return Response.json(
{ ok: false, error: 'reset requires {"confirm": true}' },
{ status: 400 },
);
}
return Response.json({ ok: true, preferences: resetPreferences() });
}
{
const m = url.pathname.match(/^\/preferences\/([^/]+)\/([^/]+)$/);
if (m && req.method === "POST") {
const category = decodeURIComponent(m[1]!);
const key = decodeURIComponent(m[2]!);
let body: { value?: unknown; by?: string; reason?: string };
try {
body = await req.json();
} catch {
return Response.json({ ok: false, error: "invalid JSON" }, { status: 400 });
}
if (body.value === undefined || body.value === null) {
return Response.json({ ok: false, error: "value required" }, { status: 400 });
}
try {
const by = body.by === "evy" ? "evy" : "operator";
const entry = setPreference(
category,
key,
body.value as string | number | boolean,
by,
body.reason,
);
return Response.json({ ok: true, entry });
} catch (err) {
return Response.json(
{ ok: false, error: (err as Error).message },
{ status: 400 },
);
}
}
if (m && req.method === "DELETE") {
try {
const removed = deletePreference(
decodeURIComponent(m[1]!),
decodeURIComponent(m[2]!),
);
return Response.json({ ok: true, removed });
} catch (err) {
return Response.json(
{ ok: false, error: (err as Error).message },
{ status: 400 },
);
}
}
}

// ── Provider Model Catalog Phase 3 — aggregator routing ─────────────
//
// GET /providers/<id>/upstream-catalog — cached if fresh
Expand Down
62 changes: 40 additions & 22 deletions dashboard/public/tabs/chat.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,20 +71,27 @@ export const id = "chat";

// ---- Module-scope handles for unmount() ----
//
// Three EventSource categories survive across closures:
// Two EventSource categories survive across closures:
// 1. masterEventSource — Master chat's long-lived SSE stream.
// `connect()` reassigns this on each reconnect; we always close the
// latest one in unmount().
// 2. profilePillEventSource — Profile-pill's quiet observer SSE.
// Single instance; created once in wireProfilePill().
// 3. oneShotEventSources — per-call EventSource handles from
// 2. oneShotEventSources — per-call EventSource handles from
// attachOneShotAssistantCapture (Projects tab uses this for each
// project chat panel). Tracked in a Set so individual instances
// can self-remove on natural close.
let masterEventSource = null;
let profilePillEventSource = null;
const oneShotEventSources = new Set();

// Profile-pill hooks (W6 row ⑤). wireProfilePill() assigns these; the
// canonical connect() lifecycle calls them so the pill rides the SAME
// reconnecting SSE stream as the chat panel. The pill used to open its
// own one-shot EventSource with no reconnect — the first drop (master
// restart, sleep/wake) killed its real-time path until a page reload.
// Lazy null-guards cover the first connect(), which runs before
// wireProfilePill() in the boot order.
let profilePillOnSwap = null; // (eventData) => paint the swapped-to profile
let profilePillResync = null; // () => re-fetch /api/profile (SSE [re]connect)

// Timers + intervals lifted out so unmount() can clear them. Each is
// initialized to null and assigned inside the wirer that owns it.
let chatModelSelectorPollTimer = null;
Expand Down Expand Up @@ -713,22 +720,18 @@ export async function mount({ root: _root }) {
}
pill.addEventListener("click", toggle);

// Initial load + 30s poll fallback. We also piggyback on the
// existing /api/master/events SSE so out-of-band swaps (Telegram
// /profile, manual file edit, another tab) reflect immediately.
// Initial load + 30s poll fallback. Real-time path (W6 row ⑤): the
// canonical connect() stream calls these hooks — profile_swapped
// frames paint instantly, and every SSE [re]connect re-fetches
// /api/profile so a master restart can't strand a stale pill. The
// pill previously opened its own EventSource with no reconnect
// logic; one drop and it was dead until reload.
profilePillOnSwap = (d) => {
if (d && typeof d.to === "string") paint(d.to);
};
profilePillResync = refresh;
refresh();
profilePillPollTimer = setInterval(refresh, 30_000);
try {
profilePillEventSource = new EventSource("/api/master/events");
profilePillEventSource.addEventListener("profile_swapped", (e) => {
try {
const d = JSON.parse(e.data);
if (d && typeof d.to === "string") paint(d.to);
} catch { /* ignore */ }
});
// Don't reconnect on error here — the chat panel's connectSSE()
// already owns the canonical lifecycle. This is a quiet observer.
} catch { /* EventSource unavailable; poll-only fallback is fine */ }
}

// One-shot SSE listener that captures the next assistant turn (from the
Expand Down Expand Up @@ -1530,6 +1533,17 @@ export async function mount({ root: _root }) {
}
setConnState("connected");
backoffMs = 1000;
// W6 row ⑤ — a reconnect usually means the master restarted
// (supervisor swap, deploy). Re-fetch the active profile now
// instead of waiting out the 30s poll.
if (profilePillResync) profilePillResync();
});
// W6 row ⑤ — profile pill rides the canonical stream so it keeps
// updating across reconnects (out-of-band swaps: Telegram /profile,
// another tab, manual profiles.json edit).
es.addEventListener("profile_swapped", (e) => {
if (!profilePillOnSwap) return;
try { profilePillOnSwap(JSON.parse(e.data)); } catch { /* ignore */ }
});
es.addEventListener("error", () => {
try { es.close(); } catch {}
Expand Down Expand Up @@ -2178,8 +2192,8 @@ export async function mount({ root: _root }) {
}

// ── Boot wirers — mirror app.js boot order (452, 453, 454). Master
// chat first so the SSE connection is in flight before the
// profile pill opens its own quiet observer. ──
// chat first so the canonical SSE connection is in flight before
// wireProfilePill assigns the pill hooks it rides on. ──
wireMasterChat();
wireChatModelSelector();
wireProfilePill();
Expand All @@ -2196,10 +2210,14 @@ export async function mount({ root: _root }) {
export function unmount() {
// Close every SSE stream this module ever opened.
if (masterEventSource) { try { masterEventSource.close(); } catch {} masterEventSource = null; }
if (profilePillEventSource) { try { profilePillEventSource.close(); } catch {} profilePillEventSource = null; }
for (const es of oneShotEventSources) { try { es.close(); } catch {} }
oneShotEventSources.clear();

// Detach the profile-pill hooks so a closed pill can't be painted by
// a late SSE frame after unmount.
profilePillOnSwap = null;
profilePillResync = null;

// Clear all the timer handles we lifted to module scope.
if (chatModelSelectorPollTimer) { clearInterval(chatModelSelectorPollTimer); chatModelSelectorPollTimer = null; }
if (profilePillPollTimer) { clearInterval(profilePillPollTimer); profilePillPollTimer = null; }
Expand Down
92 changes: 70 additions & 22 deletions dashboard/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ import {
type Skill,
type SkillCategory,
} from "../components/evy/skills-registry.ts";
// W6 row ③ — roster-shaped TOML team templates (Templates tab backing).
import {
listTemplates as listTeamTemplates,
loadTemplate as loadTeamTemplate,
} from "../components/evy/team-templates.ts";
// ── end v2.8.1 skills clarity ──
// ── v3.1.0 Kernel Fitness Phase 1: engagement instrumentation (write-only). ──
// Imported here so the dashboard can record `acted` / `acked` outcomes
Expand Down Expand Up @@ -3623,6 +3628,39 @@ const server = Bun.serve({
}
}

// ── Team templates (TOML rosters, v2.8.0; mounted W6 row ③) ─────────
// Backing module: components/evy/team-templates.ts (seeds the stock
// templates on first list). Distinct from /api/teams above — that family
// serves the legacy v2.7.x single-persona JSON dir; these are the
// roster-shaped TOML templates the Templates tab (tabs/templates.js)
// renders. The tab shipped in v2.8.6 fetching these paths; the routes
// were never mounted — 404 until now.
// GET /api/team-templates → { ok, templates, errors }
// GET /api/team-templates/<name> → { ok, template }
if (url.pathname === "/api/team-templates" && req.method === "GET") {
try {
const { templates, errors } = listTeamTemplates();
return Response.json({ ok: true, templates, errors });
} catch (err) {
return Response.json({ ok: false, error: (err as Error).message }, { status: 500 });
}
}
{
const m = url.pathname.match(/^\/api\/team-templates\/([^/]+)$/);
if (m && req.method === "GET") {
const name = decodeURIComponent(m[1]!);
try {
return Response.json({ ok: true, template: loadTeamTemplate(name) });
} catch (err) {
const msg = (err as Error).message;
return Response.json(
{ ok: false, error: msg },
{ status: msg.includes("not found") ? 404 : 400 },
);
}
}
}

// ── Skills catalog endpoints ────────────────────────────────────────
// GET /api/skills — list all skills with frontmatter
// GET /api/skills/sources — list imported sources
Expand Down Expand Up @@ -3729,28 +3767,11 @@ const server = Bun.serve({
return Response.json({ ok: true, skills_dir: SKILLS_DIR, sources });
}

{
const m = url.pathname.match(/^\/api\/skills\/(.+)$/);
if (m && req.method === "GET" && m[1] !== "sources") {
const id = decodeURIComponent(m[1]!);
// Resolve id → on-disk path
// id format: <source>/<rest> → SKILLS_DIR/<source>/skills/<rest>/SKILL.md
const segs = id.split("/");
if (segs.length < 2) return Response.json({ ok: false, error: "invalid skill id" }, { status: 400 });
const source = segs[0]!;
const rest = segs.slice(1).join("/");
const path = join(SKILLS_DIR, source, "skills", rest, "SKILL.md");
if (!existsSync(path)) {
return Response.json({ ok: false, error: `skill not found: ${id}` }, { status: 404 });
}
try {
const raw = readFileSync(path, "utf8");
return Response.json({ ok: true, id, path, content: raw });
} catch (err) {
return Response.json({ ok: false, error: (err as Error).message }, { status: 500 });
}
}
}
// NOTE (W6 row ③): the GET /api/skills/<id> catch-all used to live here,
// BEFORE /api/skills/categorized and /api/skills/evy/* below — so its
// /^\/api\/skills\/(.+)$/ regex ate those paths first ("categorized"
// split to one segment → 400 "invalid skill id"). It now sits after
// every fixed-path /api/skills/* route, at the end of this section.

if (url.pathname === "/api/skills/import" && req.method === "POST") {
let body: { repo?: string; source?: string; branch?: string };
Expand Down Expand Up @@ -3886,6 +3907,33 @@ const server = Bun.serve({
}
// ── end v2.8.1 skills clarity ──

// GET /api/skills/<id> — one skill's full SKILL.md content. MUST stay the
// LAST /api/skills/* route: its (.+) catch-all matches every sub-path, so
// any fixed route below it would be shadowed (W6 row ③ — it previously
// sat above /api/skills/categorized and 400'd that endpoint forever).
{
const m = url.pathname.match(/^\/api\/skills\/(.+)$/);
if (m && req.method === "GET" && m[1] !== "sources") {
const id = decodeURIComponent(m[1]!);
// Resolve id → on-disk path
// id format: <source>/<rest> → SKILLS_DIR/<source>/skills/<rest>/SKILL.md
const segs = id.split("/");
if (segs.length < 2) return Response.json({ ok: false, error: "invalid skill id" }, { status: 400 });
const source = segs[0]!;
const rest = segs.slice(1).join("/");
const path = join(SKILLS_DIR, source, "skills", rest, "SKILL.md");
if (!existsSync(path)) {
return Response.json({ ok: false, error: `skill not found: ${id}` }, { status: 404 });
}
try {
const raw = readFileSync(path, "utf8");
return Response.json({ ok: true, id, path, content: raw });
} catch (err) {
return Response.json({ ok: false, error: (err as Error).message }, { status: 500 });
}
}
}

// ── Settings page endpoints ─────────────────────────────────────────

// /api/settings/install-checks — broad install matrix beyond /diag.
Expand Down
Loading