Skip to main content

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
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

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.

{
  "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

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

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
# $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.6

  • Restart the daemon (phern agents shutdown): it speaks protocol 7, and a client and a daemon from different releases refuse each other's daemon/status.
  • Sessions carry on. The log format is still 3; a root with a schedule no longer writes a heartbeat every five minutes, and logs that hold them still open.
  • Nothing in the daemon wakes on a clock. Quiet roots are released, invariants checked and the socket watched when something happens or a deadline arrives, not on a cadence; phern agents doctor shows the next release where it showed the sweep.
  • A session has one writer. phern -p --session x against a session a daemon or another run holds is refused with session_already_active.
  • A signal stops a run the way phern agents shutdown does: SIGTERM or Ctrl-C suspends each sub-agent, resumable, and writes the logs before exiting, and the one-shot modes now resume an interrupted session's sub-agents first.
  • The daemon logs to $PH_HOME/logs/daemon.log.
  • For code built on pH: ph.resources.install_lifecycle is gone; use stop_on_signals or run_until_signaled.

Upgrading from 0.5

  • Restart the daemon (phern agents shutdown): it speaks protocol 6, and a client and a daemon from different releases refuse each other's new fields.
  • Sessions from 0.5 do not carry over. The session log is format 3, and a 0.5 log is refused when it is opened rather than migrated. Finish what is running on 0.5 first; the old logs stay on disk as plain JSONL.
  • A sub-agent's records are in its own log. A front end gets a root's family from the daemon — session/children, and the session.children notice whenever a row moves — rather than from the root's stream, and a restarted daemon brings each child back where it stopped.
  • The scheduler sleeps until something is due instead of checking every five seconds, so a schedule runs at its moment; phern agents doctor shows its next wake where it showed the tick.
  • The code graph and the text index are rebuilt once, one index per workspace now; a path: on the text-index row is the directory they go under.

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.

Metadata

Release files for phern 0.8.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for phern 0.8.0
File Size Uploaded
phern-0.8.0.tar.gz 767.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for phern 0.8.0
File Interpreter ABI Platform
phern-0.8.0-py3-none-any.whl Python 3 none any Details

Total release size: 1.3 MB

Release files / phern-0.8.0.tar.gz

Download URL phern-0.8.0.tar.gz
Size 767.8 kB
Tags Source
SHA-256 checksum
How to use checksums
8c52bb97d3de47813e068c6a990427ed35c805277e039987743c89dd9b1b4a61
BLAKE2b-256 checksum
How to use checksums
21bf7f1628c1a4eaf3d30e6e34b58984755e67107c02cedf182f483ae1ad53fe
Upload date
Uploaded using Trusted Publishing?
What is 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}

Release files / phern-0.8.0-py3-none-any.whl

Download URL phern-0.8.0-py3-none-any.whl
Size 505.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d7ff0723952018ffcf453bda01feedf5fb4af6abba4ad5b46f8f60d7edfd3dc8
BLAKE2b-256 checksum
How to use checksums
801bab783ffb00378600233291742ea6b9deb971f5820a3101e3481551dbf412
Upload date
Uploaded using Trusted Publishing?
What is 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}

Release history Release notifications | RSS feed

0.9.0

2 release files

This release

0.8.0 This release

2 release files

0.7.0

2 release files

0.6.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page