Perch Hub: session observatory and a perch chat channel for your bots (Phase 1) - #262
Merged
Conversation
First half of the Perch Hub bundle: the manifest lands now, the vendored
payload arrives later (C-8). Deliberately incomplete in two ways:
draft: true keeps it out of registry/add-ons.json, so a payload-less
bundle is never installable during the interim window
no payload_sha256 the vendor script stamps it; the digest gate skips
manifests that lack the field, so CI stays green
webUI is loopback-only and reached solely through the gateway's session-gated
proxy at /proxy/perch-hub; authTokenFile names the bearer the runtime mints.
Perch is upstream code we copy in, not code we write, so a reviewed diff is
not enough: the vendor script stamps a sha256 over the payload tree into the
manifest, and check-vendored-payloads.mjs recomputes it on every run.
computePayloadDigest(dir) path-and-bytes framing (<relpath> NUL <bytes> NUL,
posix paths sorted) — a rename is a change. Shared
by both scripts so there is one definition.
absent payload_sha256 skipped, by design (pre-vendor draft window).
A payload/ dir present WITHOUT a stamp still warns
rather than passing in silence.
vendor-perch.mjs reads the pinned four-file list via
'git show <ref>:<path>', so a dirty pi-lab
worktree can never leak in and payload/UPSTREAM
records the exact commit. Reads every file before
writing any: a missing pinned file is a clean
refusal naming it, with nothing written. Replaces
the payload wholesale so an unpinned stowaway
cannot ride inside a 'verified' digest.
pi-lab is live infrastructure — nothing here ever writes to it.
…41-4179) All loopback-only: 4210 is the hub web UI (reached only through the gateway proxy), 4211 its internal session registry, 4141-4179 the pi session pool. The block deliberately avoids upstream Perch's own defaults (4200/4201, pool 4101-4139) so a crow-supervised hub and a standalone pi-hub can share a host — recorded in Conventions so a later tidy-up does not collapse them.
Guards bundles/<id>/payload/ trees against hand edits and bad merges. Green on manifests with no payload_sha256, which is the whole point during the window between a draft manifest landing and its payload being vendored.
servers/gateway/perch-runtime.js supervises the perch-hub bundle's vendored pi-lab hub as a gateway-owned child, on the same generic machinery the Discord child uses (process-supervisor.js). Modelled on bot-runtime.js: injectable seams for env/superviseProcess/randomBytes, a status snapshot for the panel, and a test-reset. Start conditions, both required: the payload entrypoint bundles/perch-hub/payload/hub/server.mjs exists under CROW_HOME, and CROW_DISABLE_PERCH !== "1". The kill switch is checked first, so a disabled host reports reason "disabled" even with a payload present -- but the status snapshot's `installed` still reports the disk truth for the panel's gate card. Neither refusal path mints a token. Auth: crow-mode hub takes its bearer from PERCH_API_TOKEN instead of ~/.pi/agent/settings.json (a stranger's box has no pi settings file). The token is minted once into CROW_HOME/perch-token, 32-byte hex, mode 0600, and REUSED forever after -- the extension proxy will read that same file lazily per request, so regenerating it would silently break every proxied request until the next restart. The spawn wrapper is load-bearing, not decoration: superviseProcess spawns with stdio ["ignore","pipe","pipe"] and never reads those pipes, so an error-looping hub would fill the 64KB pipe buffer and block on write -- a stalled child that still reads as "running". Both streams are drained line-by-line into the gateway log under a [perch-hub] prefix, which is also the only visibility we have into the child. stopPerchRuntime() resolves once the child has actually exited, so the uninstall path can await it (bounded) before removing the payload dir out from under a live child whose cwd is inside it. Wiring: init runs post-listen beside the bot-runtime block, non-fatal -- a hub that will not start must never take the gateway down with it. run-suite.mjs forces CROW_DISABLE_PERCH=1 suite-wide for the same reason it forces CROW_DISABLE_BOT_RUNTIME: a scratch suite gateway must never spawn a real hub or bind 4210/4211 alongside a host's own supervised copy.
… shutdown reap (C-3)
Three gaps between "the perch-hub bundle exists" and "a user can install it
and use it":
1. The proxied hub needs a bearer. A manifest's `webUI.authTokenFile` now
names a file under CROW_HOME that the extension proxy reads LAZILY, per
request, and injects as `Authorization: Bearer <token>`. Lazy is
load-bearing: perch-runtime mints that token in the post-listen boot step,
long after the proxy's route table is built, so a construction-time read
gets nothing and would then serve an empty header for the life of the
process. Hooks go under `on: { proxyReq, proxyReqWs }` — this is
http-proxy-middleware v3, where the v2 top-level `onProxyReq` is silently
ignored (as the file's existing top-level `onError` has been since the
bump; noted in place rather than refactored). No token file → no header,
and the hub answers 401 honestly. `authTokenFile` is manifest data, so it
is resolved against CROW_HOME and refused if it escapes.
2. A bundle installed into a RUNNING gateway gets neither a /proxy/<id> route
(route table built once) nor a supervised child (supervisor starts
post-listen), so an installed `webUI` manifest now sets needsRestart — and
so does removing one. On uninstall of perch-hub the supervised child is
stopped BEFORE its payload dir is rm -rf'd (its cwd is inside that dir),
awaited but bounded at 5s so a wedged child cannot hang the job forever.
The bundle-id check on that stop is load-bearing: stopping the hub while
uninstalling some other web UI would kill a healthy, unrelated process.
3. The supervised hub used to SURVIVE gateway shutdown. gracefulShutdown's
shutdownAll() only reaps proxy.js's bundle children, and supervised
children are detached with no pdeathsig — proven live during C-2, where
the hub was still in `ps` after the gateway exited, holding its loopback
port against the restarted gateway's own child. gracefulShutdown now runs
the same bounded stop.
bot-runtime.js's Discord child has the identical gap and is deliberately NOT
fixed here — own blast radius, own test.
Tests: 20 new. Bearer present/absent/created-after-boot, the websocket hook,
CROW_HOME containment, auth-before-proxy; install/uninstall needsRestart, the
stop ordering, the bound, and the id guard; the bounded stop's three
outcomes; a real gateway boot whose supervised child must be gone after
SIGTERM. Funnel-flagged /proxy/perch-hub/* and /dashboard/perch-api/* are
rejected, and dashboardAuth refuses a session-less caller on both surfaces
(tests/auth-network.test.js — network-exposure invariant).
A bot can now have a Perch gateway attached. "perch" joins ENGINE_CHANNELS
(a perch turn runs handleInbound, which spawns pi — same engine dependency
gmail has) and the simple-gateway-type machinery in gateway-fields.js.
GATEWAY_REQUIRED_FIELDS.perch is [] on purpose: the chat surface is the
supervised perch-hub bundle, addressed by bot id, so there is nothing
per-bot to collect and a bare {type:"perch"} record is complete by
construction. That means the C4 attach gate fires on engine state alone —
intended, not an oversight. Modelled as an explicit [] rather than omitted,
because an ABSENT entry means "unknown type, make no claims" and would stop
the gate from ever seeing a perch record as a real attach.
Both save surfaces are gated, per the C4 lesson: the Gateways-tab save in
api-handlers.js and the wizard's final create. Tests prove each separately;
the engine-absent case starts from a bot with NO gateway so the refusal can
only come from the perch record itself.
Also: a perch branch in gatewayHint() so the bot knows it is in a live chat
with the operator rather than an email thread (gmail/discord strings pinned
byte-identical — they are part of the cached prompt prefix).
Suite 2736 -> 2747.
…wing, transcripts (C-5)
The bots lens is served by the vendored hub through the session-gated proxy,
but the data it renders is Crow's. This is that data plane: one router mounted
at /dashboard/perch-api, inside the dashboard router and AFTER csrfMiddleware,
so it inherits the rate-limit skip, the real double-submit CSRF rail and the
funnel-private prefix rather than reinventing any of them. It also installs
dashboardAuth on its own prefix (bot-board-api idiom) so it is closed wherever
it is mounted — asserted against a real route, not just the middleware.
Seven endpoints, exactly the shapes the shipped lens client already calls:
bots (attach + engine state), sessions (channel, board and narrowing columns
- narrowed_tools is required: the envelope endpoint is per-bot, so the row is
the only place a SESSION's narrowing can come from), envelope (allowed vs
known-denied, from PI_BUILTIN plus toolAllowlist's own derivation), transcript
(resolved by GLOB on *_<pi_session_id>.jsonl and TAIL-truncated, because a live
session file already exceeds 2 MB and head-truncation would hide today's
turns), turn, its SSE event stream, and narrow.
A turn is the gateway's first in-process handleInbound — only an in-process
call gives a streaming sendReply. Its resolved value is the contract, not the
rejection: {action:'deferred'} means pi was at capacity and sendReply was never
called, and unlike gmail there is no tick to retry, so the turn ends with a
terminal error; {action:'error'} was already delivered through sendReply and is
only marked done. Per-thread serialization is a memory set OR a fresh DB claim,
so a gateway that restarts mid-turn cannot let a second pi resume one session
file, and a stale claim is always reclaimable.
Narrowing is keyed by gateway_thread_id and upserted as SELECT-then-
UPDATE-or-INSERT in a single transaction: idx_bot_sessions_bot_thread is NOT
unique, so ON CONFLICT would throw at prepare() and 500 every request, and
duplicate rows are tolerated by design in the bridge's own getSession().
bot_sessions.narrowed_tools is declared both in the CREATE body and via
addColumnIfMissing (the #250 convention). Additive: no SCHEMA_GENERATION bump.
The shipped bots-lens client JSON-parses only the terminal events; it writes a
`log` straight into the pending line as raw e.data. A {text:…} wrapper would
render the JSON blob itself. Newlines are collapsed too — a bare \n inside an
SSE data payload splits the frame.
…(C-6) applySessionNarrowing() is an intersection: a session may ask for LESS than the bot def grants, never more. It is applied in the PiRpc constructor to the FINAL --tools csv — AFTER the subagent append — so a session can narrow subagent away too, and a narrowing that empties the list pins --tools "" rather than dropping the flag (omitting it hands pi its full default surface, i.e. narrowing would widen). Malformed narrowed_tools falls open to the def envelope. handleInbound reads narrowed_tools off the same session row that resolves pi_session_id and passes it into PiRpc, and gains opts.kind so a channel that declares one tags the row it creates. Callers that declare neither (gmail, discord, telegram, slack, crow-messages, board dispatch) are byte-identical: NULL narrowed_tools leaves the csv untouched and the INSERT still defaults kind='chat'. Proven end to end on a scratch home: real /narrow route -> real handleInbound -> real pi 0.82.0 spawned with --tools read,list and the narrowed tool absent from the tool list pi offered the model.
The Perch nav entry appears only once the perch-hub bundle is installed (a hidden PREDICATE, re-evaluated per nav render — the fediverse #230 idiom, so an install shows up with no restart), and the panel renders exactly one of three states rather than ever framing a dead upstream: running full-height iframe of /proxy/perch-hub/bots installed/down "Perch is offline" + the supervisor's own lastError (HTML-escaped) + a retry hint, never an iframe not installed a gate card whose one-click install reuses the C4 engine-gate job client, re-pointed at perch-hub Install state is disk truth first (the payload entrypoint under CROW_HOME) and the supervisor's view second. They legitimately diverge between a webUI bundle install and the restart it requires, and only the disk answer keeps the panel from offering to install something already installed. The one-click install lives here and not in the bots lens on purpose: the lens is served by the hub through the proxy, so it is a different document and cannot reach a Crow dashboard component (PL-2 finding 2). EN + ES copy for every string; nav.perch is a product name, so it joins the global parity gate's identical-by-design list.
… (C-8) Runs scripts/vendor-perch.mjs against pi-lab crow-mode @ 1cef5ec4, which reads each pinned file with `git show <ref>:<path>` (a dirty upstream tree cannot leak in) and stamps the payload digest into the manifest. The hub/ + lib/ sibling layout is preserved verbatim — hub/server.mjs imports ../lib/sessions.mjs, so a flattened payload would only fail at runtime. payload/hub/server.mjs 25622 B payload/hub/auth-source.mjs 561 B payload/hub/bots-page.mjs 25003 B payload/lib/sessions.mjs 9420 B payload/UPSTREAM 66 B (1cef5ec4 + vendoring date) payload_sha256 7ff5b3fb1d9340565b9931fac8843f654aaaa6013757f3b9fd229769e7242995 With the digest stamped, check-vendored-payloads VERIFIES perch-hub instead of skipping it, so the bytes are guarded from here on. draft flips to false and the registry is regenerated — the bundle is installable. The new E2E test is the first thing in this arc that runs the real payload: it lays out a scratch CROW_HOME the way a real install does (payload tree, manifest copy AND an installed.json entry — "installed" has two readers, the runtime and panel key off the payload dir while the extension proxy's route table is built from installed.json, and a fixture with only the former boots a healthy hub behind a route that 404s), boots it through the real superviseProcess on ephemeral loopback ports, and proves GET /bots with the minted bearer returns the crow-mode lens. Teardown goes through the real stop path and a ps scan asserts no orphan survives. Vacuity checked by mutation: deleting the payload fails the test, and breaking only the /bots route while re-stamping the digest fails it at the HTTP assertion with 404 — the 200 comes from the vendored bytes, not from the fixture. tests/vendor-perch.test.js's manifest guard pinned the PRE-vendor state (draft true, no digest) by design; re-pointed at the shipping shape and joined by a payload-integrity test, so a hand edit to the vendored bytes reddens the suite and not only static-checks. Suite 2816/2816.
docs/developers/perch-hub.md is the architecture page: the two lenses and
what is hidden in crow mode, the envelope model (Bot Builder is the single
writer, Perch only ever narrows), gateway supervision and the
CROW_DISABLE_PERCH kill switch, the loopback port block, the session-gated
proxy with server-side bearer injection, the gateway API, the
PERCH_CROW_MODE / PERCH_BASE_PATH upstream contract, and vendoring.
Three things are documented as they SHIPPED rather than as intended:
- the dev lens exposes POST /api/hub/spawn, which upstream documents as
arbitrary code execution, and the proxy injects the bearer for us — so
any authenticated dashboard session can run code on the host. Accepted
by decision, written down as an accepted risk rather than left unstated.
Same for transcript privacy: any dashboard session reads every bot.
- the update path is a bundle version bump reaching users by REINSTALLING
from Extensions. Repo auto-update does not refresh installed copies
under CROW_HOME/bundles/, and no auto-reconcile ships in P1.
- bot_sessions.narrowed_tools is additive with no SCHEMA_GENERATION bump,
so it arrives via the update script's guarded-init-db pass, not via a
gateway restart.
Plus the P1 scope boundaries (the /s/<pid> pane and peer nav are hidden
because the hub's session pass-through rewrites no URLs; the bots lens
cannot open the engine-install modal, so the panel carries the install),
and the PI_BUILTIN vs pi 0.82.0 builtin mismatch (list/glob do not exist in
pi; ls/find cannot be granted) as a known limitation.
Guide section in EN + ES: install Perch, attach the channel, message a bot,
narrow tools for one conversation, read the badges, and what to know before
installing. bot-engine.md and the tutorial corrected — ENGINE_CHANNELS has
five entries now, not four.
C-10 acceptance found the hole: with perch-hub uninstalled the wizard still
offers Perch, the save succeeds silently, POST /turn still 202s and spawns
pi — and there is nowhere to read the reply, because the lens IS the bundle.
A channel you can attach and talk to, with the answer going nowhere visible.
A warning, not a block, on the C4 warn=bot_runtime_off pattern:
- Both save surfaces raise &warn=perch_not_installed — the Gateways-tab
save and handleWizardCreate. Gating one surface leaves a hole; this arc
has learned that twice already.
- The record still saves, the turn API is untouched, and
GATEWAY_REQUIRED_FIELDS.perch stays [] (a required field there would
change what the C4 engine gate treats as a complete attach).
- The signal is perchInstalled() — disk truth at call time, NOT the
supervisor's running state. Every webUI install ends in a gateway
restart, so installed-but-not-running is a normal window and must not
nag through it.
- The banner carries a one-click install driven by the Perch panel's OWN
job client (perchGateClientJS, now imported rather than copied), so
there is one implementation of 'install perch-hub'.
- That client now reloads to a query WITHOUT warn/error, so a successful
install cannot land the operator back on a warning about a bundle that
is now installed. On the panel's own gate card the url is unchanged and
it stays a plain reload.
?warn= is read as a LIST. One save can legitimately raise both
bot_runtime_off and perch_not_installed, which express hands back as an
array; String(array) would have printed a comma-joined raw value and
dropped both banners. Free-form warn strings are never split.
EN + ES copy for the new string. Proven live on a scratch gateway: the same
POST yields warn=perch_not_installed with the payload absent and no perch
warn with it present, both banners rendering together in the first case.
The bots lens printed 'This Crow build does not report the saved narrowing' for every session whose narrowed_tools came back null — which is the ordinary state of a brand-new session on a Crow that reports the field correctly. savedNarrowing() upstream is now a tri-state (Set / null / absent) and the pane says a different, true thing for each. Vendored from pi-lab crow-mode 1b00b80, digest re-stamped, manifest 0.1.2, registry regenerated. check-vendored-payloads reports VERIFIED.
Acceptance measured what the deploy note understated. Skipping the
guarded-init-db pass does not leave 'everything else looking healthy' with
one broken list: GET /bots/:id/sessions AND POST /bots/:id/turn both return
500 'no such column: narrowed_tools'. The turn reads the newest session row
for its in-flight guard before it claims anything or imports the bridge, so
every message fails before pi is ever spawned. What survives is GET /bots
and the chrome around it, which is exactly what makes it read as 'Perch is
broken' rather than 'one migration is missing'.
Also documented while here:
- the D2 attach warning: both save surfaces, why it is a warn and not a
block, why the signal is disk truth rather than the supervisor's running
state, and why ?warn= is a list;
- narrowed_tools as a tri-state on the sessions row — emit the field even
when null, because omitting it makes every fresh session look like a
broken build (the D4 defect, from the gateway's side);
- EN + ES guide: attaching Perch before installing it saves, warns, and
offers the install.
…n row (F1) body.sessionId is caller-supplied, so it could name any of the bot's threads — including a live gmail one. claimTurn() would flip that row to active, the bridge's getSession() would resume it, the perch message would be appended to the gmail conversation's pi session file, and upsertSession() would relabel the row kind='perch' permanently. The lens badges off gateway_type, so nothing ever showed it. POST /turn now reads the existing row first and refuses 400 not_a_perch_session when its gateway_type is set and is not 'perch'. A NULL gateway_type (legacy, pre-channel row) and a thread that does not exist yet both stay usable.
…lens (F2) The narrow route is keyed by a caller-supplied gateway_thread_id, the lens drew a Controls button on EVERY session row, and the bridge reads narrowed_tools on every channel. Together that let an operator permanently strip a tool from a production Gmail thread from inside Perch — invisibly to Bot Builder, which is the declared single writer of the tool envelope. P1 scopes narrowing to perch sessions: POST /narrow refuses 400 not_a_perch_session on a row belonging to another channel, and the lens no longer offers the affordance there. Transcript is a read and stays on every row. Cross-channel narrowing becomes a Phase-2 question — it needs Bot Builder visibility first. Lens change vendored from pi-lab crow-mode 231fbc5 (payload 0.1.3, digest b6b36690).
The guard was checked, then two awaits ran, then it was taken. Today's awaits are microtask-only EXCEPT the lazy bridge import on a gateway's first turn — a real module load — so one-turn-per-thread was correctness by accident. Two concurrent first turns both got 202 and both reached handleInbound, which is two pi processes resuming one session file. The memory guard is now checked and taken in the same synchronous run; the DB claim follows. Both refusals that can happen after it (foreign channel, fresh DB claim) hand the guard back — leaking it would wedge the thread forever and badge it 'live' in the lens; both are pinned by tests. New test seam loadBridgeImpl replaces the lazy import while keeping its timing: handleInboundImpl short-circuits before it and resolves in a microtask, which can never interleave, so only this seam reproduces the racy window.
The code comment and the developer doc both said perch was the first in-process handleInbound in the gateway, and that every other dispatch spawns a detached child. That is false: the Gmail tick has run one in-process since C4 — servers/gateway/bot-runtime.js imports runBridgeTick from scripts/pi-bots/bridge_tick_lib.mjs, which imports handleInbound from the bridge directly and calls it. (Board dispatch is the real contrasting case.) Replaced with the fact that is actually load-bearing: perch turns and Gmail tick turns now share one process and one event loop, both draw on the same host-wide pi capacity budget (countLivePi vs LIFECYCLE_DEFAULTS.maxPi — so a perch turn can be deferred by a Gmail turn), and a perch turn inherits exactly the PIBOT_* timeout and env tuning a Gmail turn gets on that host. A test pins the underlying imports and rejects the affirmative claim in both files, so it cannot quietly return.
Two unbounded reads, both in the lens's hot path. GET /bots/:id/sessions was ORDER BY id DESC with no LIMIT. bot_sessions only grows — a long-lived gmail bot gets a row per thread, and duplicate rows per (bot, thread) are tolerated by design — so the whole history came back in one JSON body. Capped at 50 (Perch's own lister caps its home page at 25 and defaults to 50), fetched as LIMIT 51 so the extra row reports "truncated" without a second COUNT. The transcript's 2000-line tail cap was applied AFTER readFileSync + split held the entire file in memory twice, and it does not bound memory at all: 100 lines of 50 KB is a 5 MB slurp that never trips the line cap. Now statSync + a bounded read of the trailing 2 MB, dropping the partial first line at the boundary. "omitted" is exact when the whole file was read and null when the byte cap bit, because the head is never read and reporting 0 would be a fabrication. The lens states both truncations (vendored from pi-lab crow-mode b472a7a, payload 0.1.4, digest 0da7e59f).
extension-proxy.js injects a backend bearer on every proxied request, which satisfies the backend's own auth for anything reaching that middleware. The proxy router is mounted at app root (boot/late-mounts.js), outside the /dashboard CSRF rail. For perch-hub the backend exposes POST /api/hub/spawn, documented upstream as arbitrary code execution. The only thing closing the cross-site hole is SameSite=Lax on crow_session: Lax withholds the cookie from cross-site POSTs, so such a request arrives session-less and authMiddleware rejects it before a bearer is ever injected. That was nowhere written down, so a future 'make the session cookie SameSite=None so the dashboard can be embedded' change had nothing to warn it — and would have converted the hook into reachable RCE. Written at the injection hook, and pinned by a test that fails both if the cookie is relaxed and if the hook stops naming what it leans on.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes Perch — the self-hosted pi session manager — an installable Crow extension: a window into what your bots have been doing, and a new
perchchat channel for talking to them from the dashboard.Spec and plan live in the private engineering repo (
specs/2026-07-31-perch-hub-crow-integration-design.md,plans/2026-07-31-perch-hub-p1-implementation.md).What ships
perch-hubbundle — the hub is vendored from its upstream repo at a pinned commit, guarded by a payload digest that CI recomputes, and installed like any other bundle. Loopback-only ports (4210 / 4211, session pool 4141-4179), nothing publicly exposed.CROW_DISABLE_PERCHkill switch, stdout/stderr drained into the gateway log, and a bounded stop on uninstall and on gateway shutdown./proxy/perch-hub/behind the dashboard login, with the hub's bearer injected server-side. The hub's own listeners never leave loopback.perchas a first-class channel — attachable in Bot Builder and the wizard, gated on the bot engine like the other engine channels; turns run through the existing pi bridge (kind='perch').Verification
17 × 23with391, with process evidence that pi ran from the bot-engine bundle path and the hub from the perch-hub payload path → narrowing round-trip persisted across reload → widening rejected → uninstall leaves no orphans.bot_sessions.narrowed_tools), no generation bump; migration dry-run clean on copies of both live databases.Operator note
narrowed_toolsreaches existing hosts through the update script'sguarded-init-dbpass, not through a gateway restart. Skipping it takes the whole perch channel down (both/sessionsand/turnreturnno such column), which acceptance reproduced deliberately. Documented indocs/developers/perch-hub.md.Known limitations, documented
The per-session live pane and cross-machine peer nav are hidden in this surface (the hub's session pass-through can't work behind a subpath proxy — Phase 2). Chat threads don't resume across page loads. The dev lens can execute code on the host for any authenticated dashboard session, and any dashboard session can read every bot's transcripts — both stated plainly in the docs as accepted for a self-hosted operator tool.