phern 0.1.0__tar.gz → 0.2.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 (147) hide show
  1. {phern-0.1.0 → phern-0.2.0}/PKG-INFO +10 -9
  2. {phern-0.1.0 → phern-0.2.0}/pyproject.toml +15 -9
  3. {phern-0.1.0 → phern-0.2.0}/src/ph_app/adapters/_http.py +1 -1
  4. {phern-0.1.0 → phern-0.2.0}/src/ph_app/adapters/_media.py +1 -1
  5. {phern-0.1.0 → phern-0.2.0}/src/ph_app/adapters/anthropic.py +2 -2
  6. {phern-0.1.0 → phern-0.2.0}/src/ph_app/adapters/google.py +3 -3
  7. {phern-0.1.0 → phern-0.2.0}/src/ph_app/adapters/openai_compatible.py +2 -2
  8. {phern-0.1.0 → phern-0.2.0}/src/ph_app/agents.py +8 -25
  9. {phern-0.1.0 → phern-0.2.0}/src/ph_app/cli.py +167 -14
  10. {phern-0.1.0 → phern-0.2.0}/src/ph_app/console.py +4 -4
  11. {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/cancelsafe.py +10 -10
  12. {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/client.py +2 -2
  13. {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/duplex.py +4 -4
  14. {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/frontend.py +2 -2
  15. {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/launch.py +1 -1
  16. {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/projections.py +3 -3
  17. {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/recovery.py +0 -22
  18. {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/server.py +296 -43
  19. {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/supervisor.py +144 -27
  20. {phern-0.1.0 → phern-0.2.0}/src/ph_app/modes/transcript_mode.py +1 -1
  21. {phern-0.1.0 → phern-0.2.0}/src/ph_app/params.py +23 -0
  22. {phern-0.1.0 → phern-0.2.0}/src/ph_app/payloads.py +107 -8
  23. {phern-0.1.0 → phern-0.2.0}/src/ph_app/profiles.py +2 -2
  24. {phern-0.1.0 → phern-0.2.0}/src/ph_app/protocol.py +30 -5
  25. {phern-0.1.0 → phern-0.2.0}/src/ph_app/runtime.py +1 -1
  26. {phern-0.1.0 → phern-0.2.0}/src/ph_app/sessions.py +62 -10
  27. {phern-0.1.0 → phern-0.2.0}/src/ph_app/shell.py +2 -2
  28. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/adapter.py +7 -7
  29. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/app.py +174 -14
  30. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/commands.py +3 -2
  31. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/config.py +43 -19
  32. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/frontend.py +6 -1
  33. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/modals/base.py +2 -2
  34. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/modals/pickers.py +81 -10
  35. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/remote.py +36 -7
  36. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/screens.py +1 -1
  37. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/state.py +64 -1
  38. phern-0.2.0/src/ph_app/tui/themes/__init__.py +514 -0
  39. phern-0.2.0/src/ph_app/tui/themes/catppuccin-frappe.yaml +57 -0
  40. phern-0.2.0/src/ph_app/tui/themes/catppuccin-latte.yaml +57 -0
  41. phern-0.2.0/src/ph_app/tui/themes/catppuccin-macchiato.yaml +57 -0
  42. phern-0.2.0/src/ph_app/tui/themes/catppuccin-mocha.yaml +57 -0
  43. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/trajectory.py +2 -2
  44. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/trajectory_app.py +10 -8
  45. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/widgets/prompt.py +140 -6
  46. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/widgets/status.py +61 -6
  47. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/widgets/transcript.py +2 -2
  48. {phern-0.1.0 → phern-0.2.0}/src/ph_app/verbs.py +13 -3
  49. {phern-0.1.0 → phern-0.2.0}/src/ph_app/web/serve.py +4 -4
  50. {phern-0.1.0 → phern-0.2.0}/src/ph_app/wire.py +1 -1
  51. phern-0.2.0/tests/__snapshots__/test_tui_snapshot/test_the_full_app_shell.raw +155 -0
  52. {phern-0.1.0 → phern-0.2.0}/tests/daemon_helpers.py +38 -3
  53. {phern-0.1.0 → phern-0.2.0}/tests/test_adapters.py +2 -2
  54. {phern-0.1.0 → phern-0.2.0}/tests/test_agents_cli.py +57 -6
  55. {phern-0.1.0 → phern-0.2.0}/tests/test_cancelsafe.py +16 -16
  56. {phern-0.1.0 → phern-0.2.0}/tests/test_cli.py +147 -2
  57. {phern-0.1.0 → phern-0.2.0}/tests/test_daemon.py +15 -8
  58. {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_asks.py +1 -1
  59. {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_invariants.py +1 -1
  60. phern-0.2.0/tests/test_daemon_lifetime.py +546 -0
  61. {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_methods.py +61 -2
  62. {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_projections.py +2 -2
  63. {phern-0.1.0 → phern-0.2.0}/tests/test_modes.py +5 -2
  64. {phern-0.1.0 → phern-0.2.0}/tests/test_non_guarantees.py +2 -0
  65. {phern-0.1.0 → phern-0.2.0}/tests/test_payloads.py +23 -0
  66. {phern-0.1.0 → phern-0.2.0}/tests/test_trajectory.py +4 -4
  67. {phern-0.1.0 → phern-0.2.0}/tests/test_tui_adapter.py +3 -3
  68. {phern-0.1.0 → phern-0.2.0}/tests/test_tui_copy.py +1 -1
  69. {phern-0.1.0 → phern-0.2.0}/tests/test_tui_pilot.py +313 -3
  70. {phern-0.1.0 → phern-0.2.0}/tests/test_tui_remote.py +168 -4
  71. {phern-0.1.0 → phern-0.2.0}/tests/test_tui_state.py +115 -70
  72. phern-0.2.0/tests/test_tui_themes.py +302 -0
  73. {phern-0.1.0 → phern-0.2.0}/tests/test_uploads.py +1 -1
  74. {phern-0.1.0 → phern-0.2.0}/tests/test_uploads_google.py +2 -2
  75. {phern-0.1.0 → phern-0.2.0}/tests/test_web.py +3 -3
  76. {phern-0.1.0 → phern-0.2.0}/tests/test_workspaces_cli.py +8 -8
  77. phern-0.1.0/src/ph_app/tui/themes/__init__.py +0 -230
  78. phern-0.1.0/tests/__snapshots__/test_tui_snapshot/test_the_full_app_shell.raw +0 -155
  79. phern-0.1.0/tests/test_daemon_lifetime.py +0 -283
  80. {phern-0.1.0 → phern-0.2.0}/.gitignore +0 -0
  81. {phern-0.1.0 → phern-0.2.0}/LICENSE +0 -0
  82. {phern-0.1.0 → phern-0.2.0}/README.md +0 -0
  83. {phern-0.1.0 → phern-0.2.0}/src/ph_app/__init__.py +0 -0
  84. {phern-0.1.0 → phern-0.2.0}/src/ph_app/__main__.py +0 -0
  85. {phern-0.1.0 → phern-0.2.0}/src/ph_app/adapters/__init__.py +0 -0
  86. {phern-0.1.0 → phern-0.2.0}/src/ph_app/adapters/sse.py +0 -0
  87. {phern-0.1.0 → phern-0.2.0}/src/ph_app/attach.py +0 -0
  88. {phern-0.1.0 → phern-0.2.0}/src/ph_app/attachments.py +0 -0
  89. {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/__init__.py +0 -0
  90. {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/cards.py +0 -0
  91. {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/follow.py +0 -0
  92. {phern-0.1.0 → phern-0.2.0}/src/ph_app/daemon/framing.py +0 -0
  93. {phern-0.1.0 → phern-0.2.0}/src/ph_app/modes/__init__.py +0 -0
  94. {phern-0.1.0 → phern-0.2.0}/src/ph_app/modes/json_mode.py +0 -0
  95. {phern-0.1.0 → phern-0.2.0}/src/ph_app/modes/print_mode.py +0 -0
  96. {phern-0.1.0 → phern-0.2.0}/src/ph_app/modes/rpc_mode.py +0 -0
  97. {phern-0.1.0 → phern-0.2.0}/src/ph_app/profiles/anthropic.yaml +0 -0
  98. {phern-0.1.0 → phern-0.2.0}/src/ph_app/profiles/deepseek.yaml +0 -0
  99. {phern-0.1.0 → phern-0.2.0}/src/ph_app/profiles/google.yaml +0 -0
  100. {phern-0.1.0 → phern-0.2.0}/src/ph_app/profiles/llama.yaml +0 -0
  101. {phern-0.1.0 → phern-0.2.0}/src/ph_app/profiles/rlm-stable.yaml +0 -0
  102. {phern-0.1.0 → phern-0.2.0}/src/ph_app/profiles/tui.yaml +0 -0
  103. {phern-0.1.0 → phern-0.2.0}/src/ph_app/py.typed +0 -0
  104. {phern-0.1.0 → phern-0.2.0}/src/ph_app/trust.py +0 -0
  105. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/__init__.py +0 -0
  106. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/autocomplete.py +0 -0
  107. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/modals/__init__.py +0 -0
  108. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/modals/approval.py +0 -0
  109. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/modals/ask_user.py +0 -0
  110. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/modals/login.py +0 -0
  111. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/modals/trust.py +0 -0
  112. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/terminal.py +0 -0
  113. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/themes/high-contrast.json +0 -0
  114. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/themes/ph-dark.json +0 -0
  115. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/themes/ph-light.json +0 -0
  116. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/trajectory_screen.py +0 -0
  117. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/widgets/__init__.py +0 -0
  118. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/widgets/selection.py +0 -0
  119. {phern-0.1.0 → phern-0.2.0}/src/ph_app/tui/widgets/trajectory.py +0 -0
  120. {phern-0.1.0 → phern-0.2.0}/src/ph_app/web/__init__.py +0 -0
  121. {phern-0.1.0 → phern-0.2.0}/src/ph_app/workspaces.py +0 -0
  122. {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_a_code_cell_shows_its_program_and_what_it_cost.raw +0 -0
  123. {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_a_code_mode_card_lists_its_dispatches.raw +0 -0
  124. {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_a_compaction_marker_keeps_what_it_replaced.raw +0 -0
  125. {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_a_failed_tool_card_and_an_error_row.raw +0 -0
  126. {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_a_settled_tool_card.raw +0 -0
  127. {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_every_theme_renders[high-contrast].raw +0 -0
  128. {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_every_theme_renders[ph-light].raw +0 -0
  129. {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_markup_in_user_text_renders_literally.raw +0 -0
  130. {phern-0.1.0 → phern-0.2.0}/tests/__snapshots__/test_tui_snapshot/test_streaming_assistant_text.raw +0 -0
  131. {phern-0.1.0 → phern-0.2.0}/tests/app_fixtures.py +0 -0
  132. {phern-0.1.0 → phern-0.2.0}/tests/test_app_layering.py +0 -0
  133. {phern-0.1.0 → phern-0.2.0}/tests/test_attach.py +0 -0
  134. {phern-0.1.0 → phern-0.2.0}/tests/test_attachments_cli.py +0 -0
  135. {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_attachments.py +0 -0
  136. {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_framing.py +0 -0
  137. {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_launch.py +0 -0
  138. {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_mutations.py +0 -0
  139. {phern-0.1.0 → phern-0.2.0}/tests/test_daemon_shell.py +0 -0
  140. {phern-0.1.0 → phern-0.2.0}/tests/test_protocol.py +0 -0
  141. {phern-0.1.0 → phern-0.2.0}/tests/test_trajectory_view.py +0 -0
  142. {phern-0.1.0 → phern-0.2.0}/tests/test_tui_code_cell.py +0 -0
  143. {phern-0.1.0 → phern-0.2.0}/tests/test_tui_frames.py +0 -0
  144. {phern-0.1.0 → phern-0.2.0}/tests/test_tui_screens.py +0 -0
  145. {phern-0.1.0 → phern-0.2.0}/tests/test_tui_snapshot.py +0 -0
  146. {phern-0.1.0 → phern-0.2.0}/tests/test_uploads_openai.py +0 -0
  147. {phern-0.1.0 → phern-0.2.0}/tests/tui_helpers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: phern
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: pH — a Python agent harness with no privileged core: every part is a row in a profile.
5
5
  Project-URL: Homepage, https://github.com/chastabor/pH
6
6
  Project-URL: Repository, https://github.com/chastabor/pH
@@ -24,19 +24,20 @@ Requires-Python: >=3.12
24
24
  Requires-Dist: anyio<5,>=4.4
25
25
  Requires-Dist: filelock>=3.15
26
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
27
+ Requires-Dist: ph-code-graph==0.2.0
28
+ Requires-Dist: ph-core==0.2.0
29
+ Requires-Dist: ph-rlm==0.2.0
30
+ Requires-Dist: ph-runtime-guest==0.2.0
31
+ Requires-Dist: ph-stabilize==0.2.0
32
+ Requires-Dist: ph-text-index==0.2.0
33
+ Requires-Dist: pyyaml>=6.0
33
34
  Requires-Dist: rich>=13.0
34
35
  Requires-Dist: textual<9,>=8.2.5
35
36
  Requires-Dist: typer>=0.12
36
37
  Provides-Extra: local
37
- Requires-Dist: ph-text-index[local]==0.1.0; extra == 'local'
38
+ Requires-Dist: ph-text-index[local]==0.2.0; extra == 'local'
38
39
  Provides-Extra: otel
39
- Requires-Dist: ph-core[otel]==0.1.0; extra == 'otel'
40
+ Requires-Dist: ph-core[otel]==0.2.0; extra == 'otel'
40
41
  Provides-Extra: web
41
42
  Requires-Dist: textual-serve>=1.1.3; extra == 'web'
42
43
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "phern"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "pH — a Python agent harness with no privileged core: every part is a row in a profile."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -45,20 +45,26 @@ dependencies = [
45
45
  # `ph.bundles` composes a profile on an install of this package, and one
46
46
  # uninstalled from an install of this package takes its profiles out of
47
47
  # `phern doctor` rather than breaking the six that remain.
48
- "ph-core==0.1.0",
49
- "ph-rlm==0.1.0",
50
- "ph-stabilize==0.1.0",
51
- "ph-text-index==0.1.0",
52
- "ph-code-graph==0.1.0",
48
+ "ph-core==0.2.0",
49
+ "ph-rlm==0.2.0",
50
+ "ph-stabilize==0.2.0",
51
+ "ph-text-index==0.2.0",
52
+ "ph-code-graph==0.2.0",
53
53
  # Reached through `ph-rlm` already; pinned here too because `ph_rlm.kernel`
54
54
  # installs it *by name* into `$PH_CACHE/runtime-venv`, so the version this
55
55
  # deployment resolves is the version a cell runs against.
56
- "ph-runtime-guest==0.1.0",
56
+ "ph-runtime-guest==0.2.0",
57
57
  # Declared although `ph-core` already requires it: `ph_app.daemon` imports
58
58
  # `anyio` directly at 20-odd sites and `cancelsafe` is keyed to its
59
59
  # internals, so arriving here transitively understated the coupling.
60
60
  "anyio>=4.4,<5",
61
61
  "filelock>=3.15",
62
+ # Declared although `ph-core` already requires it, for `anyio`'s reason above:
63
+ # `ph_app` imports `yaml` directly — `cli.py` to print a composed profile, and
64
+ # `tui.themes` to write `$PH_HOME/themes/theme-profile.yaml` — so arriving here
65
+ # transitively understated the coupling. Reading YAML still goes through
66
+ # ph-core's one safe door (`safe_yaml_load`); this is the writing half.
67
+ "pyyaml>=6.0",
62
68
  "typer>=0.12",
63
69
  "rich>=13.0",
64
70
  "httpx>=0.27",
@@ -106,13 +112,13 @@ web = ["textual-serve>=1.1.3"]
106
112
  # default CUDA wheels. Without it `text_index` and `text_search` still register
107
113
  # and still search an index you supply vectors for — what refuses to mount is
108
114
  # the row that *computes* them, with a message naming this extra.
109
- local = ["ph-text-index[local]==0.1.0"]
115
+ local = ["ph-text-index[local]==0.2.0"]
110
116
 
111
117
  # Exporting the session log to an OTel collector, passed through for the same
112
118
  # reason `local` is: `ph-core` owns the row and the refusal message, but `phern`
113
119
  # is the name a person installs, so all three of this deployment's extras should
114
120
  # be reachable without first learning which member implements which.
115
- otel = ["ph-core[otel]==0.1.0"]
121
+ otel = ["ph-core[otel]==0.2.0"]
116
122
 
117
123
  [project.entry-points."ph.plugins"]
118
124
  llm-openai-compatible = "ph_app.adapters.openai_compatible:apply"
@@ -40,7 +40,7 @@ def resolve_secret(ctx: Context, env_name: str, provider: str) -> str:
40
40
  """Turn a credential *name* into its value — here, at the edge, and nowhere above (I-3).
41
41
 
42
42
  The value goes into a local that goes out of scope with the request. Nothing
43
- that travelled to get here held it.
43
+ that traveled to get here held it.
44
44
  """
45
45
  credentials = ctx.get(CREDENTIALS)
46
46
  if credentials is None:
@@ -125,7 +125,7 @@ async def load_handles(
125
125
  )
126
126
  except Exception:
127
127
  # Deliberately broad and deliberately not fatal: an upload is an
128
- # optimisation, and a route that can take the bytes inline must
128
+ # optimization, and a route that can take the bytes inline must
129
129
  # not lose a turn because a file API was down.
130
130
  log.warning(
131
131
  "ph_app.adapters: could not upload %s to %s; sending it inline",
@@ -379,7 +379,7 @@ class AnthropicAdapter:
379
379
  yield chunk
380
380
  except LlmError as error:
381
381
  # A handle this request referenced is gone — expired early, deleted
382
- # from another session, or never honoured. **Forget it first, then
382
+ # from another session, or never honored. **Forget it first, then
383
383
  # let it retry**: `FILE_EXPIRED` is in `TRANSIENT_CODES` precisely
384
384
  # because the state that caused it is already cleared by the time the
385
385
  # retry runs, so the next attempt re-uploads rather than repeating a
@@ -578,7 +578,7 @@ def _to_anthropic(
578
578
 
579
579
  `media` holds base64 for the attachments this route will take; anything absent
580
580
  from it becomes a pointer. **Nothing is dropped** — a block kind this function
581
- does not recognise still reaches the wire, rather than a message that was only an
581
+ does not recognize still reaches the wire, rather than a message that was only an
582
582
  image arriving as an empty text block.
583
583
  """
584
584
  blocks: list[dict[str, Any]] = []
@@ -101,7 +101,7 @@ ACCEPTED_MEDIA: tuple[str, ...] = (
101
101
  """The default for `Config.accepts` — what this provider's own models take.
102
102
 
103
103
  The widest of the three, and video is the entry that matters: it is the format
104
- that made upload-and-reference a row rather than an optimisation, and until this
104
+ that made upload-and-reference a row rather than an optimization, and until this
105
105
  adapter existed no route pH shipped could declare it honestly."""
106
106
 
107
107
  UPLOADED_MEDIA: tuple[str, ...] = (
@@ -257,7 +257,7 @@ class GoogleAdapter:
257
257
  Self-limiting rather than capped: an entry is written only when the readiness
258
258
  budget runs out, and removed the moment the file is usable or turns out to be
259
259
  gone — so it holds what this process is still waiting on and nothing else. A
260
- restart loses it and pays the transfer again, which is exactly the behaviour
260
+ restart loses it and pays the transfer again, which is exactly the behavior
261
261
  this replaced."""
262
262
 
263
263
  def _headers(self) -> dict[str, str]:
@@ -291,7 +291,7 @@ class GoogleAdapter:
291
291
  again. Now the name is remembered and the next attempt polls it.
292
292
 
293
293
  Failure of any kind here is caught by `load_handles` and the attachment
294
- goes inline — so the worst outcome of a slow provider is the behaviour
294
+ goes inline — so the worst outcome of a slow provider is the behavior
295
295
  every route had before the row.
296
296
  """
297
297
  spent = self._spent.get(ref.attachment_id, 0)
@@ -139,7 +139,7 @@ class WindowProbe(WireModel):
139
139
  Measured against a llama.cpp serving Qwen3.8-27B, started `-c 524288
140
140
  --parallel 2`: `/props` and both `/slots` entries report 262 144 — the total
141
141
  divided, as llama.cpp documents — while `/v1/models` reports `meta.n_ctx`
142
- 262 144 beside `meta.n_ctx_train` 262 144, its neighbours there being
142
+ 262 144 beside `meta.n_ctx_train` 262 144, its neighbors there being
143
143
  `n_vocab`, `n_embd`, `n_params` and `ftype`.
144
144
 
145
145
  **That server is what settles it.** Its total was 524 288, and the number at
@@ -392,7 +392,7 @@ class OpenAiCompatibleAdapter:
392
392
  yield chunk
393
393
  except LlmError as error:
394
394
  # A handle this request referenced is gone — expired early, deleted
395
- # from another session, or never honoured. Forget it first, then let
395
+ # from another session, or never honored. Forget it first, then let
396
396
  # it retry: `FILE_EXPIRED` is in `TRANSIENT_CODES` precisely because
397
397
  # the state that caused it is already cleared by the time the retry
398
398
  # runs, so the next attempt re-uploads rather than repeating a request
@@ -43,6 +43,7 @@ from ph.paths import RuntimeDirError, resolve_roots
43
43
  from ph.resources import GRACE_SECONDS
44
44
  from ph.seams.schedule import ScheduleKind
45
45
  from ph.selectors import Selector, matches_any
46
+ from ph.text import duration
46
47
 
47
48
  from . import verbs
48
49
  from .console import TypeOption, console, detail, fail, section, selectors_or_exit
@@ -166,30 +167,12 @@ def _cadence(seconds: float) -> str:
166
167
 
167
168
  **All five read `0` as off**, each behind its own `if x > 0` in `serve`, and
168
169
  for a day only the newest one said so on screen while the other four printed
169
- `0s`. `_duration` cannot absorb the rule: zero milliseconds is a real and
170
+ `0s`. `duration` cannot absorb the rule: zero milliseconds is a real and
170
171
  different answer elsewhere (an uptime, a turn that took no measurable time),
171
172
  where "off" would be a lie. So the convention lives here, once, next to the
172
173
  only four-plus-one readers that share it.
173
174
  """
174
- return _duration(seconds * 1000) if seconds > 0 else "off"
175
-
176
-
177
- def _duration(milliseconds: object) -> str:
178
- """`3d 4h`, `1h 30m`, `12m`, `8s` — the two largest units that are not zero.
179
-
180
- Zeros are dropped rather than kept for shape: a sweep every sixty seconds
181
- reads as `1m`, not `1m 0s`, and an hour and a half of quiet is `1h 30m`
182
- whether or not the seconds happen to be round.
183
- """
184
- if not isinstance(milliseconds, int | float):
185
- return "—"
186
- seconds = int(milliseconds // 1000)
187
- parts: list[str] = []
188
- for name, size in (("d", 86400), ("h", 3600), ("m", 60), ("s", 1)):
189
- count, seconds = divmod(seconds, size)
190
- if count:
191
- parts.append(f"{count}{name}")
192
- return " ".join(parts[:2]) or "0s"
175
+ return duration(seconds * 1000) if seconds > 0 else "off"
193
176
 
194
177
 
195
178
  def _summary(kind: str, event: JsonObject) -> str:
@@ -466,7 +449,7 @@ def attach(
466
449
  Idle is how a root that answered and a root whose last answer failed both
467
450
  look, so a script chaining on this had no way to tell them apart and read the
468
451
  second as success (P5-04). Only `error` — the kind the loop records when a
469
- turn could not be completed. A turn a person cancelled, or one a limit ended,
452
+ turn could not be completed. A turn a person canceled, or one a limit ended,
470
453
  exits 0 and says so on its own line: those are outcomes somebody already
471
454
  knows about, and turning them into a failed command would make the ordinary
472
455
  way of stopping an agent look like a fault.
@@ -566,9 +549,9 @@ def schedule(
566
549
  CancelScheduleParams(session_id=session, schedule_id=cancel),
567
550
  )
568
551
  )
569
- if not outcome.cancelled:
552
+ if not outcome.canceled:
570
553
  fail(f"[red]no schedule {cancel!r} on {session}[/red]")
571
- console.print(f"[dim]cancelled {cancel}[/dim]")
554
+ console.print(f"[dim]canceled {cancel}[/dim]")
572
555
  return
573
556
 
574
557
  if len(timings) > 1:
@@ -666,12 +649,12 @@ def doctor() -> None:
666
649
  (
667
650
  ("socket", facts.socket),
668
651
  ("pid", str(facts.pid)),
669
- ("uptime", _duration(facts.uptime_ms)),
652
+ ("uptime", duration(facts.uptime_ms)),
670
653
  ("protocol", str(facts.protocol_version)),
671
654
  ("capabilities", ", ".join(sorted(facts.capabilities))),
672
655
  ("roots", str(facts.roots)),
673
656
  ("provider", f"{facts.provider} · {facts.model}"),
674
- ("passivate after", "off" if passivate is None else _duration(passivate * 1000)),
657
+ ("passivate after", "off" if passivate is None else duration(passivate * 1000)),
675
658
  ("tick", _cadence(facts.tick_every)),
676
659
  ("sweep", _cadence(facts.sweep_every)),
677
660
  ("heartbeat", _cadence(facts.heartbeat_every)),
@@ -25,7 +25,7 @@ from __future__ import annotations
25
25
  import json
26
26
  import shlex
27
27
  import sys
28
- from collections.abc import Awaitable, Callable, Sequence
28
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
29
29
  from functools import partial
30
30
  from pathlib import Path
31
31
  from typing import Annotated, Any, Literal, TypeAlias
@@ -146,6 +146,20 @@ def default(
146
146
  resume: Annotated[
147
147
  str | None, typer.Option("--resume", help="Session id to reopen (tui and web).")
148
148
  ] = None,
149
+ keep_alive: Annotated[
150
+ str | None,
151
+ typer.Option(
152
+ "--keep-alive",
153
+ help='Keep a daemon this UI starts up for this long after it detaches ("5m").',
154
+ ),
155
+ ] = None,
156
+ new: Annotated[
157
+ bool,
158
+ typer.Option(
159
+ "--new",
160
+ help="Start a fresh session without offering this directory's previous ones (tui).",
161
+ ),
162
+ ] = False,
149
163
  no_spawn: Annotated[
150
164
  bool,
151
165
  typer.Option(
@@ -224,9 +238,14 @@ def default(
224
238
  model=model,
225
239
  patch=patch,
226
240
  keep=keep_daemon,
241
+ keep_alive=_spawned_keep_alive(keep_alive, keep=keep_daemon),
227
242
  ),
228
243
  session_id=wanted,
229
244
  spawn=not no_spawn,
245
+ # `--session`/`--resume` already name one, so they skip the offer
246
+ # by having an answer; `--new` is for the person who never wants
247
+ # to be asked.
248
+ offer_sessions=not new,
230
249
  )
231
250
  )
232
251
  return
@@ -449,24 +468,124 @@ always passes its own value.
449
468
  """
450
469
 
451
470
 
471
+ def _spawned_keep_alive(value: str | None, *, keep: bool) -> str:
472
+ """The `--keep-alive` a spawned daemon is given, in seconds, refused here on a typo.
473
+
474
+ **Parsed at this end, not forwarded as typed.** `phern --keep-alive` does not run
475
+ a daemon, it composes the argv for one — so a string handed straight through
476
+ would be refused by a process whose output goes to the null device, and the
477
+ person would see a UI that could not reach a daemon and no reason why. Same
478
+ parser, same message, whichever end they typed it at; what crosses is a bare
479
+ number of seconds, which `keep_alive_seconds` reads back unambiguously.
480
+
481
+ CLI first, then `tui.json`, then zero: a person who sets it once should not
482
+ have to type it, and a person who typed it means this run. The file is read
483
+ only when they did not, which is also why it is not hoisted — settings are
484
+ deliberately re-read for each `PHTuiApp`, so that a `/theme` written during
485
+ a session is picked up when the picker reopens one.
486
+
487
+ **`--keep-daemon` and a typed `--keep-alive` are refused together**, because
488
+ they ask for opposite things: one says the daemon stays, the other says how
489
+ long it waits before going. A warning would have been the wrong answer to a
490
+ person who has said two things and meant one of them. A *configured*
491
+ keep-alive is not a contradiction — nobody typed it for this run — so
492
+ `--keep-daemon` simply overrides it, and a service daemon has no window.
493
+
494
+ `ph_app.tui.config` is imported inside the function for `run_tui`'s reason:
495
+ it is a front-end module and `phern -p` should not load one to print an
496
+ answer.
497
+ """
498
+ if keep:
499
+ if value is not None and keep_alive_seconds(value) > 0:
500
+ raise typer.BadParameter(
501
+ "--keep-alive and --keep-daemon ask for opposite things: one says when "
502
+ "the daemon leaves, the other says it stays"
503
+ )
504
+ return "0"
505
+ if value is None:
506
+ from .tui.config import load_tui_settings
507
+
508
+ value = load_tui_settings(resolve_roots().home).daemon_keep_alive
509
+ return f"{keep_alive_seconds(value):g}"
510
+
511
+
512
+ UNITS: Mapping[str, float] = {"s": 1.0, "m": 60.0, "h": 3600.0}
513
+ """The suffixes a duration option accepts, as seconds each."""
514
+
515
+
516
+ def _duration_seconds(value: str, *, unit: float) -> float:
517
+ """A duration a person typed, as seconds. Negative for "not one".
518
+
519
+ One parser for `--keep-alive` and `--passivate-after`, which are the same
520
+ question asked twice and were two parsers with two refusal shapes. `unit` is
521
+ what a *bare* number means — seconds for a keep-alive, minutes for a passivation
522
+ window, which is what each flag's own history established — and a suffix
523
+ overrides it, so `--passivate-after 30s` now means what it reads like
524
+ instead of half an hour.
525
+
526
+ Negative rather than raising, because the two callers refuse differently:
527
+ one of them has an `"off"` spelling to check first, and a parser that raised
528
+ would have to know about it.
529
+ """
530
+ text = value.strip().lower()
531
+ if not text:
532
+ return -1.0
533
+ scale = UNITS.get(text[-1])
534
+ number = text[:-1] if scale is not None else text
535
+ try:
536
+ seconds = float(number) * (scale if scale is not None else unit)
537
+ except ValueError:
538
+ return -1.0
539
+ return seconds if seconds >= 0 else -1.0
540
+
541
+
542
+ def keep_alive_seconds(value: str) -> float:
543
+ """`"30s"`, `"5m"`, `"1h"` or a bare number of seconds, as seconds.
544
+
545
+ Refused rather than defaulted on a typo, for `_passivation`'s reason: a
546
+ mistyped duration here is a daemon that leaves immediately or never, and
547
+ either is a surprise a person attributes to something else a week later.
548
+
549
+ **Public, because both ends of the flag have to agree.** `phern daemon
550
+ --keep-alive` parses it here, and `phern --keep-alive` — which does not run the
551
+ daemon, it *spawns* one — parses it here too rather than forwarding the
552
+ string into an argv where a typo would surface as a daemon that refused to
553
+ start and a UI that said nothing. Same refusal, same message, at whichever
554
+ end the person typed it.
555
+
556
+ **A duration and nothing else.** Whether the daemon is ephemeral is the
557
+ caller's question, not this one's: `phern daemon` and `phern` express that
558
+ in opposite directions — one opts into leaving, the other opts into staying
559
+ — so a parser that also decided it would have to know which command it was
560
+ serving.
561
+ """
562
+ if not value.strip() or value.strip() == "0":
563
+ return 0.0
564
+ seconds = _duration_seconds(value, unit=1.0)
565
+ if seconds < 0:
566
+ # `typer.BadParameter` like `_passivation` and `_children_cap` beside it,
567
+ # rather than a hand-built refusal: typer already writes the usage line
568
+ # and the exit code, and three value parsers on one command refusing in
569
+ # two different shapes is how one of them comes to say something else.
570
+ raise typer.BadParameter(f'wants a duration like "30s", "5m" or "90", not "{value}"')
571
+ return seconds
572
+
573
+
452
574
  def _passivation(value: str) -> float | None:
453
- """`"off"` or a number of minutes, as seconds (P5-05).
575
+ """`"off"`, a number of minutes, or a suffixed duration, as seconds (P5-05).
454
576
 
455
- Refused rather than defaulted when it is neither: a typo in a duration is a
456
- deployment that silently keeps every root it ever started, and the daemon is
457
- the one process where that goes unnoticed for a week.
577
+ Refused rather than defaulted when it is none of those: a typo in a duration
578
+ is a deployment that silently keeps every root it ever started, and the
579
+ daemon is the one process where that goes unnoticed for a week.
458
580
  """
459
581
  if value.strip().lower() == "off":
460
582
  return None
461
- try:
462
- minutes = float(value)
463
- except ValueError:
464
- minutes = 0.0
465
- if minutes <= 0:
583
+ seconds = _duration_seconds(value, unit=60.0)
584
+ if seconds <= 0:
466
585
  # One message for one mistake: "not a number" and "not a positive
467
586
  # number" are the same correction to the same flag.
468
587
  raise typer.BadParameter(f'wants positive minutes or "off", not "{value}"')
469
- return minutes * 60.0
588
+ return seconds
470
589
 
471
590
 
472
591
  def _children_cap(value: int | None) -> list[str]:
@@ -517,6 +636,13 @@ def daemon(
517
636
  "--passivate-after", help='Minutes of quiet before a root is released, or "off".'
518
637
  ),
519
638
  ] = _DEFAULT_PASSIVATION,
639
+ keep_alive: Annotated[
640
+ str,
641
+ typer.Option(
642
+ "--keep-alive",
643
+ help='Stay up this long after the last client leaves ("5m"); implies --ephemeral.',
644
+ ),
645
+ ] = "0",
520
646
  ephemeral: Annotated[
521
647
  bool,
522
648
  typer.Option("--ephemeral", help="Exit once no client, root or appointment needs this."),
@@ -537,6 +663,9 @@ def daemon(
537
663
  """
538
664
  from .daemon.server import DaemonUnavailable, serve
539
665
 
666
+ # Parsed before anything is composed or bound, so a mistyped duration is a
667
+ # usage error rather than a daemon that got as far as printing a socket path.
668
+ window = keep_alive_seconds(keep_alive)
540
669
  composed = profile_or_exit(profile, _children_cap(max_concurrent_children))
541
670
  try:
542
671
  roots = resolve_roots(create=True)
@@ -566,7 +695,14 @@ def daemon(
566
695
  passivate_after=_passivation(passivate_after),
567
696
  # Off here, on in `spawn_command`: `DaemonServer.ephemeral` says
568
697
  # why the lifetime is decided by who started it (P7-08).
569
- ephemeral=ephemeral,
698
+ #
699
+ # **A keep-alive implies it.** "Stay up five minutes after the
700
+ # last client leaves" is a statement about *leaving*, so a person
701
+ # who typed one has said which lifetime they want; the two flags
702
+ # were one concept split across two spellings, and the split is
703
+ # what made `--keep-alive` alone mean nothing and need a warning.
704
+ ephemeral=ephemeral or window > 0,
705
+ keep_alive=window,
570
706
  path=socket_path,
571
707
  )
572
708
  )
@@ -654,7 +790,13 @@ def tab_command(
654
790
 
655
791
 
656
792
  def spawn_command(
657
- *, profile: str, provider: str, model: str, patch: Sequence[str] = (), keep: bool = False
793
+ *,
794
+ profile: str,
795
+ provider: str,
796
+ model: str,
797
+ patch: Sequence[str] = (),
798
+ keep: bool = False,
799
+ keep_alive: str = "0",
658
800
  ) -> list[str]:
659
801
  """The argv for a daemon a UI starts on its own behalf (P7-08).
660
802
 
@@ -669,8 +811,19 @@ def spawn_command(
669
811
  a decision rather than a property of the process that happened to spawn it.
670
812
  """
671
813
  ephemeral = () if keep else ("--ephemeral",)
814
+ # **The only route a client's preference has to the daemon.** A daemon may not
815
+ # read a front end's `tui.json` — after P5-14 it may not share a filesystem
816
+ # with it — so the terminal spells its own keep-alive into the command line it
817
+ # composes. Omitted when zero, which is the default on both sides.
818
+ window = ("--keep-alive", keep_alive) if keep_alive not in ("", "0") else ()
672
819
  return reinvoke(
673
- "daemon", *ephemeral, profile=profile, provider=provider, model=model, patch=patch
820
+ "daemon",
821
+ *ephemeral,
822
+ *window,
823
+ profile=profile,
824
+ provider=provider,
825
+ model=model,
826
+ patch=patch,
674
827
  )
675
828
 
676
829
 
@@ -10,7 +10,7 @@ and P5-11's socket lifetime. Every one of them is a title and a list of pairs,
10
10
  and they were already drifting apart in style.
11
11
 
12
12
  **Data does not go through a console.** `--dump-config` writes YAML and `ph
13
- events --json` writes JSON, both meant to be piped; Rich colourizes a plain
13
+ events --json` writes JSON, both meant to be piped; Rich colorizes a plain
14
14
  string it is asked to print, so with `FORCE_COLOR` set in the environment — as
15
15
  CI images and many shells do — those two commands emitted ANSI escapes into
16
16
  their own machine-readable output and `yaml.safe_load` refused it with
@@ -50,9 +50,9 @@ err = Console(stderr=True, highlight=False, soft_wrap=True)
50
50
  declared here rather than per module: a console setting applied to half the CLI
51
51
  is how the two halves come to disagree about what a line looks like.
52
52
 
53
- `highlight=False`: Rich's automatic highlighter re-colours whatever in a plain
53
+ `highlight=False`: Rich's automatic highlighter re-colors whatever in a plain
54
54
  string *looks* like a number, a path or a UUID, which in a CLI's prose is an
55
- arbitrary word coloured for looking like data — `scheduled sch-1 · interval
55
+ arbitrary word colored for looking like data — `scheduled sch-1 · interval
56
56
  3600000` came out with the interval in cyan and nothing else. Explicit markup
57
57
  still works; only the guessing is off. It is also a third of the cost of
58
58
  printing a line, and `phern agents attach` prints one per log event.
@@ -96,7 +96,7 @@ def section(title: str, rows: Iterable[tuple[str, str]]) -> Table:
96
96
  def emit(text: str) -> None:
97
97
  """Write machine-readable output, unstyled and unwrapped.
98
98
 
99
- `print` rather than `console.print`: a console decides colour from the
99
+ `print` rather than `console.print`: a console decides color from the
100
100
  environment and width from the terminal, and both are wrong for a document
101
101
  another program parses. The one thing this must never do is be clever.
102
102
  """
@@ -1,4 +1,4 @@
1
- """Make a **cancelled** socket readiness wait safe, on every platform (issue 58).
1
+ """Make a **canceled** socket readiness wait safe, on every platform (issue 58).
2
2
 
3
3
  anyio has two shapes for waiting on a socket. The hardened one, which
4
4
  `AsyncIOBackend.wait_readable` uses and `ph_rlm.kernel.manager` reaches through
@@ -20,7 +20,7 @@ registers the bound `future.set_result` itself and has no such guard:
20
20
  await f
21
21
 
22
22
  Cancel that wait after the loop has already queued the ready handle and
23
- `set_result` runs on a cancelled future, raising `InvalidStateError` from a bare
23
+ `set_result` runs on a canceled future, raising `InvalidStateError` from a bare
24
24
  loop handle with **no frames of ours on the stack** — which is why the captured
25
25
  traceback was one line of `asyncio/events.py` and nothing else. The removal
26
26
  cannot close the window: done-callbacks are `call_soon`'d, so they run an
@@ -33,7 +33,7 @@ deregister the fd: the done-callback above already does, exactly once, and the
33
33
  hardened shape guards its own removal behind a bookkeeping dict for the same
34
34
  reason. A guard that removed the fd itself would make anyio's removal a miss —
35
35
  `selectors` raises `KeyError` twice, nested, with a `repr(socket)` that costs
36
- two more syscalls, on every wait rather than only cancelled ones.
36
+ two more syscalls, on every wait rather than only canceled ones.
37
37
 
38
38
  **This is not a bug in pH.** No package here uses `call_soon`, `call_later`,
39
39
  `add_reader` or `add_writer`, and pH's one `future.set_result`
@@ -57,7 +57,7 @@ rather than covered: its default `ProactorEventLoop` has no `add_reader`, and
57
57
  `AF_UNIX` is not the daemon's transport there.
58
58
 
59
59
  **Interim, with a shelf life.** `test_cancelsafe` fails when anyio stops
60
- registering the shape this recognises, which is how we would learn the fix had
60
+ registering the shape this recognizes, which is how we would learn the fix had
61
61
  landed upstream and this module could go.
62
62
 
63
63
  @module ph_app.daemon.cancelsafe
@@ -84,8 +84,8 @@ def resolves_a_future(callback: object) -> bool:
84
84
  Deliberately a *shape* test and not an identity one. `set_result` on the C
85
85
  accelerator is a `builtin_method` with no `__func__`, and a `Future`
86
86
  subclass may override it — both of which an identity comparison stops
87
- recognising. Anything unrecognised is passed through, so the failure is
88
- always to today's behaviour rather than to a changed one.
87
+ recognizing. Anything unrecognized is passed through, so the failure is
88
+ always to today's behavior rather than to a changed one.
89
89
  """
90
90
  return isinstance(getattr(callback, "__self__", None), asyncio.Future) and (
91
91
  getattr(callback, "__name__", "") == "set_result"
@@ -119,19 +119,19 @@ def _guarding(original: Callable[..., Any]) -> Callable[..., Any]:
119
119
  # a `try` and +53 ns as `if not future.done()` — because the
120
120
  # exception is only paid on the firing that races, while the check
121
121
  # is paid on every readiness event. The check wins only if about
122
- # one wait in three is cancelled inside the window; the observed
122
+ # one wait in three is canceled inside the window; the observed
123
123
  # rate is nearer two per thirty full-suite runs. anyio's own
124
124
  # hardened `wait_readable` catches for the same reason.
125
125
  #
126
- # A pre-check would also have to ask `done()`, not `cancelled()`:
126
+ # A pre-check would also have to ask `done()`, not `canceled()`:
127
127
  # `set_result` refuses two states, and the second — an *already
128
128
  # resolved* future, fired on again before anyio's done-callback
129
- # deregisters the fd — reports `cancelled() is False`. It is the
129
+ # deregisters the fd — reports `canceled() is False`. It is the
130
130
  # rarer of the two here (measured: 1 firing under anyio's shape)
131
131
  # but the unbounded one if a deregistration is ever missed.
132
132
  #
133
133
  # `try`/`except` and not `contextlib.suppress`, same reasoning one
134
- # level down: 5.7 ns against 229 ns for identical behaviour, on the
134
+ # level down: 5.7 ns against 229 ns for identical behavior, on the
135
135
  # hottest path the daemon has.
136
136
  try: # noqa: SIM105
137
137
  callback(*args)
@@ -147,7 +147,7 @@ class DaemonClient:
147
147
 
148
148
  `RootDescription` and not `RootDescription | MutationRepeated`, which is
149
149
  what this said: `MutationRepeated` subclasses `RootDescription`, so that
150
- union normalises to the left side and asked every caller to narrow
150
+ union normalizes to the left side and asked every caller to narrow
151
151
  something the checker had already flattened. The repeat still arrives
152
152
  here — it is simply already the type it claims to be, which is the
153
153
  property `MutationRepeated`'s own docstring is about.
@@ -253,7 +253,7 @@ async def connected[T](path: Path, work: Exchange[T]) -> T:
253
253
  replies and notifications arrive on the same stream: a caller that read its
254
254
  own reply directly would consume a `session.event` it had no way to hand
255
255
  back. Closing the stream is what ends the pump, so there is no cancel here —
256
- a teardown that cancelled would race the last frame it asked for.
256
+ a teardown that canceled would race the last frame it asked for.
257
257
 
258
258
  Here rather than in `phern agents`, because a one-shot exchange is not a CLI
259
259
  shape: `ph_app.web.serve` stages a browser's upload this way too, and the