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.
uv tool install ./packages/phern # the CLI and core alone, about 60 MB
phern --profile llama --provider llama --model <model> -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 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
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 | |
/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 tui.yaml rather than built in, and it takes
its key and palette entry away with it if the row is removed.
$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.
{
"theme": "ph-dark",
"sidebar": "right",
"turn_notification": "bell",
"show_thinking": true,
"show_tool_results": true,
"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
phern --mode web --provider llama --model <model> # 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 webis a new one); --hostanything 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
phern daemon --profile tui --provider llama --model <model>
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 same three layers every pH row uses — the shipped documents, your overlay at
$PH_HOME/profiles/<name>.yaml, then --patch for one run. 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 |
— | contributed by tui.yaml |
# $PH_HOME/profiles/anthropic.yaml — a different model ceiling, caching off
- 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.
Limitations, and things that are deliberate
ph_appmust not importph_rlmorph_stabilize, although thepherndistribution depends on both wheels. It composes their profiles through theph.bundlesentry-point group and reads their events (subagent/*) without importing the rows that emit them —tests/test_app_layering.pywalks 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 guardsadd_binding, which writes through a privateBindingsMapbecause the publicbind()drops the id thatset_keymapmatches 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_HOMEthey 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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file phern-0.1.0.tar.gz.
File metadata
- Download URL: phern-0.1.0.tar.gz
- Upload date:
- Size: 553.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d71488c8f8a6280053ee991345d637ef25dfb553e49beab55a2c72c6743434f3
|
|
| MD5 |
05a18b1ec27fddaf453290d8ef1afa88
|
|
| BLAKE2b-256 |
bd844f3980949cee35f56ff553a28c6c42c0d5cb87f381469e9b343ffb51a7fc
|
File details
Details for the file phern-0.1.0-py3-none-any.whl.
File metadata
- Download URL: phern-0.1.0-py3-none-any.whl
- Upload date:
- Size: 389.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.11.21 {"installer":{"name":"uv","version":"0.11.21","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fb4533bff9d54a10352c41e20f05042ddbb101b12f1fae651e5318327c746913
|
|
| MD5 |
9285ee13bef2d1389c5f7d2428e02e07
|
|
| BLAKE2b-256 |
82b3d091f3fd41ce71f9cb7950124fbf284224fd13a2324de141db9c2454edcc
|