phern 0.3.0__tar.gz → 0.4.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 (150) hide show
  1. {phern-0.3.0 → phern-0.4.0}/PKG-INFO +9 -9
  2. {phern-0.3.0 → phern-0.4.0}/pyproject.toml +9 -9
  3. phern-0.4.0/src/ph_app/__init__.py +12 -0
  4. {phern-0.3.0 → phern-0.4.0}/src/ph_app/adapters/anthropic.py +1 -1
  5. {phern-0.3.0 → phern-0.4.0}/src/ph_app/adapters/google.py +1 -1
  6. {phern-0.3.0 → phern-0.4.0}/src/ph_app/adapters/openai_compatible.py +1 -1
  7. {phern-0.3.0 → phern-0.4.0}/src/ph_app/cli.py +12 -8
  8. {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/client.py +3 -3
  9. {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/frontend.py +32 -15
  10. {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/server.py +68 -20
  11. {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/supervisor.py +185 -88
  12. phern-0.4.0/src/ph_app/kinds.py +84 -0
  13. {phern-0.3.0 → phern-0.4.0}/src/ph_app/modes/rpc_mode.py +2 -1
  14. {phern-0.3.0 → phern-0.4.0}/src/ph_app/params.py +8 -3
  15. {phern-0.3.0 → phern-0.4.0}/src/ph_app/payloads.py +41 -2
  16. {phern-0.3.0 → phern-0.4.0}/src/ph_app/protocol.py +16 -1
  17. phern-0.4.0/src/ph_app/runtime.py +93 -0
  18. {phern-0.3.0 → phern-0.4.0}/src/ph_app/shell.py +72 -32
  19. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/adapter.py +63 -2
  20. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/app.py +20 -16
  21. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/frontend.py +9 -6
  22. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/modals/base.py +18 -0
  23. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/remote.py +17 -6
  24. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/trajectory.py +9 -0
  25. {phern-0.3.0 → phern-0.4.0}/src/ph_app/verbs.py +4 -2
  26. {phern-0.3.0 → phern-0.4.0}/tests/test_adapters.py +22 -0
  27. {phern-0.3.0 → phern-0.4.0}/tests/test_agents_cli.py +1 -1
  28. {phern-0.3.0 → phern-0.4.0}/tests/test_app_layering.py +6 -1
  29. {phern-0.3.0 → phern-0.4.0}/tests/test_attachments_cli.py +2 -1
  30. {phern-0.3.0 → phern-0.4.0}/tests/test_daemon.py +9 -34
  31. {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_asks.py +89 -20
  32. {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_invariants.py +6 -4
  33. {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_lifetime.py +28 -2
  34. phern-0.4.0/tests/test_daemon_mutations.py +371 -0
  35. {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_shell.py +38 -1
  36. {phern-0.3.0 → phern-0.4.0}/tests/test_modes.py +2 -1
  37. {phern-0.3.0 → phern-0.4.0}/tests/test_non_guarantees.py +3 -0
  38. {phern-0.3.0 → phern-0.4.0}/tests/test_payloads.py +9 -3
  39. {phern-0.3.0 → phern-0.4.0}/tests/test_protocol.py +2 -1
  40. {phern-0.3.0 → phern-0.4.0}/tests/test_runtime.py +28 -0
  41. {phern-0.3.0 → phern-0.4.0}/tests/test_trajectory.py +49 -24
  42. {phern-0.3.0 → phern-0.4.0}/tests/test_trajectory_view.py +14 -11
  43. {phern-0.3.0 → phern-0.4.0}/tests/test_tui_adapter.py +163 -33
  44. {phern-0.3.0 → phern-0.4.0}/tests/test_tui_code_cell.py +24 -22
  45. {phern-0.3.0 → phern-0.4.0}/tests/test_tui_pilot.py +42 -0
  46. {phern-0.3.0 → phern-0.4.0}/tests/test_tui_remote.py +37 -6
  47. {phern-0.3.0 → phern-0.4.0}/tests/test_uploads.py +32 -1
  48. {phern-0.3.0 → phern-0.4.0}/tests/test_workspaces_cli.py +2 -1
  49. {phern-0.3.0 → phern-0.4.0}/tests/tui_helpers.py +8 -5
  50. phern-0.3.0/src/ph_app/__init__.py +0 -5
  51. phern-0.3.0/src/ph_app/runtime.py +0 -169
  52. phern-0.3.0/tests/test_daemon_mutations.py +0 -190
  53. {phern-0.3.0 → phern-0.4.0}/.gitignore +0 -0
  54. {phern-0.3.0 → phern-0.4.0}/LICENSE +0 -0
  55. {phern-0.3.0 → phern-0.4.0}/README.md +0 -0
  56. {phern-0.3.0 → phern-0.4.0}/src/ph_app/__main__.py +0 -0
  57. {phern-0.3.0 → phern-0.4.0}/src/ph_app/adapters/__init__.py +0 -0
  58. {phern-0.3.0 → phern-0.4.0}/src/ph_app/adapters/_http.py +0 -0
  59. {phern-0.3.0 → phern-0.4.0}/src/ph_app/adapters/_media.py +0 -0
  60. {phern-0.3.0 → phern-0.4.0}/src/ph_app/adapters/sse.py +0 -0
  61. {phern-0.3.0 → phern-0.4.0}/src/ph_app/agents.py +0 -0
  62. {phern-0.3.0 → phern-0.4.0}/src/ph_app/attach.py +0 -0
  63. {phern-0.3.0 → phern-0.4.0}/src/ph_app/attachments.py +0 -0
  64. {phern-0.3.0 → phern-0.4.0}/src/ph_app/console.py +0 -0
  65. {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/__init__.py +0 -0
  66. {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/cancelsafe.py +0 -0
  67. {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/cards.py +0 -0
  68. {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/duplex.py +0 -0
  69. {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/follow.py +0 -0
  70. {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/framing.py +0 -0
  71. {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/launch.py +0 -0
  72. {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/projections.py +0 -0
  73. {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/recovery.py +0 -0
  74. {phern-0.3.0 → phern-0.4.0}/src/ph_app/modes/__init__.py +0 -0
  75. {phern-0.3.0 → phern-0.4.0}/src/ph_app/modes/json_mode.py +0 -0
  76. {phern-0.3.0 → phern-0.4.0}/src/ph_app/modes/print_mode.py +0 -0
  77. {phern-0.3.0 → phern-0.4.0}/src/ph_app/modes/transcript_mode.py +0 -0
  78. {phern-0.3.0 → phern-0.4.0}/src/ph_app/profiles/anthropic.yaml +0 -0
  79. {phern-0.3.0 → phern-0.4.0}/src/ph_app/profiles/deepseek.yaml +0 -0
  80. {phern-0.3.0 → phern-0.4.0}/src/ph_app/profiles/google.yaml +0 -0
  81. {phern-0.3.0 → phern-0.4.0}/src/ph_app/profiles/llama.yaml +0 -0
  82. {phern-0.3.0 → phern-0.4.0}/src/ph_app/profiles/rlm-stable.yaml +0 -0
  83. {phern-0.3.0 → phern-0.4.0}/src/ph_app/profiles/tui.yaml +0 -0
  84. {phern-0.3.0 → phern-0.4.0}/src/ph_app/profiles.py +0 -0
  85. {phern-0.3.0 → phern-0.4.0}/src/ph_app/py.typed +0 -0
  86. {phern-0.3.0 → phern-0.4.0}/src/ph_app/sessions.py +0 -0
  87. {phern-0.3.0 → phern-0.4.0}/src/ph_app/trust.py +0 -0
  88. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/__init__.py +0 -0
  89. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/autocomplete.py +0 -0
  90. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/commands.py +0 -0
  91. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/config.py +0 -0
  92. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/modals/__init__.py +0 -0
  93. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/modals/approval.py +0 -0
  94. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/modals/ask_user.py +0 -0
  95. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/modals/login.py +0 -0
  96. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/modals/pickers.py +0 -0
  97. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/modals/trust.py +0 -0
  98. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/screens.py +0 -0
  99. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/state.py +0 -0
  100. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/terminal.py +0 -0
  101. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/__init__.py +0 -0
  102. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-frappe.yaml +0 -0
  103. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-latte.yaml +0 -0
  104. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-macchiato.yaml +0 -0
  105. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-mocha.yaml +0 -0
  106. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/high-contrast.json +0 -0
  107. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/ph-dark.json +0 -0
  108. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/ph-light.json +0 -0
  109. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/trajectory_app.py +0 -0
  110. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/trajectory_screen.py +0 -0
  111. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/widgets/__init__.py +0 -0
  112. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/widgets/prompt.py +0 -0
  113. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/widgets/selection.py +0 -0
  114. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/widgets/status.py +0 -0
  115. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/widgets/trajectory.py +0 -0
  116. {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/widgets/transcript.py +0 -0
  117. {phern-0.3.0 → phern-0.4.0}/src/ph_app/web/__init__.py +0 -0
  118. {phern-0.3.0 → phern-0.4.0}/src/ph_app/web/serve.py +0 -0
  119. {phern-0.3.0 → phern-0.4.0}/src/ph_app/wire.py +0 -0
  120. {phern-0.3.0 → phern-0.4.0}/src/ph_app/workspaces.py +0 -0
  121. {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_code_cell_shows_its_program_and_what_it_cost.raw +0 -0
  122. {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_code_mode_card_lists_its_dispatches.raw +0 -0
  123. {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_compaction_marker_keeps_what_it_replaced.raw +0 -0
  124. {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_failed_tool_card_and_an_error_row.raw +0 -0
  125. {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_settled_tool_card.raw +0 -0
  126. {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_every_theme_renders[high-contrast].raw +0 -0
  127. {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_every_theme_renders[ph-light].raw +0 -0
  128. {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_markup_in_user_text_renders_literally.raw +0 -0
  129. {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_streaming_assistant_text.raw +0 -0
  130. {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_the_full_app_shell.raw +0 -0
  131. {phern-0.3.0 → phern-0.4.0}/tests/app_fixtures.py +0 -0
  132. {phern-0.3.0 → phern-0.4.0}/tests/daemon_helpers.py +0 -0
  133. {phern-0.3.0 → phern-0.4.0}/tests/test_attach.py +0 -0
  134. {phern-0.3.0 → phern-0.4.0}/tests/test_cancelsafe.py +0 -0
  135. {phern-0.3.0 → phern-0.4.0}/tests/test_cli.py +0 -0
  136. {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_attachments.py +0 -0
  137. {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_framing.py +0 -0
  138. {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_launch.py +0 -0
  139. {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_methods.py +0 -0
  140. {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_projections.py +0 -0
  141. {phern-0.3.0 → phern-0.4.0}/tests/test_tui_copy.py +0 -0
  142. {phern-0.3.0 → phern-0.4.0}/tests/test_tui_frames.py +0 -0
  143. {phern-0.3.0 → phern-0.4.0}/tests/test_tui_screens.py +0 -0
  144. {phern-0.3.0 → phern-0.4.0}/tests/test_tui_snapshot.py +0 -0
  145. {phern-0.3.0 → phern-0.4.0}/tests/test_tui_state.py +0 -0
  146. {phern-0.3.0 → phern-0.4.0}/tests/test_tui_themes.py +0 -0
  147. {phern-0.3.0 → phern-0.4.0}/tests/test_tui_workers.py +0 -0
  148. {phern-0.3.0 → phern-0.4.0}/tests/test_uploads_google.py +0 -0
  149. {phern-0.3.0 → phern-0.4.0}/tests/test_uploads_openai.py +0 -0
  150. {phern-0.3.0 → phern-0.4.0}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: phern
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -23,20 +23,20 @@ Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
24
  Requires-Dist: anyio<5,>=4.4
25
25
  Requires-Dist: httpx>=0.27
26
- Requires-Dist: ph-code-graph==0.3.0
27
- Requires-Dist: ph-core==0.3.0
28
- Requires-Dist: ph-rlm==0.3.0
29
- Requires-Dist: ph-runtime-guest==0.3.0
30
- Requires-Dist: ph-stabilize==0.3.0
31
- Requires-Dist: ph-text-index==0.3.0
26
+ Requires-Dist: ph-code-graph==0.4.0
27
+ Requires-Dist: ph-core==0.4.0
28
+ Requires-Dist: ph-rlm==0.4.0
29
+ Requires-Dist: ph-runtime-guest==0.4.0
30
+ Requires-Dist: ph-stabilize==0.4.0
31
+ Requires-Dist: ph-text-index==0.4.0
32
32
  Requires-Dist: pyyaml>=6.0
33
33
  Requires-Dist: rich>=13.0
34
34
  Requires-Dist: textual<9,>=8.2.5
35
35
  Requires-Dist: typer>=0.12
36
36
  Provides-Extra: local
37
- Requires-Dist: ph-text-index[local]==0.3.0; extra == 'local'
37
+ Requires-Dist: ph-text-index[local]==0.4.0; extra == 'local'
38
38
  Provides-Extra: otel
39
- Requires-Dist: ph-core[otel]==0.3.0; extra == 'otel'
39
+ Requires-Dist: ph-core[otel]==0.4.0; extra == 'otel'
40
40
  Provides-Extra: web
41
41
  Requires-Dist: textual-serve>=1.1.3; extra == 'web'
42
42
  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.3.0"
7
+ version = "0.4.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,15 +45,15 @@ 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.3.0",
49
- "ph-rlm==0.3.0",
50
- "ph-stabilize==0.3.0",
51
- "ph-text-index==0.3.0",
52
- "ph-code-graph==0.3.0",
48
+ "ph-core==0.4.0",
49
+ "ph-rlm==0.4.0",
50
+ "ph-stabilize==0.4.0",
51
+ "ph-text-index==0.4.0",
52
+ "ph-code-graph==0.4.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.3.0",
56
+ "ph-runtime-guest==0.4.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.
@@ -111,13 +111,13 @@ web = ["textual-serve>=1.1.3"]
111
111
  # default CUDA wheels. Without it `text_index` and `text_search` still register
112
112
  # and still search an index you supply vectors for — what refuses to mount is
113
113
  # the row that *computes* them, with a message naming this extra.
114
- local = ["ph-text-index[local]==0.3.0"]
114
+ local = ["ph-text-index[local]==0.4.0"]
115
115
 
116
116
  # Exporting the session log to an OTel collector, passed through for the same
117
117
  # reason `local` is: `ph-core` owns the row and the refusal message, but `phern`
118
118
  # is the name a person installs, so all three of this deployment's extras should
119
119
  # be reachable without first learning which member implements which.
120
- otel = ["ph-core[otel]==0.3.0"]
120
+ otel = ["ph-core[otel]==0.4.0"]
121
121
 
122
122
  [project.entry-points."ph.plugins"]
123
123
  llm-openai-compatible = "ph_app.adapters.openai_compatible:apply"
@@ -0,0 +1,12 @@
1
+ """`ph_app` — the pH command line, and from Phase 2 the Textual TUI.
2
+
3
+ **Importing any of the app declares its intent kinds** (T4): `kinds` is imported here,
4
+ statically, so a process that loaded any part of the app — and so may resume a log the
5
+ app wrote — has `CLIENT_COMMAND` for repair to settle. See `ph_app.kinds`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from . import kinds as kinds
11
+
12
+ __all__: list[str] = ["kinds"]
@@ -408,7 +408,7 @@ class AnthropicAdapter:
408
408
  # instruction, the validation and the retry, and not the wire's guarantee
409
409
  # (P7-17). Passing it rather than leaving a default unremarked is what
410
410
  # stops the next adapter copying an omission it thought was an oversight.
411
- return resolved(self.config, structured_output=False)
411
+ return resolved(self.config, structured_output=False, credential=self.config.api_key_env)
412
412
 
413
413
 
414
414
  @dataclass(slots=True)
@@ -515,7 +515,7 @@ class GoogleAdapter:
515
515
  # See `_body`: this route is asked for JSON and not held to a schema, so
516
516
  # it does not enforce one. Claiming otherwise would make a caller skip the
517
517
  # validation that is actually doing the work.
518
- return resolved(self.config, structured_output=False)
518
+ return resolved(self.config, structured_output=False, credential=self.config.api_key_env)
519
519
 
520
520
 
521
521
  def _file_record(payload: dict[str, Any]) -> dict[str, Any]:
@@ -441,7 +441,7 @@ class OpenAiCompatibleAdapter:
441
441
  # This wire takes `response_format: {"type": "json_schema", …}` with
442
442
  # `strict`, so the server builds a grammar and the reply cannot come back
443
443
  # another shape (P7-17).
444
- return resolved(self.profile, structured_output=True)
444
+ return resolved(self.profile, structured_output=True, credential=self.profile.api_key_env)
445
445
 
446
446
 
447
447
  @dataclass(slots=True)
@@ -203,7 +203,8 @@ def default(
203
203
  # because the logs worth auditing are the ones nobody can reopen (P3-25).
204
204
  if session_id is None:
205
205
  fail("[red]--mode trajectory needs --session <id|path>[/red]", code=2)
206
- from .tui.trajectory_app import run_trajectory
206
+ # A late load: a command pays only for what it runs (`test_app_layering.py`).
207
+ from .tui.trajectory_app import run_trajectory # noqa: PLC0415
207
208
 
208
209
  try:
209
210
  code = anyio.run(partial(run_trajectory, session_id))
@@ -228,7 +229,7 @@ def default(
228
229
  #
229
230
  # Imported here because the TUI pulls in Textual, and `phern -p` in a
230
231
  # script should not pay for a terminal UI it will never draw.
231
- from .tui.app import run_tui
232
+ from .tui.app import run_tui # noqa: PLC0415
232
233
 
233
234
  code = anyio.run(
234
235
  partial(
@@ -270,7 +271,7 @@ def default(
270
271
  # must not require aiohttp, and a person who asked for `--mode web`
271
272
  # without it gets the install line rather than a traceback.
272
273
  try:
273
- from .web.serve import WebServer, run_web
274
+ from .web.serve import WebServer, run_web # noqa: PLC0415
274
275
  except ImportError as error:
275
276
  fail(
276
277
  "[red]--mode web needs the web extra:[/red] pip install 'phern\\[web]'",
@@ -325,7 +326,7 @@ def default(
325
326
  )
326
327
  # Here rather than at the top: `phern --help` must not pay for the persistence
327
328
  # layer, and `route` is about to import it anyway.
328
- from ph.persistence import SessionBusy
329
+ from ph.persistence import SessionBusy # noqa: PLC0415
329
330
 
330
331
  try:
331
332
  outcome = anyio.run(route)
@@ -442,7 +443,7 @@ def doctor(
442
443
  # Imported here rather than at module scope: `ph_app.daemon.supervisor`
443
444
  # pulls in the agent, the persistence layer and `filelock`, and `phern --print`
444
445
  # has no daemon in it. `daemon()` below reaches for `serve` the same way.
445
- from .daemon.supervisor import NON_GUARANTEES
446
+ from .daemon.supervisor import NON_GUARANTEES # noqa: PLC0415
446
447
 
447
448
  before_mount = [
448
449
  ("daemon socket lifetime", lifetime(roots=roots).describe()),
@@ -522,7 +523,8 @@ def _spawned_keep_alive(value: str | None, *, keep: bool) -> str:
522
523
  )
523
524
  return "0"
524
525
  if value is None:
525
- from .tui.config import load_tui_settings
526
+ # A late load: a command pays only for what it runs (`test_app_layering.py`).
527
+ from .tui.config import load_tui_settings # noqa: PLC0415
526
528
 
527
529
  value = load_tui_settings(resolve_roots().home).daemon_keep_alive
528
530
  return f"{keep_alive_seconds(value):g}"
@@ -632,7 +634,8 @@ def _children_cap(value: int | None) -> list[str]:
632
634
  return []
633
635
  if value < 1:
634
636
  raise typer.BadParameter(f"wants a positive number of children, not {value}")
635
- from ph.seams.jobs import CHILDREN_KIND
637
+ # A late load: a command pays only for what it runs (`test_app_layering.py`).
638
+ from ph.seams.jobs import CHILDREN_KIND # noqa: PLC0415
636
639
 
637
640
  return [f"{{id: jobs, config: {{concurrency: {{{CHILDREN_KIND}: {value}}}}}}}"]
638
641
 
@@ -680,7 +683,8 @@ def daemon(
680
683
  it. A parent's own fair share is `rlm-subagent-provider`'s `maxConcurrent`,
681
684
  and both apply. Neither refuses a delegation — the overflow queues.
682
685
  """
683
- from .daemon.server import DaemonUnavailable, serve
686
+ # A late load: a command pays only for what it runs (`test_app_layering.py`).
687
+ from .daemon.server import DaemonUnavailable, serve # noqa: PLC0415
684
688
 
685
689
  # Parsed before anything is composed or bound, so a mistyped duration is a
686
690
  # usage error rather than a daemon that got as far as printing a socket path.
@@ -110,7 +110,7 @@ class DaemonClient:
110
110
  landed, and an unkeyed retry runs the effect twice. So the stamp is
111
111
  applied here, once, rather than at each verb — `prompt` was the only verb
112
112
  that had it, and `session/command`, `session/shell`, `session/stage`,
113
- `session/preset` and `credentials/store` are all in the same table.
113
+ and `session/preset` are all in the same table.
114
114
 
115
115
  The counter is this client's own, which is what makes a retry after a
116
116
  reconnect safe by default rather than by discipline.
@@ -127,8 +127,8 @@ class DaemonClient:
127
127
  **The reply is `R | MutationRepeated`, and that union is the honest
128
128
  one.** A repeat answers with one shape for every verb (`MUTATIONS` says
129
129
  why), and for the verbs whose own reply is a `RootDescription` the
130
- repeat is a subtype — but `session/shell` answers with an exit code and
131
- `credentials/store` with a name, so a caller that ignored the union
130
+ repeat is a subtype — but `session/shell` answers with an exit code, so a
131
+ caller that ignored the union
132
132
  would be reading fields off a description that is not there. Narrow on
133
133
  `isinstance(reply, MutationRepeated)`, which is the branch
134
134
  `MutationRepeated.repeated` was always for.
@@ -22,9 +22,9 @@ That re-posing is **in-memory, and so lasts as long as the daemon does** — whi
22
22
  is the whole of what it promises, and no longer a silent edge. A restart cannot
23
23
  carry the ask across, because what was waiting on it was a coroutine inside a
24
24
  turn, and both are gone. What it does instead is **settle** it: repair writes the
25
- `approval/decided` the crash never got to (P5-13), so `pending_approvals` stops
26
- reporting a question nobody can answer and the transcript says the person was
27
- still being asked when the harness stopped.
25
+ `approval/decided` the crash never got to (P5-13), so the log holds no ask nobody
26
+ can answer and the transcript says the person was still being asked when the
27
+ harness stopped.
28
28
 
29
29
  So the guarantee is bounded and stated rather than absent. Within one daemon, an
30
30
  ask outlives every front end and waits for whoever turns up. Across a restart it
@@ -104,6 +104,22 @@ class AskDesk:
104
104
  root: Root
105
105
  front_ends: set[FrontEnd] = field(default_factory=set)
106
106
  asks: dict[str, PendingAsk] = field(default_factory=dict)
107
+ asked: int = 0
108
+ """How many asks this desk has named. The next is `ask-<asked + 1>`.
109
+
110
+ The desk names each ask itself, because nothing the seams hand it is unique.
111
+ The Continual Harness's `refine` approvals have no call id, which leaves only
112
+ the tool name, and a question's `ask_id` is whatever its caller passed. Two
113
+ asks under one name shared an entry in `asks`: the first to finish took the
114
+ other's with it, and the root read as idle while still parked on a person.
115
+
116
+ Unique within this root only, and on purpose: a root is self-contained and
117
+ coordinates with no other. Every ask frame and `ask.settled` carries the root's
118
+ `sessionId`, so a front end attached to two roots files an ask by both
119
+ (`AskKey`, each frame's `key`). A root started again has a new desk, counting
120
+ from 1 again. That is safe because `Supervisor.passivatable` never releases a
121
+ root with a front end attached, and a front end that loses the daemon takes its
122
+ own modals down."""
107
123
 
108
124
  # ------------------------------------------------------------- wiring --
109
125
 
@@ -175,13 +191,14 @@ class AskDesk:
175
191
  ) -> ApprovalAnswer:
176
192
  """`ctx.approval`'s answerer, over the socket.
177
193
 
178
- The ask is keyed by the same string `pending_approvals` uses — the call
179
- id, or the tool name when there is none — so a question re-posed after a
180
- restart is recognizably the one the log is still holding open.
194
+ Named on the wire by the desk (`asked`), not by its call id, which a
195
+ `refine` ask does not have. The call id and the tool name still travel, in
196
+ `request`.
181
197
  """
182
- ask_id = request.call_id or request.tool_name
183
198
  result = ApprovalAskReply.model_validate(
184
- await self._ask(ApprovalAsk(session_id=self.root.id, ask_id=ask_id, request=request))
199
+ await self._ask(
200
+ ApprovalAsk(session_id=self.root.id, ask_id=self._name(), request=request)
201
+ )
185
202
  )
186
203
  if reason := result.reason:
187
204
  # "No, use the existing helper" redirects a turn where a bare refusal
@@ -199,22 +216,22 @@ class AskDesk:
199
216
  ) -> str | None:
200
217
  """`ctx.user_questions`' answerer, over the socket.
201
218
 
202
- Keyed by the question's own `ask_id`, which `UserQuestionService.ask`
203
- fills in before any answerer sees it — so the frame a front end answers
204
- and the record a resume would re-pose from are the same string rather
205
- than two that happen to line up. Minting a fallback here would be a
206
- second, weaker id scheme (a counter that restarts at 1 after a resume)
207
- for a value the seam already mints so that it cannot collide.
219
+ Named on the wire by the desk (`asked`), as an approval is. The question's
220
+ own `ask_id`, the log's `askId`, still travels, in `question`.
208
221
  """
209
222
  result = QuestionAskReply.model_validate(
210
223
  await self._ask(
211
- QuestionAsk(session_id=self.root.id, ask_id=str(question.ask_id), question=question)
224
+ QuestionAsk(session_id=self.root.id, ask_id=self._name(), question=question)
212
225
  )
213
226
  )
214
227
  return result.answer
215
228
 
216
229
  # ------------------------------------------------------------ the ask --
217
230
 
231
+ def _name(self) -> str:
232
+ self.asked += 1
233
+ return f"ask-{self.asked}"
234
+
218
235
  async def _ask(self, ask: ApprovalAsk | QuestionAsk) -> dict[str, Any]:
219
236
  """Put one question to every front end and wait for the first answer.
220
237
 
@@ -46,11 +46,12 @@ from ph.resources import GRACE_SECONDS
46
46
  from ph.seams.attachments import mime_for
47
47
  from ph.seams.schedule import Schedule
48
48
  from ph.seams.shell import ShellService
49
- from ph.session import now_ms
49
+ from ph.session import Prior, intents_of, now_ms, session_written
50
50
  from ph.text import duration
51
51
  from ph.wire import WireModel
52
52
 
53
53
  from .. import verbs
54
+ from ..kinds import CLIENT_COMMAND, command_settled
54
55
  from ..params import (
55
56
  BrowseParams,
56
57
  CancelScheduleParams,
@@ -83,6 +84,7 @@ from ..payloads import (
83
84
  Hold,
84
85
  MutationRepeated,
85
86
  PresetApplied,
87
+ RepeatOutcome,
86
88
  RootDescription,
87
89
  RootListing,
88
90
  RootStatusReply,
@@ -135,7 +137,6 @@ from .supervisor import NON_GUARANTEES, Root, Supervisor
135
137
  if TYPE_CHECKING:
136
138
  from ph.seams.attachments import AttachmentStore
137
139
  from ph.seams.commands import CommandRegistry
138
- from ph.seams.credentials import CredentialService
139
140
  from ph.seams.permission_presets import PermissionPresetService
140
141
 
141
142
  __all__ = ["DaemonServer", "serve"]
@@ -173,7 +174,7 @@ the answer to one question decided how often the other was asked.
173
174
 
174
175
  Five minutes, and slow on purpose. A check is O(events) in every live root's log
175
176
  and will almost always pass — it exists to catch a writer that bypassed
176
- `Session.append`, which is a bug nobody has on a schedule. Fast enough to name
177
+ `Session._append`, which is a bug nobody has on a schedule. Fast enough to name
177
178
  the drift inside a working session, slow enough that the refold is not a tax
178
179
  anybody would later be tempted to remove.
179
180
 
@@ -275,6 +276,12 @@ for free — where a shared base would have to declare a `handle` returning
275
276
  """
276
277
 
277
278
 
279
+ def _repeat_outcome(prior: Prior) -> RepeatOutcome:
280
+ """`settled` only for a verb its act saw finish: one still acting in this process,
281
+ or one repair or a raising act closed, is `unknown` in so many words."""
282
+ return "settled" if prior.outcome == "done" else "unknown"
283
+
284
+
278
285
  def _command_key(params: MutationParams) -> str:
279
286
  """A client's idempotence key, joined at the wire edge and only here.
280
287
 
@@ -495,9 +502,45 @@ class _Connection:
495
502
  parsed = mutation.verb.parse(params)
496
503
  root = await self.server.supervisor.start(parsed.session_id)
497
504
  plan = await mutation.prepare(self, root, parsed)
498
- if not root.once(_command_key(parsed)):
499
- return MutationRepeated(**root.describe().model_dump())
500
- return await mutation.act(self, root, parsed, plan)
505
+ key = _command_key(parsed)
506
+ if not key:
507
+ # No identity offered, so no deduplication wanted: it always acts.
508
+ reply = await mutation.act(self, root, parsed, plan)
509
+ else:
510
+ journal = intents_of(root.ctx)
511
+ # The key is claimed between the halves (`Mutation` says why), and an
512
+ # act that raises is settled `unknown` by `claim` rather than leaving
513
+ # a key that refuses the retry with no word that nothing finished.
514
+ async with journal.claim_once(root.session, CLIENT_COMMAND, {"command": key}) as held:
515
+ if isinstance(held, Prior):
516
+ # The retry a reconnecting client cannot avoid sending: it
517
+ # does not know whether the first one landed. Answering "yes,
518
+ # that one — and here is whether it finished" is what makes
519
+ # asking twice safe.
520
+ return MutationRepeated(
521
+ **root.describe().model_dump(), outcome=_repeat_outcome(held)
522
+ )
523
+ reply = await mutation.act(self, root, parsed, plan)
524
+ journal.settle(root.session, held, command_settled(key))
525
+ # **Durable before the client is told** (F7). A client that has its reply
526
+ # never re-sends, so a reply sent while the key and the act's own record
527
+ # were still in memory made a crash lose the act outright — and
528
+ # `session/prompt`'s "the message is logged" meant logged in a buffer.
529
+ # After the act, which is why `CLIENT_COMMAND` is `buffered`: a key made
530
+ # durable first would turn a crash before the act into a refused retry
531
+ # for work that never began. An act that records its own start before an
532
+ # effect it cannot take back (`!!`, `run_shell`) carries the key to disk
533
+ # with that record, and a crash then leaves an open key repair settles
534
+ # `unknown` — which the retry is told. One flush of the root, which
535
+ # carries the key, the act and its settle.
536
+ #
537
+ # `written`, not `flush`: the act has happened by now, so a write that
538
+ # fails does not make it not have happened, and answering the verb with
539
+ # an error would tell the client the one thing that is false. A disk that
540
+ # refuses writes is the root's own flushes failing next, which is the
541
+ # retry ladder's to report (`supervisor/failed`).
542
+ await session_written(root.ctx, root.session)
543
+ return reply
501
544
 
502
545
  # --- the methods -----------------------------------------------------------
503
546
  # One per row of `METHODS`, each taking its own params model. Read-only
@@ -766,21 +809,25 @@ class _Connection:
766
809
  _refresh_readings(root)
767
810
  return PresetApplied(session_id=root.id, preset=applied.name)
768
811
 
769
- async def _prepare_credential(
770
- self, root: Root, params: StoreCredentialParams
771
- ) -> CredentialService:
812
+ async def _credentials_store(self, params: StoreCredentialParams) -> CredentialStored:
813
+ """Hand a root a credential's value, which it uses and does not keep.
814
+
815
+ **Unkeyed** (T5). Storing one value twice stores it once, so a retry needs no
816
+ key; and a key would outlive what it named — the value lives in this
817
+ process's memory, so after a restart a re-send is the only way it comes back.
818
+ `start` rather than `_root`: the value belongs to a mounted root's seam.
819
+ """
820
+ root = await self.server.supervisor.start(params.session_id)
772
821
  service = root.ctx.get(CREDENTIALS)
773
822
  if service is None:
774
823
  raise SeamAbsent("this deployment stores no credentials")
775
- return service
776
-
777
- async def _act_credential(
778
- self, root: Root, params: StoreCredentialParams, service: CredentialService
779
- ) -> CredentialStored:
780
824
  # **The value is used and not kept.** It is never logged, never echoed
781
825
  # in the reply, and never reaches `describe()` — the reply is the name
782
826
  # and a boolean, which is everything a UI needs to redraw.
783
827
  service.provide_value(params.name, params.value)
828
+ # And whatever was waiting on it goes (T5): the root, if its route named
829
+ # this, and every child held for it.
830
+ await self.server.supervisor.credential_supplied(root)
784
831
  return CredentialStored(session_id=root.id, name=params.name)
785
832
 
786
833
  def _attachments(self, root: Root, refs: list[AttachmentRef]) -> list[AttachmentRef]:
@@ -1060,11 +1107,6 @@ MUTATIONS: dict[str, Mutation[Any, Any]] = dict(
1060
1107
  _mutating(verbs.SESSION_STAGE, _Connection._prepare_stage, _Connection._act_stage),
1061
1108
  _mutating(verbs.SESSION_SHELL, _Connection._prepare_shell, _Connection._act_shell),
1062
1109
  _mutating(verbs.SESSION_PRESET, _Connection._prepare_preset, _Connection._act_preset),
1063
- _mutating(
1064
- verbs.CREDENTIALS_STORE,
1065
- _Connection._prepare_credential,
1066
- _Connection._act_credential,
1067
- ),
1068
1110
  )
1069
1111
  )
1070
1112
  """Every method that changes a root, and the one place their idempotence lives.
@@ -1077,7 +1119,9 @@ a method in this table gets the write-ahead guard whether its author thought of
1077
1119
  it or not, and one that is not in it cannot claim to be idempotent by key.
1078
1120
 
1079
1121
  The repeat reply has one shape — `MutationRepeated`, a description carrying
1080
- `repeated: true` — so a client branches on one field for every verb.
1122
+ `repeated: true` and whether the first attempt finished (`outcome`) — so a client
1123
+ branches on one field for every verb. The keys and their outcomes are the
1124
+ journal's (`CLIENT_COMMAND`, P10-10).
1081
1125
  Deliberately absent: `attachment/put`
1082
1126
  (content-addressed, so a retry is a no-op already, and its reply *is* the
1083
1127
  reference a repeat must still return) and `session/new` (`start` is idempotent
@@ -1098,6 +1142,7 @@ METHODS: dict[str, _Row] = dict(
1098
1142
  _unkeyed(verbs.SESSION_SNAPSHOT, _Connection._snapshot),
1099
1143
  _unkeyed(verbs.ATTACHMENT_PUT, _Connection._put),
1100
1144
  _unkeyed(verbs.CREDENTIALS_HELD, _Connection._credentials_held),
1145
+ _unkeyed(verbs.CREDENTIALS_STORE, _Connection._credentials_store),
1101
1146
  # The read-only projections (P5-14). What a front end used to read straight
1102
1147
  # off `ctx`; each is a fold computed now, so a reconnecting client gets
1103
1148
  # today's answer rather than one cached when somebody last wrote it down.
@@ -1297,6 +1342,9 @@ class DaemonServer:
1297
1342
  ],
1298
1343
  ),
1299
1344
  ("socket lifetime", self.socket_lifetime().describe()),
1345
+ # What is held for a credential, by name (T5) — the question a person
1346
+ # asks when a child resumed after a restart and is not moving.
1347
+ ("credentials awaited", self.supervisor.awaited() or [("held", "nothing")]),
1300
1348
  # The daemon's own non-guarantees (N5, I-2), printed by the command a
1301
1349
  # person runs to ask what this daemon is. Rule 6 wants them beside
1302
1350
  # where they would be assumed, and this reply *is* that place: it