diff --git a/docs/superpowers/plans/2026-09-09-perch-hub.md b/docs/superpowers/plans/2026-09-09-perch-hub.md new file mode 100644 index 00000000..4c85dc46 --- /dev/null +++ b/docs/superpowers/plans/2026-09-09-perch-hub.md @@ -0,0 +1,2072 @@ +# Perch Hub Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Bring back Perch Hub as a core, gateway-served page that lists every live bot session and owns the conversation, then delete the bot board's session drawer so there is one chat surface. + +**Architecture:** A full HTML document served from inside the dashboard router (so it inherits `dashboardAuth`, CSRF, Serve and the front door) rather than a dashboard panel (whose shell is what makes the current surface cramped on a phone). It adds **no API**: `GET /dashboard/perch-api/roost` already aggregates every bot and session in one pass, and the `/dashboard/perch-api/interactive/*` routes already carry a whole conversation. Ships in two phases so a working chat surface exists at every commit. + +**Tech Stack:** Node 22 ESM, Express, server-rendered template strings with an emitted client script (the pattern `dashboard/panels/bot-board/` uses), `node:test`, Chrome DevTools Protocol for render assertions. + +**Spec:** `docs/superpowers/specs/2026-09-09-perch-hub-design.md` + +## Global Constraints + +- **Never `innerHTML`. `textContent` and `createElement` only.** Every render path here puts bot names, model output and ask-card text into the DOM. `crow_csrf` is deliberately **not** HttpOnly (`shared/csrf.js:11`) so a script injection on this page is a CSRF-token exfil on a fully authenticated surface. `tests/perch-hub-client.test.js` asserts the emitted script contains no `innerHTML`. + +- **Node 22.** Run tests with the v22 binary: `/home/kh0pp/.nvm/versions/node/v22.23.1/bin/node`. Node 20 on `PATH` cannot load `better-sqlite3` (ABI mismatch). +- **In a worktree, symlink deps first:** `ln -sfn ~/crow/node_modules node_modules`. Remove the symlink before `git add -A`. +- **Every client script lives inside a template literal.** A bare backtick or `${` in a comment or string inside `perchHubJs()` breaks the module at import. Verify with a parse test in every task that touches it. +- **No Claude attribution** on any commit or PR. +- **i18n:** every user-visible string goes through `t(key, lang)` server-side or `tJs(key, lang)` inside the client script, with **both `en` and `es`** in `servers/gateway/dashboard/shared/i18n.js`. `tests/i18n-global-parity.test.js` fails if `es` is missing. +- **The SSE stream is live-only and carries no backlog.** Attach before posting anything. The engine replays session state onto every new subscriber; that replay is the only proof the subscription is live. +- **`state.turnInFlight` is false both in the pre-post replay and at the end of a turn.** It only counts as an ending once it has been seen true. +- **`plan_state.state` is an object**, never a string. Format it; never append it raw. +- **Mobile rules, non-negotiable:** `100dvh` after a `100vh` fallback, never bare `100vh`; the composer is `position:sticky; bottom:0` with its own background; no unlabelled control; full-bleed width on a phone. +- **Never run `git checkout ` in `~/crow`.** It is the live gateway checkout and parking it off `main` silently disables fleet auto-update. Use the worktree. + +--- + +## File Structure + +**Created** + +| File | Responsibility | +|---|---| +| `servers/gateway/dashboard/perch-hub/css.js` | The Perch stylesheet, ported from the deleted bundle. Palette + layout only. | +| `servers/gateway/dashboard/perch-hub/html.js` | The full HTML document: head, list view shell, chat view shell. Static; hydrated client-side. | +| `servers/gateway/dashboard/perch-hub/client.js` | Emitted client script: hash router, list render, chat render, SSE, composer, controls. | +| `servers/gateway/routes/perch-hub.js` | Express router. `GET /perch` (the page) mounted under `/dashboard`. | +| `tests/perch-hub-page.test.js` | Route + document + markup tests. | +| `tests/perch-hub-client.test.js` | Client-script behaviour: hash routing, event handling, formatters. | +| `tests/perch-hub-render.test.js` | CDP render assertions at phone and desktop widths. | + +**Modified** + +| File | Change | +|---|---| +| `servers/gateway/dashboard/index.js` | Mount the hub router; add the top-level `/perch` redirect (Task 1). | +| `servers/gateway/dashboard/shared/i18n.js` | New `perch.*` and `nav.perch` keys, `en` + `es` (Tasks 1, 3, 4, 5, 7). | +| `tests/i18n-global-parity.test.js` | `perch.title` and `nav.perch` into `IDENTICAL_OK` — product names, identical in both languages (Tasks 1, 7). | +| `servers/gateway/dashboard/shared/layout.js` | Hard-coded nav link, `class="nav-item" data-turbo="false"` (Task 7). **Not** a nav-registry entry — see Task 7's reasoning. | +| `servers/gateway/dashboard/panels/bot-board/client.js` | All six hand-off call sites navigate to the hub (Task 7); drop the `birdDrawerJs` import and splice (Task 8). | +| `tests/roost-strip-ui.test.js` | Rewrite lines 198, 263 and 265, which pin pre-hub behaviour (Task 7). | +| `servers/gateway/dashboard/panels/bot-board/css.js` | Drop the `birdDrawerCss` import (line 8) and its interpolation (line 65) (Task 8). | +| `servers/gateway/dashboard/panels/bot-board/html.js` | Remove `birdDrawerMarkup` (288) and **both** call sites, 606 and 769 (Task 8). | +| `tests/board-i18n-literals.test.js` | Drop the drawer import, bindings, `DRAWER_JS_KEYS`, `DRAWER_SSR_KEYS` and the two groups that consume them (Task 8). | + +**Deleted (Task 8)** + +| File | Why | +|---|---| +| `servers/gateway/dashboard/panels/bot-board/drawer.js` | The hub is the chat surface. | +| `tests/bird-drawer-controls.test.js` | Tests a deleted module. | +| `tests/bird-drawer-core.test.js` | Tests a deleted module. | + +### Deliberate divergence from the spec + +The spec says the ported CSS should share crow's `--crow-*` tokens. **It does not.** The original `PERCH_CSS` carries its own palette (`--sky`, `--card`, `--ink`, `--teal`, `--wire`, `--alive`, `--attn`, `--line`) with its own `prefers-color-scheme` block, and that palette **is** the look the operator asked to get back. Mapping it onto `--crow-*` would change the thing being restored. The page therefore defines its own variables and honours the OS dark preference. Recorded here so a reviewer does not treat it as an oversight. + +**Unattached bots are filtered OUT of the list, not listed-and-disabled.** The spec's error table says list them with a link to Bot Builder. Decided against on 2026-09-09: the hub is a session surface, and a bot that cannot hold a session is noise there. Attaching one is Bot Builder's job. `listRows` drops them and Task 2 tests that it does. + +--- + +## Task 1: The route, the document, and the Perch stylesheet + +**Files:** +- Create: `servers/gateway/dashboard/perch-hub/css.js` +- Create: `servers/gateway/dashboard/perch-hub/html.js` +- Create: `servers/gateway/routes/perch-hub.js` +- Modify: `servers/gateway/dashboard/index.js` +- Modify: `servers/gateway/dashboard/shared/i18n.js` +- Test: `tests/perch-hub-page.test.js` + +**Interfaces:** +- Produces: `perchHubCss(): string` (bare CSS, no ` + + +${engineBanner(engine, lang)} +
Perch${escapeHtml(t("perch.subtitle", lang))}
+
+
+
+

${escapeHtml(t("perch.sessionsHeading", lang))}

+
${escapeHtml(t("perch.loading", lang))}
+
+
+ +
+
+
+
+
+
+ +
+ + +
+
+
+
+ +`; +} +``` + +Create `servers/gateway/routes/perch-hub.js`: + +```js +import { Router } from "express"; +import { perchHubDocument } from "../dashboard/perch-hub/html.js"; +import { SUPPORTED_LANGS } from "../dashboard/shared/i18n.js"; +import { parseCookies } from "../dashboard/auth.js"; +import { engineStatus } from "../bot-engine-status.js"; + +/** Mounted at "/dashboard" by dashboard/index.js, so this serves + * /dashboard/perch and inherits that mount's auth + CSRF chain. */ +export default function perchHubRouter(dashboardAuth) { + const router = Router(); + // Belt and braces, deliberately. dashboard/index.js:614 already applies + // dashboardAuth to the whole /dashboard mount BEFORE this router is added at + // ~713, so this is redundant TODAY. It is kept because perchApiRouter does + // the same (routes/perch-interactive-api.js:628-633: "installs dashboardAuth + // on its own prefix so it is closed wherever it is mounted") — the router + // stays safe if it is ever re-mounted somewhere else. dashboardAuth is + // idempotent, so running twice costs a session lookup and nothing else. + router.use("/perch", dashboardAuth); + router.get("/perch", (req, res) => { + const cookies = parseCookies(req); + const lang = SUPPORTED_LANGS.includes(cookies.crow_lang) ? cookies.crow_lang : "en"; + // engineStatus() is a LEAF module of synchronous fs stats + // (bot-engine-status.js:84) — safe and cheap from a route. /roost cannot + // report engine state, so without this the list looks normal and the first + // tap fails with a raw error. + res.type("html").send(perchHubDocument(lang, engineStatus())); + }); + return router; +} +``` + +In `servers/gateway/dashboard/index.js`, beside the `router.use("/dashboard", bundlesRouter);` line (~713), add: + +```js + router.use("/dashboard", perchHubRouter(dashboardAuth)); + // Short link. Outside the /dashboard mount, so it carries no auth — it is a + // redirect with no content, and the destination is fully gated. + router.get("/perch", (req, res) => res.redirect(302, "/dashboard/perch")); +``` + +with `import perchHubRouter from "../routes/perch-hub.js";` at the top. + +Add to `servers/gateway/dashboard/shared/i18n.js` (both languages): + +```js + "perch.title": { en: "Perch", es: "Perch" }, + "perch.subtitle": { en: "your bot sessions", es: "tus sesiones de bots" }, + "perch.navBoard": { en: "Board", es: "Tablero" }, + "perch.sessionsHeading": { en: "Sessions", es: "Sesiones" }, + "perch.loading": { en: "Loading sessions…", es: "Cargando sesiones…" }, + "perch.noSessions": { en: "No live sessions.", es: "No hay sesiones activas." }, + "perch.waitingOnYou": { en: "waiting on you", es: "esperándote" }, + "perch.open": { en: "Open", es: "Abrir" }, + "perch.talk": { en: "Talk", es: "Hablar" }, + "perch.startFailed": { en: "Could not start a session.", es: "No se pudo iniciar una sesión." }, + "perch.notAttached": { en: "That bot has no Perch channel attached.", es: "Ese bot no tiene canal Perch." }, + "perch.engineRequired": { en: "The bot engine is not installed.", es: "El motor de bots no está instalado." }, + "perch.sendFailed": { en: "The message did not send.", es: "El mensaje no se envió." }, + "perch.back": { en: "← Sessions", es: "← Sesiones" }, + "perch.composerPlaceholder": { en: "Message your bot…", es: "Escribe a tu bot…" }, + "perch.send": { en: "Send", es: "Enviar" }, + "perch.abort": { en: "Stop", es: "Detener" }, +``` + +Create a minimal `servers/gateway/dashboard/perch-hub/client.js` so the import resolves; Task 2 fills it in: + +```js +import { tJs } from "../shared/i18n.js"; + +/** The hub's client script. Emitted INSIDE a template literal — a bare + * backtick or ${ anywhere in here breaks the module at import time. + * tJs escapes \, ', ` and ${, so translations interpolate safely into + * single-quoted client strings. */ +export function perchHubJs(lang = "en") { + return `(function(){ + "use strict"; + var body=document.body; + function setView(v){ body.setAttribute('data-view',v); } + setView('list'); +})();`; +} +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `/home/kh0pp/.nvm/versions/node/v22.23.1/bin/node --test tests/perch-hub-page.test.js` +Expected: PASS, 5 tests (Step 1 wrote four, Step 4 added the engine-banner case). The `100vh`/`100dvh` rule is asserted properly in Task 6 Step 4; the tautological version that short-circuited true has been removed. + +- [ ] **Step 7: Verify the client script parses** + +Add to `tests/perch-hub-page.test.js`: + +```js +test("the emitted client script is valid JavaScript", async () => { + const { perchHubJs } = await import("../servers/gateway/dashboard/perch-hub/client.js"); + // new Function throws a SyntaxError on malformed source without running it. + assert.doesNotThrow(() => new Function(perchHubJs("en"))); +}); +``` + +Run the file again. Expected: PASS, 6 tests. + +- [ ] **Step 8: Check i18n parity** + +`tests/i18n-global-parity.test.js:104` fails any key where **`es === en`**, not merely a missing `es`: + +```js +(k) => translations[k].es === translations[k].en && !IDENTICAL_OK.has(k), +``` + +`"perch.title": { en: "Perch", es: "Perch" }` trips it — the product name is the same in both languages. Add it to the `IDENTICAL_OK` set at `tests/i18n-global-parity.test.js:30`, with a reason comment matching the existing entries' style: + +```js + "perch.title", // a product name, identical in both languages +``` + +Run: `/home/kh0pp/.nvm/versions/node/v22.23.1/bin/node --test tests/i18n-global-parity.test.js` +Expected: PASS. A failure here names the offending keys directly. + +- [ ] **Step 9: Commit** + +```bash +rm -f node_modules +git add servers/gateway/dashboard/perch-hub servers/gateway/routes/perch-hub.js \ + servers/gateway/dashboard/index.js servers/gateway/dashboard/shared/i18n.js \ + tests/perch-hub-page.test.js +git commit -m "Perch Hub: the page, the route, and the ported stylesheet + +A full HTML document at /dashboard/perch rather than a dashboard panel — +the panel shell is a large part of what makes the current session surface +cramped on a phone. It lives under /dashboard so it inherits that mount's +dashboardAuth, CSRF and Funnel rejection; a bare top-level /perch would +inherit none of them, so /perch is a 302 to it. + +The stylesheet is ported verbatim from the deleted bundle and keeps +Perch's own palette rather than crow's --crow-* tokens. That palette is +the look this page exists to restore." +``` + +--- + +## Task 2: The session list + +**Files:** +- Modify: `servers/gateway/dashboard/perch-hub/client.js` +- Test: `tests/perch-hub-client.test.js` + +**Interfaces:** +- Consumes: `perchHubJs(lang)` from Task 1. +- Produces: client functions `perchApi(method, path, body)`, `renderList(roost)`, `loadList()`. `renderList` takes the `GET /roost` payload verbatim. + +**The feed already exists.** `GET /dashboard/perch-api/roost` returns, in one pass over every bot def plus one `engine.list()`: + +```json +{ + "birds": [ + { "id": "r4-assistant", "name": "R4 Assistant", "perch_attached": true, + "state": "idle|working|waiting|hibernating|observing", + "sessions": [ { "sessionId": "perchlive-ab12", "state": "awake", + "cardId": 49, "pendingUi": false, "control": "run" } ] } + ], + "occupiedCardIds": [12, 49] +} +``` + +**Casing trap:** `/roost` returns **`cardId`** (camelCase) on each session, while `POST /interactive//attach-card` reads **`card_id`** (snake_case, `perch-interactive-api.js:175`). Both are correct as written. Do not "normalise" one into the other. + +`occupiedCardIds` is real but **the hub does not use it** — it is the board dispatch picker's concern, not this list's. It is shown here only so the shape is complete; `listRows` ignores it. + +- [ ] **Step 1: Write the failing test** + +Create `tests/perch-hub-client.test.js`: + +```js +// The client script is a string. These tests extract named functions from it +// with new Function(...) and exercise them against the real /roost payload +// shape, so the list logic is covered without a browser. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +/** Pull one named function out of the emitted script and make it callable. */ +async function extract(name, extra = "") { + const { perchHubJs } = await import("../servers/gateway/dashboard/perch-hub/client.js"); + const src = perchHubJs("en"); + const start = src.indexOf("function " + name); + assert.ok(start > -1, name + " is not in the emitted script"); + let depth = 0, end = -1; + for (let i = src.indexOf("{", start); i < src.length; i++) { + if (src[i] === "{") depth++; + else if (src[i] === "}") { depth--; if (!depth) { end = i; break; } } + } + return new Function(extra + src.slice(start, end + 1) + "; return " + name + ";")(); +} + +const ROOST = { + birds: [ + { id: "r4-assistant", name: "R4 Assistant", perch_attached: true, state: "working", + sessions: [{ sessionId: "perchlive-aa", state: "awake", cardId: 49, pendingUi: false, control: "run" }] }, + { id: "asker", name: "Asker", perch_attached: true, state: "waiting", + sessions: [{ sessionId: "perchlive-bb", state: "awake", cardId: null, pendingUi: true, control: "run" }] }, + { id: "idle-bot", name: "Idle Bot", perch_attached: true, state: "idle", sessions: [] }, + { id: "quiet", name: "Quiet", perch_attached: false, state: "observing", sessions: [] }, + ], + occupiedCardIds: [49], +}; + +test("every live session becomes a row, whichever bot it belongs to", async () => { + const rowsFor = await extract("listRows"); + const rows = rowsFor(ROOST); + const live = rows.filter((r) => r.sessionId); + assert.equal(live.length, 2); + assert.deepEqual(live.map((r) => r.sessionId).sort(), ["perchlive-aa", "perchlive-bb"]); +}); + +test("a bot with no session still gets a row, so you can start one", async () => { + const rowsFor = await extract("listRows"); + const idle = rowsFor(ROOST).find((r) => r.botId === "idle-bot"); + assert.ok(idle, "an attached bot with no session must be startable from here"); + assert.equal(idle.sessionId, null); +}); + +test("a bot without perch attached is not offered — the spawn would 403", async () => { + const rowsFor = await extract("listRows"); + assert.ok(!rowsFor(ROOST).some((r) => r.botId === "quiet")); +}); + +test("a session waiting on you sorts above a working one", async () => { + const rowsFor = await extract("listRows"); + const rows = rowsFor(ROOST).filter((r) => r.sessionId); + assert.equal(rows[0].sessionId, "perchlive-bb", "pendingUi first — it is blocked on you"); +}); + +test("a stopped session is not a tappable row that dead-ends", async () => { + const rowsFor = await extract("listRows"); + const rows = rowsFor({ birds: [{ id: "b", name: "B", perch_attached: true, state: "idle", + sessions: [{ sessionId: "perchlive-dead0000", state: "stopped", cardId: null, pendingUi: false }] }] }); + assert.equal(rows.length, 1); + assert.equal(rows[0].sessionId, null, "the bot stays startable; the dead session does not show"); +}); + +test("an empty roost is an empty list, not a crash", async () => { + const rowsFor = await extract("listRows"); + assert.deepEqual(rowsFor({ birds: [], occupiedCardIds: [] }), []); + assert.deepEqual(rowsFor({}), []); + assert.deepEqual(rowsFor(null), []); +}); +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `/home/kh0pp/.nvm/versions/node/v22.23.1/bin/node --test tests/perch-hub-client.test.js` +Expected: FAIL, "listRows is not in the emitted script". + +- [ ] **Step 3: Implement `listRows`, `perchApi` and `loadList`** + +In `client.js`, inside the IIFE: + +```js + /* Tiny DOM helpers used throughout. Defined HERE, in the first task that + emits a script, so no later task references something undefined. */ + function el(id){ return document.getElementById(id); } + function clearEl(node){ while(node&&node.firstChild) node.removeChild(node.firstChild); } + /* textContent only, never innerHTML — see Global Constraints. */ + function line(cls,text){ var d=document.createElement('div'); + if(cls) d.className=cls; d.textContent=text==null?'':String(text); return d; } + + /* The two transcript writers. Defined HERE because the error and + empty-transcript paths call them, and those are the FIRST paths a user + hits when something goes wrong — a ReferenceError there is invisible to a + parse test (new Function binds at call time) and fatal at runtime. */ + function appendNote(text){ + var tr=el('perch-transcript'); if(!tr) return; + tr.appendChild(line('entry note',text)); + tr.scrollTop=tr.scrollHeight; + } + function appendMessage(cls,who,text){ + var tr=el('perch-transcript'); if(!tr) return; + var row=document.createElement('div'); row.className='entry '+cls; + row.appendChild(line('who',who)); + row.appendChild(line('what',text)); /* textContent, never innerHTML */ + tr.appendChild(row); + tr.scrollTop=tr.scrollHeight; + } + + var API='/dashboard/perch-api'; + /* The hub is a STANDALONE document, so it does NOT get shared/layout.js's + global fetch/XHR patch (layout.js:401-465) that attaches X-Crow-Csrf for + every dashboard page. That is why the board's own perchApi sends no header + and this one must. Do not "simplify" by copying the board's version: every + POST would 403. */ + function csrf(){ var m=document.cookie.match(/(?:^|; )crow_csrf=([^;]*)/); return m?decodeURIComponent(m[1]):''; } + function perchApi(method,path,body){ + var opts={method:method,headers:{'X-Crow-Csrf':csrf()}}; + if(body!==undefined){ opts.headers['Content-Type']='application/json'; opts.body=JSON.stringify(body); } + return fetch(API+path,opts).then(function(r){ + return r.json().catch(function(){return null;}).then(function(j){ return {ok:r.ok,status:r.status,j:j}; }); + }); + } + + /* One row per live session, plus one per attached bot with none. A bot with + no perch gateway record is omitted: POST /bots//interactive 403s. */ + function listRows(roost){ + var birds=(roost&&roost.birds)||[]; + var out=[]; + birds.forEach(function(b){ + if(!b||!b.perch_attached) return; + var ss=b.sessions||[]; + if(!ss.length){ out.push({botId:b.id,botName:b.name,sessionId:null,state:'idle',cardId:null,pendingUi:false}); return; } + var live=ss.filter(function(x){ return x&&x.state!=='stopped'; }); + if(!live.length){ out.push({botId:b.id,botName:b.name,sessionId:null,state:'idle',cardId:null,pendingUi:false}); return; } + live.forEach(function(s){ + out.push({botId:b.id,botName:b.name,sessionId:s.sessionId,state:s.state, + cardId:s.cardId==null?null:s.cardId,pendingUi:!!s.pendingUi}); + }); + }); + /* Blocked-on-you first: those are the only rows that need you right now. */ + out.sort(function(a,b){ + if(!!b.pendingUi!==!!a.pendingUi) return b.pendingUi?1:-1; + if(!!b.sessionId!==!!a.sessionId) return b.sessionId?1:-1; + return String(a.botName).localeCompare(String(b.botName)); + }); + return out; + } +``` + +**These constants and the two note helpers live HERE, not in Task 3.** The pre-flight scan caught it: `renderList` and `startSession` below reference all nine, and defining them in a later task would ship a `ReferenceError` on first render — invisible to this task's tests, which only extract the pure `listRows`. + +```js + /* tJs escapes \, ', ` and ${, so these interpolate safely. */ + var NO_SESSIONS='${tJs("perch.noSessions", lang)}'; + var WAITING_ON_YOU='${tJs("perch.waitingOnYou", lang)}'; + var OPEN_LABEL='${tJs("perch.open", lang)}'; + var TALK_LABEL='${tJs("perch.talk", lang)}'; + var START_FAILED='${tJs("perch.startFailed", lang)}'; + var NOT_ATTACHED='${tJs("perch.notAttached", lang)}'; + var ENGINE_REQUIRED='${tJs("perch.engineRequired", lang)}'; + + var pendingNote=null; /* survives the loadList that follows a note */ + function showListNote(text){ + var body=el('perch-list-body'); clearEl(body); body.appendChild(line('empty',text)); + } + /* renderList() ends with this, so a parked note survives the re-render that + would otherwise erase it. */ + function flushPendingNote(){ + if(!pendingNote) return; + el('perch-list-body').insertBefore(line('empty',pendingNote), el('perch-list-body').firstChild); + pendingNote=null; + } +``` + +`renderList` owns three contracts and every one of them is load-bearing, so it is written out rather than described: + +```js + var rowIndex={}; /* sessionId -> row, for a warm openSession */ + + function renderList(rows){ + var body=el('perch-list-body'); if(!body) return; + clearEl(body); rowIndex={}; + if(!rows.length){ body.appendChild(line('empty',NO_SESSIONS)); flushPendingNote(); return; } + rows.forEach(function(r){ + var row=document.createElement('div'); row.className='roost-row'; + row.appendChild(line('roost-dot','')); + var main=document.createElement('div'); main.className='roost-main'; + main.appendChild(line('roost-cwd',r.botName)); + main.appendChild(line('roost-when',r.pendingUi?WAITING_ON_YOU:r.state)); + row.appendChild(main); + var b=document.createElement('button'); + b.type='button'; b.textContent=r.sessionId?OPEN_LABEL:TALK_LABEL; + b.onclick=r.sessionId + ? function(){ rowIndex[r.sessionId]=r; location.hash=r.sessionId; } + : function(){ startSession(r.botId,r.botName); }; + row.appendChild(b); + if(r.sessionId) rowIndex[r.sessionId]=r; + body.appendChild(row); + }); + flushPendingNote(); /* a parked note must survive this render */ + } + + /* A bot with no session: spawn, then let the hash router open it, so history + stays correct and the cold-deep-link path is the same code. */ + function startSession(botId,botName){ + perchApi('POST','/bots/'+encodeURIComponent(botId)+'/interactive').then(function(r){ + if(r.status===409){ showListNote(ENGINE_REQUIRED); return; } + if(r.status===403){ showListNote(NOT_ATTACHED); return; } + if(!r.ok||!r.j||!r.j.sessionId){ showListNote(START_FAILED); return; } + rowIndex[r.j.sessionId]={botId:botId,botName:botName,sessionId:r.j.sessionId}; + location.hash=r.j.sessionId; + }); + } +``` + +`showListNote` is called from `startSession`'s three failure paths — round 4 flagged it as defined-but-unused, and these are its callers. + +And: + +```js + var listTimer=null; + function loadList(){ + return perchApi('GET','/roost').then(function(r){ + if(r.ok&&r.j) renderList(listRows(r.j)); + else renderList([]); + }); + } + /* Poll only while the list is showing. In the chat view the SSE stream is + already the live signal, so polling there is pure waste. */ + function startListPolling(){ stopListPolling(); listTimer=setInterval(loadList,10000); } + function stopListPolling(){ if(listTimer){ clearInterval(listTimer); listTimer=null; } } + window.addEventListener('focus',function(){ if(body.getAttribute('data-view')==='list') loadList(); }); +``` + +Add the nine list-facing keys to `shared/i18n.js` in this task (both languages) — `perch.noSessions`, `perch.waitingOnYou`, `perch.open`, `perch.talk`, `perch.startFailed`, `perch.notAttached`, `perch.engineRequired`, `perch.sendFailed`, and keep `perch.sessionGone` for Task 3. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `/home/kh0pp/.nvm/versions/node/v22.23.1/bin/node --test tests/perch-hub-client.test.js tests/perch-hub-page.test.js tests/i18n-global-parity.test.js` +Expected: PASS, all tests including the parse test. + +- [ ] **Step 5: Commit** + +```bash +rm -f node_modules +git add servers/gateway/dashboard/perch-hub/client.js servers/gateway/dashboard/shared/i18n.js tests/perch-hub-client.test.js +git commit -m "Perch Hub: the session list + +GET /roost already aggregates every bot def, one engine.list() and the +bot_sessions rows in a single pass — it was built for the roost strip and +is exactly this list's feed, so the hub adds no API. + +One row per live session plus one per attached bot with none, so a +session can be started from here. Bots without a perch gateway record are +omitted rather than shown and then 403'd. Rows blocked on you sort first. +Polling runs only while the list is showing; in the chat view the SSE +stream is already the live signal." +``` + +--- + +## Task 3: The chat view — routing, stream, transcript, composer + +**Files:** +- Modify: `servers/gateway/dashboard/perch-hub/client.js` +- Modify: `servers/gateway/dashboard/shared/i18n.js` +- Test: `tests/perch-hub-client.test.js` + +**Interfaces:** +- Consumes: `setView`, `perchApi`, `listRows`, `loadList`, `startListPolling`, `stopListPolling` (Tasks 1-2). +- Produces: `parseHash(hash)` → `{sessionId}` or `null`; `openSession(sid)`; `closeSession()`; `messageText(message)`; `planStateText(state)`; `openStream(sid)`; `closeStream()`; `loadHistory(botId, sid)`; `sendable(text)`; `sendPath(sid, inFlight)`; `send()`; `setTurnInFlight(flag)`. + +**Why routing, stream and composer are ONE task.** Round-1 review: a commit that ships `openSession` while `openStream`, `loadHistory` and `setTurnInFlight` do not yet exist still *parses* — `new Function` binds references at call time — so a parse test passes while every session open throws `ReferenceError`. That contradicts "a working chat surface exists at every commit". These three only become independently reviewable together. + +**Order: stream FIRST, history second.** The stream is live-only with no backlog, so anything emitted between a history fetch and the subscription is lost forever. + +**`sessionId` and `threadId` are the same value** for a perch-live session — `perch-interactive.js:74` states the identity, `:337` sets `sessionId: threadId`, `:483` returns both. So `GET /bots//sessions//transcript` is correct; there is no second id to resolve. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/perch-hub-client.test.js`: + +```js +test("only a real engine-minted session id opens a chat", async () => { + const parseHash = await extract("parseHash"); + // The engine mints "perchlive-" + 8 hex (perch-interactive.js:1473). A loose + // pattern would let ".." through, and openStream builds a URL from this. + assert.deepEqual(parseHash("#perchlive-ab12cd34"), { sessionId: "perchlive-ab12cd34" }); + assert.deepEqual(parseHash("perchlive-ab12cd34"), { sessionId: "perchlive-ab12cd34" }); + for (const bad of ["", "#", undefined, "#..", "#../../etc/passwd", "# +`; +} diff --git a/servers/gateway/dashboard/shared/i18n.js b/servers/gateway/dashboard/shared/i18n.js index 837754fe..745dcf77 100644 --- a/servers/gateway/dashboard/shared/i18n.js +++ b/servers/gateway/dashboard/shared/i18n.js @@ -2220,6 +2220,52 @@ export const translations = { en: "No starter memories left to clear.", es: "No quedan memorias iniciales por borrar.", }, + // ─── Perch Hub (2026-09-09 restore) ─── + "perch.title": { en: "Perch", es: "Perch" }, + "perch.subtitle": { en: "your bot sessions", es: "tus sesiones de bots" }, + "perch.navBoard": { en: "Board", es: "Tablero" }, + "perch.sessionsHeading": { en: "Sessions", es: "Sesiones" }, + "perch.loading": { en: "Loading sessions…", es: "Cargando sesiones…" }, + "perch.noSessions": { en: "No live sessions.", es: "No hay sesiones activas." }, + "perch.waitingOnYou": { en: "waiting on you", es: "esperándote" }, + "perch.open": { en: "Open", es: "Abrir" }, + "perch.talk": { en: "Talk", es: "Hablar" }, + "perch.startFailed": { en: "Could not start a session.", es: "No se pudo iniciar una sesión." }, + "perch.notAttached": { en: "That bot has no Perch channel attached.", es: "Ese bot no tiene canal Perch." }, + "perch.engineRequired": { en: "The bot engine is not installed.", es: "El motor de bots no está instalado." }, + "perch.sendFailed": { en: "The message did not send.", es: "El mensaje no se envió." }, + "perch.back": { en: "← Sessions", es: "← Sesiones" }, + "perch.composerPlaceholder": { en: "Message your bot…", es: "Escribe a tu bot…" }, + "perch.send": { en: "Send", es: "Enviar" }, + "perch.abort": { en: "Stop", es: "Detener" }, + "perch.engineAbsent": { en: "The bot engine is not installed.", es: "El motor de bots no está instalado." }, + "perch.engineInstall": { en: "Install it", es: "Instalarlo" }, + "perch.engineInstalling": { en: "The bot engine is still installing.", es: "El motor de bots aún se está instalando." }, + "perch.engineUnhealthy": { en: "The bot engine is not responding.", es: "El motor de bots no responde." }, + "perch.planOn": { en: "plan mode on", es: "modo de plan activado" }, + "perch.planExecuting": { en: "executing the plan", es: "ejecutando el plan" }, + "perch.sessionGone": { en: "That session is gone.", es: "Esa sesión ya no existe." }, + "perch.steer": { en: "Steer", es: "Guiar" }, + "perch.noTranscript": { en: "No transcript yet.", es: "Aún no hay transcripción." }, + "perch.reconnecting": { en: "Reconnecting…", es: "Reconectando…" }, + "perch.reconnectFailed": { en: "Lost the connection to this session.", es: "Se perdió la conexión con esta sesión." }, + "perch.modelLabel": { en: "Model", es: "Modelo" }, + "perch.thinkingLabel": { en: "Thinking", es: "Razonamiento" }, + "perch.permissionLabel": { en: "Permissions", es: "Permisos" }, + "perch.planModeLabel": { en: "Plan mode", es: "Modo de plan" }, + "perch.permGuarded": { en: "Guarded", es: "Vigilado" }, + "perch.permAsk": { en: "Ask", es: "Preguntar" }, + "perch.permBypass": { en: "Bypass", es: "Omitir" }, + "perch.modelOnDemand": { en: "starts on demand", es: "se inicia bajo demanda" }, + "perch.modelUnavailable": { en: "not running", es: "no está en ejecución" }, + "perch.askConfirm": { en: "Confirm", es: "Confirmar" }, + "perch.askDeny": { en: "Deny", es: "Denegar" }, + "perch.askCancel": { en: "Cancel", es: "Cancelar" }, + "perch.askSubmit": { en: "Answer", es: "Responder" }, + "perch.askStale": { en: "That question is no longer open.", es: "Esa pregunta ya no está abierta." }, + "perch.attachFile": { en: "Attach image", es: "Adjuntar imagen" }, + "perch.fileQueued": { en: "Image attached to the next message.", es: "Imagen adjunta al siguiente mensaje." }, + "perch.fileFailed": { en: "The image did not upload.", es: "La imagen no se subió." }, }; export const SUPPORTED_LANGS = ["en", "es"]; diff --git a/servers/gateway/routes/perch-hub.js b/servers/gateway/routes/perch-hub.js new file mode 100644 index 00000000..7d37e7b3 --- /dev/null +++ b/servers/gateway/routes/perch-hub.js @@ -0,0 +1,29 @@ +import { Router } from "express"; +import { perchHubDocument } from "../dashboard/perch-hub/html.js"; +import { SUPPORTED_LANGS } from "../dashboard/shared/i18n.js"; +import { parseCookies } from "../dashboard/auth.js"; +import { engineStatus } from "../bot-engine-status.js"; + +/** Mounted at "/dashboard" by dashboard/index.js, so this serves + * /dashboard/perch and inherits that mount's auth + CSRF chain. */ +export default function perchHubRouter(dashboardAuth) { + const router = Router(); + // Belt and braces, deliberately. dashboard/index.js:614 already applies + // dashboardAuth to the whole /dashboard mount BEFORE this router is added at + // ~713, so this is redundant TODAY. It is kept because perchApiRouter does + // the same (routes/perch-interactive-api.js:628-633: "installs dashboardAuth + // on its own prefix so it is closed wherever it is mounted") — the router + // stays safe if it is ever re-mounted somewhere else. dashboardAuth is + // idempotent, so running twice costs a session lookup and nothing else. + router.use("/perch", dashboardAuth); + router.get("/perch", (req, res) => { + const cookies = parseCookies(req); + const lang = SUPPORTED_LANGS.includes(cookies.crow_lang) ? cookies.crow_lang : "en"; + // engineStatus() is a LEAF module of synchronous fs stats + // (bot-engine-status.js:84) — safe and cheap from a route. /roost cannot + // report engine state, so without this the list looks normal and the first + // tap fails with a raw error. + res.type("html").send(perchHubDocument(lang, engineStatus())); + }); + return router; +} diff --git a/tests/i18n-global-parity.test.js b/tests/i18n-global-parity.test.js index 616ea6eb..afdfbef3 100644 --- a/tests/i18n-global-parity.test.js +++ b/tests/i18n-global-parity.test.js @@ -85,6 +85,7 @@ const IDENTICAL_OK = new Set([ "onboarding.ai.sizeGb", // "{gb} GB" — the unit abbreviation is unchanged in Spanish // Bot-engine uninstall blast-radius (C4 Task 10) "extensions.engineBlastItem", // "{name} ({types})" — pure template shape, no words to translate + "perch.title", // a product name, identical in both languages ]); const keys = Object.keys(translations); diff --git a/tests/perch-hub-client.test.js b/tests/perch-hub-client.test.js new file mode 100644 index 00000000..073792a3 --- /dev/null +++ b/tests/perch-hub-client.test.js @@ -0,0 +1,727 @@ +// The client script is a string. These tests extract named functions from it +// with new Function(...) and exercise them against the real /roost payload +// shape, so the list logic is covered without a browser. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import vm from "node:vm"; + +/** Replace the interior of every block and line comment with spaces (keeping + * length and newlines), so a `{` or `}` inside a comment can't unbalance the + * depth counter below. Same length as the input, so indices found against + * the masked copy still address the original source. Does not account for + * braces inside string/template literals — none of this codebase's + * extracted functions put one there. */ +function maskComments(src) { + return src.replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*/g, (m) => m.replace(/[^\n]/g, " ")); +} + +/** Brace-matched end index of the function body starting at `start` (the + * index of "function "), depth-counted against a comment-masked copy + * of `src` so a stray brace inside a comment can't extend the match past + * the real end. */ +function braceMatchEnd(src, start) { + const masked = maskComments(src); + let depth = 0, end = -1; + for (let i = masked.indexOf("{", start); i < masked.length; i++) { + if (masked[i] === "{") depth++; + else if (masked[i] === "}") { depth--; if (!depth) { end = i; break; } } + } + return end; +} + +/** Pull one named function out of the emitted script and make it callable. */ +async function extract(name, extra = "") { + const { perchHubJs } = await import("../servers/gateway/dashboard/perch-hub/client.js"); + const src = perchHubJs("en"); + const start = src.indexOf("function " + name); + assert.ok(start > -1, name + " is not in the emitted script"); + const end = braceMatchEnd(src, start); + return new Function(extra + src.slice(start, end + 1) + "; return " + name + ";")(); +} + +const ROOST = { + birds: [ + { id: "r4-assistant", name: "R4 Assistant", perch_attached: true, state: "working", + sessions: [{ sessionId: "perchlive-aa", state: "awake", cardId: 49, pendingUi: false, control: "run" }] }, + { id: "asker", name: "Asker", perch_attached: true, state: "waiting", + sessions: [{ sessionId: "perchlive-bb", state: "awake", cardId: null, pendingUi: true, control: "run" }] }, + { id: "idle-bot", name: "Idle Bot", perch_attached: true, state: "idle", sessions: [] }, + { id: "quiet", name: "Quiet", perch_attached: false, state: "observing", sessions: [] }, + ], + occupiedCardIds: [49], +}; + +test("every live session becomes a row, whichever bot it belongs to", async () => { + const rowsFor = await extract("listRows"); + const rows = rowsFor(ROOST); + const live = rows.filter((r) => r.sessionId); + assert.equal(live.length, 2); + assert.deepEqual(live.map((r) => r.sessionId).sort(), ["perchlive-aa", "perchlive-bb"]); +}); + +test("a bot with no session still gets a row, so you can start one", async () => { + const rowsFor = await extract("listRows"); + const idle = rowsFor(ROOST).find((r) => r.botId === "idle-bot"); + assert.ok(idle, "an attached bot with no session must be startable from here"); + assert.equal(idle.sessionId, null); +}); + +test("a bot without perch attached is not offered — the spawn would 403", async () => { + const rowsFor = await extract("listRows"); + assert.ok(!rowsFor(ROOST).some((r) => r.botId === "quiet")); +}); + +test("a session waiting on you sorts above a working one", async () => { + const rowsFor = await extract("listRows"); + const rows = rowsFor(ROOST).filter((r) => r.sessionId); + assert.equal(rows[0].sessionId, "perchlive-bb", "pendingUi first — it is blocked on you"); +}); + +test("a stopped session is not a tappable row that dead-ends", async () => { + const rowsFor = await extract("listRows"); + const rows = rowsFor({ birds: [{ id: "b", name: "B", perch_attached: true, state: "idle", + sessions: [{ sessionId: "perchlive-dead0000", state: "stopped", cardId: null, pendingUi: false }] }] }); + assert.equal(rows.length, 1); + assert.equal(rows[0].sessionId, null, "the bot stays startable; the dead session does not show"); +}); + +test("an empty roost is an empty list, not a crash", async () => { + const rowsFor = await extract("listRows"); + assert.deepEqual(rowsFor({ birds: [], occupiedCardIds: [] }), []); + assert.deepEqual(rowsFor({}), []); + assert.deepEqual(rowsFor(null), []); +}); + +test("only a real engine-minted session id opens a chat", async () => { + const parseHash = await extract("parseHash"); + // The engine mints "perchlive-" + 8 hex (perch-interactive.js:1473). A loose + // pattern would let ".." through, and openStream builds a URL from this. + assert.deepEqual(parseHash("#perchlive-ab12cd34"), { sessionId: "perchlive-ab12cd34" }); + assert.deepEqual(parseHash("perchlive-ab12cd34"), { sessionId: "perchlive-ab12cd34" }); + for (const bad of ["", "#", undefined, "#..", "#../../etc/passwd", "#