panopticon-app 0.0.1__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.
- panopticon/__init__.py +3 -0
- panopticon/client.py +324 -0
- panopticon/container/__init__.py +7 -0
- panopticon/container/__main__.py +8 -0
- panopticon/container/agent.py +211 -0
- panopticon/container/config.py +28 -0
- panopticon/container/entrypoint.py +143 -0
- panopticon/container/hook.py +177 -0
- panopticon/container/hooks.py +66 -0
- panopticon/container/pricing.py +67 -0
- panopticon/container/skills.py +68 -0
- panopticon/core/__init__.py +53 -0
- panopticon/core/artifacts.py +68 -0
- panopticon/core/git.py +109 -0
- panopticon/core/layers.py +25 -0
- panopticon/core/models.py +324 -0
- panopticon/core/provisioning.py +37 -0
- panopticon/core/state.py +107 -0
- panopticon/core/store.py +222 -0
- panopticon/core/workflow.py +546 -0
- panopticon/py.typed +0 -0
- panopticon/sessionservice/__init__.py +6 -0
- panopticon/sessionservice/__main__.py +70 -0
- panopticon/sessionservice/clones.py +65 -0
- panopticon/sessionservice/daemon.py +144 -0
- panopticon/sessionservice/host.py +237 -0
- panopticon/sessionservice/images.py +70 -0
- panopticon/sessionservice/local_runner.py +236 -0
- panopticon/sessionservice/provisioner.py +58 -0
- panopticon/sessionservice/runner.py +32 -0
- panopticon/sessionservice/spawn.py +54 -0
- panopticon/sessionservice/spawner.py +336 -0
- panopticon/sessionservice/stub_runner.py +44 -0
- panopticon/taskservice/__init__.py +5 -0
- panopticon/taskservice/__main__.py +86 -0
- panopticon/taskservice/api.py +761 -0
- panopticon/taskservice/artifacts_fs.py +88 -0
- panopticon/taskservice/layers_fs.py +39 -0
- panopticon/taskservice/mcp.py +168 -0
- panopticon/taskservice/service.py +764 -0
- panopticon/taskservice/store_sqlalchemy.py +394 -0
- panopticon/terminal/__init__.py +6 -0
- panopticon/terminal/__main__.py +75 -0
- panopticon/terminal/attach.py +21 -0
- panopticon/terminal/console.py +215 -0
- panopticon/terminal/dashboard.py +1646 -0
- panopticon/workflows/__init__.py +14 -0
- panopticon/workflows/discovery.py +71 -0
- panopticon/workflows/github_forge.py +198 -0
- panopticon/workflows/github_peer_reviewed.py +93 -0
- panopticon/workflows/github_self_reviewed.py +75 -0
- panopticon/workflows/local_git_self_reviewed.py +90 -0
- panopticon/workflows/orchestrator.py +132 -0
- panopticon/workflows/planned_workflow.py +73 -0
- panopticon/workflows/spike.py +37 -0
- panopticon_app-0.0.1.dist-info/METADATA +15 -0
- panopticon_app-0.0.1.dist-info/RECORD +60 -0
- panopticon_app-0.0.1.dist-info/WHEEL +4 -0
- panopticon_app-0.0.1.dist-info/entry_points.txt +2 -0
- panopticon_app-0.0.1.dist-info/licenses/LICENSE +26 -0
panopticon/__init__.py
ADDED
panopticon/client.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""The task service's REST client — shared by every caller outside the service itself.
|
|
2
|
+
|
|
3
|
+
Both the in-container agent harness and the terminal controller (dashboard + CLI) talk to the
|
|
4
|
+
task service over the same HTTP API, so they share one client. It wraps an
|
|
5
|
+
:class:`httpx.Client` — real (pointed at the runner-injected service URL) or a FastAPI
|
|
6
|
+
``TestClient`` in tests. Methods mirror the API one-for-one; reads return parsed JSON, writes
|
|
7
|
+
return the updated resource. LLM-free — agents reach the LLM only inside the container.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Generator
|
|
13
|
+
from typing import Any, cast
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from panopticon.core.models import Status
|
|
18
|
+
|
|
19
|
+
JsonObj = dict[str, Any]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TaskServiceClient:
|
|
23
|
+
def __init__(self, http: httpx.Client) -> None:
|
|
24
|
+
self._http = http
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def _json(resp: httpx.Response) -> Any:
|
|
28
|
+
resp.raise_for_status()
|
|
29
|
+
return resp.json()
|
|
30
|
+
|
|
31
|
+
# -- reads --------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def list_workflows(self) -> list[JsonObj]:
|
|
34
|
+
return cast("list[JsonObj]", self._json(self._http.get("/workflows")))
|
|
35
|
+
|
|
36
|
+
def list_workflows_for_repo(self, repo_id: str) -> list[dict[str, str]]:
|
|
37
|
+
"""Workflows visible for a repo, filtered by its preferences and each workflow's opt_in."""
|
|
38
|
+
return cast("list[dict[str, str]]", self._json(self._http.get(f"/repos/{repo_id}/workflows")))
|
|
39
|
+
|
|
40
|
+
def workflow_image_layer(self, name: str) -> str:
|
|
41
|
+
"""The workflow's Dockerfile layer (ADR 0005); empty when it needs none."""
|
|
42
|
+
body = cast(JsonObj, self._json(self._http.get(f"/workflows/{name}/image-layer")))
|
|
43
|
+
return cast(str, body["layer"])
|
|
44
|
+
|
|
45
|
+
def get_repo(self, repo_id: str) -> JsonObj:
|
|
46
|
+
return cast(JsonObj, self._json(self._http.get(f"/repos/{repo_id}")))
|
|
47
|
+
|
|
48
|
+
def repo_image_layer(self, repo_id: str) -> str:
|
|
49
|
+
"""The repo's Dockerfile layer (ADR 0005), read from its ``image_layer_file``; empty when
|
|
50
|
+
it declares none. Mirrors :meth:`workflow_image_layer`."""
|
|
51
|
+
body = cast(JsonObj, self._json(self._http.get(f"/repos/{repo_id}/image-layer")))
|
|
52
|
+
return cast(str, body["layer"])
|
|
53
|
+
|
|
54
|
+
def list_repos(self) -> list[JsonObj]:
|
|
55
|
+
return cast("list[JsonObj]", self._json(self._http.get("/repos")))
|
|
56
|
+
|
|
57
|
+
def list_tasks(self) -> list[JsonObj]:
|
|
58
|
+
return cast("list[JsonObj]", self._json(self._http.get("/tasks")))
|
|
59
|
+
|
|
60
|
+
def list_tasks_versioned(
|
|
61
|
+
self, *, since: int = 0, wait: float | None = None
|
|
62
|
+
) -> tuple[list[JsonObj], int]:
|
|
63
|
+
"""Block-until-change ``GET /tasks``: return ``(tasks, version)`` where ``version`` is the
|
|
64
|
+
store's change-feed cursor (the ``X-Tasks-Version`` header).
|
|
65
|
+
|
|
66
|
+
With ``wait`` set, the call long-polls — it parks on the server until a task changes past
|
|
67
|
+
``since`` (the last version this caller saw) or ``wait`` seconds elapse, then returns the
|
|
68
|
+
current snapshot + version. Without ``wait`` it's an immediate snapshot + version. Feed the
|
|
69
|
+
returned ``version`` back as ``since`` on the next call to wait for the *next* change —
|
|
70
|
+
replacing a ``list_tasks()`` + ``sleep`` poll loop with one event-driven call.
|
|
71
|
+
"""
|
|
72
|
+
params: dict[str, Any] = {"since": since}
|
|
73
|
+
if wait is None:
|
|
74
|
+
resp = self._http.get("/tasks", params=params)
|
|
75
|
+
else:
|
|
76
|
+
# Give the socket headroom past the server-side hold so the long-poll isn't cut short
|
|
77
|
+
# by httpx's default read timeout.
|
|
78
|
+
params["wait"] = wait
|
|
79
|
+
resp = self._http.get("/tasks", params=params, timeout=httpx.Timeout(wait + 10.0))
|
|
80
|
+
resp.raise_for_status()
|
|
81
|
+
version = int(resp.headers.get("X-Tasks-Version", "0"))
|
|
82
|
+
return cast("list[JsonObj]", resp.json()), version
|
|
83
|
+
|
|
84
|
+
def get_task(self, task_id: str) -> JsonObj:
|
|
85
|
+
return cast(JsonObj, self._json(self._http.get(f"/tasks/{task_id}")))
|
|
86
|
+
|
|
87
|
+
def list_transitions(self, task_id: str) -> list[str]:
|
|
88
|
+
return cast("list[str]", self._json(self._http.get(f"/tasks/{task_id}/transitions")))
|
|
89
|
+
|
|
90
|
+
def list_operations(self, task_id: str) -> dict[str, str]:
|
|
91
|
+
return cast("dict[str, str]", self._json(self._http.get(f"/tasks/{task_id}/operations")))
|
|
92
|
+
|
|
93
|
+
def list_states(self, task_id: str) -> list[str]:
|
|
94
|
+
"""Every state of the task's workflow — the candidates for a free state-set."""
|
|
95
|
+
return cast("list[str]", self._json(self._http.get(f"/tasks/{task_id}/states")))
|
|
96
|
+
|
|
97
|
+
def list_skills(self, task_id: str) -> list[JsonObj]:
|
|
98
|
+
"""The active workflow's in-container skills (the harness renders these to the CLI)."""
|
|
99
|
+
return cast("list[JsonObj]", self._json(self._http.get(f"/tasks/{task_id}/skills")))
|
|
100
|
+
|
|
101
|
+
def get_briefing(self, task_id: str) -> str:
|
|
102
|
+
"""The agent's current-phase briefing (the user-prompt hook emits it into context)."""
|
|
103
|
+
body = cast(JsonObj, self._json(self._http.get(f"/tasks/{task_id}/briefing")))
|
|
104
|
+
return cast(str, body["briefing"])
|
|
105
|
+
|
|
106
|
+
def workflow_overview(self, task_id: str) -> str:
|
|
107
|
+
"""The task's whole-workflow map (the launcher puts it in claude's system prompt)."""
|
|
108
|
+
body = cast(JsonObj, self._json(self._http.get(f"/tasks/{task_id}/workflow-overview")))
|
|
109
|
+
return cast(str, body["overview"])
|
|
110
|
+
|
|
111
|
+
def list_registrations(self, task_id: str) -> list[JsonObj]:
|
|
112
|
+
return cast("list[JsonObj]", self._json(self._http.get(f"/tasks/{task_id}/registrations")))
|
|
113
|
+
|
|
114
|
+
# -- repos / tasks ------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def create_repo(
|
|
117
|
+
self,
|
|
118
|
+
repo_id: str,
|
|
119
|
+
name: str,
|
|
120
|
+
git_url: str,
|
|
121
|
+
default_base: str = "main",
|
|
122
|
+
*,
|
|
123
|
+
env_file: str | None = None,
|
|
124
|
+
capabilities: dict[str, Any] | None = None,
|
|
125
|
+
enabled_workflows: list[str] | None = None,
|
|
126
|
+
disabled_workflows: list[str] | None = None,
|
|
127
|
+
) -> JsonObj:
|
|
128
|
+
body: dict[str, Any] = {
|
|
129
|
+
"id": repo_id, "name": name, "git_url": git_url, "default_base": default_base,
|
|
130
|
+
"env_file": env_file,
|
|
131
|
+
"enabled_workflows": enabled_workflows or [],
|
|
132
|
+
"disabled_workflows": disabled_workflows or [],
|
|
133
|
+
}
|
|
134
|
+
if capabilities is not None:
|
|
135
|
+
body["capabilities"] = capabilities
|
|
136
|
+
return cast(JsonObj, self._json(self._http.post("/repos", json=body)))
|
|
137
|
+
|
|
138
|
+
def update_repo(self, repo_id: str, **changes: Any) -> JsonObj:
|
|
139
|
+
"""Partially update a repo (PATCH): only the supplied fields are sent, and the service
|
|
140
|
+
merges them onto the stored repo — so untouched fields (e.g. image_layer_file/capabilities)
|
|
141
|
+
are preserved."""
|
|
142
|
+
return cast(JsonObj, self._json(self._http.patch(f"/repos/{repo_id}", json=changes)))
|
|
143
|
+
|
|
144
|
+
def create_task(
|
|
145
|
+
self,
|
|
146
|
+
repo_id: str,
|
|
147
|
+
workflow: str,
|
|
148
|
+
memo: str | None = None,
|
|
149
|
+
*,
|
|
150
|
+
initial_prompt: str | None = None,
|
|
151
|
+
) -> JsonObj:
|
|
152
|
+
body: JsonObj = {"repo_id": repo_id, "workflow": workflow, "memo": memo}
|
|
153
|
+
if initial_prompt is not None:
|
|
154
|
+
body["initial_prompt"] = initial_prompt
|
|
155
|
+
return cast(JsonObj, self._json(self._http.post("/tasks", json=body)))
|
|
156
|
+
|
|
157
|
+
def set_slug(self, task_id: str, slug: str) -> JsonObj:
|
|
158
|
+
return cast(JsonObj, self._json(self._http.put(f"/tasks/{task_id}/slug", json={"slug": slug})))
|
|
159
|
+
|
|
160
|
+
def set_url(self, task_id: str, url: str) -> JsonObj:
|
|
161
|
+
return cast(JsonObj, self._json(self._http.put(f"/tasks/{task_id}/url", json={"url": url})))
|
|
162
|
+
|
|
163
|
+
def set_tokens_used(self, task_id: str, tokens_used: int) -> JsonObj:
|
|
164
|
+
"""Record cumulative tokens used by claude in this container (the Stop hook reports it)."""
|
|
165
|
+
return cast(JsonObj, self._json(self._http.put(
|
|
166
|
+
f"/tasks/{task_id}/tokens-used", json={"tokens_used": tokens_used})))
|
|
167
|
+
|
|
168
|
+
def set_token_estimate(self, task_id: str, token_estimate: int) -> JsonObj:
|
|
169
|
+
"""Record the agent's forecast of the total tokens this task will consume (set in planning)."""
|
|
170
|
+
return cast(JsonObj, self._json(self._http.put(
|
|
171
|
+
f"/tasks/{task_id}/token-estimate", json={"token_estimate": token_estimate})))
|
|
172
|
+
|
|
173
|
+
def set_state(self, task_id: str, state: str) -> JsonObj:
|
|
174
|
+
"""The user's free override — move the task to any state (bypasses the graph and gate)."""
|
|
175
|
+
return cast(JsonObj, self._json(self._http.put(f"/tasks/{task_id}/state", json={"state": state})))
|
|
176
|
+
|
|
177
|
+
def set_turn(self, task_id: str, turn: str) -> JsonObj:
|
|
178
|
+
"""Flip who holds the turn (the in-container stop/user-prompt hooks call this)."""
|
|
179
|
+
return cast(JsonObj, self._json(self._http.put(f"/tasks/{task_id}/turn", json={"turn": turn})))
|
|
180
|
+
|
|
181
|
+
def set_blocked(self, task_id: str, blocked: bool) -> JsonObj:
|
|
182
|
+
"""Set/clear the deliberate `blocked` marker (survives turn flips)."""
|
|
183
|
+
return cast(JsonObj, self._json(self._http.put(f"/tasks/{task_id}/blocked", json={"blocked": blocked})))
|
|
184
|
+
|
|
185
|
+
def set_governor(self, task_id: str, governor_task_id: str | None) -> JsonObj:
|
|
186
|
+
"""Set or clear the governor task (the task that oversees this one)."""
|
|
187
|
+
return cast(JsonObj, self._json(self._http.put(
|
|
188
|
+
f"/tasks/{task_id}/governor", json={"governor_task_id": governor_task_id})))
|
|
189
|
+
|
|
190
|
+
def set_dependencies(self, task_id: str, dep_ids: list[str]) -> JsonObj:
|
|
191
|
+
"""Replace the task's dependency list (task IDs that must complete first)."""
|
|
192
|
+
return cast(JsonObj, self._json(self._http.put(f"/tasks/{task_id}/dependencies", json={"dep_ids": dep_ids})))
|
|
193
|
+
|
|
194
|
+
def record_provisioning(self, task_id: str, branch: str, clone: str) -> JsonObj:
|
|
195
|
+
"""Record the slug-named branch + per-task clone the session service created (ADR 0011)."""
|
|
196
|
+
body: JsonObj = {"branch": branch, "clone": clone}
|
|
197
|
+
return cast(JsonObj, self._json(self._http.put(f"/tasks/{task_id}/provisioning", json=body)))
|
|
198
|
+
|
|
199
|
+
def claim(self, task_id: str, runner_id: str) -> JsonObj:
|
|
200
|
+
"""Claim an unclaimed task for `runner_id` (the spawn gate); 409 if another runner holds it."""
|
|
201
|
+
return cast(JsonObj, self._json(self._http.put(f"/tasks/{task_id}/claim", json={"runner_id": runner_id})))
|
|
202
|
+
|
|
203
|
+
def release(self, task_id: str) -> JsonObj:
|
|
204
|
+
"""Release a task's claim (back to unclaimed) so it can be re-claimed / respawned."""
|
|
205
|
+
return cast(JsonObj, self._json(self._http.delete(f"/tasks/{task_id}/claim")))
|
|
206
|
+
|
|
207
|
+
def request_transition(
|
|
208
|
+
self, task_id: str, to_state: str, *, trigger: str | None = None, note: str | None = None
|
|
209
|
+
) -> JsonObj:
|
|
210
|
+
body: JsonObj = {"to_state": to_state, "trigger": trigger, "note": note}
|
|
211
|
+
return cast(JsonObj, self._json(self._http.post(f"/tasks/{task_id}/transition", json=body)))
|
|
212
|
+
|
|
213
|
+
def apply_operation(self, task_id: str, operation: str) -> JsonObj:
|
|
214
|
+
"""Apply a named core operation (e.g. advance/drop); the workflow resolves the target."""
|
|
215
|
+
return cast(JsonObj, self._json(self._http.post(f"/tasks/{task_id}/operations/{operation}")))
|
|
216
|
+
|
|
217
|
+
def resolve_responsibility(
|
|
218
|
+
self, task_id: str, key: str, status: Status, comment: str | None = None
|
|
219
|
+
) -> JsonObj:
|
|
220
|
+
"""Resolve one of the current state's promised responsibilities (MET or FAILED)."""
|
|
221
|
+
body: JsonObj = {"key": key, "status": status.value, "comment": comment}
|
|
222
|
+
return cast(JsonObj, self._json(self._http.post(f"/tasks/{task_id}/responsibilities", json=body)))
|
|
223
|
+
|
|
224
|
+
# -- artifacts ----------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def list_artifacts(self, task_id: str) -> list[str]:
|
|
227
|
+
return cast(list[str], self._json(self._http.get(f"/tasks/{task_id}/artifacts")))
|
|
228
|
+
|
|
229
|
+
def put_artifact(self, task_id: str, name: str, content: bytes) -> None:
|
|
230
|
+
self._http.put(f"/tasks/{task_id}/artifacts/{name}", content=content).raise_for_status()
|
|
231
|
+
|
|
232
|
+
def get_artifact(self, task_id: str, name: str) -> bytes:
|
|
233
|
+
resp = self._http.get(f"/tasks/{task_id}/artifacts/{name}")
|
|
234
|
+
resp.raise_for_status()
|
|
235
|
+
return resp.content
|
|
236
|
+
|
|
237
|
+
# -- liveness -----------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
def register(self, task_id: str, container_id: str, runner_id: str | None = None) -> JsonObj:
|
|
240
|
+
return cast(
|
|
241
|
+
JsonObj,
|
|
242
|
+
self._json(
|
|
243
|
+
self._http.post(
|
|
244
|
+
f"/tasks/{task_id}/registrations",
|
|
245
|
+
json={"container_id": container_id, "runner_id": runner_id},
|
|
246
|
+
)
|
|
247
|
+
),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def live(
|
|
251
|
+
self, task_id: str, *, container_id: str, runner_id: str | None = None
|
|
252
|
+
) -> Generator[None, None, None]:
|
|
253
|
+
"""Hold the long-lived liveness connection open, yielding once per server keepalive.
|
|
254
|
+
|
|
255
|
+
The open connection is the liveness signal: the service registers the container on connect
|
|
256
|
+
and removes it the instant this stream drops (clean ``close()``, or the process dying). The
|
|
257
|
+
caller iterates and may stop at any tick (closing the generator closes the connection — a
|
|
258
|
+
clean deregister); if the connection drops underneath, ``httpx`` raises, which the caller
|
|
259
|
+
treats as a cue to reconnect. Replaces the old register + heartbeat-loop + deregister.
|
|
260
|
+
"""
|
|
261
|
+
with self._http.stream(
|
|
262
|
+
"GET",
|
|
263
|
+
f"/tasks/{task_id}/live",
|
|
264
|
+
params={"container_id": container_id, "runner_id": runner_id},
|
|
265
|
+
timeout=None, # the connection is meant to stay open for the container's lifetime
|
|
266
|
+
) as resp:
|
|
267
|
+
resp.raise_for_status()
|
|
268
|
+
for _ in resp.iter_lines():
|
|
269
|
+
yield None
|
|
270
|
+
|
|
271
|
+
def deregister(self, registration_id: str) -> None:
|
|
272
|
+
self._http.delete(f"/registrations/{registration_id}").raise_for_status()
|
|
273
|
+
|
|
274
|
+
# -- container lifecycle (the session service reports its spawn progress) -----
|
|
275
|
+
|
|
276
|
+
def report_lifecycle(
|
|
277
|
+
self, task_id: str, runner_id: str, phase: str, detail: str | None = None
|
|
278
|
+
) -> JsonObj:
|
|
279
|
+
"""Report this runner's latest spawn phase for a task (claiming → … → awaiting, or failed),
|
|
280
|
+
so the dashboard can surface the steps to becoming live. Cleared on claim release/reclaim."""
|
|
281
|
+
body: JsonObj = {"runner_id": runner_id, "phase": phase, "detail": detail}
|
|
282
|
+
return cast(JsonObj, self._json(self._http.put(f"/tasks/{task_id}/lifecycle", json=body)))
|
|
283
|
+
|
|
284
|
+
def clear_lifecycle(self, task_id: str) -> JsonObj:
|
|
285
|
+
"""Drop a task's reported spawn phase (e.g. its container vanished → composes ``down``)."""
|
|
286
|
+
return cast(JsonObj, self._json(self._http.delete(f"/tasks/{task_id}/lifecycle")))
|
|
287
|
+
|
|
288
|
+
# -- host (runner) liveness + reclaim -----------------------------------------
|
|
289
|
+
|
|
290
|
+
def live_runner(self, runner_id: str, *, host: str | None = None) -> Generator[None, None, None]:
|
|
291
|
+
"""Hold this host's liveness connection open, yielding once per server keepalive.
|
|
292
|
+
|
|
293
|
+
The host-liveness mirror of :meth:`live` one layer up: the open ``/runners/{id}/live`` stream
|
|
294
|
+
is the signal that this session-service daemon is alive. The task service marks the runner
|
|
295
|
+
live on connect and drops it from ``live_runners`` the instant this stream closes (a clean
|
|
296
|
+
``close()`` or the daemon dying). The caller (the daemon) holds it for its whole life,
|
|
297
|
+
reconnecting if it drops underneath. ``host`` is the runner's hostname, passed as a query
|
|
298
|
+
param so the task service can surface it for the terminal supervisor's remote attach (M5).
|
|
299
|
+
"""
|
|
300
|
+
params = {"host": host} if host is not None else {}
|
|
301
|
+
with self._http.stream(
|
|
302
|
+
"GET",
|
|
303
|
+
f"/runners/{runner_id}/live",
|
|
304
|
+
params=params,
|
|
305
|
+
timeout=None, # the connection is meant to stay open for the daemon's lifetime
|
|
306
|
+
) as resp:
|
|
307
|
+
resp.raise_for_status()
|
|
308
|
+
for _ in resp.iter_lines():
|
|
309
|
+
yield None
|
|
310
|
+
|
|
311
|
+
def live_runners(self) -> list[JsonObj]:
|
|
312
|
+
"""The runners currently holding a host-liveness connection, each as ``{id, host}``."""
|
|
313
|
+
return cast(list[JsonObj], self._json(self._http.get("/runners")))
|
|
314
|
+
|
|
315
|
+
def get_runner(self, runner_id: str) -> JsonObj | None:
|
|
316
|
+
"""The registration details for a single live runner, or ``None`` if not connected."""
|
|
317
|
+
resp = self._http.get(f"/runners/{runner_id}")
|
|
318
|
+
if resp.status_code == 404:
|
|
319
|
+
return None
|
|
320
|
+
return cast(JsonObj, self._json(resp))
|
|
321
|
+
|
|
322
|
+
def reclaim_runner(self, runner_id: str) -> list[JsonObj]:
|
|
323
|
+
"""Release a (dead) runner's non-terminal claims so a healthy host respawns them."""
|
|
324
|
+
return cast(list[JsonObj], self._json(self._http.post(f"/runners/{runner_id}/reclaim")))
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""In-container code: the task-service client and the entrypoint protocol.
|
|
2
|
+
|
|
3
|
+
This is the *only* package permitted to call an LLM (the agent runs here) — the
|
|
4
|
+
determinism invariant exempts it. The entrypoint (``python -m panopticon.container``) runs the
|
|
5
|
+
real slug + held-liveness-connection protocol; the agent step is still a stay-alive
|
|
6
|
+
placeholder (no LLM yet), wired up in a later slice.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""The in-container **agent launcher** — what the runner's tmux pane runs.
|
|
2
|
+
|
|
3
|
+
It prepares the agent CLI's surface from the active workflow (skills + turn-flip hooks), then
|
|
4
|
+
`exec`s the agent. This is the only LLM-bearing path (the determinism invariant): the
|
|
5
|
+
**bootstrap** is deterministic and unit-tested with fakes; the **launch** (real `claude`) is
|
|
6
|
+
injectable and only runs for real in a `skipif`-gated integration / a live container — never in CI.
|
|
7
|
+
|
|
8
|
+
Auth is the ``CLAUDE_CODE_OAUTH_TOKEN`` env var the runner injects from the repo's ``env_file``;
|
|
9
|
+
the launcher does no credential wiring of its own.
|
|
10
|
+
|
|
11
|
+
The container's entrypoint (`python -m panopticon.container`) holds the liveness connection;
|
|
12
|
+
this runs alongside it in the tmux pane, so `tmux attach` reaches the live agent.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import signal
|
|
20
|
+
import subprocess
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from panopticon.client import TaskServiceClient
|
|
27
|
+
from panopticon.container.config import update_json_config
|
|
28
|
+
from panopticon.container.hooks import write_settings
|
|
29
|
+
from panopticon.container.skills import write_commands, write_operation_commands
|
|
30
|
+
from panopticon.core.models import Skill
|
|
31
|
+
|
|
32
|
+
#: claude's main config file. Holds (besides per-container state) per-project trust acceptance.
|
|
33
|
+
CONFIG_FILE = ".claude.json"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
#: Filename of the rendered MCP client config in the config dir; claude is pointed at it via
|
|
37
|
+
#: ``--mcp-config`` so it connects to the task service's MCP server (task operations as tools).
|
|
38
|
+
MCP_CONFIG_FILE = "panopticon-mcp.json"
|
|
39
|
+
#: Filename of the rendered workflow overview (the whole-lifecycle map); claude gets its contents in
|
|
40
|
+
#: the system prompt via ``--append-system-prompt`` so the agent always knows the workflow's shape.
|
|
41
|
+
WORKFLOW_OVERVIEW_FILE = "workflow-overview.md"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def render_skills(client: TaskServiceClient, task_id: str, home: Path) -> list[Path]:
|
|
45
|
+
"""Render the active workflow's skills to the agent CLI surface (`.claude/commands/`)."""
|
|
46
|
+
skills = [Skill(**s) for s in client.list_skills(task_id)]
|
|
47
|
+
return write_commands(skills, home, task_id)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render_operations(client: TaskServiceClient, task_id: str, home: Path) -> list[Path]:
|
|
51
|
+
"""Render the active workflow's available core operations (advance/drop/…) as slash-commands.
|
|
52
|
+
|
|
53
|
+
Reflects the *active workflow's* declared moves (ADR 0004), so a github-peer-reviewed and a
|
|
54
|
+
free-form container expose different operation commands — not a fixed global menu.
|
|
55
|
+
"""
|
|
56
|
+
return write_operation_commands(client.list_operations(task_id), home, task_id)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def write_mcp_config(config_dir: Path, service_url: str) -> Path:
|
|
60
|
+
"""Write claude's MCP client config so it connects to the task service's MCP server.
|
|
61
|
+
|
|
62
|
+
A single ``panopticon`` HTTP server at ``<service_url>/mcp`` — the same control plane the
|
|
63
|
+
container already polls (``PANOPTICON_SERVICE_URL``, the in-container view). Returns the path,
|
|
64
|
+
which the launcher passes to ``claude --mcp-config``."""
|
|
65
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
path = config_dir / MCP_CONFIG_FILE
|
|
67
|
+
server = {"type": "http", "url": f"{service_url.rstrip('/')}/mcp"}
|
|
68
|
+
path.write_text(json.dumps({"mcpServers": {"panopticon": server}}, indent=2))
|
|
69
|
+
return path
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def write_workflow_overview(config_dir: Path, overview: str) -> Path | None:
|
|
73
|
+
"""Write the whole-workflow map so the launcher can put it in claude's system prompt. Returns the
|
|
74
|
+
path, or ``None`` when there's no overview (skipped — the agent just gets the per-turn briefing)."""
|
|
75
|
+
if not overview.strip():
|
|
76
|
+
return None
|
|
77
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
path = config_dir / WORKFLOW_OVERVIEW_FILE
|
|
79
|
+
path.write_text(overview)
|
|
80
|
+
return path
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def trust_workspace(config_dir: Path, cwd: Path) -> Path:
|
|
84
|
+
"""Pre-accept claude's "Do you trust the files in this folder?" dialog for ``cwd``.
|
|
85
|
+
|
|
86
|
+
The trust dialog is **separate** from the permission prompts ``--dangerously-skip-permissions``
|
|
87
|
+
skips (cf. claude issue #45298): it blocks on startup until accepted, and there's no operator in
|
|
88
|
+
the container to accept it. claude records acceptance per-project in ``<config>/.claude.json``
|
|
89
|
+
under ``projects[<cwd>].hasTrustDialogAccepted``; we seed exactly that (and mark onboarding done,
|
|
90
|
+
the other first-run blocker). Merge-in-place so we don't clobber config claude writes itself, and
|
|
91
|
+
idempotent. The path encoding is
|
|
92
|
+
undocumented internals — a safe degradation if it ever drifts is that the dialog reappears, which
|
|
93
|
+
only matters in an (already attended) interactive re-attach.
|
|
94
|
+
"""
|
|
95
|
+
config = config_dir / CONFIG_FILE
|
|
96
|
+
with update_json_config(config) as data:
|
|
97
|
+
data["hasCompletedOnboarding"] = True
|
|
98
|
+
projects = data.setdefault("projects", {})
|
|
99
|
+
projects.setdefault(str(cwd), {})["hasTrustDialogAccepted"] = True
|
|
100
|
+
return config
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
#: Sent to claude as the first message when a container restarts mid-task on the agent's turn.
|
|
104
|
+
INTERRUPT_PROMPT = "You were interrupted. Continue."
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _claude_argv(
|
|
108
|
+
config_dir: Path,
|
|
109
|
+
cwd: Path,
|
|
110
|
+
*,
|
|
111
|
+
initial_prompt: str | None = None,
|
|
112
|
+
turn: str | None = None,
|
|
113
|
+
) -> list[str]:
|
|
114
|
+
"""`claude` argv, resuming the project's most recent conversation if one exists.
|
|
115
|
+
|
|
116
|
+
The agent runs unattended in a throwaway container on a per-task clone, so it launches with
|
|
117
|
+
``--dangerously-skip-permissions`` — there's no operator to answer permission prompts, and the
|
|
118
|
+
blast radius is the task's own checkout. claude keeps per-project transcripts under
|
|
119
|
+
``<config>/projects/<cwd with '/' → '-'>``; when one is there we ``--continue`` it instead of
|
|
120
|
+
starting fresh. The config dir is a **per-task volume** (the runner mounts it; ``CONFIG_MOUNT``),
|
|
121
|
+
so this resumes both within a container's life and **across respawn/recreate** — claude history
|
|
122
|
+
persists even though the container layer is thrown away. If our path encoding ever misses
|
|
123
|
+
claude's, we simply start fresh — a safe degradation.
|
|
124
|
+
|
|
125
|
+
On a **first run** (no prior session) with an ``initial_prompt``, the prompt is appended as a
|
|
126
|
+
positional argument so claude processes it immediately. On a **resumed session** (``--continue``)
|
|
127
|
+
the ``initial_prompt`` is omitted — the agent is already mid-task. When the resumed session is
|
|
128
|
+
the agent's turn (``turn == "agent"``), :data:`INTERRUPT_PROMPT` is appended instead so the
|
|
129
|
+
agent automatically picks up where it left off rather than waiting for user input.
|
|
130
|
+
"""
|
|
131
|
+
argv = ["claude", "--dangerously-skip-permissions"]
|
|
132
|
+
overview = config_dir / WORKFLOW_OVERVIEW_FILE
|
|
133
|
+
if overview.exists(): # the whole-workflow map → claude's system prompt (so it knows the shape)
|
|
134
|
+
argv += ["--append-system-prompt", overview.read_text()]
|
|
135
|
+
mcp_config = config_dir / MCP_CONFIG_FILE
|
|
136
|
+
if mcp_config.exists(): # connect to the task service's MCP server, and *only* it
|
|
137
|
+
argv += ["--mcp-config", str(mcp_config), "--strict-mcp-config"]
|
|
138
|
+
project = config_dir / "projects" / str(cwd).replace("/", "-")
|
|
139
|
+
if any(project.glob("*.jsonl")):
|
|
140
|
+
argv.append("--continue")
|
|
141
|
+
if turn == "agent":
|
|
142
|
+
argv.append(INTERRUPT_PROMPT) # positional: auto-resume after container restart
|
|
143
|
+
elif initial_prompt:
|
|
144
|
+
argv.append(initial_prompt) # positional: claude sends this as the agent's first message
|
|
145
|
+
return argv
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _run_claude(config_dir: Path) -> None: # pragma: no cover - real LLM; skipif-gated / live only
|
|
149
|
+
"""Run `claude` (resuming the session if any) in the foreground; return when it exits.
|
|
150
|
+
|
|
151
|
+
Unlike an ``exec``, this returns control to :func:`main` when claude exits, so it can stop the
|
|
152
|
+
container (the task → down → respawn). claude inherits this pane's TTY (it's the interactive
|
|
153
|
+
surface ``tmux attach`` reaches)."""
|
|
154
|
+
initial_prompt = os.environ.get("PANOPTICON_INITIAL_PROMPT") or None
|
|
155
|
+
turn = os.environ.get("PANOPTICON_TASK_TURN") or None
|
|
156
|
+
argv = _claude_argv(config_dir, Path.cwd(), initial_prompt=initial_prompt, turn=turn)
|
|
157
|
+
subprocess.run(argv, env={**os.environ, "CLAUDE_CONFIG_DIR": str(config_dir)})
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _stop_container() -> None: # pragma: no cover - signals the real container's PID 1
|
|
161
|
+
"""Stop the container by signalling the entrypoint (PID 1, the liveness connection). Both it and this
|
|
162
|
+
launcher run as the same unprivileged user, so the signal is permitted; PID 1 deregisters and
|
|
163
|
+
exits on SIGTERM, so the container stops → the task shows **down** → the operator respawns (`R`),
|
|
164
|
+
resuming from the per-task config volume."""
|
|
165
|
+
os.kill(1, signal.SIGTERM)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _default_client(service_url: str) -> TaskServiceClient:
|
|
169
|
+
return TaskServiceClient(httpx.Client(base_url=service_url))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def main(
|
|
173
|
+
*,
|
|
174
|
+
client_factory: Callable[[str], TaskServiceClient] = _default_client,
|
|
175
|
+
home: Path | None = None,
|
|
176
|
+
launch: Callable[[Path], None] = _run_claude,
|
|
177
|
+
on_exit: Callable[[], None] = _stop_container,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Bootstrap the agent CLI from the active workflow (skills + turn-flip hooks), run the agent,
|
|
180
|
+
then stop the container when it exits. The CLI config dir is a per-task volume
|
|
181
|
+
(`<home>/.claude`); auth comes from the ``CLAUDE_CODE_OAUTH_TOKEN`` env var the runner injects.
|
|
182
|
+
|
|
183
|
+
When the agent (claude) exits, ``on_exit`` stops the container so the task goes **down** rather
|
|
184
|
+
than lingering live-but-unconnectable — the operator respawns it with `R` (history resumes)."""
|
|
185
|
+
env = os.environ
|
|
186
|
+
service_url = env["PANOPTICON_SERVICE_URL"]
|
|
187
|
+
client = client_factory(service_url)
|
|
188
|
+
config_dir = (home or Path.home()) / ".claude"
|
|
189
|
+
task_id = env["PANOPTICON_TASK_ID"]
|
|
190
|
+
runner_id = env.get("PANOPTICON_RUNNER_ID")
|
|
191
|
+
if not env.get("CLAUDE_CODE_OAUTH_TOKEN") and not env.get("ANTHROPIC_API_KEY"):
|
|
192
|
+
if runner_id:
|
|
193
|
+
client.report_lifecycle(
|
|
194
|
+
task_id,
|
|
195
|
+
runner_id,
|
|
196
|
+
phase="failed",
|
|
197
|
+
detail="No auth token — set CLAUDE_CODE_OAUTH_TOKEN in the repo's env_file (see docs/container-auth.md)",
|
|
198
|
+
)
|
|
199
|
+
return
|
|
200
|
+
render_skills(client, task_id, config_dir.parent)
|
|
201
|
+
render_operations(client, task_id, config_dir.parent) # advance/drop/… as slash-commands
|
|
202
|
+
write_settings(config_dir.parent) # turn-flip hooks → <home>/.claude/settings.json
|
|
203
|
+
write_mcp_config(config_dir, service_url) # point claude at the task service's MCP server
|
|
204
|
+
write_workflow_overview(config_dir, client.workflow_overview(task_id)) # → system prompt (the map)
|
|
205
|
+
trust_workspace(config_dir, Path.cwd()) # pre-accept the trust dialog (no operator to)
|
|
206
|
+
launch(config_dir) # the agent runs until it exits...
|
|
207
|
+
on_exit() # ...then stop the container (task → down → respawn)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__": # pragma: no cover
|
|
211
|
+
main()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The single place we touch claude's on-disk JSON config (`.claude.json`, `.claude/settings.json`).
|
|
2
|
+
|
|
3
|
+
A **read-merge-write** so a caller states only the keys it cares about and never clobbers the
|
|
4
|
+
rest: load whatever's already there (or start empty), let the caller mutate it in the ``with``
|
|
5
|
+
block, then write it back with stable 2-space indentation on a clean exit. claude-specific, like
|
|
6
|
+
its callers (M3 revisits for other CLIs).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@contextmanager
|
|
19
|
+
def update_json_config(path: Path) -> Iterator[dict[str, Any]]:
|
|
20
|
+
"""Yield ``path``'s JSON (``{}`` if absent) to mutate in place, then write it back on clean exit.
|
|
21
|
+
|
|
22
|
+
Creates ``path``'s parent directory if needed, so callers needn't pre-create the config dir. If
|
|
23
|
+
the ``with`` block raises, the file is left untouched — no half-written config.
|
|
24
|
+
"""
|
|
25
|
+
data: dict[str, Any] = json.loads(path.read_text()) if path.exists() else {}
|
|
26
|
+
yield data
|
|
27
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
path.write_text(json.dumps(data, indent=2))
|