taut-summon 0.5.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.
@@ -0,0 +1,23 @@
1
+ .DS_Store
2
+ .ruff_cache/
3
+ .mypy_cache/
4
+ .pytest_cache/
5
+ .hypothesis/
6
+ .coverage
7
+ .coverage.*
8
+ coverage.xml
9
+ htmlcov/
10
+ dist/
11
+ build/
12
+ *.egg-info/
13
+ __pycache__/
14
+ *.py[cod]
15
+ .venv/
16
+ .taut.db
17
+ .taut.lock
18
+ .taut.db-wal
19
+ .taut.db-shm
20
+ .envrc
21
+ .taut.toml
22
+ .weft
23
+ .broker*
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Van Lindberg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: taut-summon
3
+ Version: 0.5.0
4
+ Summary: Summon extension for Taut: host an agent harness as a workspace member
5
+ Author-email: Van Lindberg <van.lindberg@gmail.com>
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: taut>=0.5.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: mypy>=1.0; extra == 'dev'
12
+ Requires-Dist: pytest-timeout>=2.4.0; extra == 'dev'
13
+ Requires-Dist: pytest-xdist>=3.0; extra == 'dev'
14
+ Requires-Dist: pytest>=7.0; extra == 'dev'
15
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # taut-summon
19
+
20
+ Summon extension for Taut: host an existing agent harness (Claude Code and
21
+ friends) as an ordinary member of a taut workspace.
22
+
23
+ This package is intentionally separate from `taut`. The summon driver is the
24
+ agent's terminal, not its runtime: it injects chat into the harness's own
25
+ live session (its ears), and the agent speaks through the ordinary `taut`
26
+ CLI selected by its continuity token (its mouth). The full contract lives in
27
+ the core repository at `docs/specs/04-summon.md`.
28
+
29
+ ## Status
30
+
31
+ Functional. The CLI surface (`taut-summon run|stop|status`) and the core
32
+ delegation verbs (`taut summon`, `taut dismiss`), the foreground driver
33
+ (bootstrap, chat injection, event pump, crash-resume, clean shutdown), the
34
+ session ledger with a single-driver guard and PTY `wired` flag, the control
35
+ plane (STOP/STATUS/PING) with a rate backstop, the default persona, and the
36
+ provider adapters are implemented. `pty` is the default adapter for the
37
+ interactive harnesses (`claude`, `codex`, `coder`, `grok`, `qwen`, `kimi`,
38
+ `opencode`, `pi`); `claude-stream` remains available for Claude Code's
39
+ structured stream-json mode. See
40
+ `docs/plans/2026-07-06-taut-summon-plan.md`,
41
+ `docs/plans/2026-07-07-taut-summon-pty-harness-adapter-plan.md`, and
42
+ `docs/implementation/05-taut-summon-architecture.md` for the design.
43
+
44
+ ## Requirements
45
+
46
+ - Python 3.11+
47
+ - Core `taut` and `taut-summon` installed in the same environment
48
+ - A SQL-sidecar backend (SQLite or Postgres) — summon state rides sidecar
49
+ tables
50
+
51
+ ## Installation
52
+
53
+ Taut releases are GitHub-only until package-name clearance changes. Install
54
+ the core package first, then inject the extension wheel into the same
55
+ environment:
56
+
57
+ ```bash
58
+ pipx install "git+https://github.com/VanL/taut.git@vX.Y.Z"
59
+ pipx inject taut ./taut_summon-0.1.0-py3-none-any.whl
60
+ ```
61
+
62
+ ## Usage
63
+
64
+ ```bash
65
+ taut summon claude # summon a claude into #general
66
+ taut summon reviewer --provider claude dev
67
+ taut summon reviewer --provider claude-stream dev
68
+ taut dismiss reviewer
69
+ taut-summon status
70
+ ```
71
+
72
+ `taut summon`/`taut dismiss` delegate argv verbatim to `taut-summon
73
+ run`/`taut-summon stop`; both surfaces share one resolution contract.
74
+
75
+ On first PTY use, summon attaches your terminal so you can answer trust,
76
+ login, or model prompts in the real harness UI. Detach with `Ctrl-\ Ctrl-\`.
77
+ After detach the member is marked wired and future summons run detached.
78
+ Use `taut summon --attach NAME` to re-enter setup, or `--detach` for an
79
+ explicit detached run. PTY output is never parsed as speech; the agent speaks
80
+ by running `taut say`.
81
+
82
+ ## Testing
83
+
84
+ From the repository root:
85
+
86
+ ```bash
87
+ uv run pytest extensions/taut_summon/tests
88
+ ```
89
+
90
+ Local runs attempt the live PTY harness smoke matrix by default. A provider
91
+ skips with an explicit reason when its binary is absent, the fresh test
92
+ database has not been onboarded with a real attach/detach cycle, or status
93
+ cannot reach a usable detached session. CI skips the real-harness matrix
94
+ unless `TAUT_SUMMON_LIVE_HARNESS=1` is set. For a fast local loop, use:
95
+
96
+ ```bash
97
+ TAUT_SUMMON_LIVE_HARNESS=0 uv run pytest extensions/taut_summon/tests
98
+ ```
99
+
100
+ Run `taut summon --attach <name>` once for a provider that still needs trust,
101
+ login, or model setup before expecting its detached live smoke to pass.
102
+ For a hard local external-provider smoke, use strict mode. It prewires the
103
+ temporary test session to model an already-onboarded provider and fails on
104
+ missing binaries, readiness gaps, status timeouts, unanswered terminal queries,
105
+ or injection catch-up failures. The external-provider lane does not require
106
+ hosted CLIs to auto-execute shell commands; the local LLM lane below owns the
107
+ deterministic sentinel-posting proof.
108
+
109
+ ```bash
110
+ TAUT_SUMMON_LIVE_HARNESS_STRICT=1 uv run pytest extensions/taut_summon/tests/test_live_harness.py
111
+ ```
112
+
113
+ The local LLM smoke runs locally by default when a loopback OpenAI-compatible
114
+ endpoint lists the served model, and it runs in CI through the dedicated
115
+ Ollama-backed workflow job. Defaults:
116
+
117
+ ```bash
118
+ TAUT_SUMMON_LOCAL_LLM_ENDPOINT=http://127.0.0.1:11434/v1
119
+ TAUT_SUMMON_LOCAL_LLM_MODEL=taut-summon-local-model:latest
120
+ ```
121
+
122
+ To run it locally with Ollama:
123
+
124
+ ```bash
125
+ ollama pull qwen2.5:0.5b
126
+ cat > /tmp/TautSummonModelfile <<'EOF'
127
+ FROM qwen2.5:0.5b
128
+ PARAMETER num_ctx 2048
129
+ PARAMETER num_predict 64
130
+ PARAMETER temperature 0
131
+ EOF
132
+ ollama create taut-summon-local-model:latest -f /tmp/TautSummonModelfile
133
+ uv run pytest extensions/taut_summon/tests/test_live_local_llm.py
134
+ ```
135
+
136
+ Use `TAUT_SUMMON_LOCAL_LLM=0` to skip the local LLM smoke locally, or
137
+ `TAUT_SUMMON_LOCAL_LLM=1` to make missing endpoint/model setup fail instead of
138
+ skip.
@@ -0,0 +1,121 @@
1
+ # taut-summon
2
+
3
+ Summon extension for Taut: host an existing agent harness (Claude Code and
4
+ friends) as an ordinary member of a taut workspace.
5
+
6
+ This package is intentionally separate from `taut`. The summon driver is the
7
+ agent's terminal, not its runtime: it injects chat into the harness's own
8
+ live session (its ears), and the agent speaks through the ordinary `taut`
9
+ CLI selected by its continuity token (its mouth). The full contract lives in
10
+ the core repository at `docs/specs/04-summon.md`.
11
+
12
+ ## Status
13
+
14
+ Functional. The CLI surface (`taut-summon run|stop|status`) and the core
15
+ delegation verbs (`taut summon`, `taut dismiss`), the foreground driver
16
+ (bootstrap, chat injection, event pump, crash-resume, clean shutdown), the
17
+ session ledger with a single-driver guard and PTY `wired` flag, the control
18
+ plane (STOP/STATUS/PING) with a rate backstop, the default persona, and the
19
+ provider adapters are implemented. `pty` is the default adapter for the
20
+ interactive harnesses (`claude`, `codex`, `coder`, `grok`, `qwen`, `kimi`,
21
+ `opencode`, `pi`); `claude-stream` remains available for Claude Code's
22
+ structured stream-json mode. See
23
+ `docs/plans/2026-07-06-taut-summon-plan.md`,
24
+ `docs/plans/2026-07-07-taut-summon-pty-harness-adapter-plan.md`, and
25
+ `docs/implementation/05-taut-summon-architecture.md` for the design.
26
+
27
+ ## Requirements
28
+
29
+ - Python 3.11+
30
+ - Core `taut` and `taut-summon` installed in the same environment
31
+ - A SQL-sidecar backend (SQLite or Postgres) — summon state rides sidecar
32
+ tables
33
+
34
+ ## Installation
35
+
36
+ Taut releases are GitHub-only until package-name clearance changes. Install
37
+ the core package first, then inject the extension wheel into the same
38
+ environment:
39
+
40
+ ```bash
41
+ pipx install "git+https://github.com/VanL/taut.git@vX.Y.Z"
42
+ pipx inject taut ./taut_summon-0.1.0-py3-none-any.whl
43
+ ```
44
+
45
+ ## Usage
46
+
47
+ ```bash
48
+ taut summon claude # summon a claude into #general
49
+ taut summon reviewer --provider claude dev
50
+ taut summon reviewer --provider claude-stream dev
51
+ taut dismiss reviewer
52
+ taut-summon status
53
+ ```
54
+
55
+ `taut summon`/`taut dismiss` delegate argv verbatim to `taut-summon
56
+ run`/`taut-summon stop`; both surfaces share one resolution contract.
57
+
58
+ On first PTY use, summon attaches your terminal so you can answer trust,
59
+ login, or model prompts in the real harness UI. Detach with `Ctrl-\ Ctrl-\`.
60
+ After detach the member is marked wired and future summons run detached.
61
+ Use `taut summon --attach NAME` to re-enter setup, or `--detach` for an
62
+ explicit detached run. PTY output is never parsed as speech; the agent speaks
63
+ by running `taut say`.
64
+
65
+ ## Testing
66
+
67
+ From the repository root:
68
+
69
+ ```bash
70
+ uv run pytest extensions/taut_summon/tests
71
+ ```
72
+
73
+ Local runs attempt the live PTY harness smoke matrix by default. A provider
74
+ skips with an explicit reason when its binary is absent, the fresh test
75
+ database has not been onboarded with a real attach/detach cycle, or status
76
+ cannot reach a usable detached session. CI skips the real-harness matrix
77
+ unless `TAUT_SUMMON_LIVE_HARNESS=1` is set. For a fast local loop, use:
78
+
79
+ ```bash
80
+ TAUT_SUMMON_LIVE_HARNESS=0 uv run pytest extensions/taut_summon/tests
81
+ ```
82
+
83
+ Run `taut summon --attach <name>` once for a provider that still needs trust,
84
+ login, or model setup before expecting its detached live smoke to pass.
85
+ For a hard local external-provider smoke, use strict mode. It prewires the
86
+ temporary test session to model an already-onboarded provider and fails on
87
+ missing binaries, readiness gaps, status timeouts, unanswered terminal queries,
88
+ or injection catch-up failures. The external-provider lane does not require
89
+ hosted CLIs to auto-execute shell commands; the local LLM lane below owns the
90
+ deterministic sentinel-posting proof.
91
+
92
+ ```bash
93
+ TAUT_SUMMON_LIVE_HARNESS_STRICT=1 uv run pytest extensions/taut_summon/tests/test_live_harness.py
94
+ ```
95
+
96
+ The local LLM smoke runs locally by default when a loopback OpenAI-compatible
97
+ endpoint lists the served model, and it runs in CI through the dedicated
98
+ Ollama-backed workflow job. Defaults:
99
+
100
+ ```bash
101
+ TAUT_SUMMON_LOCAL_LLM_ENDPOINT=http://127.0.0.1:11434/v1
102
+ TAUT_SUMMON_LOCAL_LLM_MODEL=taut-summon-local-model:latest
103
+ ```
104
+
105
+ To run it locally with Ollama:
106
+
107
+ ```bash
108
+ ollama pull qwen2.5:0.5b
109
+ cat > /tmp/TautSummonModelfile <<'EOF'
110
+ FROM qwen2.5:0.5b
111
+ PARAMETER num_ctx 2048
112
+ PARAMETER num_predict 64
113
+ PARAMETER temperature 0
114
+ EOF
115
+ ollama create taut-summon-local-model:latest -f /tmp/TautSummonModelfile
116
+ uv run pytest extensions/taut_summon/tests/test_live_local_llm.py
117
+ ```
118
+
119
+ Use `TAUT_SUMMON_LOCAL_LLM=0` to skip the local LLM smoke locally, or
120
+ `TAUT_SUMMON_LOCAL_LLM=1` to make missing endpoint/model setup fail instead of
121
+ skip.
@@ -0,0 +1,62 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "taut-summon"
7
+ version = "0.5.0"
8
+ description = "Summon extension for Taut: host an agent harness as a workspace member"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = {text = "MIT"}
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ {name = "Van Lindberg", email = "van.lindberg@gmail.com"},
15
+ ]
16
+ dependencies = [
17
+ # Floor is the core version shipping the `summon`/`dismiss` delegation
18
+ # verbs and the blessed identity/state surfaces this extension builds on.
19
+ "taut>=0.5.0",
20
+ ]
21
+
22
+ [project.scripts]
23
+ taut-summon = "taut_summon.cli:main"
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "mypy>=1.0",
28
+ "pytest>=7.0",
29
+ "pytest-timeout>=2.4.0",
30
+ "pytest-xdist>=3.0",
31
+ "ruff>=0.1.0",
32
+ ]
33
+
34
+ [tool.uv.sources]
35
+ taut = { path = "../..", editable = true }
36
+
37
+ [tool.hatch.build]
38
+ include = [
39
+ "/taut_summon/**/*.py",
40
+ "/taut_summon/py.typed",
41
+ "/README.md",
42
+ "/LICENSE",
43
+ ]
44
+
45
+ [tool.pytest.ini_options]
46
+ minversion = "7.0"
47
+ testpaths = ["tests"]
48
+ addopts = "-ra -q --strict-markers -n auto --dist loadgroup"
49
+ markers = [
50
+ "requires_claude: live smoke tests that spawn the installed claude CLI (skipped when absent)",
51
+ "requires_live_harness: live PTY harness smoke tests; local by default, CI opt-in",
52
+ "requires_local_llm: local-LLM-backed PTY smoke tests; local by default, prepared CI opt-in",
53
+ "requires_codex: live smoke tests that spawn the installed codex CLI",
54
+ "requires_coder: live smoke tests that spawn the installed coder CLI",
55
+ "requires_grok: live smoke tests that spawn the installed grok CLI",
56
+ "requires_qwen: live smoke tests that spawn the installed qwen CLI",
57
+ "requires_kimi: live smoke tests that spawn the installed kimi CLI",
58
+ "requires_opencode: live smoke tests that spawn the installed opencode CLI",
59
+ "requires_pi: live smoke tests that spawn the installed pi CLI",
60
+ "xdist_group: group tests that share global external resources",
61
+ "sqlite_only: tests that validate built-in SQLite/path behavior",
62
+ ]
@@ -0,0 +1,40 @@
1
+ """Summon extension for Taut: host an agent harness as a workspace member.
2
+
3
+ Spec: docs/specs/04-summon.md ([SUM-1]-[SUM-12]) in the core repository.
4
+
5
+ The adapter surface is exported here because the ``scripted`` adapter
6
+ ships for downstream integrators ([SUM-7.2]): conformance runners spawn
7
+ it as a real subprocess speaking real stream shapes ([SUM-12]).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from taut_summon._adapter import (
13
+ ActivityEvent,
14
+ AdapterError,
15
+ AdapterEvent,
16
+ AdapterHandle,
17
+ AssistantTextEvent,
18
+ ExitEvent,
19
+ ProviderAdapter,
20
+ SessionEvent,
21
+ UnknownAdapterError,
22
+ adapter_names,
23
+ get_adapter,
24
+ )
25
+ from taut_summon._scripted import ScriptedAdapter
26
+
27
+ __all__ = [
28
+ "ActivityEvent",
29
+ "AdapterError",
30
+ "AdapterEvent",
31
+ "AdapterHandle",
32
+ "AssistantTextEvent",
33
+ "ExitEvent",
34
+ "ProviderAdapter",
35
+ "ScriptedAdapter",
36
+ "SessionEvent",
37
+ "UnknownAdapterError",
38
+ "adapter_names",
39
+ "get_adapter",
40
+ ]
@@ -0,0 +1,8 @@
1
+ """Module entry point: ``python -m taut_summon``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from taut_summon.cli import main
6
+
7
+ if __name__ == "__main__": # pragma: no cover
8
+ raise SystemExit(main())
@@ -0,0 +1,212 @@
1
+ """Provider adapter interface: the [SUM-7.1] contract and the registry.
2
+
3
+ An adapter owns exactly four things — spawn, inject, events, interrupt —
4
+ and translates its provider's native streaming envelope into the closed
5
+ ``AdapterEvent`` union below. There is no summon-defined wire protocol:
6
+ adapters translate, they do not define ([SUM-7.1]).
7
+
8
+ Contract requirements on every adapter (enforced by the conformance
9
+ tests, exercised today through the ``scripted`` adapter):
10
+
11
+ - ``inject()`` returns only after the event is written *and flushed* to
12
+ the child's stdin, and surfaces failures synchronously ([SUM-5.4]'s
13
+ at-least-once delivery to the harness process boundary depends on it).
14
+ - ``interrupt()`` and ``close()`` are thread-safe and unblock any
15
+ in-flight ``inject()`` — [SUM-9]'s STOP path must always be able to
16
+ stop a stalled harness.
17
+ - ``events()`` must be drained continuously by its (single) consumer; an
18
+ undrained stream is a child-stdout deadlock waiting to happen. The
19
+ stream ends with exactly one ``ExitEvent``.
20
+ - ``env`` passed to ``spawn`` is merged over the parent environment —
21
+ it carries additions such as ``TAUT_TOKEN``/``TAUT_DB`` ([SUM-6]),
22
+ not a replacement environment.
23
+
24
+ Spec references:
25
+ - docs/specs/04-summon.md [SUM-7.1], [SUM-7.2]
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ from collections.abc import Callable, Iterator, Mapping
33
+ from dataclasses import dataclass
34
+ from typing import Protocol
35
+
36
+
37
+ class AdapterError(Exception):
38
+ """An adapter operation failed (spawn, inject, or stream translation)."""
39
+
40
+
41
+ class UnknownAdapterError(AdapterError):
42
+ """No adapter is registered under the requested provider name."""
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class AssistantTextEvent:
47
+ """Assistant-authored text (posted to chat only in terminal mode)."""
48
+
49
+ text: str
50
+
51
+
52
+ @dataclass(frozen=True, slots=True)
53
+ class ActivityEvent:
54
+ """Tool use or comparable liveness signal — feeds presence, never chat."""
55
+
56
+ description: str
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class SessionEvent:
61
+ """The provider announced or changed its session id (resume handle)."""
62
+
63
+ session_id: str
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class ExitEvent:
68
+ """The harness child exited; the event stream ends after this."""
69
+
70
+ returncode: int
71
+
72
+
73
+ AdapterEvent = AssistantTextEvent | ActivityEvent | SessionEvent | ExitEvent
74
+
75
+
76
+ class AdapterHandle(Protocol):
77
+ """A live harness child owned by an adapter."""
78
+
79
+ @property
80
+ def session_id(self) -> str | None:
81
+ """Current provider session id, updated as ``SessionEvent``s arrive."""
82
+ ...
83
+
84
+ @property
85
+ def pid(self) -> int:
86
+ """Harness child pid — the [SUM-4] re-anchor evidence.
87
+
88
+ The summoned member's identity anchor is ultimately the harness
89
+ child process; the driver builds its rejoin capture from this pid,
90
+ so exposing it is part of the handle surface.
91
+ """
92
+ ...
93
+
94
+ def inject(self, text: str) -> None:
95
+ """Write one user-role event; return only after a flushed write."""
96
+ ...
97
+
98
+ def events(self) -> Iterator[AdapterEvent]:
99
+ """Typed output stream; single consumer, ends with ``ExitEvent``."""
100
+ ...
101
+
102
+ def interrupt(self) -> None:
103
+ """Harness-graceful stop; unblocks an in-flight ``inject()``."""
104
+ ...
105
+
106
+ def close(self) -> None:
107
+ """Stop (bounded escalation), reap the child, release the pipes."""
108
+ ...
109
+
110
+ def status_fields(self) -> dict[str, str]:
111
+ """Adapter-specific STATUS fields; empty for structured adapters."""
112
+ ...
113
+
114
+
115
+ class ProviderAdapter(Protocol):
116
+ """One provider harness family (claude, scripted, codex...)."""
117
+
118
+ name: str
119
+ supports_terminal_mode: bool
120
+ supports_attach: bool
121
+ orientation_via_inject: bool
122
+
123
+ def spawn(
124
+ self,
125
+ *,
126
+ session_id: str | None,
127
+ system_prompt: str,
128
+ env: Mapping[str, str],
129
+ ) -> AdapterHandle:
130
+ """Start the harness child, resuming ``session_id`` when given."""
131
+ ...
132
+
133
+
134
+ def _scripted_factory() -> ProviderAdapter:
135
+ from taut_summon._scripted import ScriptedAdapter
136
+
137
+ return ScriptedAdapter()
138
+
139
+
140
+ def _claude_factory() -> ProviderAdapter:
141
+ from taut_summon._claude import ClaudeAdapter
142
+
143
+ return ClaudeAdapter()
144
+
145
+
146
+ def _pty_factory() -> ProviderAdapter:
147
+ from taut_summon._pty import PtyAdapter, PtySpec
148
+
149
+ raw_argv = os.environ.get("TAUT_SUMMON_PTY_ARGV")
150
+ if raw_argv:
151
+ parsed = json.loads(raw_argv)
152
+ if not isinstance(parsed, list) or not all(
153
+ isinstance(item, str) and item for item in parsed
154
+ ):
155
+ raise AdapterError("TAUT_SUMMON_PTY_ARGV must be a JSON string list")
156
+ spec = PtySpec(
157
+ name="pty",
158
+ argv=tuple(parsed),
159
+ rows=int(os.environ.get("TAUT_SUMMON_PTY_ROWS", "24")),
160
+ cols=int(os.environ.get("TAUT_SUMMON_PTY_COLS", "80")),
161
+ stall_s=float(os.environ.get("TAUT_SUMMON_PTY_STALL_S", "10.0")),
162
+ quiet_ms=int(os.environ.get("TAUT_SUMMON_PTY_QUIET_MS", "500")),
163
+ max_settle_s=float(os.environ.get("TAUT_SUMMON_PTY_MAX_SETTLE_S", "10.0")),
164
+ )
165
+ return PtyAdapter(spec)
166
+ return PtyAdapter()
167
+
168
+
169
+ def _pty_harness_factory(name: str, binary: str) -> Callable[[], ProviderAdapter]:
170
+ def _factory() -> ProviderAdapter:
171
+ from taut_summon._pty import PtyAdapter, PtySpec
172
+
173
+ return PtyAdapter(PtySpec(name=name, argv=(binary,)))
174
+
175
+ return _factory
176
+
177
+
178
+ _FACTORIES: dict[str, Callable[[], ProviderAdapter]] = {
179
+ "claude": _pty_harness_factory("claude", "claude"),
180
+ "claude-stream": _claude_factory,
181
+ "codex": _pty_harness_factory("codex", "codex"),
182
+ "coder": _pty_harness_factory("coder", "coder"),
183
+ "grok": _pty_harness_factory("grok", "grok"),
184
+ "kimi": _pty_harness_factory("kimi", "kimi"),
185
+ "opencode": _pty_harness_factory("opencode", "opencode"),
186
+ "pi": _pty_harness_factory("pi", "pi"),
187
+ "pty": _pty_factory,
188
+ "qwen": _pty_harness_factory("qwen", "qwen"),
189
+ "scripted": _scripted_factory,
190
+ }
191
+
192
+
193
+ def adapter_names() -> tuple[str, ...]:
194
+ """Return the registered provider names, sorted."""
195
+
196
+ return tuple(sorted(_FACTORIES))
197
+
198
+
199
+ def get_adapter(name: str) -> ProviderAdapter:
200
+ """Return the adapter registered under ``name``.
201
+
202
+ Raises ``UnknownAdapterError`` naming the known adapters ([SUM-3]
203
+ resolution step 4).
204
+ """
205
+
206
+ factory = _FACTORIES.get(name)
207
+ if factory is None:
208
+ known = ", ".join(adapter_names())
209
+ raise UnknownAdapterError(
210
+ f"no adapter named '{name}' (known adapters: {known})"
211
+ )
212
+ return factory()
@@ -0,0 +1,64 @@
1
+ """taut-summon's broker-specific retry policy, over the generic engine.
2
+
3
+ Mirrors simplebroker's own layering: the generic, re-vendorable loop lives
4
+ in ``_retry.py`` and the domain-specific policy — which errors are
5
+ transient, how many attempts, how to back off — sits on top here, the way
6
+ ``simplebroker/helpers.py`` layers ``_execute_with_retry`` /
7
+ ``_execute_watcher_operational_retry`` over the same engine.
8
+
9
+ ``_retry.py`` is **vendored byte-for-byte** from ``simplebroker/_retry.py``
10
+ (simplebroker 5.1.0), which is published as a copy-me module for exactly
11
+ this use. It is kept pristine (no local edits) so it stays a diffable
12
+ drop-in copy; all provenance lives here, not inside that file. Re-vendor by
13
+ re-copying the upstream file when its ``__version__`` bumps. taut's
14
+ facades-only rule forbids importing ``simplebroker._retry`` directly, which
15
+ is why the engine is vendored rather than imported.
16
+
17
+ Spec reference: docs/specs/04-summon.md [SUM-9] (control-plane retry defense).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from collections.abc import Callable
24
+ from typing import TypeVar
25
+
26
+ from taut._broker_retry import is_transient_broker_error
27
+ from taut_summon._retry import execute_retry, expo, stop_after_attempt
28
+
29
+ logger = logging.getLogger("taut_summon.broker")
30
+
31
+ T = TypeVar("T")
32
+
33
+ _BROKER_RETRIES = 30
34
+ _BROKER_RETRY_DELAY = 0.05
35
+ _BROKER_RETRY_MAX_DELAY = 0.5
36
+
37
+
38
+ def broker_retry(
39
+ fn: Callable[[], T], *, what: str, attempts: int = _BROKER_RETRIES
40
+ ) -> T:
41
+ """Run a bare broker op, riding out transient WAL read errors.
42
+
43
+ Bare ``read_one``/``write`` on the control queues go through this so a
44
+ command or reply is not lost to a checkpoint-race transient (core's
45
+ watcher retries its own ops; bare ops do not). A persistent failure —
46
+ real corruption, or a non-transient class — is re-raised after the
47
+ bounded budget, so nothing genuinely broken is masked.
48
+ """
49
+
50
+ def _log(state: object, exc: Exception, delay: float) -> None:
51
+ logger.debug("transient broker error on %s; retrying: %s", what, exc)
52
+
53
+ return execute_retry(
54
+ fn,
55
+ retry_on=is_transient_broker_error,
56
+ wait_gen=expo,
57
+ wait_gen_kwargs={
58
+ "base": 2,
59
+ "factor": _BROKER_RETRY_DELAY,
60
+ "max_value": _BROKER_RETRY_MAX_DELAY,
61
+ },
62
+ stop=stop_after_attempt(attempts),
63
+ before_sleep=_log,
64
+ )