# The `pi-loops` command line

`pi-loops` opens a conversation. With no arguments it opens the browser page when you are at a
local terminal, and pi itself when you are not — over ssh, or with no terminal at all, where a
browser on this machine would help nobody. `--web` and `--tui` say which when the guess is wrong.
With no terminal at all (stdin or stdout piped) and nothing that tells pi to run without one (`-p`,
`--mode`, `--help` and the like after `--`), there is nowhere for pi to draw: `pi-loops` exits 1 and
says so, with `pi-loops: there is no terminal here to open pi in; run pi-loops --web to open the
page, or pi-loops -- -p "<prompt>" for one answer`.

```
pi-loops                             # open a conversation, in whichever window makes sense here
pi-loops --tui                       # the terminal one
pi-loops --web                       # the browser one, also over ssh (it prints the address)
pi-loops tui | pi-loops web          # the same two, as words
pi-loops --loops-dir <dir>           # the page uses that loops directory, as PI_LOOPS_DIR does
pi-loops --model anthropic/claude-opus-5 -e .   # anything it does not recognise goes to pi
```

The page is served by pi-loops in the background, not by this command. `pi-loops` starts pi-loops
in the background if it is not running, asks it to open the conversation, prints a link, opens your
browser and exits:

```
$ pi-loops
started pi-loops in the background
pi-loops: http://127.0.0.1:4173/c/424aba43-…/?open=c0ff…
  opened in your browser
```

The first line appears only when this command started pi-loops. The link holds a one-use key that
works for 10 minutes, never the token, because what a terminal prints stays in its scrollback. It is
printed before the browser is tried, so a browser on this machine that did not open gets in with a
paste. The last line says what happened:

- `  opened in your browser`: a browser loaded the page and spent the launch key within 8 s,
  watched while the opener runs. The opener is `open` on macOS,
  `rundll32 url.dll,FileProtocolHandler` on Windows, `rundll32.exe url.dll,FileProtocolHandler` on
  WSL (`WSL_DISTRO_NAME` set, or a kernel release that names Microsoft; then `cmd.exe /c start`
  when `rundll32.exe` cannot start), and `xdg-open` on other Linux, which gets 10 s because it takes
  about 5 s to give up when there is no browser. An opener's exit code is not enough: `rundll32`
  exits 0 for a target it cannot open.
- `  if no browser opened, open the link above (it works once, for 10 minutes)`: the opener ran, but
  no browser spent the key within 8 s.
- `  could not open a browser here; open the link above (it works once, for 10 minutes)`: every
  opener failed, or Linux has no `DISPLAY` or `WAYLAND_DISPLAY`.
- `  open the link above in a browser on this machine`: over ssh or mosh, where nothing is opened.
- `  open the link above in a browser (it works once, for 10 minutes)`: with `--no-open`.

The browser is opened on a file that only you can read, under the loops directory's `pages/open/`,
which sends it to the conversation with a second one-use key; that key is in no command line, and
the file is removed once the browser has used it, or after a minute. On WSL the Windows browser
reads that file as `file://wsl.localhost/<distro>/…`, which it cannot do when the loops directory's
path holds a character Windows does not allow in a name (`\ : * ? " < > |`); then the last line says
it could not open a browser. A browser confined by snap or flatpak
(Ubuntu's default Firefox is one) cannot read a hidden directory such as `~/.pi`, so it shows an
error instead: use the printed link. With no terminal (stdout piped or redirected), no key is
printed, because a file would keep it: the link is the page's address, and the next line is
`  pi-loops open prints a link that signs a browser in`. A conversation the page already has open
is not started again, so pi arguments given for it are not applied, and the last line says which:
`  this conversation was already open; --model was not applied`. Opening can wait up to 15 s for
an older pi in that conversation to stop; after 3 s the command says
`waiting for pi-loops to open the conversation…` on stderr, and it waits up to 30 s in all.

### A fresh link: `pi-loops open` and `/pi-loops web`

```
pi-loops open [--cwd <dir>] [--loops-dir <dir>]
```

opens the page on the project's newest conversation (a new one when the project has none), with
the same output as `pi-loops`, except that it prints the link with its key with no terminal too,
because that link is what it is for; it opens no browser then. `--loops-dir` names the loops
directory, as it does for `pi-loops` and as `PI_LOOPS_DIR` does. Run it when a browser shows the door ("This browser has not been
here before."), or when the printed link has been used or is older than 10 minutes. Inside pi,
`/pi-loops web` does the same for the conversation you are in (a new one in the project when pi
has saved nothing of it yet, before its first answer or under `--no-session`), shows the same
lines, and copies the link to the clipboard (on WSL, the Windows clipboard). It adds
`  copied to the clipboard` only when `pbcopy`, `clip`, `clip.exe`, `wl-copy`, `xclip` or `xsel`
said it took the link; a copy through the terminal (OSC 52), which no terminal confirms, is tried
without a line. It needs no `pi-loops` on your PATH.

Both windows are complete pi sessions: each conversation in the page is a `pi --mode rpc` that
pi-loops runs for it, so the session file, your models, tools and extensions are the same either
way. What the browser cannot do is pi's own built-in slash commands, which do not exist in that
mode, and `/login`, whose OAuth flow has no equivalent — log in once with `pi` and the rest follows.

The page outlives the terminal. Closing the terminal, or Ctrl-C in it, ends nothing: the command
has already exited. A conversation nobody looks at stops after 15 minutes with no tab open and no
turn running, and the page keeps at most six open at once (the one used least recently and idle
goes first). Opening it again starts it again, with the same transcript. `pi-loops stop` closes the
page and every conversation in it.

### The address

`http://127.0.0.1:4173/` for the default loops directory. Bookmark it. A loops directory moved with
`PI_LOOPS_DIR` takes a free port, and `pi-loops status` names it. The token that guards the page
lives in `~/.pi/agent/loops/web-token` (mode 0600) and is kept between runs, so the address is the
same one tomorrow, and the first visit pairs the browser, so you never see the token again.
`GET /` opens the conversation you looked at last; `/c/<session id>/` opens a particular one. When
the conversation you looked at last was empty and pi never saved it, `GET /` opens a new conversation
in its project instead.

The address belongs to pi-loops in the background, so it is set in `config.toml`, not on the command
line ([configuration.md](configuration.md#page)):

```toml
[page]
port = 4200
```

then `pi-loops restart`. `pi-loops --port`, `--host`, `--allow-host` and `--no-auth` exit 2 and print
the lines to write.

There is a token at all because anything that reaches this server gets every conversation in it,
and "anything" includes a website you have open in another tab: it cannot read the answers, but
without a check it could still tell your agent what to do. The page sends the token in a header
that no other site can set, never in a cookie: a cookie goes to every port on this machine. What the
first visit leaves is a device cookie (`SameSite=Strict`, which is a browser's promise not to send
it on anything another site started) and a secret the page keeps for this address; together they
get a reloaded page its token, and the cookie alone opens nothing. So the cost of this to you is one
visit and then nothing.

When 4173 is held, `pi-loops` opens nothing and exits 1. An older pi-loops page (0.23 or before,
which ran in its terminal) gets
`pi-loops: an older pi-loops page is still open on 127.0.0.1:4173; quit it (Ctrl-C in its terminal) and this page takes its place within 30 seconds`.
Any other program gets
`pi-loops: 127.0.0.1:4173 is used by another program; set [page] port in config.toml, then pi-loops restart`.
Loops and rule checks run either way.

### From a phone

The page binds loopback, which a phone cannot reach. Two ways round it, and the first is better:

**Tailscale.** Leave the server where it is and put Tailscale in front:

```
tailscale serve --bg 4173
```

Your phone then opens `https://<machine>.<tailnet>.ts.net/` — TLS terminated on the tailnet, only
your own devices, and this server still bound to 127.0.0.1. It arrives here looking local and
carrying the tailnet name as its `Host`, which is accepted for that reason.

**The local network.** `[page] host = "0.0.0.0"` in `config.toml`, then `pi-loops restart`, binds
every interface; `host.log` names the address a phone on the same wifi can use. Anything that can
open the port is then talking to your conversations, which is why the token is never optional.

`tailscale funnel` is the same shape and puts it on the open internet; the token is what stands
between that and anyone.

Either way the phone has to get in once, and the token is 32 hex characters, which is fine to click
and miserable to type. So: press **add device** in the browser you are already signed in on. It
shows a **QR code** — point the phone at it and it is in, having typed nothing — and the same six
digits underneath for when a camera is not the thing you want to use. From a terminal, `pi-loops pair`
makes the same code and names the addresses that device can open:

```
$ pi-loops pair
pairing code for another device: 482913 (10 minutes)
  on that device, open http://192.168.1.20:4173/ and type it
```

A code exists only when you ask for one, with the button or with `pi-loops pair`. Opening a
conversation does not make one, so no other program on the machine can spend its twenty guesses
before you need it.

The QR encodes the address *this browser reached the server on*, which under `tailscale serve` is
the tailnet name and is the one that works from anywhere on your tailnet. If the window you press it
in is on `127.0.0.1`, there is no address to put in a QR that would work from a phone, and it says
so instead of showing you one that does not.

A code is good for one use and lasts ten minutes; twenty wrong guesses disable it until you ask
for another.

On a network you do not own, remember that `[page] host` is plain http: every request the page
makes carries a token that outlives the process, and anyone on the wire can read it. That is the
case `tailscale serve` exists for.

The phone has a page only while pi-loops runs in the background. Until something runs `pi-loops`
(or a pi window, or the service unit in
[Running pi-loops under your own service manager](#running-pi-loops-under-your-own-service-manager)),
there is nothing on 4173 to answer it.

Add it to the home screen and it opens without browser chrome: there is a web app manifest, and no
service worker — nothing here is worth caching, and a stale copy of a live front end is worse than
no copy.

A browser that has never been here (a different one, or after clearing cookies) gets a page saying
so. Run `pi-loops` from a terminal on this machine and it opens a window that works from then on,
or use the `?token=…` address it printed.

### No token, no longer

`--no-auth` is gone in 0.24: the page is always on and always behind its token. A page that runs
whether or not a terminal is open, with a shell behind every conversation, is not one to leave open
to every program on the machine.

### Opening a conversation you already have

The page starts a `pi --mode rpc` for each conversation it opens. Picking up where you left off
uses pi's own words for it:

```
pi-loops --continue                  # the newest conversation in this directory, in the page
pi-loops --session <id-or-prefix>    # a particular one
```

`pi-loops` finds the conversation the way `pi-loops export` does and asks the page for it. An id it
cannot find is refused, with exit 1. `--resume` is pi's picker, which the page has as **resume**
(`/resume`), so `pi-loops --resume` exits 2 and says so.

A flag for pi goes after `--` when there is one: `pi-loops --web -- --session <id>`. A flag that is
not this command's placed before `--` is refused with the command line to use, rather than dropped.
The page keeps a conversation's pi arguments under `pages/`, so it reopens the same way after an
idle stop or a restart; `--api-key` is refused for that reason (exit 1), and the line says to set the
provider's environment variable instead.

A conversation a terminal holds can be open in the page at the same time. A message sent from the
page is then answered in the terminal, and the page says so.

### Starting over, without leaving the window

The context is finished with far more often than the window is, so the things you do between turns
are in the page rather than in the terminal that launched it:

| | |
|---|---|
| **clear** / `/clear`, `/new` | start a new session here |
| **resume** / `/resume` | go back to an earlier session in this project |
| **compact** / `/compact <what to keep>` | summarise what is there and carry on |

Nothing is deleted by any of them. The session you leave is a file on disk, and **resume** lists
this project's sessions newest first, each labelled by what was said in it rather than by a
filename. Compaction reports what the context was, what it became, and what the summary cost.

Underneath, **clear** and **resume** are pi's `switch_session`: the session is replaced inside the
process that is already running, so the model, the MCP connections and the extensions are the same
ones, rebuilt against the new session. That is what `/new` and `/resume` do in the terminal, and
automation follows it the same way: pi-loops keeps running through the swap, and the conversation's
plain jobs move with it to whichever window now holds it.

One thing the front end adds on top of the swap: **resume** puts the session back on the model it
was last using. pi re-resolves the launch command line's `--model` on every swap — and the launcher
supplies one from the model you last chose ([configuration.md](configuration.md#what-is-remembered-between-sessions))
— so without this, going back to a conversation had with one model landed it on a different one. If
the model it used is not available any more (credentials gone), the session stays on the one it has
and `host.log` says which model it could not go back to.

Both are refused while a turn is running, because the swap would abort it: stop the turn first. A
session started this way is named by the front end rather than by pi, which is invisible everywhere
except that its filename does not contain its session id — pi and pi-loops both identify a session
by the id in its header.

## Getting the command onto your PATH

`pi install` puts this package under pi's managed directory rather than on your `PATH`, so the
command that is meant to start your sessions is at first reachable only by absolute path. The
extension does that step for you, with no question: the first interactive pi session after
`pi install` writes `pi-loops` into the first of `~/.local/bin` and `/usr/local/bin` that is on your
`PATH`, and says so in one line:

```text
[pi-loops] added pi-loops to ~/.local/bin; /pi-loops uninstall-launcher removes it
```

It writes only where nothing named `pi-loops` is there yet. A link, even one that points nowhere, a
directory, or a file it cannot read counts as something there, and the file is created only if it
does not exist, so the write never goes through a link at that name. The directory itself may be a
link to a directory, as a dotfiles `~/.local/bin` often is. A `pi-loops` this project did not write,
such as your own wrapper, is left alone, and so is one that runs another copy of this package still
on disk, which is that copy's to keep. With neither directory on your `PATH`, or with
the one on your `PATH` not made yet, nothing is written and nothing is said: a session start never
makes a directory. When the write fails, for example because `/usr/local/bin` belongs
to root, one warning line says why, and no later session tries that directory again; a `PATH` that
names another directory is a new try. A checkout never writes a launcher that is not there.

Afterwards the extension keeps that launcher current: if the copy it runs is not there any more
(pi installed the package by the other route), or that copy is the one now running and the node has
changed, the next session rewrites it and says so in one line.

To remove it, and stop every later session from writing it again:

```text
/pi-loops uninstall-launcher
```

```bash
pi-loops uninstall-launcher
pi-loops uninstall-launcher --dir ~/bin
```

`--dir` needs a directory after it, here and on `install-launcher`: a `--dir` with nothing after it,
or with a flag after it, is a usage error (exit 2), not the default directory.

It removes only a launcher of ours that this copy keeps. For any other file named `pi-loops` it
says whose file it is, leaves it as it is, and exits 1. Either way it remembers the choice in
`ui.json`.

To write one yourself, after `uninstall-launcher` or into another directory, run it from inside pi,
where the extension is already loaded:

```text
/pi-loops install-launcher
```

or from the directory pi installed the package into — `~/.pi/agent/npm/node_modules/@alphacoder-v0/pi-loops`
for a `pi install npm:` package, `~/.pi/agent/git/<host>/<owner>/<repo>` for a `pi install git:` one,
which is the way in on a machine where pi itself is never opened:

```bash
node src/cli-entry.mjs install-launcher
node src/cli-entry.mjs install-launcher --dir ~/bin
```

`install-launcher` clears the choice `uninstall-launcher` remembered.

It writes a small `sh` script that names the node you ran it with and the package it lives in — a
launcher rather than a symlink. Neither path is trusted for ever: if the package is not where it
was, the launcher looks in both of the directories above (the ones pi installs into, under the agent
directory this was written with), so installing again by the other route does not break it, and if
that node has gone it falls back to the `node` on your `PATH`. When no copy is in any of them it
prints one line saying to run `install-launcher` again — from inside pi, or from wherever pi has put
the package now — instead of a Node stack trace about a missing file.

## Upgrading

```bash
pi-loops upgrade                     # install the newest release
pi-loops upgrade --check             # say whether there is one, and stop
```

It reads release tags from the repository this copy came from (`repository.url` in its
`package.json`, so a fork upgrades from the fork), takes the highest `vN.N.N` — comparing
numerically, so `v0.10.0` beats `v0.9.0` — and runs `pi install` for it. Release candidates and
branch-shaped tags are ignored: those are not things to move someone onto without being asked.

The spec it installs follows how this copy was installed, because a spec from another source adds a
second package instead of replacing this one. A git install gets `git:<host>/<owner>/<repo>@<tag>`.
An npm install gets its spec from the entry pi recorded in `settings.json`: pinned to an exact
version, it is pinned to the new one (`npm:@alphacoder-v0/pi-loops@<version>`); installed without a
version, with `@latest` or with a range, it gets `npm:@alphacoder-v0/pi-loops@latest` — not the bare
name, which over an existing install makes npm keep the range it saved and install nothing, and not
an exact version, which pi would then skip in every later update. If there is no entry to read, it
pins.

`pi update --extensions` does something different and both are useful: for a git package it
reconciles the clone to the ref already pinned in your settings, which is how you repair a clone,
not how you take a new version. For an npm package installed without an exact version it does take
the newest release, and `upgrade` and it then agree.

## The tools

The rest of the command line does not need a pi session and has no build step; it resolves pi's
packages the way pi-loops does. `PI_LOOPS_DIR` selects the loops directory, as everywhere.

```
pi-loops sessions [--all] [--limit <n>] [--cwd <dir>]
pi-loops inspect <file>
pi-loops export [--session <id>] [--cwd <dir>] [--output <file>] [--exclude-triggers]
pi-loops import <file> [--cwd <dir>] [--activate-triggers=off|ask|on]
pi-loops start [--foreground] | stop | restart | status [--url] | abort <run-id|trace-id|session-id>
pi-loops recipe list | show <name|path> | add <name|path> [--cwd <dir>] [--level report|propose|act] [--overwrite]
pi-loops inbox list [--all] [--cwd <dir>] | claim <id> | dismiss <id> [--reason <text>]   [--json]
```

`-h` or `--help` anywhere in a subcommand's arguments before a `--` prints that subcommand's usage
and exits 0 without doing anything else; `pi-loops -h`, `pi-loops help` and `pi-loops --help` are the same. After `--` they are pi's.
`pi-loops --version` (or `-v`) alone prints the pi-loops version, such as `pi-loops v0.24.2`;
`pi-loops --tui -- --version` prints pi's.
A command typed wrong, such as a missing archive path, a flag the command does not read
(`status does not take --json`) or a word it does not read (`export: unexpected argument "out.pisession"`),
prints `pi-loops: <what is wrong>` and that command's usage, and exits 2. Any other error prints only its line and exits 1. An unknown command
names itself and the version you run, and exits 2.

The command line reads, triages one finding at a time and moves sessions. It never creates or
removes: there is no `cron remove`, no `recipe remove` and no `inbox clear`. Creating and removing
jobs, rules and recipes are slash commands, because each asks a person first and records what it did
in the session's control-plane audit.

## sessions, inspect

`sessions` lists this project's sessions (`--cwd <dir>` names another project) newest first, one line each: the first sixteen
characters of the id, when the session started, the automation it
has (`[2 cron, 1 trigger]`, or `[automation off]` when all of it is disabled), and what was first
said in it — the session's name if it was given one, otherwise the first message, cut at eighty
characters. `--all` lists every project and puts the cwd after the id. `--limit <n>` shows at most
`n` (default 20); a whole number of at least 1 is required, and anything else is refused rather than
read as an offset. The id is what
`export --session` accepts; a unique prefix will do. `inspect` prints an archive's schedules, prompts and rules without
writing anything, which is what you want before restoring on a machine you care about.

## export

Bundles a session's transcript with the automation that session created — its cron jobs, trigger
rules and loop state — into one `.pisession` archive.

With no `--session`, the newest session recorded for `--cwd` (default: the current directory) is
taken. `--session` accepts a full id or a unique prefix. `--exclude-triggers` drops every automation
sidecar.

```
pi-loops export --session 01a084a0-1c2d --output ~/backups/api.pisession
```

## import

Restores an archive into `--cwd` (default: the current directory), rewriting ids, project and
machine so the automation runs where it landed.

`--activate-triggers=off|ask|on` decides what happens to the automation the archive carries, and
means the same thing here as in `/session-import`. The default is `ask`: the import leaves it
disabled and then asks once, where there is a terminal or a UI to ask in, whether to enable what
was enabled in the source. `off` never asks; `on` activates it as part of the import.
Importing the same archive twice adds nothing the second time.

## pi-loops in the background

`pi-loops status` prints the process status, including its pid, version, start time, current work,
conversations held by windows, and log path. If it is stopped, the command says why. The older
spellings `pi-loops host status|abort|stop|start` and `/cron host` still work.

```
$ pi-loops status
pi-loops
  pid 2949521 on box, started 2026-09-09 13:33, model openai-codex/gpt-5.5
  pi-loops 0.24.2 · 1/1 loops, 0/0 rules enabled · inbox: 0 new
  started by the shell that ran pi-loops
  conversation 424aba43-0c1f "fix the flaky login test" open in pid 939915 (tui)
  running nightly (run-97ffb250) since 2026-09-09 13:34: check the repo issues
  3 messages waiting for 2 conversations, 1 could not be delivered
    424aba43-0c1f: 2 waiting · oldest 5m · 1 could not be delivered · open in pid 939915
    9e1f3b07-77a0: 1 waiting · oldest 30s · closed
```

The waiting lines appear when something waits to be delivered to a conversation (in
`requests/<session>.jsonl`): how many, how long the oldest has waited, how many failed three times
and go to the inbox next, and where the conversation is (`open in pid <pid>`, `in the background`,
or `closed`, one pi-loops is not running a turn in). They are read from the files, so
`pi-loops status` prints them with pi-loops down too, after the line that says it is not running.

`pi-loops stop` stops it until `pi-loops start`, `/pi-loops start` or `pi-loops` opening the page
(which prints `pi-loops was stopped by hand; it runs again now (loops and rules resume)` before the
address), and waits up to 15 seconds for
it to exit; a process that has not exited by then (its event loop blocked) gets SIGKILL. The page
stops with it, and so does every conversation open in it:
`  the page and its 2 open conversations closed with it; pi-loops start opens it again`. `pi-loops
start` starts it now, even with nothing to run yet. `pi-loops restart` stops it, waits until the old
process has exited (SIGKILL after 180 seconds), and starts it. A restart, and an upgrade that a pi
window with a newer pi-loops asks for, first wait up to two minutes for a turn in the browser page to
finish, so a conversation open there is not cut off mid-answer; a restart first prints `waiting up
to 2 min for a turn in the browser page to finish…`, and `pi-loops` opening the page prints the same
line when the older process has not left after 2 seconds. A pi window that is quitting asks for the
upgrade and does not wait: the next window or `pi-loops` starts the new version. A plain `pi-loops stop` ends it at once, and one
run during that wait ends the wait. A restart or an upgrade asked for while it already waits is
answered `already leaving` and does not start the wait again. With `--help`, each of these prints its
usage and does nothing else.

`pi-loops abort` takes three kinds of id. `abort <run-id>` interrupts one run and `abort <trace-id>`
one rule check, both through pi-loops in the background (short trace ids are accepted). `abort
<session-id>`, a full id or a unique prefix of one pi-loops knows (a conversation with a lease or a
queue), stops the turn running in that conversation in a terminal's pi, in the browser's pi, or in
pi-loops in the background. It does not reach a `pi -p` or `--mode json` run: such a run reads no
requests, and nothing else may stop a turn in a conversation a live process holds. That stop is a
request in `requests/<session>.jsonl`, read by whichever process holds the conversation, so it needs
no socket; a run id whose process does not answer goes the same way when a lease names it. It stops
only a turn that began before you asked, so a stop that arrives late never stops the next turn, and a
held turn it stops is done: pi-loops does not ask that prompt again. `abort` waits up to 5 seconds
and ends with one of three lines: `stopped the turn in session <id>`; `nothing was running in session
<id>` (exit code 1); or `stop queued for session <id>; it takes effect when that conversation's pi or
pi-loops reads it`. A prefix shorter than 8 characters is read as a trace id. A prefix that fits more
than one session, or a run id prefix that fits more than one running turn, lists them and exits 1.

The times are the local time of the machine pi-loops runs on, with no offset
([loops.md](loops.md#time-and-which-clock-it-is)).

### The Background view

What pi-loops in the background is doing is a view of the page, at `<page address>background`
(`http://127.0.0.1:4173/background` for the default loops directory). With no conversation to open
and no project to start a new one in, `GET /` shows it too. `pi-loops status --url` prints the page's address with its token, for a
browser that has not been there yet:

```
$ pi-loops status --url
http://127.0.0.1:4173/?token=3f9c…
```

The view lists every conversation that is open somewhere or has a plain job, a rule or something
waiting, with where it is (`a terminal`, `this page`, `the background`, `a pi-loops 0.22 window`,
`closed`), then what is running, what waits in the words `pi-loops status` uses, the budget, failing
loops (three failures in a row, as `/cron` marks them), recent runs and MCP servers, every time in the browser's own format and no pid. It refreshes
every 15 seconds. `/background.json` is the same as JSON, every field as before.
It is behind the page's token, like every other address of the page.

`pi-loops status` has a `page:` line for it: `  page: http://127.0.0.1:4173/ · 2 open conversations`,
or why there is none (`  page: waiting for 127.0.0.1:4173 (an older pi-loops page holds it)`). A
conversation open in the page reads `  conversation 424aba43-0c1f open in the page (pid 939915, 1 tab,
idle 3m)`. A conversation is named by the first 13 characters of its id, which only conversations
started in the same millisecond share, followed by its name or first message (at most 40 characters)
when its session file has one. The terminal, the page and the Background view name it the same way,
and `--session` takes it.

The token is kept in `web-token` (0600) in the loops directory, so a bookmarked address and a paired
phone keep working after pi-loops restarts. A stolen token reaches every conversation for as long as
pi-loops runs. To retire one that was shared, remove `web-token` and run `pi-loops restart`: the new
process makes a new token, and every browser signs in again. A token file other users can read or
write is set back to 0600 when pi-loops starts, and one that another user owns is refused.

### Running pi-loops under your own service manager

`pi-loops start --foreground` runs pi-loops in the terminal until it is stopped, for a service
manager that you set up. pi-loops writes no unit itself. A systemd user unit, with the path to
`pi-loops` filled in, at `~/.config/systemd/user/pi-loops.service`:

```ini
[Unit]
Description=pi-loops

[Service]
ExecStart=<path to pi-loops> start --foreground
Restart=on-failure
RestartPreventExitStatus=78

[Install]
WantedBy=default.target
```

Then `systemctl --user enable --now pi-loops`. Keys saved with pi's `/login` reach the process; keys
exported only in a shell do not. The unit is also what keeps the page up: until something runs
`pi-loops`, a pi window or the unit, a phone has no page to open.

The exit status tells systemd what happened:

- 0: stopped by hand (`pi-loops stop`, SIGTERM, SIGHUP), or asked to make way for a newer
  pi-loops. `Restart=on-failure` leaves it stopped.
- 78: it cannot run here (the package or pi has moved). `RestartPreventExitStatus=78` leaves it
  stopped.
- Anything else: a crash, which systemd restarts.

After `pi-loops stop`, `systemctl --user start pi-loops` starts it again, and so does the next login,
as with any systemd service. Started during a crash pause, the unit's process waits it out (half an
hour, longer for each pause in a row) and then runs; `/pi-loops start` ends the pause at once. `pi-loops start` and
`pi-loops restart` start a process of their own that the unit does not manage, and so does a pi
window when it upgrades pi-loops. While one of those runs, the unit's process finds it and exits 0.
To hand it back to the unit, run `pi-loops stop`, then `systemctl --user start pi-loops`. A LaunchAgent restarts every non-zero exit when `KeepAlive`
has `SuccessfulExit` false, so under launchd an exit of 78 is restarted too.

When pi-loops is stopped, `status` says why: stopped by hand, `PI_LOOPS_HOST=off`, `[host] auto =
false`, paused after repeated crashes, or a start that failed and when it is retried. The channel is a
unix socket at `host-service.sock` in the loops directory, created 0600, and it is read-mostly on
purpose: a background process you could prompt would be a second chat. Besides status, abort and stop
it accepts only `run` (a window's `/cron run` of a loop that already exists) and `upgrade` (a window
running a newer pi-loops asking the old process to make way).

## recipe

```sh
pi-loops recipe list                       # the packaged recipes, and which are installed in this directory
pi-loops recipe show issue-loop            # its files, jobs, levels, setup script
pi-loops recipe add issue-loop [--cwd <dir>] [--level report|propose|act] [--overwrite]
```

`add` is the deterministic half of `/recipe add` ([recipes.md](recipes.md)): it copies the
playbooks to `.agents/skills/<name>/`, lists that directory in `.git/info/exclude` and writes the
record — and stops. The setup script and the jobs need a pi open in the project: `/recipe add
<name>` there finds the copies in place, shows the script and the `/cron add` lines, and creates
them after you say yes. Without `--level` the lowest level the recipe supports is written; an edited
copy is kept unless `--overwrite` says otherwise.

## inbox

```sh
pi-loops inbox list --json                    # this project's new findings, checkpoints first
pi-loops inbox claim inb-3f2a… --json         # mark it claimed; acting on it is yours
pi-loops inbox dismiss inb-3f2a… --reason "that file is generated" --json
```

`/inbox` with no pi open ([loops.md](loops.md#the-inbox)). `claim` and `dismiss` take an id or a
unique prefix of one and change the entry the way the slash command does; a claim here cannot start
a turn, so it marks the finding and leaves the acting to whoever asked. A finding of a loop that
belongs to its project, not a conversation ([loops.md](loops.md)), is marked claimed the same way,
and no conversation acts on it: the caller handles it. Without `--json` the same,
one line per finding, for a person: the full id first, then the finding, with a decision's clauses
on the same line as `· waiting on: …` and `· if you don't: …` (the words the page uses). The JSON is fixed in [CONTRACT.md](../CONTRACT.md) §2.2 and shown in [downstream.md](downstream.md), which is
the page a program should be written against.
