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.
- whiletrue/__init__.py +0 -0
- whiletrue/__main__.py +3 -0
- whiletrue/abort.py +79 -0
- whiletrue/basics/__init__.py +99 -0
- whiletrue/basics/agent.py +249 -0
- whiletrue/basics/comments.py +113 -0
- whiletrue/basics/diff_size.py +80 -0
- whiletrue/basics/exec_.py +84 -0
- whiletrue/basics/history.py +117 -0
- whiletrue/basics/human.py +201 -0
- whiletrue/basics/json_source.py +35 -0
- whiletrue/basics/markdown_source.py +55 -0
- whiletrue/basics/process.py +99 -0
- whiletrue/basics/sessions.py +62 -0
- whiletrue/basics/set_.py +29 -0
- whiletrue/basics/shell.py +98 -0
- whiletrue/basics/wake.py +27 -0
- whiletrue/claude_code/__init__.py +58 -0
- whiletrue/claude_code/endpoint.py +143 -0
- whiletrue/claude_code/headless.py +181 -0
- whiletrue/claude_code/interactive.py +344 -0
- whiletrue/claude_code/learnings.py +32 -0
- whiletrue/claude_code/panes.py +164 -0
- whiletrue/claude_code/plugin/.claude-plugin/plugin.json +5 -0
- whiletrue/claude_code/plugin/hook.py +44 -0
- whiletrue/claude_code/plugin/hooks/hooks.json +81 -0
- whiletrue/claude_code/plugin/mcp_server.py +98 -0
- whiletrue/claude_code/session.py +345 -0
- whiletrue/claude_code/transcript.py +123 -0
- whiletrue/cli/__init__.py +15 -0
- whiletrue/cli/__main__.py +3 -0
- whiletrue/cli/app.py +550 -0
- whiletrue/cli/common.py +36 -0
- whiletrue/cli/gates.py +156 -0
- whiletrue/cli/ops.py +339 -0
- whiletrue/cli/panel.py +198 -0
- whiletrue/cli/registry.py +143 -0
- whiletrue/cli/templates.py +24 -0
- whiletrue/codex/__init__.py +14 -0
- whiletrue/codex/runner.py +243 -0
- whiletrue/control/__init__.py +38 -0
- whiletrue/control/chain.py +61 -0
- whiletrue/control/loop.py +139 -0
- whiletrue/control/map_.py +101 -0
- whiletrue/control/parallel.py +32 -0
- whiletrue/control/retry.py +100 -0
- whiletrue/control/sequence.py +40 -0
- whiletrue/control/switch.py +67 -0
- whiletrue/core/__init__.py +260 -0
- whiletrue/core/caps.py +139 -0
- whiletrue/core/comments.py +80 -0
- whiletrue/core/composite.py +140 -0
- whiletrue/core/config.py +461 -0
- whiletrue/core/context.py +177 -0
- whiletrue/core/duration.py +19 -0
- whiletrue/core/events.py +94 -0
- whiletrue/core/expr.py +85 -0
- whiletrue/core/history.py +83 -0
- whiletrue/core/lock.py +86 -0
- whiletrue/core/memory_store.py +352 -0
- whiletrue/core/module.py +146 -0
- whiletrue/core/outcomes.py +82 -0
- whiletrue/core/registry.py +129 -0
- whiletrue/core/replay.py +58 -0
- whiletrue/core/runner.py +98 -0
- whiletrue/core/runtime.py +538 -0
- whiletrue/core/source.py +45 -0
- whiletrue/core/store.py +207 -0
- whiletrue/core/template.py +60 -0
- whiletrue/core/validate.py +142 -0
- whiletrue/core/wake.py +117 -0
- whiletrue/core/workspace.py +55 -0
- whiletrue/git/__init__.py +31 -0
- whiletrue/git/modules.py +128 -0
- whiletrue/git/workspaces.py +77 -0
- whiletrue/github/__init__.py +30 -0
- whiletrue/github/client.py +269 -0
- whiletrue/github/filebacked.py +87 -0
- whiletrue/github/memory.py +96 -0
- whiletrue/github/plugin.py +343 -0
- whiletrue/github/specs.py +39 -0
- whiletrue/opencode/__init__.py +25 -0
- whiletrue/opencode/runner.py +186 -0
- whiletrue/py.typed +0 -0
- whiletrue/recipes/issue-loop.yaml +59 -0
- whiletrue/recipes/ralph.yaml +14 -0
- whiletrue/recipes/review-pass.yaml +27 -0
- whiletrue/recipes/review-respond.yaml +22 -0
- whiletrue/store_sqlite/__init__.py +6 -0
- whiletrue/store_sqlite/migrations.py +132 -0
- whiletrue/store_sqlite/sqlite_store.py +537 -0
- whiletrue/tmux/__init__.py +11 -0
- whiletrue/tmux/controller.py +197 -0
- whiletrue-0.1.0.dist-info/METADATA +48 -0
- whiletrue-0.1.0.dist-info/RECORD +98 -0
- whiletrue-0.1.0.dist-info/WHEEL +4 -0
- whiletrue-0.1.0.dist-info/entry_points.txt +2 -0
- 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,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
|
+
}
|