phern 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. phern-0.1.0/.gitignore +31 -0
  2. phern-0.1.0/LICENSE +21 -0
  3. phern-0.1.0/PKG-INFO +297 -0
  4. phern-0.1.0/README.md +254 -0
  5. phern-0.1.0/pyproject.toml +139 -0
  6. phern-0.1.0/src/ph_app/__init__.py +5 -0
  7. phern-0.1.0/src/ph_app/__main__.py +15 -0
  8. phern-0.1.0/src/ph_app/adapters/__init__.py +5 -0
  9. phern-0.1.0/src/ph_app/adapters/_http.py +231 -0
  10. phern-0.1.0/src/ph_app/adapters/_media.py +181 -0
  11. phern-0.1.0/src/ph_app/adapters/anthropic.py +635 -0
  12. phern-0.1.0/src/ph_app/adapters/google.py +785 -0
  13. phern-0.1.0/src/ph_app/adapters/openai_compatible.py +884 -0
  14. phern-0.1.0/src/ph_app/adapters/sse.py +58 -0
  15. phern-0.1.0/src/ph_app/agents.py +713 -0
  16. phern-0.1.0/src/ph_app/attach.py +160 -0
  17. phern-0.1.0/src/ph_app/attachments.py +179 -0
  18. phern-0.1.0/src/ph_app/cli.py +875 -0
  19. phern-0.1.0/src/ph_app/console.py +181 -0
  20. phern-0.1.0/src/ph_app/daemon/__init__.py +32 -0
  21. phern-0.1.0/src/ph_app/daemon/cancelsafe.py +163 -0
  22. phern-0.1.0/src/ph_app/daemon/cards.py +97 -0
  23. phern-0.1.0/src/ph_app/daemon/client.py +295 -0
  24. phern-0.1.0/src/ph_app/daemon/duplex.py +389 -0
  25. phern-0.1.0/src/ph_app/daemon/follow.py +234 -0
  26. phern-0.1.0/src/ph_app/daemon/framing.py +134 -0
  27. phern-0.1.0/src/ph_app/daemon/frontend.py +284 -0
  28. phern-0.1.0/src/ph_app/daemon/launch.py +184 -0
  29. phern-0.1.0/src/ph_app/daemon/projections.py +283 -0
  30. phern-0.1.0/src/ph_app/daemon/recovery.py +270 -0
  31. phern-0.1.0/src/ph_app/daemon/server.py +1505 -0
  32. phern-0.1.0/src/ph_app/daemon/supervisor.py +1541 -0
  33. phern-0.1.0/src/ph_app/modes/__init__.py +20 -0
  34. phern-0.1.0/src/ph_app/modes/json_mode.py +68 -0
  35. phern-0.1.0/src/ph_app/modes/print_mode.py +67 -0
  36. phern-0.1.0/src/ph_app/modes/rpc_mode.py +208 -0
  37. phern-0.1.0/src/ph_app/modes/transcript_mode.py +94 -0
  38. phern-0.1.0/src/ph_app/params.py +205 -0
  39. phern-0.1.0/src/ph_app/payloads.py +717 -0
  40. phern-0.1.0/src/ph_app/profiles/anthropic.yaml +9 -0
  41. phern-0.1.0/src/ph_app/profiles/deepseek.yaml +16 -0
  42. phern-0.1.0/src/ph_app/profiles/google.yaml +18 -0
  43. phern-0.1.0/src/ph_app/profiles/llama.yaml +118 -0
  44. phern-0.1.0/src/ph_app/profiles/rlm-stable.yaml +67 -0
  45. phern-0.1.0/src/ph_app/profiles/tui.yaml +36 -0
  46. phern-0.1.0/src/ph_app/profiles.py +414 -0
  47. phern-0.1.0/src/ph_app/protocol.py +639 -0
  48. phern-0.1.0/src/ph_app/py.typed +0 -0
  49. phern-0.1.0/src/ph_app/runtime.py +154 -0
  50. phern-0.1.0/src/ph_app/sessions.py +251 -0
  51. phern-0.1.0/src/ph_app/shell.py +221 -0
  52. phern-0.1.0/src/ph_app/trust.py +80 -0
  53. phern-0.1.0/src/ph_app/tui/__init__.py +21 -0
  54. phern-0.1.0/src/ph_app/tui/adapter.py +1203 -0
  55. phern-0.1.0/src/ph_app/tui/app.py +882 -0
  56. phern-0.1.0/src/ph_app/tui/autocomplete.py +163 -0
  57. phern-0.1.0/src/ph_app/tui/commands.py +221 -0
  58. phern-0.1.0/src/ph_app/tui/config.py +169 -0
  59. phern-0.1.0/src/ph_app/tui/frontend.py +224 -0
  60. phern-0.1.0/src/ph_app/tui/modals/__init__.py +1 -0
  61. phern-0.1.0/src/ph_app/tui/modals/approval.py +188 -0
  62. phern-0.1.0/src/ph_app/tui/modals/ask_user.py +95 -0
  63. phern-0.1.0/src/ph_app/tui/modals/base.py +230 -0
  64. phern-0.1.0/src/ph_app/tui/modals/login.py +81 -0
  65. phern-0.1.0/src/ph_app/tui/modals/pickers.py +226 -0
  66. phern-0.1.0/src/ph_app/tui/modals/trust.py +55 -0
  67. phern-0.1.0/src/ph_app/tui/remote.py +771 -0
  68. phern-0.1.0/src/ph_app/tui/screens.py +141 -0
  69. phern-0.1.0/src/ph_app/tui/state.py +311 -0
  70. phern-0.1.0/src/ph_app/tui/terminal.py +49 -0
  71. phern-0.1.0/src/ph_app/tui/themes/__init__.py +230 -0
  72. phern-0.1.0/src/ph_app/tui/themes/high-contrast.json +20 -0
  73. phern-0.1.0/src/ph_app/tui/themes/ph-dark.json +20 -0
  74. phern-0.1.0/src/ph_app/tui/themes/ph-light.json +20 -0
  75. phern-0.1.0/src/ph_app/tui/trajectory.py +533 -0
  76. phern-0.1.0/src/ph_app/tui/trajectory_app.py +164 -0
  77. phern-0.1.0/src/ph_app/tui/trajectory_screen.py +271 -0
  78. phern-0.1.0/src/ph_app/tui/widgets/__init__.py +1 -0
  79. phern-0.1.0/src/ph_app/tui/widgets/prompt.py +232 -0
  80. phern-0.1.0/src/ph_app/tui/widgets/selection.py +197 -0
  81. phern-0.1.0/src/ph_app/tui/widgets/status.py +370 -0
  82. phern-0.1.0/src/ph_app/tui/widgets/trajectory.py +301 -0
  83. phern-0.1.0/src/ph_app/tui/widgets/transcript.py +563 -0
  84. phern-0.1.0/src/ph_app/verbs.py +253 -0
  85. phern-0.1.0/src/ph_app/web/__init__.py +26 -0
  86. phern-0.1.0/src/ph_app/web/serve.py +450 -0
  87. phern-0.1.0/src/ph_app/wire.py +246 -0
  88. phern-0.1.0/src/ph_app/workspaces.py +186 -0
  89. phern-0.1.0/tests/__snapshots__/test_tui_snapshot/test_a_code_cell_shows_its_program_and_what_it_cost.raw +131 -0
  90. phern-0.1.0/tests/__snapshots__/test_tui_snapshot/test_a_code_mode_card_lists_its_dispatches.raw +114 -0
  91. phern-0.1.0/tests/__snapshots__/test_tui_snapshot/test_a_compaction_marker_keeps_what_it_replaced.raw +129 -0
  92. phern-0.1.0/tests/__snapshots__/test_tui_snapshot/test_a_failed_tool_card_and_an_error_row.raw +114 -0
  93. phern-0.1.0/tests/__snapshots__/test_tui_snapshot/test_a_settled_tool_card.raw +114 -0
  94. phern-0.1.0/tests/__snapshots__/test_tui_snapshot/test_every_theme_renders[high-contrast].raw +95 -0
  95. phern-0.1.0/tests/__snapshots__/test_tui_snapshot/test_every_theme_renders[ph-light].raw +95 -0
  96. phern-0.1.0/tests/__snapshots__/test_tui_snapshot/test_markup_in_user_text_renders_literally.raw +103 -0
  97. phern-0.1.0/tests/__snapshots__/test_tui_snapshot/test_streaming_assistant_text.raw +112 -0
  98. phern-0.1.0/tests/__snapshots__/test_tui_snapshot/test_the_full_app_shell.raw +155 -0
  99. phern-0.1.0/tests/app_fixtures.py +87 -0
  100. phern-0.1.0/tests/daemon_helpers.py +352 -0
  101. phern-0.1.0/tests/test_adapters.py +1094 -0
  102. phern-0.1.0/tests/test_agents_cli.py +892 -0
  103. phern-0.1.0/tests/test_app_layering.py +121 -0
  104. phern-0.1.0/tests/test_attach.py +205 -0
  105. phern-0.1.0/tests/test_attachments_cli.py +195 -0
  106. phern-0.1.0/tests/test_cancelsafe.py +287 -0
  107. phern-0.1.0/tests/test_cli.py +1097 -0
  108. phern-0.1.0/tests/test_daemon.py +1820 -0
  109. phern-0.1.0/tests/test_daemon_asks.py +402 -0
  110. phern-0.1.0/tests/test_daemon_attachments.py +329 -0
  111. phern-0.1.0/tests/test_daemon_framing.py +97 -0
  112. phern-0.1.0/tests/test_daemon_invariants.py +244 -0
  113. phern-0.1.0/tests/test_daemon_launch.py +199 -0
  114. phern-0.1.0/tests/test_daemon_lifetime.py +283 -0
  115. phern-0.1.0/tests/test_daemon_methods.py +348 -0
  116. phern-0.1.0/tests/test_daemon_mutations.py +190 -0
  117. phern-0.1.0/tests/test_daemon_projections.py +752 -0
  118. phern-0.1.0/tests/test_daemon_shell.py +308 -0
  119. phern-0.1.0/tests/test_modes.py +304 -0
  120. phern-0.1.0/tests/test_non_guarantees.py +174 -0
  121. phern-0.1.0/tests/test_payloads.py +254 -0
  122. phern-0.1.0/tests/test_protocol.py +138 -0
  123. phern-0.1.0/tests/test_trajectory.py +470 -0
  124. phern-0.1.0/tests/test_trajectory_view.py +470 -0
  125. phern-0.1.0/tests/test_tui_adapter.py +667 -0
  126. phern-0.1.0/tests/test_tui_code_cell.py +308 -0
  127. phern-0.1.0/tests/test_tui_copy.py +185 -0
  128. phern-0.1.0/tests/test_tui_frames.py +217 -0
  129. phern-0.1.0/tests/test_tui_pilot.py +982 -0
  130. phern-0.1.0/tests/test_tui_remote.py +595 -0
  131. phern-0.1.0/tests/test_tui_screens.py +360 -0
  132. phern-0.1.0/tests/test_tui_snapshot.py +255 -0
  133. phern-0.1.0/tests/test_tui_state.py +355 -0
  134. phern-0.1.0/tests/test_uploads.py +451 -0
  135. phern-0.1.0/tests/test_uploads_google.py +606 -0
  136. phern-0.1.0/tests/test_uploads_openai.py +334 -0
  137. phern-0.1.0/tests/test_web.py +446 -0
  138. phern-0.1.0/tests/test_workspaces_cli.py +225 -0
  139. phern-0.1.0/tests/tui_helpers.py +199 -0
phern-0.1.0/.gitignore ADDED
@@ -0,0 +1,31 @@
1
+ # Build and environment
2
+ .venv/
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ __pycache__/
7
+ *.py[cod]
8
+ jjt/
9
+ w2/
10
+
11
+ # Tooling caches
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ .coverage
16
+ # Written by the guest's subprocess collectors and combined at the end of the
17
+ # run; see `conftest.GUEST_COVERAGE_RC`.
18
+ .coverage-guest*
19
+ htmlcov/
20
+ # Dropped at the repo root by pytest-textual-snapshot when a snapshot test
21
+ # fails. The reference snapshots under `__snapshots__/` are the committed
22
+ # expectation; this is the diff viewer for a run that did not match one.
23
+ snapshot_report.html
24
+
25
+ # Reference checkouts of the upstream projects this port reads from. Vendored
26
+ # locally so the plans' citations are verifiable; never part of this repo.
27
+ sources/
28
+
29
+ # Local scratch
30
+ .ph/
31
+ *.local.yaml
phern-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Charles Tabor
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
phern-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,297 @@
1
+ Metadata-Version: 2.5
2
+ Name: phern
3
+ Version: 0.1.0
4
+ Summary: pH — a Python agent harness with no privileged core: every part is a row in a profile.
5
+ Project-URL: Homepage, https://github.com/chastabor/pH
6
+ Project-URL: Repository, https://github.com/chastabor/pH
7
+ Project-URL: Documentation, https://github.com/chastabor/pH/blob/main/docs/README.md
8
+ Project-URL: Issues, https://github.com/chastabor/pH/issues
9
+ Author: Charles Tabor
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agent,cli,code-mode,harness,llm,plugin,react,tui
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Classifier: Topic :: Software Development
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: anyio<5,>=4.4
25
+ Requires-Dist: filelock>=3.15
26
+ Requires-Dist: httpx>=0.27
27
+ Requires-Dist: ph-code-graph==0.1.0
28
+ Requires-Dist: ph-core==0.1.0
29
+ Requires-Dist: ph-rlm==0.1.0
30
+ Requires-Dist: ph-runtime-guest==0.1.0
31
+ Requires-Dist: ph-stabilize==0.1.0
32
+ Requires-Dist: ph-text-index==0.1.0
33
+ Requires-Dist: rich>=13.0
34
+ Requires-Dist: textual<9,>=8.2.5
35
+ Requires-Dist: typer>=0.12
36
+ Provides-Extra: local
37
+ Requires-Dist: ph-text-index[local]==0.1.0; extra == 'local'
38
+ Provides-Extra: otel
39
+ Requires-Dist: ph-core[otel]==0.1.0; extra == 'otel'
40
+ Provides-Extra: web
41
+ Requires-Dist: textual-serve>=1.1.3; extra == 'web'
42
+ Description-Content-Type: text/markdown
43
+
44
+ # phern
45
+
46
+ *The `phern` command: four output modes, the Textual TUI, the browser tab, the
47
+ daemon and its client — and the three real model wires.*
48
+
49
+ `ph-core` is the harness; this is the thing a person runs. It owns no seam and
50
+ publishes no service that a profile could not do without: what it contributes is
51
+ **front ends** (print, json, transcript, rpc, tui, web, trajectory), the
52
+ **supervisor** that lets a run outlive the terminal that started it, the
53
+ **adapters** that speak Anthropic, Gemini and the OpenAI-compatible wire, and
54
+ the **profile table** that composes everything installed into a name you can
55
+ type.
56
+
57
+ ```bash
58
+ uv tool install ./packages/phern # the CLI and core alone, about 60 MB
59
+ phern --profile llama --provider llama --model <model> -p "what is in this repo?"
60
+ phern --profile tui --provider llama --model <model> --mode tui
61
+ ```
62
+
63
+ Installed on its own it composes seven profiles — `anthropic`, `base`,
64
+ `deepseek`, `google`, `headless`, `llama` and `tui`. The `rlm*` profiles need
65
+ bundles it deliberately does not depend on; see *Profiles* below.
66
+
67
+ ## The commands
68
+
69
+ | command | what it does |
70
+ |---|---|
71
+ | `phern -p "…"` | one prompt, one answer. `-a/--attach FILE` sends a file with it, repeatable |
72
+ | `phern --mode json \| transcript \| rpc \| tui \| web \| trajectory` | what reaches stdout, or which front end runs |
73
+ | `phern doctor [--profile …]` | the three path roots, then mount a profile and let every row report what activated |
74
+ | `phern events [--json]` | the event producer/consumer matrix, generated from the declaration registry |
75
+ | `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 |
76
+ | `phern --dump-config` | the composed rows in order, before anything runs |
77
+ | `phern daemon` | run the supervisor |
78
+ | `phern agents …` | the client that talks to it: bare (list roots), `status`, `attach`, `send`, `schedule`, `doctor`, `shutdown` |
79
+ | `phern workspaces gc` | the git trees agents left behind, across every stored session. Reports by default; collects with `--remove` |
80
+ | `phern attachments gc` | media no stored session references. Same rule |
81
+
82
+ `phern doctor` reports what *this invocation's* flags and environment would
83
+ produce; `phern agents doctor` reports what is **in force** in the running daemon.
84
+ They are different questions and it is worth knowing which one you asked.
85
+
86
+ ### The modes, and why the choice matters
87
+
88
+ `json` and `rpc` emit the session log's **own** envelopes rather than a per-mode
89
+ rendering (I-7), so a wrapper streaming from a pipe and a tool reading the
90
+ stored JSONL parse one format. `transcript` reads `session.transcript()` — what
91
+ the person saw — so a compacted conversation still shows the turns they actually
92
+ had, where the model surface deliberately shadows replaced ranges. `text` is the
93
+ default. `trajectory` audits a stored log and mounts nothing.
94
+
95
+ ## The TUI
96
+
97
+ ```bash
98
+ phern --mode tui --provider llama --model <model> # new session
99
+ phern --mode tui --resume 20260908T041607-1c5576 # reopen one
100
+ phern --mode tui --no-spawn # refuse rather than start a daemon
101
+ phern --mode tui --keep-daemon # the daemon it starts is a service
102
+ ```
103
+
104
+ The front end talks to the daemon over `$PH_RUNTIME/daemon.sock` and starts an
105
+ ephemeral one if nothing is listening, so closing the TUI does not end the turn.
106
+
107
+ Every front-end action is a `TuiVerb` reachable three ways — a slash command
108
+ registered into `ctx.commands` (so the palette lists it, the prompt completes
109
+ it, and `command/run` records it), a Textual action, and a key:
110
+
111
+ | slash | key | |
112
+ |---|---|---|
113
+ | `/commands` | `ctrl+k` | browse every command, the daemon's and the client's |
114
+ | `/model` | `ctrl+p` | choose the provider and model |
115
+ | `/theme` | `ctrl+y` | `ph-dark`, `ph-light`, `high-contrast` |
116
+ | `/sessions` | `ctrl+r` | reopen a stored session |
117
+ | `/permissions` | `ctrl+g` | change what pH may do without asking |
118
+ | `/thinking` | `ctrl+t` | show or hide the model's reasoning |
119
+ | `/tools` | `ctrl+o` | show or hide tool results |
120
+ | `/sidebar` | `ctrl+b` | show or hide the sidebar |
121
+ | `/login` | | provide a provider credential for this process |
122
+ | `/attach <path> …` | | attach files to the next prompt |
123
+ | `/quit` | `ctrl+d` | |
124
+
125
+ Rows contribute their own screens and commands through `ctx.tui_screens` and
126
+ `ctx.commands`, and they arrive with the same three routes — `/trajectory` is
127
+ one such screen, contributed by `tui.yaml` rather than built in, and it takes
128
+ its key and palette entry away with it if the row is removed.
129
+
130
+ ### `$PH_HOME/tui.json`
131
+
132
+ Keybindings, theme and preferences. **Never hard-code a key check**: every
133
+ binding is a named field whose name doubles as the Textual binding id, so one
134
+ `set_keymap` rebinds the whole app, screens and modals included — a contributed
135
+ screen's key is remappable exactly like a built-in.
136
+
137
+ ```json
138
+ {
139
+ "theme": "ph-dark",
140
+ "sidebar": "right",
141
+ "turn_notification": "bell",
142
+ "show_thinking": true,
143
+ "show_tool_results": true,
144
+ "keybindings": { "command_palette": "ctrl+k", "quit": "ctrl+d" }
145
+ }
146
+ ```
147
+
148
+ A file that fails to parse does not stop the TUI starting: it launches on
149
+ defaults and says so. Unrecognized keys are kept rather than dropped, because
150
+ one of them is a plugin screen's binding id.
151
+
152
+ ## The browser tab
153
+
154
+ ```bash
155
+ phern --mode web --provider llama --model <model> # 127.0.0.1:8000
156
+ phern --mode web --port 8080 --open
157
+ ```
158
+
159
+ `textual-serve` runs a real `PHTuiApp` as a subprocess and streams its frames,
160
+ so the browser shows the terminal — one layout, not two. Three things it prints
161
+ before it binds, each of which is load-bearing:
162
+
163
+ - **the token in the URL is the whole authentication story** — no TLS, no users;
164
+ treat the URL like the terminal it came from;
165
+ - **every tab of one launch is on one session** (a second tab joins the
166
+ conversation; a second `phern --mode web` is a new one);
167
+ - `--host` anything but loopback reaches anyone who can route to the port.
168
+
169
+ Needs the `web` extra: `uv tool install "phern[web]"`, or
170
+ `uv tool install "./packages/phern[web]"`. Without it, `--mode web` fails with
171
+ the install line rather than an `ImportError`.
172
+
173
+ ## The daemon
174
+
175
+ ```bash
176
+ phern daemon --profile tui --provider llama --model <model>
177
+ phern daemon --max-concurrent-children 6 # across every root; the rest queue
178
+ phern daemon --passivate-after 30 # minutes of quiet before a root is released, or `off`
179
+ phern daemon --ephemeral # exit once no client, root or appointment needs it
180
+ ```
181
+
182
+ One `anyio` task per root, and the client is not it: a root owns a mounted
183
+ profile, a session, an agent and a queue, and its task drains that queue whether
184
+ or not anybody is attached. Attaching subscribes a connection to the root's
185
+ events; detaching unsubscribes it. **Neither starts nor stops the work**, which
186
+ is why leaving is free.
187
+
188
+ The socket is per boot and per user. A stale socket from a crashed daemon is
189
+ cleared; a live one is refused rather than stolen. On Linux, note that logind
190
+ reaps `$XDG_RUNTIME_DIR` at logout for a user who is not lingering — a daemon
191
+ can keep running and *lose its socket*, after which every client is told to
192
+ start one and the leases the first still holds will refuse it. `phern doctor` and
193
+ `phern daemon` say so in advance; `loginctl enable-linger` is the fix.
194
+
195
+ ## Profiles
196
+
197
+ The table lives in `src/ph_app/profiles.py`, the documents in
198
+ `src/ph_app/profiles/`.
199
+
200
+ | `--profile` | layers | credential |
201
+ |---|---|---|
202
+ | `base` | `ph-base` | — |
203
+ | `headless` | `base` + the fake adapter | — |
204
+ | `tui` | `headless` + `tui.yaml` (writable workspace, `/trajectory`, `ask_user` armed) | — |
205
+
206
+ Every row of that table also layers the **`stabilize` bundle**, so every profile
207
+ compacts at 85% of its window and offers `/compact`. It is layered *optionally*:
208
+ an install without `ph-stabilize` composes each of these profiles unchanged and
209
+ simply never compacts, which is what keeps the lean `uv tool install
210
+ ./packages/phern` target whole. `phern doctor` reports which rows activated.
211
+ | `llama` | `base` + a local llama.cpp route | `LLAMA_API_KEY` (a formality llama.cpp ignores, but it must be set) |
212
+ | `deepseek` | `base` + DeepSeek over the OpenAI-compatible wire | `DEEPSEEK_API_KEY` |
213
+ | `anthropic` | `base` + the messages API | `ANTHROPIC_API_KEY` |
214
+ | `google` | `base` + Gemini (the one route declaring video, so `uploads` has a provider) | `GEMINI_API_KEY` |
215
+ | `rlm` | `tui` + the `rlm` bundle | needs `ph-rlm` |
216
+ | `rlm-stable` | `rlm` + `stabilize`, gates on | needs `ph-rlm`, `ph-stabilize` |
217
+ | `rlm-indexed` | `rlm-stable` + `code-graph` + `text-index` | needs both plugin distributions too |
218
+
219
+ **A profile is offered only if every layer it names resolves.**
220
+ `available_profiles()` asks exactly the question `resolve_profile` will answer,
221
+ so a `--help` line and a command line cannot disagree; an install missing a
222
+ bundle sees no `rlm-indexed` rather than one that fails at mount, and the
223
+ refusal names the package to install. A `--profile` value that is a path to a
224
+ `.yaml` is used directly, which is what makes a scenario test or a one-off
225
+ deployment one file rather than an install step.
226
+
227
+ ## Adjusting it
228
+
229
+ The same three layers every pH row uses — the shipped documents, your overlay at
230
+ `$PH_HOME/profiles/<name>.yaml`, then `--patch` for one run. What is specific to
231
+ this package is the rows it registers:
232
+
233
+ | row | config | default |
234
+ |---|---|---|
235
+ | `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 |
236
+ | `llm-google` | the same shape plus `uploadReadyMs` | `1048576` window, images/audio/video/PDF, 20 MiB, video routed through the Files API |
237
+ | `llm-openai-compatible` | `profiles: [ProviderProfile, …]` | one entry per route; this is the row `llama` and `deepseek` insert |
238
+ | `tui-screen-trajectory` | — | contributed by `tui.yaml` |
239
+
240
+ ```yaml
241
+ # $PH_HOME/profiles/anthropic.yaml — a different model ceiling, caching off
242
+ - id: llm-anthropic
243
+ config:
244
+ provider: anthropic
245
+ apiKeyEnv: ANTHROPIC_API_KEY
246
+ contextWindow: 200000
247
+ defaultMaxTokens: 16384
248
+ cacheControl: false
249
+ ```
250
+
251
+ **A list is replaced, not merged**, and on these rows that is the trap worth
252
+ naming: an overlay that restates one field of an OpenAI-compatible route must
253
+ restate the whole route entry, or it inherits `api.openai.com` from the row
254
+ default — a local deployment quietly calling a hosted provider. `llama.yaml`'s
255
+ own comments carry the worked example.
256
+
257
+ An `apiKeyEnv` is a **name**, never an interpolation. The adapter resolves it at
258
+ the request edge, so the value never enters a row, an event, or a child process
259
+ (I-3). `${env:…}` interpolation is available for everything that is not a
260
+ secret, with `${env:VAR:-default}` for a fallback.
261
+
262
+ ## Limitations, and things that are deliberate
263
+
264
+ - **`ph_app` must not import `ph_rlm` or `ph_stabilize`**, although the `phern`
265
+ distribution depends on both wheels. It composes their profiles through the
266
+ `ph.bundles` entry-point group and reads their events (`subagent/*`) without
267
+ importing the rows that emit them — `tests/test_app_layering.py` walks the AST
268
+ and enforces it. Depending on a wheel decides what is installed; it does not
269
+ grant a name this module may write down, and keeping the two separate is what
270
+ lets a bundle nobody here ships compose a profile on the same footing.
271
+ - **Textual is pinned at both ends (`>=8.2.5,<9`), and both ends are
272
+ load-bearing.** The floor is where the suite actually passes: `MarkdownStream`
273
+ (the transcript's streaming append) does not exist before Textual 5, and the
274
+ committed SVG snapshots then narrow it further — 8.2.4 fails one of them. The
275
+ ceiling guards `add_binding`, which writes through a private `BindingsMap`
276
+ because the public `bind()` drops the id that `set_keymap` matches on — and
277
+ that id is what makes a contributed screen's key rebindable like every other.
278
+ - **The web UI is the terminal in a canvas**, not an HTML renderer on the same
279
+ view model. That is the trade that buys layout parity by construction.
280
+ - **A client reads no session file at all.** After P5-14 the daemon holds them
281
+ and answers `sessions/browse`, which is what makes a front end on another
282
+ machine possible and stops a client and a daemon disagreeing about which
283
+ `$PH_HOME` they meant.
284
+ - **The daemon's method vocabulary is typed and closed** (`ph_app.verbs`,
285
+ `ph_app.params`, `ph_app.payloads`): a field a method does not take is a
286
+ refusal that names the field, not a silent drop. A client that believed it had
287
+ said something is the failure a typed edge exists to end.
288
+
289
+ ## Tests
290
+
291
+ `tests/` — 36 modules covering the CLI, the four non-interactive modes, the
292
+ daemon (framing, methods, mutations, lifetime, recovery, asks), the TUI (pilot
293
+ runs, remote verbs, screens, state) and the three adapters, plus committed
294
+ Textual SVG snapshots. `test_non_guarantees.py` is worth reading first: it pins
295
+ the *claims* rather than a mechanism — the sentences a person reads before
296
+ deciding whether to run six agents under one daemon, and that `phern doctor` and
297
+ `phern agents doctor` still print them.
phern-0.1.0/README.md ADDED
@@ -0,0 +1,254 @@
1
+ # phern
2
+
3
+ *The `phern` command: four output modes, the Textual TUI, the browser tab, the
4
+ daemon and its client — and the three real model wires.*
5
+
6
+ `ph-core` is the harness; this is the thing a person runs. It owns no seam and
7
+ publishes no service that a profile could not do without: what it contributes is
8
+ **front ends** (print, json, transcript, rpc, tui, web, trajectory), the
9
+ **supervisor** that lets a run outlive the terminal that started it, the
10
+ **adapters** that speak Anthropic, Gemini and the OpenAI-compatible wire, and
11
+ the **profile table** that composes everything installed into a name you can
12
+ type.
13
+
14
+ ```bash
15
+ uv tool install ./packages/phern # the CLI and core alone, about 60 MB
16
+ phern --profile llama --provider llama --model <model> -p "what is in this repo?"
17
+ phern --profile tui --provider llama --model <model> --mode tui
18
+ ```
19
+
20
+ Installed on its own it composes seven profiles — `anthropic`, `base`,
21
+ `deepseek`, `google`, `headless`, `llama` and `tui`. The `rlm*` profiles need
22
+ bundles it deliberately does not depend on; see *Profiles* below.
23
+
24
+ ## The commands
25
+
26
+ | command | what it does |
27
+ |---|---|
28
+ | `phern -p "…"` | one prompt, one answer. `-a/--attach FILE` sends a file with it, repeatable |
29
+ | `phern --mode json \| transcript \| rpc \| tui \| web \| trajectory` | what reaches stdout, or which front end runs |
30
+ | `phern doctor [--profile …]` | the three path roots, then mount a profile and let every row report what activated |
31
+ | `phern events [--json]` | the event producer/consumer matrix, generated from the declaration registry |
32
+ | `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 |
33
+ | `phern --dump-config` | the composed rows in order, before anything runs |
34
+ | `phern daemon` | run the supervisor |
35
+ | `phern agents …` | the client that talks to it: bare (list roots), `status`, `attach`, `send`, `schedule`, `doctor`, `shutdown` |
36
+ | `phern workspaces gc` | the git trees agents left behind, across every stored session. Reports by default; collects with `--remove` |
37
+ | `phern attachments gc` | media no stored session references. Same rule |
38
+
39
+ `phern doctor` reports what *this invocation's* flags and environment would
40
+ produce; `phern agents doctor` reports what is **in force** in the running daemon.
41
+ They are different questions and it is worth knowing which one you asked.
42
+
43
+ ### The modes, and why the choice matters
44
+
45
+ `json` and `rpc` emit the session log's **own** envelopes rather than a per-mode
46
+ rendering (I-7), so a wrapper streaming from a pipe and a tool reading the
47
+ stored JSONL parse one format. `transcript` reads `session.transcript()` — what
48
+ the person saw — so a compacted conversation still shows the turns they actually
49
+ had, where the model surface deliberately shadows replaced ranges. `text` is the
50
+ default. `trajectory` audits a stored log and mounts nothing.
51
+
52
+ ## The TUI
53
+
54
+ ```bash
55
+ phern --mode tui --provider llama --model <model> # new session
56
+ phern --mode tui --resume 20260908T041607-1c5576 # reopen one
57
+ phern --mode tui --no-spawn # refuse rather than start a daemon
58
+ phern --mode tui --keep-daemon # the daemon it starts is a service
59
+ ```
60
+
61
+ The front end talks to the daemon over `$PH_RUNTIME/daemon.sock` and starts an
62
+ ephemeral one if nothing is listening, so closing the TUI does not end the turn.
63
+
64
+ Every front-end action is a `TuiVerb` reachable three ways — a slash command
65
+ registered into `ctx.commands` (so the palette lists it, the prompt completes
66
+ it, and `command/run` records it), a Textual action, and a key:
67
+
68
+ | slash | key | |
69
+ |---|---|---|
70
+ | `/commands` | `ctrl+k` | browse every command, the daemon's and the client's |
71
+ | `/model` | `ctrl+p` | choose the provider and model |
72
+ | `/theme` | `ctrl+y` | `ph-dark`, `ph-light`, `high-contrast` |
73
+ | `/sessions` | `ctrl+r` | reopen a stored session |
74
+ | `/permissions` | `ctrl+g` | change what pH may do without asking |
75
+ | `/thinking` | `ctrl+t` | show or hide the model's reasoning |
76
+ | `/tools` | `ctrl+o` | show or hide tool results |
77
+ | `/sidebar` | `ctrl+b` | show or hide the sidebar |
78
+ | `/login` | | provide a provider credential for this process |
79
+ | `/attach <path> …` | | attach files to the next prompt |
80
+ | `/quit` | `ctrl+d` | |
81
+
82
+ Rows contribute their own screens and commands through `ctx.tui_screens` and
83
+ `ctx.commands`, and they arrive with the same three routes — `/trajectory` is
84
+ one such screen, contributed by `tui.yaml` rather than built in, and it takes
85
+ its key and palette entry away with it if the row is removed.
86
+
87
+ ### `$PH_HOME/tui.json`
88
+
89
+ Keybindings, theme and preferences. **Never hard-code a key check**: every
90
+ binding is a named field whose name doubles as the Textual binding id, so one
91
+ `set_keymap` rebinds the whole app, screens and modals included — a contributed
92
+ screen's key is remappable exactly like a built-in.
93
+
94
+ ```json
95
+ {
96
+ "theme": "ph-dark",
97
+ "sidebar": "right",
98
+ "turn_notification": "bell",
99
+ "show_thinking": true,
100
+ "show_tool_results": true,
101
+ "keybindings": { "command_palette": "ctrl+k", "quit": "ctrl+d" }
102
+ }
103
+ ```
104
+
105
+ A file that fails to parse does not stop the TUI starting: it launches on
106
+ defaults and says so. Unrecognized keys are kept rather than dropped, because
107
+ one of them is a plugin screen's binding id.
108
+
109
+ ## The browser tab
110
+
111
+ ```bash
112
+ phern --mode web --provider llama --model <model> # 127.0.0.1:8000
113
+ phern --mode web --port 8080 --open
114
+ ```
115
+
116
+ `textual-serve` runs a real `PHTuiApp` as a subprocess and streams its frames,
117
+ so the browser shows the terminal — one layout, not two. Three things it prints
118
+ before it binds, each of which is load-bearing:
119
+
120
+ - **the token in the URL is the whole authentication story** — no TLS, no users;
121
+ treat the URL like the terminal it came from;
122
+ - **every tab of one launch is on one session** (a second tab joins the
123
+ conversation; a second `phern --mode web` is a new one);
124
+ - `--host` anything but loopback reaches anyone who can route to the port.
125
+
126
+ Needs the `web` extra: `uv tool install "phern[web]"`, or
127
+ `uv tool install "./packages/phern[web]"`. Without it, `--mode web` fails with
128
+ the install line rather than an `ImportError`.
129
+
130
+ ## The daemon
131
+
132
+ ```bash
133
+ phern daemon --profile tui --provider llama --model <model>
134
+ phern daemon --max-concurrent-children 6 # across every root; the rest queue
135
+ phern daemon --passivate-after 30 # minutes of quiet before a root is released, or `off`
136
+ phern daemon --ephemeral # exit once no client, root or appointment needs it
137
+ ```
138
+
139
+ One `anyio` task per root, and the client is not it: a root owns a mounted
140
+ profile, a session, an agent and a queue, and its task drains that queue whether
141
+ or not anybody is attached. Attaching subscribes a connection to the root's
142
+ events; detaching unsubscribes it. **Neither starts nor stops the work**, which
143
+ is why leaving is free.
144
+
145
+ The socket is per boot and per user. A stale socket from a crashed daemon is
146
+ cleared; a live one is refused rather than stolen. On Linux, note that logind
147
+ reaps `$XDG_RUNTIME_DIR` at logout for a user who is not lingering — a daemon
148
+ can keep running and *lose its socket*, after which every client is told to
149
+ start one and the leases the first still holds will refuse it. `phern doctor` and
150
+ `phern daemon` say so in advance; `loginctl enable-linger` is the fix.
151
+
152
+ ## Profiles
153
+
154
+ The table lives in `src/ph_app/profiles.py`, the documents in
155
+ `src/ph_app/profiles/`.
156
+
157
+ | `--profile` | layers | credential |
158
+ |---|---|---|
159
+ | `base` | `ph-base` | — |
160
+ | `headless` | `base` + the fake adapter | — |
161
+ | `tui` | `headless` + `tui.yaml` (writable workspace, `/trajectory`, `ask_user` armed) | — |
162
+
163
+ Every row of that table also layers the **`stabilize` bundle**, so every profile
164
+ compacts at 85% of its window and offers `/compact`. It is layered *optionally*:
165
+ an install without `ph-stabilize` composes each of these profiles unchanged and
166
+ simply never compacts, which is what keeps the lean `uv tool install
167
+ ./packages/phern` target whole. `phern doctor` reports which rows activated.
168
+ | `llama` | `base` + a local llama.cpp route | `LLAMA_API_KEY` (a formality llama.cpp ignores, but it must be set) |
169
+ | `deepseek` | `base` + DeepSeek over the OpenAI-compatible wire | `DEEPSEEK_API_KEY` |
170
+ | `anthropic` | `base` + the messages API | `ANTHROPIC_API_KEY` |
171
+ | `google` | `base` + Gemini (the one route declaring video, so `uploads` has a provider) | `GEMINI_API_KEY` |
172
+ | `rlm` | `tui` + the `rlm` bundle | needs `ph-rlm` |
173
+ | `rlm-stable` | `rlm` + `stabilize`, gates on | needs `ph-rlm`, `ph-stabilize` |
174
+ | `rlm-indexed` | `rlm-stable` + `code-graph` + `text-index` | needs both plugin distributions too |
175
+
176
+ **A profile is offered only if every layer it names resolves.**
177
+ `available_profiles()` asks exactly the question `resolve_profile` will answer,
178
+ so a `--help` line and a command line cannot disagree; an install missing a
179
+ bundle sees no `rlm-indexed` rather than one that fails at mount, and the
180
+ refusal names the package to install. A `--profile` value that is a path to a
181
+ `.yaml` is used directly, which is what makes a scenario test or a one-off
182
+ deployment one file rather than an install step.
183
+
184
+ ## Adjusting it
185
+
186
+ The same three layers every pH row uses — the shipped documents, your overlay at
187
+ `$PH_HOME/profiles/<name>.yaml`, then `--patch` for one run. What is specific to
188
+ this package is the rows it registers:
189
+
190
+ | row | config | default |
191
+ |---|---|---|
192
+ | `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 |
193
+ | `llm-google` | the same shape plus `uploadReadyMs` | `1048576` window, images/audio/video/PDF, 20 MiB, video routed through the Files API |
194
+ | `llm-openai-compatible` | `profiles: [ProviderProfile, …]` | one entry per route; this is the row `llama` and `deepseek` insert |
195
+ | `tui-screen-trajectory` | — | contributed by `tui.yaml` |
196
+
197
+ ```yaml
198
+ # $PH_HOME/profiles/anthropic.yaml — a different model ceiling, caching off
199
+ - id: llm-anthropic
200
+ config:
201
+ provider: anthropic
202
+ apiKeyEnv: ANTHROPIC_API_KEY
203
+ contextWindow: 200000
204
+ defaultMaxTokens: 16384
205
+ cacheControl: false
206
+ ```
207
+
208
+ **A list is replaced, not merged**, and on these rows that is the trap worth
209
+ naming: an overlay that restates one field of an OpenAI-compatible route must
210
+ restate the whole route entry, or it inherits `api.openai.com` from the row
211
+ default — a local deployment quietly calling a hosted provider. `llama.yaml`'s
212
+ own comments carry the worked example.
213
+
214
+ An `apiKeyEnv` is a **name**, never an interpolation. The adapter resolves it at
215
+ the request edge, so the value never enters a row, an event, or a child process
216
+ (I-3). `${env:…}` interpolation is available for everything that is not a
217
+ secret, with `${env:VAR:-default}` for a fallback.
218
+
219
+ ## Limitations, and things that are deliberate
220
+
221
+ - **`ph_app` must not import `ph_rlm` or `ph_stabilize`**, although the `phern`
222
+ distribution depends on both wheels. It composes their profiles through the
223
+ `ph.bundles` entry-point group and reads their events (`subagent/*`) without
224
+ importing the rows that emit them — `tests/test_app_layering.py` walks the AST
225
+ and enforces it. Depending on a wheel decides what is installed; it does not
226
+ grant a name this module may write down, and keeping the two separate is what
227
+ lets a bundle nobody here ships compose a profile on the same footing.
228
+ - **Textual is pinned at both ends (`>=8.2.5,<9`), and both ends are
229
+ load-bearing.** The floor is where the suite actually passes: `MarkdownStream`
230
+ (the transcript's streaming append) does not exist before Textual 5, and the
231
+ committed SVG snapshots then narrow it further — 8.2.4 fails one of them. The
232
+ ceiling guards `add_binding`, which writes through a private `BindingsMap`
233
+ because the public `bind()` drops the id that `set_keymap` matches on — and
234
+ that id is what makes a contributed screen's key rebindable like every other.
235
+ - **The web UI is the terminal in a canvas**, not an HTML renderer on the same
236
+ view model. That is the trade that buys layout parity by construction.
237
+ - **A client reads no session file at all.** After P5-14 the daemon holds them
238
+ and answers `sessions/browse`, which is what makes a front end on another
239
+ machine possible and stops a client and a daemon disagreeing about which
240
+ `$PH_HOME` they meant.
241
+ - **The daemon's method vocabulary is typed and closed** (`ph_app.verbs`,
242
+ `ph_app.params`, `ph_app.payloads`): a field a method does not take is a
243
+ refusal that names the field, not a silent drop. A client that believed it had
244
+ said something is the failure a typed edge exists to end.
245
+
246
+ ## Tests
247
+
248
+ `tests/` — 36 modules covering the CLI, the four non-interactive modes, the
249
+ daemon (framing, methods, mutations, lifetime, recovery, asks), the TUI (pilot
250
+ runs, remote verbs, screens, state) and the three adapters, plus committed
251
+ Textual SVG snapshots. `test_non_guarantees.py` is worth reading first: it pins
252
+ the *claims* rather than a mechanism — the sentences a person reads before
253
+ deciding whether to run six agents under one daemon, and that `phern doctor` and
254
+ `phern agents doctor` still print them.