Metadata-Version: 2.5
Name: phern
Version: 0.5.0
Summary: pH — a Python agent harness with no privileged core: every part is a row in a profile.
Project-URL: Homepage, https://github.com/chastabor/pH
Project-URL: Repository, https://github.com/chastabor/pH
Project-URL: Documentation, https://github.com/chastabor/pH/blob/main/docs/README.md
Project-URL: Issues, https://github.com/chastabor/pH/issues
Author: Charles Tabor
License-Expression: MIT
License-File: LICENSE
Keywords: agent,cli,code-mode,harness,llm,plugin,react,tui
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Software Development
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: anyio<5,>=4.4
Requires-Dist: httpx>=0.27
Requires-Dist: ph-code-graph==0.5.0
Requires-Dist: ph-core==0.5.0
Requires-Dist: ph-rlm==0.5.0
Requires-Dist: ph-runtime-guest==0.5.0
Requires-Dist: ph-stabilize==0.5.0
Requires-Dist: ph-text-index==0.5.0
Requires-Dist: pyyaml>=6.0
Requires-Dist: rich>=13.0
Requires-Dist: textual<9,>=8.2.5
Requires-Dist: typer>=0.12
Provides-Extra: local
Requires-Dist: ph-text-index[local]==0.5.0; extra == 'local'
Provides-Extra: otel
Requires-Dist: ph-core[otel]==0.5.0; extra == 'otel'
Provides-Extra: web
Requires-Dist: textual-serve>=1.1.3; extra == 'web'
Description-Content-Type: text/markdown

# phern

*The `phern` command: four output modes, the Textual TUI, the browser tab, the
daemon and its client — and the three real model wires.*

`ph-core` is the harness; this is the thing a person runs. It owns no seam and
publishes no service that a profile could not do without: what it contributes is
**front ends** (print, json, transcript, rpc, tui, web, trajectory), the
**supervisor** that lets a run outlive the terminal that started it, the
**adapters** that speak Anthropic, Gemini and the OpenAI-compatible wire, and
the **profile table** that composes everything installed into a name you can
type.

```bash
uv tool install ./packages/phern        # the CLI and core alone, about 60 MB
LLAMA_MODEL=<model> phern --profile llama -p "what is in this repo?"
phern --profile tui --provider llama --model <model> --mode tui
```

Installed on its own it composes seven profiles — `anthropic`, `base`,
`deepseek`, `google`, `headless`, `llama` and `tui`. The `rlm*` profiles need
bundles it deliberately does not depend on; see *Profiles* below.

## The commands

| command | what it does |
|---|---|
| `phern -p "…"` | one prompt, one answer. `-a/--attach FILE` sends a file with it, repeatable |
| `phern --mode json \| transcript \| rpc \| tui \| web \| trajectory` | what reaches stdout, or which front end runs |
| `phern doctor [--profile …]` | the three path roots, then mount a profile and let every row report what activated |
| `phern events [--json]` | the event producer/consumer matrix, generated from the declaration registry |
| `phern config [--row id] [--all]` | what every row accepts, its default, and what this profile sets. Composed, not mounted: it starts no agent and opens no session |
| `phern --dump-config` | the composed rows in order, before anything runs |
| `phern daemon` | run the supervisor |
| `phern agents …` | the client that talks to it: bare (list roots), `status`, `attach`, `send`, `schedule`, `doctor`, `shutdown` |
| `phern profiles …` | named profiles: `show [--full]`, `session <id>`, `fold`, and `diff` / `adopt` — what taking a profile's current version would change in each session on it, and taking it at their next start |
| `phern workspaces gc` | the git trees agents left behind, across every stored session. Reports by default; collects with `--remove` |
| `phern attachments gc` | media no stored session references. Same rule |

`phern doctor` reports what *this invocation's* flags and environment would
produce; `phern agents doctor` reports what is **in force** in the running daemon.
They are different questions and it is worth knowing which one you asked.

### The modes, and why the choice matters

`json` and `rpc` emit the session log's **own** envelopes rather than a per-mode
rendering (I-7), so a wrapper streaming from a pipe and a tool reading the
stored JSONL parse one format. `transcript` reads `session.transcript()` — what
the person saw — so a compacted conversation still shows the turns they actually
had, where the model surface deliberately shadows replaced ranges. `text` is the
default. `trajectory` audits a stored log and mounts nothing.

## The TUI

```bash
phern --mode tui --provider llama --model <model>     # new session
phern --mode tui --resume 20260908T041607-1c5576      # reopen one
phern --mode tui --no-spawn                           # refuse rather than start a daemon
phern --mode tui --keep-daemon                        # the daemon it starts is a service
```

The front end talks to the daemon over `$PH_RUNTIME/daemon.sock` and starts an
ephemeral one if nothing is listening, so closing the TUI does not end the turn.

Every front-end action is a `TuiVerb` reachable three ways — a slash command
registered into `ctx.commands` (so the palette lists it, the prompt completes
it, and `command/run` records it), a Textual action, and a key:

| slash | key | |
|---|---|---|
| `/commands` | `ctrl+k` | browse every command, the daemon's and the client's |
| `/model` | `ctrl+p` | choose the provider and model |
| `/theme` | `ctrl+y` | `ph-dark`, `ph-light`, `high-contrast` |
| `/sessions` | `ctrl+r` | reopen a stored session |
| `/permissions` | `ctrl+g` | change what pH may do without asking |
| `/thinking` | `ctrl+t` | show or hide the model's reasoning |
| `/tools` | `ctrl+o` | show or hide tool results |
| `/sidebar` | `ctrl+b` | show or hide the sidebar |
| `/login` | | provide a provider credential for this process |
| `/profile …` | | the session's profile: `show`, `diff`, `save <name>`, `use <name>`, `clear [row]` — the daemon's, registered on every root |
| `/attach <path> …` | | attach files to the next prompt |
| `/quit` | `ctrl+d` | |

Rows contribute their own screens and commands through `ctx.tui_screens` and
`ctx.commands`, and they arrive with the same three routes — `/trajectory` is
one such screen, contributed by a row rather than built in, and it takes its key
and palette entry away with it if the row is removed. Screens are presentation,
so every named profile ships them (`profiles/presentation.yaml`) and a profile
may not remove one; `hidden_screens` in `tui.json` hides one from this terminal.

### `$PH_HOME/tui.json`

Keybindings, theme and preferences. **Never hard-code a key check**: every
binding is a named field whose name doubles as the Textual binding id, so one
`set_keymap` rebinds the whole app, screens and modals included — a contributed
screen's key is remappable exactly like a built-in.

```json
{
  "theme": "ph-dark",
  "sidebar": "right",
  "turn_notification": "bell",
  "show_thinking": true,
  "show_tool_results": true,
  "hidden_screens": [],
  "keybindings": { "command_palette": "ctrl+k", "quit": "ctrl+d" }
}
```

A file that fails to parse does not stop the TUI starting: it launches on
defaults and says so. Unrecognized keys are kept rather than dropped, because
one of them is a plugin screen's binding id.

## The browser tab

```bash
phern --mode web --profile llama                      # 127.0.0.1:8000
phern --mode web --port 8080 --open
```

`textual-serve` runs a real `PHTuiApp` as a subprocess and streams its frames,
so the browser shows the terminal — one layout, not two. Three things it prints
before it binds, each of which is load-bearing:

- **the token in the URL is the whole authentication story** — no TLS, no users;
  treat the URL like the terminal it came from;
- **every tab of one launch is on one session** (a second tab joins the
  conversation; a second `phern --mode web` is a new one);
- `--host` anything but loopback reaches anyone who can route to the port.

Needs the `web` extra: `uv tool install "phern[web]"`, or
`uv tool install "./packages/phern[web]"`. Without it, `--mode web` fails with
the install line rather than an `ImportError`.

## The daemon

```bash
phern daemon --profile llama                  # the profile new sessions start on
phern daemon --max-concurrent-children 6      # across every root; the rest queue
phern daemon --passivate-after 30             # minutes of quiet before a root is released, or `off`
phern daemon --ephemeral                      # exit once no client, root or appointment needs it
```

One `anyio` task per root, and the client is not it: a root owns a mounted
profile, a session, an agent and a queue, and its task drains that queue whether
or not anybody is attached. Attaching subscribes a connection to the root's
events; detaching unsubscribes it. **Neither starts nor stops the work**, which
is why leaving is free.

The socket is per boot and per user. A stale socket from a crashed daemon is
cleared; a live one is refused rather than stolen. On Linux, note that logind
reaps `$XDG_RUNTIME_DIR` at logout for a user who is not lingering — a daemon
can keep running and *lose its socket*, after which every client is told to
start one and the leases the first still holds will refuse it. `phern doctor` and
`phern daemon` say so in advance; `loginctl enable-linger` is the fix.

## Profiles

The table lives in `src/ph_app/profiles.py`, the documents in
`src/ph_app/profiles/`.

| `--profile` | layers | credential |
|---|---|---|
| `base` | `ph-base` | — |
| `headless` | `base` + the fake adapter | — |
| `tui` | `headless` + `tui.yaml` (writable workspace, `/trajectory`, `ask_user` armed) | — |

Every row of that table also layers the **`stabilize` bundle**, so every profile
compacts at 85% of its window and offers `/compact`. It is layered *optionally*:
an install without `ph-stabilize` composes each of these profiles unchanged and
simply never compacts, which is what keeps the lean `uv tool install
./packages/phern` target whole. `phern doctor` reports which rows activated.
| `llama` | `base` + a local llama.cpp route | `LLAMA_API_KEY` (a formality llama.cpp ignores, but it must be set) |
| `deepseek` | `base` + DeepSeek over the OpenAI-compatible wire | `DEEPSEEK_API_KEY` |
| `anthropic` | `base` + the messages API | `ANTHROPIC_API_KEY` |
| `google` | `base` + Gemini (the one route declaring video, so `uploads` has a provider) | `GEMINI_API_KEY` |
| `rlm` | `tui` + the `rlm` bundle | needs `ph-rlm` |
| `rlm-stable` | `rlm` + `stabilize`, gates on | needs `ph-rlm`, `ph-stabilize` |
| `rlm-indexed` | `rlm-stable` + `code-graph` + `text-index` | needs both plugin distributions too |

**A profile is offered only if every layer it names resolves.**
`available_profiles()` asks exactly the question `resolve_profile` will answer,
so a `--help` line and a command line cannot disagree; an install missing a
bundle sees no `rlm-indexed` rather than one that fails at mount, and the
refusal names the package to install. A `--profile` value that is a path to a
`.yaml` is used directly, which is what makes a scenario test or a one-off
deployment one file rather than an install step.

## Adjusting it

The layers every pH row uses — the shipped documents; `rows:` in
`$PH_HOME/daemon.yaml` for the host's own machinery; your named profile at
`$PH_HOME/profiles/<name>.yaml` (`extends` plus the rows that differ) for what an
agent runs with; then `--patch` for one run. Each row belongs to one of those files
by what it shapes, and one set in the wrong file is refused with the file it belongs
in (`phern config` shows each row's kind; screens and footer readings are
`tui.json`'s). What is specific to this package is the rows it registers:

| row | config | default |
|---|---|---|
| `llm-anthropic` | `provider`, `baseUrl`, `apiKeyEnv`, `contextWindow`, `defaultMaxTokens`, `accepts`, `maxAttachmentBytes`, `uploads`, `filesBeta`, `maxImageEdge`, `usableImageEdge`, `cacheControl` | `anthropic`, `ANTHROPIC_API_KEY`, `200000`, `8192`, images + PDF, 5 MiB, prompt caching on |
| `llm-google` | the same shape plus `uploadReadyMs` | `1048576` window, images/audio/video/PDF, 20 MiB, video routed through the Files API |
| `llm-openai-compatible` | `profiles: [ProviderProfile, …]` | one entry per route; this is the row `llama` and `deepseek` insert |
| `tui-screen-trajectory` | — | in every named profile (`presentation.yaml`); hidden per terminal in `tui.json` |

```yaml
# $PH_HOME/profiles/anthropic.yaml — a different model ceiling, caching off
extends: anthropic
rows:
  - id: llm-anthropic
    config:
      provider: anthropic
      apiKeyEnv: ANTHROPIC_API_KEY
      contextWindow: 200000
      defaultMaxTokens: 16384
      cacheControl: false
```

**A list is replaced, not merged**, and on these rows that is the trap worth
naming: an overlay that restates one field of an OpenAI-compatible route must
restate the whole route entry, or it inherits `api.openai.com` from the row
default — a local deployment quietly calling a hosted provider. `llama.yaml`'s
own comments carry the worked example.

An `apiKeyEnv` is a **name**, never an interpolation. The adapter resolves it at
the request edge, so the value never enters a row, an event, or a child process
(I-3). `${env:…}` interpolation is available for everything that is not a
secret, with `${env:VAR:-default}` for a fallback.

## Upgrading from 0.4

- **Restart the daemon** (`phern agents shutdown`): it speaks protocol 5, and a
  client and a daemon from different releases refuse each other's new fields.
- **Fold your profiles.** A file in the old list format and the `<name>.d/` drop-ins
  `/sandbox` wrote are read until `phern profiles fold` folds them into one named
  file, and `phern doctor` names each one until then.
- **A row in the wrong file is refused, by name** — persistence, telemetry and the
  job bound belong in `daemon.yaml`, screens in `tui.json`.
- **`--model` alone is a key** of the profile's `models` list; a model the list does
  not hold needs `--provider` beside it. A child's `model` is a key too.
- **Sessions carry on**: the log format is unchanged, and a session from 0.4 records
  the profile it starts on at its next start and comes back as its log says after.

A session keeps the version of its named profile it started on; `/profile diff` and
`phern profiles diff` say how it has moved, and `/profile use` or `phern profiles
adopt` takes it. A version that will not mount is taken back at the start that tried
it, and the session runs on the one it had.

## Limitations, and things that are deliberate

- **`ph_app` must not import `ph_rlm` or `ph_stabilize`**, although the `phern`
  distribution depends on both wheels. It composes their profiles through the
  `ph.bundles` entry-point group and reads their events (`subagent/*`) without
  importing the rows that emit them — `tests/test_app_layering.py` walks the AST
  and enforces it. Depending on a wheel decides what is installed; it does not
  grant a name this module may write down, and keeping the two separate is what
  lets a bundle nobody here ships compose a profile on the same footing.
- **Textual is pinned at both ends (`>=8.2.5,<9`), and both ends are
  load-bearing.** The floor is where the suite actually passes: `MarkdownStream`
  (the transcript's streaming append) does not exist before Textual 5, and the
  committed SVG snapshots then narrow it further — 8.2.4 fails one of them. The
  ceiling guards `add_binding`, which writes through a private `BindingsMap`
  because the public `bind()` drops the id that `set_keymap` matches on — and
  that id is what makes a contributed screen's key rebindable like every other.
- **The web UI is the terminal in a canvas**, not an HTML renderer on the same
  view model. That is the trade that buys layout parity by construction.
- **A client reads no session file at all.** After P5-14 the daemon holds them
  and answers `sessions/browse`, which is what makes a front end on another
  machine possible and stops a client and a daemon disagreeing about which
  `$PH_HOME` they meant.
- **The daemon's method vocabulary is typed and closed** (`ph_app.verbs`,
  `ph_app.params`, `ph_app.payloads`): a field a method does not take is a
  refusal that names the field, not a silent drop. A client that believed it had
  said something is the failure a typed edge exists to end.

## Tests

`tests/` — 36 modules covering the CLI, the four non-interactive modes, the
daemon (framing, methods, mutations, lifetime, recovery, asks), the TUI (pilot
runs, remote verbs, screens, state) and the three adapters, plus committed
Textual SVG snapshots. `test_non_guarantees.py` is worth reading first: it pins
the *claims* rather than a mechanism — the sentences a person reads before
deciding whether to run six agents under one daemon, and that `phern doctor` and
`phern agents doctor` still print them.
