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.
- {phern-0.3.0 → phern-0.4.0}/PKG-INFO +9 -9
- {phern-0.3.0 → phern-0.4.0}/pyproject.toml +9 -9
- phern-0.4.0/src/ph_app/__init__.py +12 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/adapters/anthropic.py +1 -1
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/adapters/google.py +1 -1
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/adapters/openai_compatible.py +1 -1
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/cli.py +12 -8
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/client.py +3 -3
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/frontend.py +32 -15
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/server.py +68 -20
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/supervisor.py +185 -88
- phern-0.4.0/src/ph_app/kinds.py +84 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/modes/rpc_mode.py +2 -1
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/params.py +8 -3
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/payloads.py +41 -2
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/protocol.py +16 -1
- phern-0.4.0/src/ph_app/runtime.py +93 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/shell.py +72 -32
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/adapter.py +63 -2
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/app.py +20 -16
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/frontend.py +9 -6
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/modals/base.py +18 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/remote.py +17 -6
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/trajectory.py +9 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/verbs.py +4 -2
- {phern-0.3.0 → phern-0.4.0}/tests/test_adapters.py +22 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_agents_cli.py +1 -1
- {phern-0.3.0 → phern-0.4.0}/tests/test_app_layering.py +6 -1
- {phern-0.3.0 → phern-0.4.0}/tests/test_attachments_cli.py +2 -1
- {phern-0.3.0 → phern-0.4.0}/tests/test_daemon.py +9 -34
- {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_asks.py +89 -20
- {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_invariants.py +6 -4
- {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_lifetime.py +28 -2
- phern-0.4.0/tests/test_daemon_mutations.py +371 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_shell.py +38 -1
- {phern-0.3.0 → phern-0.4.0}/tests/test_modes.py +2 -1
- {phern-0.3.0 → phern-0.4.0}/tests/test_non_guarantees.py +3 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_payloads.py +9 -3
- {phern-0.3.0 → phern-0.4.0}/tests/test_protocol.py +2 -1
- {phern-0.3.0 → phern-0.4.0}/tests/test_runtime.py +28 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_trajectory.py +49 -24
- {phern-0.3.0 → phern-0.4.0}/tests/test_trajectory_view.py +14 -11
- {phern-0.3.0 → phern-0.4.0}/tests/test_tui_adapter.py +163 -33
- {phern-0.3.0 → phern-0.4.0}/tests/test_tui_code_cell.py +24 -22
- {phern-0.3.0 → phern-0.4.0}/tests/test_tui_pilot.py +42 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_tui_remote.py +37 -6
- {phern-0.3.0 → phern-0.4.0}/tests/test_uploads.py +32 -1
- {phern-0.3.0 → phern-0.4.0}/tests/test_workspaces_cli.py +2 -1
- {phern-0.3.0 → phern-0.4.0}/tests/tui_helpers.py +8 -5
- phern-0.3.0/src/ph_app/__init__.py +0 -5
- phern-0.3.0/src/ph_app/runtime.py +0 -169
- phern-0.3.0/tests/test_daemon_mutations.py +0 -190
- {phern-0.3.0 → phern-0.4.0}/.gitignore +0 -0
- {phern-0.3.0 → phern-0.4.0}/LICENSE +0 -0
- {phern-0.3.0 → phern-0.4.0}/README.md +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/__main__.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/adapters/__init__.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/adapters/_http.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/adapters/_media.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/adapters/sse.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/agents.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/attach.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/attachments.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/console.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/__init__.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/cancelsafe.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/cards.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/duplex.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/follow.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/framing.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/launch.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/projections.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/daemon/recovery.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/modes/__init__.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/modes/json_mode.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/modes/print_mode.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/modes/transcript_mode.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/profiles/anthropic.yaml +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/profiles/deepseek.yaml +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/profiles/google.yaml +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/profiles/llama.yaml +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/profiles/rlm-stable.yaml +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/profiles/tui.yaml +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/profiles.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/py.typed +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/sessions.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/trust.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/__init__.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/autocomplete.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/commands.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/config.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/modals/__init__.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/modals/approval.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/modals/ask_user.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/modals/login.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/modals/pickers.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/modals/trust.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/screens.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/state.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/terminal.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/__init__.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-frappe.yaml +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-latte.yaml +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-macchiato.yaml +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/catppuccin-mocha.yaml +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/high-contrast.json +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/ph-dark.json +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/themes/ph-light.json +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/trajectory_app.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/trajectory_screen.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/widgets/__init__.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/widgets/prompt.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/widgets/selection.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/widgets/status.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/widgets/trajectory.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/tui/widgets/transcript.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/web/__init__.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/web/serve.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/wire.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/src/ph_app/workspaces.py +0 -0
- {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
- {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_code_mode_card_lists_its_dispatches.raw +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_compaction_marker_keeps_what_it_replaced.raw +0 -0
- {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
- {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_a_settled_tool_card.raw +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_every_theme_renders[high-contrast].raw +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_every_theme_renders[ph-light].raw +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_markup_in_user_text_renders_literally.raw +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_streaming_assistant_text.raw +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/__snapshots__/test_tui_snapshot/test_the_full_app_shell.raw +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/app_fixtures.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/daemon_helpers.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_attach.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_cancelsafe.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_cli.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_attachments.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_framing.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_launch.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_methods.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_daemon_projections.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_tui_copy.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_tui_frames.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_tui_screens.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_tui_snapshot.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_tui_state.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_tui_themes.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_tui_workers.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_uploads_google.py +0 -0
- {phern-0.3.0 → phern-0.4.0}/tests/test_uploads_openai.py +0 -0
- {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
|
+
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.
|
|
27
|
-
Requires-Dist: ph-core==0.
|
|
28
|
-
Requires-Dist: ph-rlm==0.
|
|
29
|
-
Requires-Dist: ph-runtime-guest==0.
|
|
30
|
-
Requires-Dist: ph-stabilize==0.
|
|
31
|
-
Requires-Dist: ph-text-index==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.
|
|
37
|
+
Requires-Dist: ph-text-index[local]==0.4.0; extra == 'local'
|
|
38
38
|
Provides-Extra: otel
|
|
39
|
-
Requires-Dist: ph-core[otel]==0.
|
|
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.
|
|
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.
|
|
49
|
-
"ph-rlm==0.
|
|
50
|
-
"ph-stabilize==0.
|
|
51
|
-
"ph-text-index==0.
|
|
52
|
-
"ph-code-graph==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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
|
131
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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(
|
|
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
|
-
|
|
203
|
-
|
|
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=
|
|
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.
|
|
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
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
|
770
|
-
|
|
771
|
-
|
|
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`
|
|
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
|