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.
Files changed (60) hide show
  1. panopticon/__init__.py +3 -0
  2. panopticon/client.py +324 -0
  3. panopticon/container/__init__.py +7 -0
  4. panopticon/container/__main__.py +8 -0
  5. panopticon/container/agent.py +211 -0
  6. panopticon/container/config.py +28 -0
  7. panopticon/container/entrypoint.py +143 -0
  8. panopticon/container/hook.py +177 -0
  9. panopticon/container/hooks.py +66 -0
  10. panopticon/container/pricing.py +67 -0
  11. panopticon/container/skills.py +68 -0
  12. panopticon/core/__init__.py +53 -0
  13. panopticon/core/artifacts.py +68 -0
  14. panopticon/core/git.py +109 -0
  15. panopticon/core/layers.py +25 -0
  16. panopticon/core/models.py +324 -0
  17. panopticon/core/provisioning.py +37 -0
  18. panopticon/core/state.py +107 -0
  19. panopticon/core/store.py +222 -0
  20. panopticon/core/workflow.py +546 -0
  21. panopticon/py.typed +0 -0
  22. panopticon/sessionservice/__init__.py +6 -0
  23. panopticon/sessionservice/__main__.py +70 -0
  24. panopticon/sessionservice/clones.py +65 -0
  25. panopticon/sessionservice/daemon.py +144 -0
  26. panopticon/sessionservice/host.py +237 -0
  27. panopticon/sessionservice/images.py +70 -0
  28. panopticon/sessionservice/local_runner.py +236 -0
  29. panopticon/sessionservice/provisioner.py +58 -0
  30. panopticon/sessionservice/runner.py +32 -0
  31. panopticon/sessionservice/spawn.py +54 -0
  32. panopticon/sessionservice/spawner.py +336 -0
  33. panopticon/sessionservice/stub_runner.py +44 -0
  34. panopticon/taskservice/__init__.py +5 -0
  35. panopticon/taskservice/__main__.py +86 -0
  36. panopticon/taskservice/api.py +761 -0
  37. panopticon/taskservice/artifacts_fs.py +88 -0
  38. panopticon/taskservice/layers_fs.py +39 -0
  39. panopticon/taskservice/mcp.py +168 -0
  40. panopticon/taskservice/service.py +764 -0
  41. panopticon/taskservice/store_sqlalchemy.py +394 -0
  42. panopticon/terminal/__init__.py +6 -0
  43. panopticon/terminal/__main__.py +75 -0
  44. panopticon/terminal/attach.py +21 -0
  45. panopticon/terminal/console.py +215 -0
  46. panopticon/terminal/dashboard.py +1646 -0
  47. panopticon/workflows/__init__.py +14 -0
  48. panopticon/workflows/discovery.py +71 -0
  49. panopticon/workflows/github_forge.py +198 -0
  50. panopticon/workflows/github_peer_reviewed.py +93 -0
  51. panopticon/workflows/github_self_reviewed.py +75 -0
  52. panopticon/workflows/local_git_self_reviewed.py +90 -0
  53. panopticon/workflows/orchestrator.py +132 -0
  54. panopticon/workflows/planned_workflow.py +73 -0
  55. panopticon/workflows/spike.py +37 -0
  56. panopticon_app-0.0.1.dist-info/METADATA +15 -0
  57. panopticon_app-0.0.1.dist-info/RECORD +60 -0
  58. panopticon_app-0.0.1.dist-info/WHEEL +4 -0
  59. panopticon_app-0.0.1.dist-info/entry_points.txt +2 -0
  60. panopticon_app-0.0.1.dist-info/licenses/LICENSE +26 -0
panopticon/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """panopticon — keep an eye on your agents."""
2
+
3
+ __version__ = "0.0.1"
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,8 @@
1
+ """``python -m panopticon.container`` — the task container's entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from panopticon.container.entrypoint import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
@@ -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))