@computesdk/loops
Loops for people who do not live in a terminal: a loops CLI and a localhost web UI, both clients of
@computesdk/pi-loops. The extension is the foundation — it owns the loops, the records and every
capability. This package owns none of that; it drives the engine.
loops status # what ran here, read straight from the records (no pi process, no tokens)
loops doctor --live # can a round work here? boots serve, runs check, asks the judge to read an image
loops run "<goal>" # a round — the one verb that costs tokens
loops clean | apply | take | rule | questions | answer
loops serve # one browser app for this VM; choose workspaces in the UI
Installed from npm it is one command, and that is the one to hand someone:
npx @computesdk/loops serve
It is a client of a real Pi session, not a re-implementation of one. Chat state uses RPC and SSE rather than
polling or another database: the session is the durable record, and .pi/loops/ is a rebuildable index plus its worktrees.
Commands, providers, and chat models
Workspaces are persistent horizontal tabs above the entire app, including the loop inspector.
The chat sidebar belongs to the selected workspace; tabs scroll independently when many projects are open.
The compact conversation header opens full repository details. Commands (including model/provider actions)
open through /, Ctrl/Cmd K, or the compact / composer button; there are no standalone Commands or Chat model buttons.
Provider setup is also available before the first chat in workspace onboarding, and through Loop settings.
The workspace strip, chat sidebar, and conversation use distinct neutral surfaces in light and dark themes.
Use the compact / control or press Cmd/Ctrl K. Typing / in the composer opens the same keyboard-navigable menu.
Commands uses one palette frame and one close control. Other forms and Loop settings use native modal
dialogs/sheets; Cmd/Ctrl K does not stack Commands over them or cancel an active provider connection.
Use ↑/↓ to browse and Enter to choose. Model/provider forms focus their controls when ready;
the model filter's arrows enter the model list, and Enter confirms the selected model. Pi choice forms
also support ↑/↓ (plus Home/End), then Enter/Space to answer. Browsing alone never submits a choice.
An incoming decision preserves focus if you have started a new, unsubmitted draft.
Pi discovers that chat's extension commands, prompt templates, and skills; their exact names and source are shown.
Choosing one inserts it into the composer—review its arguments and explicitly Send. Unknown slash commands are
rejected without sending them to the model. This is not an emulation of Pi's full terminal interface.
| App action | Behavior |
|---|---|
/new | Create a new app chat in the selected workspace; retain the old chat and its draft |
/model | Choose an exact provider/model from this writer's available catalog |
/compact | Confirm a potentially paid compaction; show Pi's progress/result without automatically retrying |
/login | Connect a provider using Pi's OAuth/API-key flow |
Discovered Pi names win collisions. The corresponding app action moves to an available app: alias displayed
in the menu, instead of silently shadowing an extension, template, or skill. Model selection changes the parent
chat only; it does not rewrite loop role/attempt configuration or global defaults. Model changes and compaction require an idle
parent and no running background loops. Neither operation replaces the Pi session or creates another writer.
Connect provider also works before selecting a chat. Pi's public provider metadata determines the available login methods. OAuth supports browser authorization, manual callback/code prompts for remote VMs, and device-code flows when offered by the provider. Finish the provider's instructions in the dialog; on a remote VM, a localhost redirect may fail on your laptop, in which case copy its full callback URL into Pi's manual prompt. Closing the dialog cancels its pending flow. Expiry, cancellation, and server shutdown clean up callback listeners. Cancellation cannot undo a credential already committed or revoke an account grant.
API keys and authorization responses go only in JSON to the dialog's pinned server with its pinned bearer. They are not written to chat, URLs, logs, or browser storage. Pi owns credential persistence and OAuth refresh; the UI does not copy or implement provider token storage. A submitted key can require follow-up prompts: Connected means Pi confirmed persistence, not that a paid verification request succeeded. Merely opening the dialog lists stored-presence metadata and does not execute configured credential commands or sign in. There is one bounded, in-memory login flow per server; its status is polled only while the dialog is open/pending.
After login, idle writers refresh Pi's public model runtime without a model request or automatic model switch. The bundled SHA-pinned RPC adapter adds this metadata refresh; custom Pi executables without it show a restart warning and are never restarted automatically. Startup extension decisions remain separate and must be answered before that writer's commands/models are available. Loop settings retain role configuration and checks, while their former key-only panel now opens the shared provider dialog.
| What the UI shows | Where it comes from |
|---|---|
| the transcript | get_messages from the chat's pi --mode rpc child |
| a loop, its rounds, its scores | the loops-state snapshot and loops-round entries the extension appends to that session |
| the current winner / applied commit | the newest loops-state snapshot (best, winner, baseCommit) |
| a round in flight | the loops-progress entries it appends while it works (throttled; phase changes always) |
| questions for you | loops-question / loops-answer entries — append-only, so nothing is rewritten |
| the brief / approve card | the extension's ctx.ui.select, as an extension_ui_request |
| diffs, screenshots, sheets | read-only routes over the run's worktrees and archived shots under .pi/loops/ |
Visual previews wait for actual screenshot files rather than treating configured output paths as images. Unavailable or failed images show an explicit state and Retry; retrying an image does not rerun the loop. Attempt details show archived captures without requiring a live server or applying the candidate. Use the capture selector for the actual desktop/mobile screenshots, and Open screenshot to inspect full size. Live pages use a sandboxed iframe only when a real live URL exists.
Outcome questions belong to their exact run and round. Later execution can supersede them (including a recorded same-round retry); expired/superseded cards leave Needs you but remain in the conversation with their reason. This is derived from Pi's entries, including existing histories, never a manufactured human answer. Generic asks remain open until answered or expired. Stale answer submissions are refused; stopping alone does not answer a card.
Completed results keep the metric, candidate check, changed files, and next step together in one header.
Loop thumbnails occupy their own independently scrolling column below workspace and stop controls, beside
the conversation and composer—not a floating overlay. Phones retain an in-flow horizontal strip.
“Tracked edits applied” requires the engine's recorded best.applied commit; answering an Apply question
does not prove success. Old records without that fact remain unconfirmed. Added/deleted files are called
out separately because the engine's tracked-file apply does not include them. Apply stays in the conversation.
“Best version vs original” and its file list keep comparing the first round's recorded fork commit to the
winning candidate, even after Apply, resume, or a checkout rebase. This is historical candidate evidence, not a
claim that added/deleted files landed in your checkout. Legacy records without that original anchor retain their
recorded execution-base comparison; the server does not guess an original from a later round or commit parent.
A chat's directory comes from Pi itself: each session header records its cwd.
The server resumes that exact session file in that directory; selecting a workspace cannot move a chat.
Pi remains the only session writer, with one RPC child per opened chat, shared by its browser subscribers.
There is no second transcript store and no workspace-wide editing lock.
workspaces.json indexes sessions and persists workspace IDs, session locations, and which tabs are open.
Closing a tab retains its workspace, chat bindings, and sessions. Recovery can rebuild bindings from session headers
if the registry is lost, but an intact registry's closed tabs stay closed across restarts.
One server per VM
The server defaults to 127.0.0.1. Remote listening requires a bearer token (generated automatically when needed);
this process can prompt an agent with bash access, so protect the token as a password to the VM.
An SSH tunnel is another option (ssh -L 8001:127.0.0.1:8001 …).
Behind an authenticating reverse proxy, keep the listener on loopback and explicitly name its public origin:
loops serve --public-origin https://my-vm.exe.xyz,https://my-vm.exe.xyz:8001 --no-open
This trusts exactly those Host/Origin names, not arbitrary X-Forwarded-* headers.
The proxy must restrict who can access the app; this flag does not add authentication.
Do not use it to expose an unauthenticated agent. See deploy/loops.service.example
for a supervised checkout deployment. Rebuild and restart the server after an update; rebuilding the SPA alone
can leave a new client talking to an old backend.
Run it
Start it once for the machine, then open directories from the workspace launcher:
npx @computesdk/loops serve # from npm — one server, independent of launch directory
# from a checkout:
loops serve # or: node server/index.ts --port 8001
# optionally seed a workspace:
loops serve --cwd ~/code/some-repo
It ships as one bundled server plus the built SPA, so there is no build step on the other end. The pi-loops extension
and Pi runtime are installed as runtime dependencies. Chats use the bundled Pi RPC runner by default;
--pi <executable> explicitly selects another Pi installation.
It prints the address and opens it:
open http://127.0.0.1:8001
loopback only
--no-open (or LOOPS_UI_OPEN=0) prints the address without launching a browser. When the URL carries a token — off
loopback — the opened address is the attach form, /?server=…&token=…. The app adopts it once and removes the token
from the address bar and current history entry. A non-secret external server address stays in navigation links.
A loop needs the engine, so the boot says when it is missing rather than leaving you to find out on the first prompt:
if the selected Pi executable or an --extensions path does not exist, it prints what is wrong and the fix under !! a loop cannot run here yet, and /api/health reports ok: false with the same list. The app still serves — you can add
repos and read what already ran.
A workspace is a directory on the connected VM, with its chats, runs, and .pi/loops.json.
+ opens the launcher: choose a recent workspace, browse the VM's directories, or paste an absolute path (~/ works).
Navigation links
Workspaces, chats, and loop views have bookmarkable URLs:
/workspaces
/workspaces/<workspace-id>
/workspaces/<workspace-id>/chats/<chat-id>
/workspaces/<workspace-id>/chats/<chat-id>/loops/<run-id>
/workspaces/<workspace-id>/chats/<chat-id>/loops/<run-id>?round=2&attempt=R2-safe
IDs and attempt names are URL-encoded, durable identifiers, not display titles. Links support copy-link,
new tabs, reload, and browser Back/Forward. Explicit links take precedence over remembered selections;
only the legacy bare / entry restores the last selection and replaces itself with its concrete URL.
Opening a link never creates a chat or rebinds its directory. A known closed workspace reopens with the
same ID. Unknown, unavailable, or mismatched targets show an error and recovery links, not another
chat's content or actions. Round/attempt focus is validated against that chat's actual loop history.
When attached to another backend, links include ?server=… (alongside any round/attempt parameters),
but never its bearer token. Recipients need their own access to that server. A server-only link cannot
forward another backend's saved credential; same-origin authenticated links retain their token on reload.
An explicit link without a server parameter refers to the origin serving the UI, not a stale attached-server preference.
Home, Up, breadcrumbs, and Show hidden navigate the server's filesystem, not the browser's computer.
The picker shows whether Git has a commit; opening a directory never initializes Git or edits the project.
Opening a directory never creates a chat. A new workspace starts with its chat list and no Pi child.
Choose an existing chat or explicitly create one with New chat in that workspace. The active full path stays visible.
Returning to a workspace restores its last selected chat (resuming that writer if necessary),
remembered separately for each connected server. × only closes a tab: it does not stop its agent,
delete history, or change the session's directory. Reopen it from Recent workspaces.
The first launch has no implicit project; --cwd optionally seeds one. All tabs can be closed.
Canonical paths deduplicate symlink aliases, same-named directories retain distinct stable IDs, and missing
directories remain visible as unavailable instead of silently falling back to another workspace.
If Pi exits, the chat keeps its diagnostic instead of restarting the child on every read. Retry agent explicitly restarts that same session after the problem is resolved. Startup extension decisions (including exe.dev's first-run LLM setup) can be answered directly in the browser. The default runner uses Pi 1.4.0's own CLI with a narrow, in-memory compatibility fix: attach the RPC input reader before awaiting startup extensions, handle their answers immediately, and hold ordinary commands until setup finishes. It verifies the pinned version and exact upstream source before applying the fix; no global Pi files are modified. Pi still owns the session, extensions, model configuration, and all decisions.
The bundled Loops extension is a fallback: if Pi's enabled, trusted packages already supply
@computesdk/pi-loops, the default runner uses that installation instead of loading a second copy.
Other extensions remain enabled, and genuine conflicts still fail. This selection is also SHA-pinned
to Pi 1.4.0's resource loader; explicit --extensions or --pi retain normal Pi loading semantics.
Failed answer submissions remain visible with a retryable error; Loops never accepts or declines an integration for you.
An explicit --pi bypasses this compatibility runner, so that executable must support startup dialogs itself.
Server metadata is in ~/.pi/loops-ui; browser navigation state and unsent drafts are kept in local storage.
Drafts and failed messages are scoped to the connected server and chat. A send keeps its draft until acknowledged;
errors retain the message for review and retry, including after switching chats or reloading.
loops serve is a foreground server command, not an attach command: start it once (or supervise it as a service),
then use that URL for every workspace. Running it again on the same port does not replace or attach to the first process.
Flags (each has a LOOPS_UI_* environment variable):
| flag | default | meaning |
|---|---|---|
--port | 8001 | where the app listens |
--cwd | unset | optionally seed a workspace; launch directory is otherwise irrelevant |
--pi | bundled Pi RPC runner | explicitly override the Pi executable each chat spawns |
--extensions | ../pi-loops/extensions/loop.ts | comma-separated -e paths for the child |
--data | ~/.pi/loops-ui | workspaces.json and the chats of every repo |
--host | 127.0.0.1 | off-loopback listening requires a bearer token |
--public-origin | unset | comma-separated exact origins of a trusted authenticating reverse proxy |
--model | Pi's default | the model every chat starts on |
--no-open | off | print the URL without opening a browser (LOOPS_UI_OPEN=0) |
--dev | off | proxy the SPA to Vite on :5173 (npm run dev) |
Attach to a server on another machine
The SPA is a client. By default it talks to the process that served it; it can be pointed at another one:
# on the box that owns the repos — off-loopback listening needs a token
node server/index.ts --port 3000 --host 0.0.0.0 --token # prints the URL + token once
# anywhere else: either open the served UI at box:3000, or attach this one to it
http://box:3000/?server=http://box:3000&token=<printed>
The server/token pair is adopted once and stripped from the address bar; after that the sidebar footer names the
server you are attached to (click it to change). A URL that lands in a log is not a problem because the token lives in
localStorage afterwards, but treat it as the password it is: ~/.pi/loops-ui/token, mode 0600, --token to rotate.
The API is the product. Anything that wants its own chat UI, its own loop canvas, or its own needs-you inbox should
use it rather than this SPA: see API.md for every route and the loop lifecycle, and client.ts
for a typed client (no dependencies) that can be imported straight into another app.
The routes are the contract, not the SPA: GET /api/workspaces, /api/chats?ws=, /api/state?chat=, /api/config?chat=,
POST /api/chat|prompt|steer|answer|ui|config|live, and one event stream per chat:
GET /api/chats/:id/events Accept: text/event-stream
id: <session entry id> ← the cursor; send it back as Last-Event-ID after a drop
event: state ← the same snapshot /api/state returns
event: ping ← keep-alive every 20s
SSE over fetch (not EventSource) so the token can travel in a header, and so a reconnect resumes from the cursor
instead of starting blind.
A server on loopback answers only its own origins (127.0.0.1, localhost, [::1] on its port) and only under those
names in the Host header — the second half is what stops a page that points its own hostname at 127.0.0.1 and
becomes same-origin. A loopback server needs no token for that, because the boundary is the name. Once the port is
reachable off the machine the token is the authorization and any origin may attach; --allow-origin a,b narrows it.
What it is made of
server/
index.ts Hono: /api/state, /api/prompt, /api/ui, /api/steer, /api/shot, /api/diff, /ws, static
chats.ts one Chat per session: spawns `pi --mode rpc`, folds its events into one snapshot
pi-rpc.ts the RPC client: JSONL framing, id correlation, extension dialogs
runs.ts the durable session entries first, the `.pi/loops` index as fallback -> the shapes the canvas draws
workspaces.ts the repos this process may touch; chats are bound to one
directories.ts bounded, authenticated directory browsing on the server
config.ts settings with provenance, agent-write proposals, and the doctor checks
live.ts one live server for the winner, on demand
src/
shared/ the wire types both halves use
client/ the SPA (React 19 + Tailwind v4), ported from the POC at ~/factory/mock
Why an event stream and not a poll
A loop runs in the background in Pi: the loop tool returns as soon as the round starts, and the round card arrives
later as a message. In a terminal that is fine — the status line and the card are enough. A canvas wants more, so the
extension appends a loops-progress entry (at most one per 1.5s, always on a phase change) and calls setStatus, which
reaches us as an extension_ui_request. Either one wakes the server, which re-reads the session with a cursor
(get_entries?since=). One event in, at most one cheap read out — and a browser that reloads mid-round rebuilds the same
picture from the entries alone.
Proof that the judge saw the picture
A judge that never received an image used to score a visual round anyway — the visible symptom was
reasons: "Image unavailable" next to a 5–5 "tie". Two mechanisms make that impossible to miss:
- Declared capability is checked first. A judge (or panel member) whose model does not claim image input is refused before an attempt runs, with the setting to change. No model, no round.
- Every judged screenshot carries a code in its top-right corner. The judge must read it back
(
"tokens": {"<label>": "<code>"}); one edit apart counts as a misread, anything else does not count at all. A candidate whose code was not read back isunseen: it gets no score, the round is marked inconclusive (never "even"), and a question is raised — retry, switch the judge, or keep the current best.
The code lives in the pixels only, so it cannot be echoed from the prompt. lib/shot.ts does the stamping
(stamp, canaryImage), lib/judge.ts the comparison (sameCode), and probeVision() can settle a model's
capability offline — not inside a tool call, where creating a session waits on the runtime the turn holds.
Live: one server, on demand
A screenshot is a still of an app that already runs — lib/shot.ts starts serve inside an attempt's worktree to take
it. Run it keeps that same server alive for the winner instead: one slot, started when you ask, killed when you stop
it, after ten idle minutes, or when this process exits. It is offered only when the repo has serve in
.pi/loops.json. The iframe gets an opaque origin (sandbox, no allow-same-origin), because the code inside it was
written by an attempt.
Judging happens in a child process
An in-session prompt(text, { images }) does not deliver the picture on this Pi — the message carries the block, the
provider request does not — so a judge that has to look at screenshots is asked through pi --print (the same binary,
model and auth, with @file arguments, which do carry images). Two plumbing rules matter there and both cost a bug
each: close stdin (ignore) or --print waits for EOF forever, and parse stdout only (pi warns on stderr).
The checkout is not the attempts' to touch
An attempt has bash and inherits the repo's absolute path, so it can edit or commit into your checkout instead of its
worktree. Every round therefore checks, after the attempts: did HEAD move, did the working tree gain changes, while
every attempt's own worktree stayed at the base commit? If so the round is inconclusive (nothing of theirs exists to
compare), your tree is put back, and the card says what happened — the loop's promise is that your checkout is
untouched until loop_apply.
Cleanup
Worktrees are scratch copies: the commit lives in the repo and on the branch loops/<run>/rN-<style>, so removing a
worktree loses only what the attempt left uncommitted. clean up (or /loop clean [run|all]) removes the attempts'
worktrees, drops the losing branches and runs git worktree prune, keeping the winner's worktree, the durable session
snapshot, the JSON index, the sessions and the archived screenshots. loop_apply does it for the losers as soon as a
winner lands.
Settings that change under you
.pi/loops.json decides which models judge, what the checks are and how much runs. The server watches it and the app
shows what changed (models judge exe/… → particle/…) while it is open — an agent editing config is a real event, not
a detail.
Actions
The buttons talk to the engine, not to a model: /loop take, /loop apply, /loop stop, /loop rule, /loop talk,
/loop answer. Anything that needs judgement ("two more rounds", "steer it this way") is sent to the agent as a message,
because that is a decision the agent owns.
Stop response aborts the current chat's parent turn. Stop loop cancels only the named background run, even while the parent is busy; its worktrees stay. Controls appear only while their target is active, beside the composer, loop activity, or inspector (including mobile). Each target keeps one persistent controller as its button moves between views, preserving pending state and errors through resizing and inspector navigation. They guard duplicate requests and show errors. Acknowledging a button does not mark work stopped: streamed session state must confirm it. Missing signals, incomplete judging and budget/timeout failures are not labeled Done. All run actions keep the exact selected run ID; attempt selection sends its durable name, not its A/B display letter.
Invalid watched .pi/loops.json no longer terminates the server. The rejected text is saved beside the config,
the last accepted settings are restored when possible, and a workspace-scoped error explains how to recover.
Parity
npm run shots writes ./shots/ at 1440, 1024 and 430 wide. The pass condition for this port was the screens the POC at
~/factory/mock shows, at the same density; see PARITY.md for the state-by-state comparison.
Development
npm run dev # Hono on :8001 proxying the SPA to Vite on :5173
npm run check # typecheck the server and the client separately
npm test # offline engine/API tests, including workspace restart and routing regressions
npm run test:browser # build first; real Chromium workspace navigation, with a fake Pi (no model calls)
npm run shots # headless-chrome screenshots of every state