whiletrue 0.1.0__py3-none-any.whl

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 (98) hide show
  1. whiletrue/__init__.py +0 -0
  2. whiletrue/__main__.py +3 -0
  3. whiletrue/abort.py +79 -0
  4. whiletrue/basics/__init__.py +99 -0
  5. whiletrue/basics/agent.py +249 -0
  6. whiletrue/basics/comments.py +113 -0
  7. whiletrue/basics/diff_size.py +80 -0
  8. whiletrue/basics/exec_.py +84 -0
  9. whiletrue/basics/history.py +117 -0
  10. whiletrue/basics/human.py +201 -0
  11. whiletrue/basics/json_source.py +35 -0
  12. whiletrue/basics/markdown_source.py +55 -0
  13. whiletrue/basics/process.py +99 -0
  14. whiletrue/basics/sessions.py +62 -0
  15. whiletrue/basics/set_.py +29 -0
  16. whiletrue/basics/shell.py +98 -0
  17. whiletrue/basics/wake.py +27 -0
  18. whiletrue/claude_code/__init__.py +58 -0
  19. whiletrue/claude_code/endpoint.py +143 -0
  20. whiletrue/claude_code/headless.py +181 -0
  21. whiletrue/claude_code/interactive.py +344 -0
  22. whiletrue/claude_code/learnings.py +32 -0
  23. whiletrue/claude_code/panes.py +164 -0
  24. whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
  25. whiletrue/claude_code/plugin/hook.py +44 -0
  26. whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
  27. whiletrue/claude_code/plugin/mcp_server.py +98 -0
  28. whiletrue/claude_code/session.py +345 -0
  29. whiletrue/claude_code/transcript.py +123 -0
  30. whiletrue/cli/__init__.py +15 -0
  31. whiletrue/cli/__main__.py +3 -0
  32. whiletrue/cli/app.py +550 -0
  33. whiletrue/cli/common.py +36 -0
  34. whiletrue/cli/gates.py +156 -0
  35. whiletrue/cli/ops.py +339 -0
  36. whiletrue/cli/panel.py +198 -0
  37. whiletrue/cli/registry.py +143 -0
  38. whiletrue/cli/templates.py +24 -0
  39. whiletrue/codex/__init__.py +14 -0
  40. whiletrue/codex/runner.py +243 -0
  41. whiletrue/control/__init__.py +38 -0
  42. whiletrue/control/chain.py +61 -0
  43. whiletrue/control/loop.py +139 -0
  44. whiletrue/control/map_.py +101 -0
  45. whiletrue/control/parallel.py +32 -0
  46. whiletrue/control/retry.py +100 -0
  47. whiletrue/control/sequence.py +40 -0
  48. whiletrue/control/switch.py +67 -0
  49. whiletrue/core/__init__.py +260 -0
  50. whiletrue/core/caps.py +139 -0
  51. whiletrue/core/comments.py +80 -0
  52. whiletrue/core/composite.py +140 -0
  53. whiletrue/core/config.py +461 -0
  54. whiletrue/core/context.py +177 -0
  55. whiletrue/core/duration.py +19 -0
  56. whiletrue/core/events.py +94 -0
  57. whiletrue/core/expr.py +85 -0
  58. whiletrue/core/history.py +83 -0
  59. whiletrue/core/lock.py +86 -0
  60. whiletrue/core/memory_store.py +352 -0
  61. whiletrue/core/module.py +146 -0
  62. whiletrue/core/outcomes.py +82 -0
  63. whiletrue/core/registry.py +129 -0
  64. whiletrue/core/replay.py +58 -0
  65. whiletrue/core/runner.py +98 -0
  66. whiletrue/core/runtime.py +538 -0
  67. whiletrue/core/source.py +45 -0
  68. whiletrue/core/store.py +207 -0
  69. whiletrue/core/template.py +60 -0
  70. whiletrue/core/validate.py +142 -0
  71. whiletrue/core/wake.py +117 -0
  72. whiletrue/core/workspace.py +55 -0
  73. whiletrue/git/__init__.py +31 -0
  74. whiletrue/git/modules.py +128 -0
  75. whiletrue/git/workspaces.py +77 -0
  76. whiletrue/github/__init__.py +30 -0
  77. whiletrue/github/client.py +269 -0
  78. whiletrue/github/filebacked.py +87 -0
  79. whiletrue/github/memory.py +96 -0
  80. whiletrue/github/plugin.py +343 -0
  81. whiletrue/github/specs.py +39 -0
  82. whiletrue/opencode/__init__.py +25 -0
  83. whiletrue/opencode/runner.py +186 -0
  84. whiletrue/py.typed +0 -0
  85. whiletrue/recipes/issue-loop.yaml +59 -0
  86. whiletrue/recipes/ralph.yaml +14 -0
  87. whiletrue/recipes/review-pass.yaml +27 -0
  88. whiletrue/recipes/review-respond.yaml +22 -0
  89. whiletrue/store_sqlite/__init__.py +6 -0
  90. whiletrue/store_sqlite/migrations.py +132 -0
  91. whiletrue/store_sqlite/sqlite_store.py +537 -0
  92. whiletrue/tmux/__init__.py +11 -0
  93. whiletrue/tmux/controller.py +197 -0
  94. whiletrue-0.1.0.dist-info/METADATA +48 -0
  95. whiletrue-0.1.0.dist-info/RECORD +98 -0
  96. whiletrue-0.1.0.dist-info/WHEEL +4 -0
  97. whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
  98. whiletrue-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,344 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import json
6
+ import os
7
+ import re
8
+ import shlex
9
+ import sys
10
+ import uuid
11
+ from collections.abc import Coroutine
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from whiletrue.claude_code.endpoint import EventEndpoint
17
+ from whiletrue.claude_code.learnings import LearningsStore
18
+ from whiletrue.claude_code.panes import Pane, PaneHost, ProcessPaneHost
19
+ from whiletrue.claude_code.session import ParkReason, SessionOutcome, SessionSpec, StepSession
20
+ from whiletrue.claude_code.transcript import Price
21
+ from whiletrue.core import AgentRequest, AgentResult, RunContext, RunnerCapabilities
22
+
23
+ #: The bundled Claude Code plugin (hooks); `--plugin-dir` points here.
24
+ PLUGIN_DIR = Path(__file__).resolve().parent / "plugin"
25
+
26
+ #: The MCP server's name, which fixes its tools' names (`mcp__whiletrue__whiletrue_submit`).
27
+ MCP_SERVER = "whiletrue"
28
+
29
+ #: Linux caps a single argument at 128 KiB; stay well under it.
30
+ _MAX_PROMPT_ARG = 100_000
31
+
32
+ _SYSTEM_NOTE = """You are running as one step of a whiletrue pipeline.
33
+ - When your work is complete, call the whiletrue_submit tool with your result. That is how the \
34
+ step finishes; do not just print the result.
35
+ - If you are blocked and need a person's decision, call whiletrue_ask with your question and stop.
36
+ - Record durable, reusable lessons about this project with whiletrue_learn.
37
+ - whiletrue_context returns this item's details and recent learnings.
38
+ """
39
+
40
+
41
+ @dataclass
42
+ class InteractiveOptions:
43
+ #: The claude binary. Defaults to `claude` on PATH.
44
+ command: str = "claude"
45
+ #: Interpreter that runs the plugin's hook and MCP scripts (they use only the stdlib).
46
+ python: str = field(default_factory=lambda: sys.executable)
47
+ #: Where sessions run; defaults to plain subprocesses (the CLI passes a tmux host).
48
+ host: PaneHost | None = None
49
+ #: Shared endpoint; defaults to one started on first use.
50
+ endpoint: EventEndpoint | None = None
51
+ #: How many times a `Stop` without a result is turned back before the step parks.
52
+ max_nudges: int = 3
53
+ prices: dict[str, Price] | None = None
54
+ #: Seconds between checks on the pane and the wall-time limit.
55
+ poll_interval: float = 0.5
56
+ #: After asking claude to exit, how long to wait before killing the pane.
57
+ exit_grace: float = 5.0
58
+ #: After a limit is hit, how long the agent gets to stop on its own before we park it.
59
+ limit_grace: float = 20.0
60
+ learnings_path: str | None = None
61
+ extra_args: list[str] = field(default_factory=lambda: [])
62
+
63
+
64
+ def build_interactive_args(
65
+ req: AgentRequest, *, plugin_dir: str, mcp_config: str, system_prompt_file: str
66
+ ) -> list[str]:
67
+ """argv (after the binary) for an interactive session; the prompt follows `--`."""
68
+ args = ["--plugin-dir", plugin_dir, "--mcp-config", mcp_config]
69
+ args += ["--append-system-prompt-file", system_prompt_file]
70
+ if req.resume:
71
+ args += ["--resume", req.resume]
72
+ if req.fork:
73
+ args += ["--fork-session"]
74
+ elif req.session_id:
75
+ args += ["--session-id", req.session_id]
76
+ if req.model:
77
+ args += ["--model", req.model]
78
+ if req.permission_mode:
79
+ args += ["--permission-mode", req.permission_mode]
80
+ # Always allow the whiletrue MCP tools; the rule names the server, so it covers all four.
81
+ allowed = [*(req.allowed_tools or []), f"mcp__{MCP_SERVER}"]
82
+ args += [f"--allowedTools={','.join(allowed)}"]
83
+ if req.disallowed_tools:
84
+ args += [f"--disallowedTools={','.join(req.disallowed_tools)}"]
85
+ if req.tools:
86
+ args += [f"--tools={','.join(req.tools)}"]
87
+ for directory in req.add_dirs or []:
88
+ args += ["--add-dir", directory]
89
+ if req.bare:
90
+ args += ["--bare"]
91
+ return args
92
+
93
+
94
+ #: Variables a pane needs even when the tmux server was started from a different environment.
95
+ _PASSTHROUGH = re.compile(
96
+ r"^(PATH|HOME|USER|SHELL|LANG|LC_.*|TERM|VIRTUAL_ENV|LD_LIBRARY_PATH|NIX_.*"
97
+ r"|ANTHROPIC_.*|CLAUDE_.*|WHILETRUE_.*)$"
98
+ )
99
+
100
+
101
+ def pane_environment() -> dict[str, str]:
102
+ return {k: v for k, v in os.environ.items() if _PASSTHROUGH.match(k)}
103
+
104
+
105
+ def _safe(name: str) -> str:
106
+ return re.sub(r"[^A-Za-z0-9_.-]+", "_", name)[:80]
107
+
108
+
109
+ class InteractiveRunner:
110
+ """Runs a step as a live, interactive Claude Code session in a pane.
111
+
112
+ The agent can be watched and steered from the pane. It finishes by calling `whiletrue_submit`
113
+ (validated against the step's output cap); hooks give whiletrue turns, tool calls, steering and
114
+ the stop signal, which is how limits are enforced without headless flags.
115
+ """
116
+
117
+ id = "claude-code"
118
+ capabilities = RunnerCapabilities(
119
+ interactive=True, structured_output=True, resume=True, fork=True, budget=True
120
+ )
121
+
122
+ def __init__(self, options: InteractiveOptions | None = None) -> None:
123
+ self._options = options or InteractiveOptions()
124
+ self._endpoint = self._options.endpoint
125
+ self._host = self._options.host or ProcessPaneHost()
126
+ self._starting = asyncio.Lock()
127
+
128
+ async def _ensure_endpoint(self) -> EventEndpoint:
129
+ async with self._starting:
130
+ if self._endpoint is None:
131
+ self._endpoint = EventEndpoint()
132
+ if not self._endpoint.started:
133
+ await self._endpoint.start()
134
+ return self._endpoint
135
+
136
+ async def run(self, request: AgentRequest, ctx: RunContext) -> AgentResult:
137
+ if ctx.signal.aborted:
138
+ return AgentResult(ok=False, text="", error="aborted")
139
+ opts = self._options
140
+ endpoint = await self._ensure_endpoint()
141
+ key = request.step_key or str(uuid.uuid4())
142
+ label = request.label or key
143
+ workdir = (
144
+ Path(request.cwd) / ".whiletrue" / "sessions" / f"{_safe(key)}-{uuid.uuid4().hex[:6]}"
145
+ )
146
+ try:
147
+ argv = self._prepare(request, endpoint, key, workdir)
148
+ except OSError as error:
149
+ return AgentResult(ok=False, text="", error=f"Cannot prepare the session: {error}")
150
+
151
+ pane_holder: list[Pane] = []
152
+ loop = asyncio.get_running_loop()
153
+ background: set[asyncio.Task[None]] = set()
154
+
155
+ def spawn(coro: Coroutine[Any, Any, None]) -> None:
156
+ task = asyncio.ensure_future(coro)
157
+ background.add(task)
158
+ task.add_done_callback(background.discard)
159
+
160
+ async def retitle(title: str) -> None:
161
+ if pane_holder:
162
+ with contextlib.suppress(Exception):
163
+ await pane_holder[0].set_title(title)
164
+
165
+ def emit(event: dict[str, object]) -> None:
166
+ if ctx.on_event is not None:
167
+ ctx.on_event({str(k): v for k, v in event.items()})
168
+
169
+ session_holder: list[StepSession] = []
170
+
171
+ def on_limit(reason: ParkReason) -> None:
172
+ # The agent normally stops once its tools are denied; if it doesn't, park anyway.
173
+ def force() -> None:
174
+ if session_holder:
175
+ session_holder[0].park(reason, f"limit hit: {reason}")
176
+
177
+ loop.call_later(opts.limit_grace, force)
178
+
179
+ session = StepSession(
180
+ SessionSpec(
181
+ key=key,
182
+ item=request.item or {},
183
+ validate=request.validate_output,
184
+ result_cap=request.output_cap,
185
+ result_schema=request.output_schema,
186
+ limits=request.limits,
187
+ learnings=LearningsStore(
188
+ opts.learnings_path or Path(request.cwd) / ".whiletrue" / "learnings.jsonl"
189
+ ),
190
+ provenance={"step": key},
191
+ max_nudges=opts.max_nudges,
192
+ prices=opts.prices,
193
+ on_event=emit,
194
+ on_attention=lambda message: spawn(retitle(f"! {label} needs you")),
195
+ on_attention_cleared=lambda: spawn(retitle(label)),
196
+ on_limit=on_limit,
197
+ )
198
+ )
199
+ session_holder.append(session)
200
+ unregister = endpoint.register(key, session)
201
+ try:
202
+ env = {**pane_environment(), **endpoint.env_for(key), "WT_PYTHON": opts.python}
203
+ try:
204
+ pane = await self._host.open(title=label, argv=argv, cwd=request.cwd, env=env)
205
+ except Exception as error:
206
+ return AgentResult(ok=False, text="", error=f"Failed to start a pane: {error}")
207
+ pane_holder.append(pane)
208
+ outcome = await self._wait(session, pane, request, ctx)
209
+ await self._wind_down(pane, session, request, label, aborted=outcome is None)
210
+ finally:
211
+ unregister()
212
+ for task in list(background):
213
+ task.cancel()
214
+ if outcome is None:
215
+ return AgentResult(ok=False, text="", error="aborted")
216
+ return self._result(outcome, request)
217
+
218
+ def _prepare(
219
+ self, request: AgentRequest, endpoint: EventEndpoint, key: str, workdir: Path
220
+ ) -> list[str]:
221
+ """Writes the per-session files and builds the shell command the pane runs."""
222
+ opts = self._options
223
+ workdir.mkdir(parents=True, exist_ok=True)
224
+ prompt_file = workdir / "prompt.md"
225
+ prompt_file.write_text(request.prompt, encoding="utf-8")
226
+ note = _SYSTEM_NOTE
227
+ if request.append_system_prompt_file:
228
+ note += "\n" + Path(request.append_system_prompt_file).read_text(encoding="utf-8")
229
+ system_file = workdir / "system.md"
230
+ system_file.write_text(note, encoding="utf-8")
231
+ mcp_file = workdir / "mcp.json"
232
+ mcp_file.write_text(
233
+ json.dumps(
234
+ {
235
+ "mcpServers": {
236
+ MCP_SERVER: {
237
+ "command": opts.python,
238
+ "args": [str(PLUGIN_DIR / "mcp_server.py")],
239
+ "env": endpoint.env_for(key),
240
+ }
241
+ }
242
+ },
243
+ indent=2,
244
+ ),
245
+ encoding="utf-8",
246
+ )
247
+ flags = build_interactive_args(
248
+ request,
249
+ plugin_dir=str(PLUGIN_DIR),
250
+ mcp_config=str(mcp_file),
251
+ system_prompt_file=str(system_file),
252
+ )
253
+ for extra in request.mcp_config or []:
254
+ flags += ["--mcp-config", extra]
255
+ flags += opts.extra_args
256
+ # The prompt is read from a file so the pane's command stays short. One argument is capped
257
+ # (128 KiB on Linux), so a very large prompt is handed over as a pointer to its file.
258
+ if len(request.prompt.encode()) > _MAX_PROMPT_ARG:
259
+ pointer = (
260
+ f"Your task is written in `{prompt_file}`. Read that file completely, "
261
+ "then carry it out."
262
+ )
263
+ prompt_file.with_name("pointer.md").write_text(pointer, encoding="utf-8")
264
+ argument_file = prompt_file.with_name("pointer.md")
265
+ else:
266
+ argument_file = prompt_file
267
+ script = (
268
+ f"exec {shlex.quote(opts.command)} {shlex.join(flags)} -- "
269
+ f'"$(cat {shlex.quote(str(argument_file))})"'
270
+ )
271
+ return ["sh", "-c", script]
272
+
273
+ async def _wait(
274
+ self, session: StepSession, pane: Pane, request: AgentRequest, ctx: RunContext
275
+ ) -> SessionOutcome | None:
276
+ """Waits for the step to finish. None means the run was aborted."""
277
+ opts = self._options
278
+ loop = asyncio.get_running_loop()
279
+ wall = request.limits.wall_ms if request.limits is not None else None
280
+ deadline = None if wall is None else loop.time() + wall / 1000
281
+ abort = asyncio.ensure_future(ctx.signal.wait())
282
+ try:
283
+ while not session.outcome.done():
284
+ if ctx.signal.aborted:
285
+ return None
286
+ await asyncio.wait(
287
+ {session.outcome, abort},
288
+ timeout=opts.poll_interval,
289
+ return_when=asyncio.FIRST_COMPLETED,
290
+ )
291
+ if session.outcome.done():
292
+ break
293
+ if await pane.state() != "running":
294
+ # The process is gone; give its last hook a moment to land first.
295
+ await asyncio.sleep(min(opts.poll_interval, 0.2))
296
+ if not session.outcome.done():
297
+ await session.on_hook("SessionEnd", {})
298
+ elif deadline is not None and loop.time() >= deadline and not session.steered:
299
+ session.park("wall_exceeded", "the wall-time limit was reached")
300
+ return session.outcome.result()
301
+ finally:
302
+ abort.cancel()
303
+
304
+ async def _wind_down(
305
+ self, pane: Pane, session: StepSession, request: AgentRequest, label: str, *, aborted: bool
306
+ ) -> None:
307
+ opts = self._options
308
+ with contextlib.suppress(Exception):
309
+ if aborted:
310
+ await pane.kill()
311
+ return
312
+ if await pane.state() == "running":
313
+ await pane.send_text("/exit")
314
+ loop = asyncio.get_running_loop()
315
+ end = loop.time() + opts.exit_grace
316
+ while loop.time() < end and await pane.state() == "running": # noqa: ASYNC110
317
+ await asyncio.sleep(0.05)
318
+ if await pane.state() == "running":
319
+ await pane.kill()
320
+ return
321
+ await pane.finish(keep=request.on_done == "keep", title=f"{label} (done)")
322
+
323
+ def _result(self, outcome: SessionOutcome, request: AgentRequest) -> AgentResult:
324
+ common = {
325
+ "text": outcome.text,
326
+ "session_id": outcome.session_id or request.session_id or request.resume,
327
+ "cost_usd": outcome.cost_usd,
328
+ "turns": outcome.turns,
329
+ "steered": outcome.steered,
330
+ "steered_cost_usd": outcome.steered_cost_usd if outcome.steered else None,
331
+ }
332
+ if outcome.status == "submitted":
333
+ return AgentResult(ok=True, output=outcome.value, **common) # pyright: ignore[reportArgumentType]
334
+ error = outcome.detail or "the session ended without a result"
335
+ return AgentResult(
336
+ ok=False,
337
+ error=error,
338
+ park=outcome.reason if outcome.status == "parked" else None,
339
+ **common, # pyright: ignore[reportArgumentType]
340
+ )
341
+
342
+
343
+ def create_interactive_runner(options: InteractiveOptions | None = None) -> InteractiveRunner:
344
+ return InteractiveRunner(options)
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+
9
+ class LearningsStore:
10
+ """Append-only JSONL of notes agents record with `whiletrue_learn`, each with provenance."""
11
+
12
+ def __init__(self, path: str | Path) -> None:
13
+ self._path = Path(path)
14
+
15
+ def append(self, note: str, **provenance: Any) -> None:
16
+ self._path.parent.mkdir(parents=True, exist_ok=True)
17
+ row = {"note": note, "at": int(time.time() * 1000), **provenance}
18
+ with self._path.open("a", encoding="utf-8") as handle:
19
+ handle.write(json.dumps(row) + "\n")
20
+
21
+ def recent(self, limit: int) -> list[dict[str, Any]]:
22
+ if not self._path.exists():
23
+ return []
24
+ rows: list[dict[str, Any]] = []
25
+ for line in self._path.read_text(encoding="utf-8").splitlines():
26
+ try:
27
+ row = json.loads(line)
28
+ except ValueError:
29
+ continue
30
+ if isinstance(row, dict):
31
+ rows.append(row) # pyright: ignore[reportUnknownArgumentType]
32
+ return rows[-limit:]
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import contextlib
5
+ import os
6
+ import re
7
+ import shlex
8
+ import uuid
9
+ from pathlib import Path
10
+ from typing import Literal, Protocol
11
+
12
+ from whiletrue.basics.process import kill_process_group
13
+ from whiletrue.tmux import PaneRef, TmuxController
14
+
15
+ PaneState = Literal["running", "dead", "gone"]
16
+
17
+
18
+ class Pane(Protocol):
19
+ """One live agent session on screen (or, in tests, in a plain process)."""
20
+
21
+ async def state(self) -> PaneState: ...
22
+
23
+ async def send_text(self, text: str) -> None: ...
24
+
25
+ async def set_title(self, title: str) -> None: ...
26
+
27
+ async def finish(self, *, keep: bool, title: str) -> None:
28
+ """The step is over. Close the pane, or `keep` it (and its output) on screen."""
29
+ ...
30
+
31
+ async def kill(self) -> None: ...
32
+
33
+
34
+ class PaneHost(Protocol):
35
+ async def open(self, *, title: str, argv: list[str], cwd: str, env: dict[str, str]) -> Pane: ...
36
+
37
+
38
+ class TmuxPaneHost:
39
+ """Opens each agent session as a pane in the project's tmux session."""
40
+
41
+ def __init__(
42
+ self, tmux: TmuxController, control_command: str | None = None, cwd: str | None = None
43
+ ) -> None:
44
+ self._tmux = tmux
45
+ self._control_command = control_command
46
+ self._cwd = cwd
47
+
48
+ async def open(self, *, title: str, argv: list[str], cwd: str, env: dict[str, str]) -> Pane:
49
+ await self._tmux.ensure_session(self._control_command, self._cwd)
50
+ pane = await self._tmux.open_pane(title=title, argv=argv, cwd=cwd, env=env)
51
+ return _TmuxPane(self._tmux, pane)
52
+
53
+
54
+ class _TmuxPane:
55
+ def __init__(self, tmux: TmuxController, pane: PaneRef) -> None:
56
+ self._tmux = tmux
57
+ self._pane = pane
58
+
59
+ async def state(self) -> PaneState:
60
+ return await self._tmux.pane_state(self._pane)
61
+
62
+ async def send_text(self, text: str) -> None:
63
+ await self._tmux.send_text(self._pane, text)
64
+
65
+ async def set_title(self, title: str) -> None:
66
+ await self._tmux.set_title(self._pane, title)
67
+
68
+ async def finish(self, *, keep: bool, title: str) -> None:
69
+ if keep:
70
+ await self._tmux.keep_pane(self._pane, title)
71
+ else:
72
+ await self._tmux.close_pane(self._pane)
73
+
74
+ async def kill(self) -> None:
75
+ await self._tmux.close_pane(self._pane)
76
+
77
+
78
+ class ProcessPaneHost:
79
+ """A "pane" that is a plain subprocess: for tests and machines without tmux."""
80
+
81
+ async def open(self, *, title: str, argv: list[str], cwd: str, env: dict[str, str]) -> Pane:
82
+ proc = await asyncio.create_subprocess_exec(
83
+ *argv,
84
+ cwd=cwd,
85
+ env={**os.environ, **env},
86
+ stdin=asyncio.subprocess.PIPE,
87
+ stdout=asyncio.subprocess.DEVNULL,
88
+ stderr=asyncio.subprocess.DEVNULL,
89
+ start_new_session=True,
90
+ )
91
+ return _ProcessPane(proc, title)
92
+
93
+
94
+ class _ProcessPane:
95
+ def __init__(self, proc: asyncio.subprocess.Process, title: str) -> None:
96
+ self._proc = proc
97
+ self.title = title
98
+
99
+ async def state(self) -> PaneState:
100
+ return "running" if self._proc.returncode is None else "dead"
101
+
102
+ async def send_text(self, text: str) -> None:
103
+ if self._proc.stdin is not None and self._proc.returncode is None:
104
+ with contextlib.suppress(BrokenPipeError, ConnectionResetError):
105
+ self._proc.stdin.write(text.encode() + b"\n")
106
+ await self._proc.stdin.drain()
107
+
108
+ async def set_title(self, title: str) -> None:
109
+ self.title = title
110
+
111
+ async def finish(self, *, keep: bool, title: str) -> None:
112
+ self.title = title
113
+ await self.kill()
114
+
115
+ async def kill(self) -> None:
116
+ if self._proc.returncode is None:
117
+ kill_process_group(self._proc.pid)
118
+ await self._proc.wait()
119
+
120
+
121
+ class StreamMirror:
122
+ """Shows a headless step's live output in a read-only pane (`tail -F` of a log file).
123
+
124
+ Without a pane host it does nothing, so runners can call it unconditionally.
125
+ """
126
+
127
+ def __init__(self, host: PaneHost | None) -> None:
128
+ self._host = host
129
+ self._log: Path | None = None
130
+ self._pane: Pane | None = None
131
+
132
+ async def open(self, cwd: str, step_key: str | None, label: str | None) -> Path | None:
133
+ if self._host is None:
134
+ return None
135
+ key = re.sub(r"[^A-Za-z0-9_.-]+", "_", step_key or uuid.uuid4().hex)[:80]
136
+ directory = Path(cwd) / ".whiletrue" / "sessions" / f"{key}-{uuid.uuid4().hex[:6]}"
137
+ directory.mkdir(parents=True, exist_ok=True)
138
+ self._log = directory / "stream.log"
139
+ self._log.write_text("")
140
+ done = shlex.quote(str(self._log) + ".done")
141
+ script = (
142
+ f"tail -n +1 -F {shlex.quote(str(self._log))} & p=$!; "
143
+ f"while [ ! -e {done} ]; do sleep 0.3; done; sleep 0.5; kill $p"
144
+ )
145
+ try:
146
+ self._pane = await self._host.open(
147
+ title=label or "agent", argv=["sh", "-c", script], cwd=cwd, env={}
148
+ )
149
+ except Exception:
150
+ self._pane = None
151
+ return self._log
152
+
153
+ def note(self, line: str) -> None:
154
+ if self._log is not None:
155
+ with self._log.open("a", encoding="utf-8") as handle:
156
+ handle.write(line + "\n")
157
+
158
+ async def close(self, *, keep: bool, title: str) -> None:
159
+ if self._log is None:
160
+ return
161
+ self._log.with_name(self._log.name + ".done").write_text("done")
162
+ if self._pane is not None:
163
+ with contextlib.suppress(Exception):
164
+ await self._pane.finish(keep=keep, title=title)
@@ -0,0 +1,5 @@
1
+ {
2
+ "name": "whiletrue",
3
+ "version": "0.1.0",
4
+ "description": "Connects an interactive Claude Code session to a whiletrue run (hooks only; the MCP server is passed with --mcp-config)."
5
+ }
@@ -0,0 +1,44 @@
1
+ """Claude Code hook -> whiletrue endpoint.
2
+
3
+ Run by the bundled plugin as `hook.py <EventName>`: reads the hook payload (JSON) on stdin, posts
4
+ it to the whiletrue process and prints the answer, which is how whiletrue blocks a `Stop` or
5
+ denies a `PreToolUse`. Standard library only. A hook must never break the session, so any failure
6
+ (endpoint gone, bad reply) is silent and exits 0.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sys
13
+ import urllib.request
14
+ from collections.abc import Mapping
15
+ from typing import IO, Any, cast
16
+
17
+
18
+ def post_json(url: str, path: str, body: Any, token: str, session: str) -> Any:
19
+ request = urllib.request.Request(
20
+ url + path,
21
+ data=json.dumps(body).encode(),
22
+ headers={"X-WT-Token": token, "X-WT-Session": session, "Content-Type": "application/json"},
23
+ )
24
+ with urllib.request.urlopen(request, timeout=25) as response:
25
+ return json.loads(response.read() or b"{}")
26
+
27
+
28
+ def main(argv: list[str], stdin: IO[str], stdout: IO[str], env: Mapping[str, str]) -> int:
29
+ url, token, session = env.get("WT_ENDPOINT"), env.get("WT_TOKEN"), env.get("WT_SESSION")
30
+ if not (url and token and session and len(argv) > 1):
31
+ return 0
32
+ try:
33
+ raw = stdin.read()
34
+ payload = cast(dict[str, Any], json.loads(raw)) if raw.strip() else {}
35
+ reply = post_json(url, "/hook", {"event": argv[1], "payload": payload}, token, session)
36
+ except Exception:
37
+ return 0
38
+ if isinstance(reply, dict) and reply:
39
+ stdout.write(json.dumps(reply))
40
+ return 0
41
+
42
+
43
+ if __name__ == "__main__": # pragma: no cover
44
+ sys.exit(main(sys.argv, sys.stdin, sys.stdout, __import__("os").environ))
@@ -0,0 +1,81 @@
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "\"${WT_PYTHON:-python3}\" \"${CLAUDE_PLUGIN_ROOT}/hook.py\" SessionStart",
9
+ "timeout": 30
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "PreToolUse": [
15
+ {
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "\"${WT_PYTHON:-python3}\" \"${CLAUDE_PLUGIN_ROOT}/hook.py\" PreToolUse",
20
+ "timeout": 30
21
+ }
22
+ ]
23
+ }
24
+ ],
25
+ "PostToolUse": [
26
+ {
27
+ "hooks": [
28
+ {
29
+ "type": "command",
30
+ "command": "\"${WT_PYTHON:-python3}\" \"${CLAUDE_PLUGIN_ROOT}/hook.py\" PostToolUse",
31
+ "timeout": 30
32
+ }
33
+ ]
34
+ }
35
+ ],
36
+ "UserPromptSubmit": [
37
+ {
38
+ "hooks": [
39
+ {
40
+ "type": "command",
41
+ "command": "\"${WT_PYTHON:-python3}\" \"${CLAUDE_PLUGIN_ROOT}/hook.py\" UserPromptSubmit",
42
+ "timeout": 30
43
+ }
44
+ ]
45
+ }
46
+ ],
47
+ "Notification": [
48
+ {
49
+ "hooks": [
50
+ {
51
+ "type": "command",
52
+ "command": "\"${WT_PYTHON:-python3}\" \"${CLAUDE_PLUGIN_ROOT}/hook.py\" Notification",
53
+ "timeout": 30
54
+ }
55
+ ]
56
+ }
57
+ ],
58
+ "Stop": [
59
+ {
60
+ "hooks": [
61
+ {
62
+ "type": "command",
63
+ "command": "\"${WT_PYTHON:-python3}\" \"${CLAUDE_PLUGIN_ROOT}/hook.py\" Stop",
64
+ "timeout": 30
65
+ }
66
+ ]
67
+ }
68
+ ],
69
+ "SessionEnd": [
70
+ {
71
+ "hooks": [
72
+ {
73
+ "type": "command",
74
+ "command": "\"${WT_PYTHON:-python3}\" \"${CLAUDE_PLUGIN_ROOT}/hook.py\" SessionEnd",
75
+ "timeout": 30
76
+ }
77
+ ]
78
+ }
79
+ ]
80
+ }
81
+ }