tsugite-cc-driver 0.18.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tsugite_cc_driver-0.18.0/.gitignore +184 -0
- tsugite_cc_driver-0.18.0/PKG-INFO +9 -0
- tsugite_cc_driver-0.18.0/pyproject.toml +32 -0
- tsugite_cc_driver-0.18.0/tsugite_cc_driver/__init__.py +7 -0
- tsugite_cc_driver-0.18.0/tsugite_cc_driver/adapter.py +204 -0
- tsugite_cc_driver-0.18.0/tsugite_cc_driver/executor.py +478 -0
- tsugite_cc_driver-0.18.0/tsugite_cc_driver/hooks.py +128 -0
- tsugite_cc_driver-0.18.0/tsugite_cc_driver/settings.py +40 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# ---> Python
|
|
2
|
+
# Byte-compiled / optimized / DLL files
|
|
3
|
+
__pycache__/
|
|
4
|
+
*.py[cod]
|
|
5
|
+
*$py.class
|
|
6
|
+
|
|
7
|
+
# C extensions
|
|
8
|
+
*.so
|
|
9
|
+
|
|
10
|
+
# Distribution / packaging
|
|
11
|
+
.Python
|
|
12
|
+
build/
|
|
13
|
+
develop-eggs/
|
|
14
|
+
dist/
|
|
15
|
+
downloads/
|
|
16
|
+
eggs/
|
|
17
|
+
.eggs/
|
|
18
|
+
lib/
|
|
19
|
+
lib64/
|
|
20
|
+
parts/
|
|
21
|
+
sdist/
|
|
22
|
+
var/
|
|
23
|
+
wheels/
|
|
24
|
+
share/python-wheels/
|
|
25
|
+
*.egg-info/
|
|
26
|
+
.installed.cfg
|
|
27
|
+
*.egg
|
|
28
|
+
MANIFEST
|
|
29
|
+
|
|
30
|
+
# PyInstaller
|
|
31
|
+
# Usually these files are written by a python script from a template
|
|
32
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
33
|
+
*.manifest
|
|
34
|
+
*.spec
|
|
35
|
+
|
|
36
|
+
# Installer logs
|
|
37
|
+
pip-log.txt
|
|
38
|
+
pip-delete-this-directory.txt
|
|
39
|
+
|
|
40
|
+
# Unit test / coverage reports
|
|
41
|
+
htmlcov/
|
|
42
|
+
.tox/
|
|
43
|
+
.nox/
|
|
44
|
+
.coverage
|
|
45
|
+
.coverage.*
|
|
46
|
+
.cache
|
|
47
|
+
nosetests.xml
|
|
48
|
+
coverage.xml
|
|
49
|
+
*.cover
|
|
50
|
+
*.py,cover
|
|
51
|
+
.hypothesis/
|
|
52
|
+
.pytest_cache/
|
|
53
|
+
cover/
|
|
54
|
+
|
|
55
|
+
# Translations
|
|
56
|
+
*.mo
|
|
57
|
+
*.pot
|
|
58
|
+
|
|
59
|
+
# Django stuff:
|
|
60
|
+
*.log
|
|
61
|
+
local_settings.py
|
|
62
|
+
db.sqlite3
|
|
63
|
+
db.sqlite3-journal
|
|
64
|
+
|
|
65
|
+
# Flask stuff:
|
|
66
|
+
instance/
|
|
67
|
+
.webassets-cache
|
|
68
|
+
|
|
69
|
+
# Scrapy stuff:
|
|
70
|
+
.scrapy
|
|
71
|
+
|
|
72
|
+
# Sphinx documentation
|
|
73
|
+
docs/_build/
|
|
74
|
+
|
|
75
|
+
# PyBuilder
|
|
76
|
+
.pybuilder/
|
|
77
|
+
target/
|
|
78
|
+
|
|
79
|
+
# Jupyter Notebook
|
|
80
|
+
.ipynb_checkpoints
|
|
81
|
+
|
|
82
|
+
# IPython
|
|
83
|
+
profile_default/
|
|
84
|
+
ipython_config.py
|
|
85
|
+
|
|
86
|
+
# pyenv
|
|
87
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
88
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
89
|
+
# .python-version
|
|
90
|
+
|
|
91
|
+
# pipenv
|
|
92
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
93
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
94
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
95
|
+
# install all needed dependencies.
|
|
96
|
+
#Pipfile.lock
|
|
97
|
+
|
|
98
|
+
# poetry
|
|
99
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
100
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
101
|
+
# commonly ignored for libraries.
|
|
102
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
103
|
+
#poetry.lock
|
|
104
|
+
|
|
105
|
+
# pdm
|
|
106
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
107
|
+
#pdm.lock
|
|
108
|
+
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
109
|
+
# in version control.
|
|
110
|
+
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
111
|
+
.pdm.toml
|
|
112
|
+
.pdm-python
|
|
113
|
+
.pdm-build/
|
|
114
|
+
|
|
115
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
116
|
+
__pypackages__/
|
|
117
|
+
|
|
118
|
+
# Celery stuff
|
|
119
|
+
celerybeat-schedule
|
|
120
|
+
celerybeat.pid
|
|
121
|
+
|
|
122
|
+
# SageMath parsed files
|
|
123
|
+
*.sage.py
|
|
124
|
+
|
|
125
|
+
# Environments
|
|
126
|
+
.env
|
|
127
|
+
.venv
|
|
128
|
+
env/
|
|
129
|
+
venv/
|
|
130
|
+
ENV/
|
|
131
|
+
env.bak/
|
|
132
|
+
venv.bak/
|
|
133
|
+
|
|
134
|
+
# Spyder project settings
|
|
135
|
+
.spyderproject
|
|
136
|
+
.spyproject
|
|
137
|
+
|
|
138
|
+
# Rope project settings
|
|
139
|
+
.ropeproject
|
|
140
|
+
|
|
141
|
+
# mkdocs documentation
|
|
142
|
+
/site
|
|
143
|
+
|
|
144
|
+
# mypy
|
|
145
|
+
.mypy_cache/
|
|
146
|
+
.dmypy.json
|
|
147
|
+
dmypy.json
|
|
148
|
+
|
|
149
|
+
# Pyre type checker
|
|
150
|
+
.pyre/
|
|
151
|
+
|
|
152
|
+
# pytype static type analyzer
|
|
153
|
+
.pytype/
|
|
154
|
+
|
|
155
|
+
# Cython debug symbols
|
|
156
|
+
cython_debug/
|
|
157
|
+
|
|
158
|
+
# PyCharm
|
|
159
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
160
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
161
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
162
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
163
|
+
#.idea/
|
|
164
|
+
|
|
165
|
+
.env
|
|
166
|
+
.env
|
|
167
|
+
benchmark_results/
|
|
168
|
+
test_output/
|
|
169
|
+
.claude/settings.local.json
|
|
170
|
+
std*.txt
|
|
171
|
+
secrets/*
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# TODO: temp - I need to clean up the docs
|
|
175
|
+
docs-old/
|
|
176
|
+
docs/design/
|
|
177
|
+
examples/*
|
|
178
|
+
!examples/tsugite-example-plugin/
|
|
179
|
+
agents/
|
|
180
|
+
.claude/
|
|
181
|
+
.tsugite/
|
|
182
|
+
benchmarks/
|
|
183
|
+
docker-compose.test.yml
|
|
184
|
+
#### TODO ^^^
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tsugite-cc-driver
|
|
3
|
+
Version: 0.18.0
|
|
4
|
+
Summary: Tsugite plugin: drive interactive Claude Code sessions as verified jobs via HTTP hooks (daemon-only)
|
|
5
|
+
Author: Justyn Shull
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: tsugite-cli==0.18.0
|
|
8
|
+
Requires-Dist: tsugite-daemon==0.18.0
|
|
9
|
+
Requires-Dist: tsugite-pty==0.18.0
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tsugite-cc-driver"
|
|
3
|
+
version = "0.18.0"
|
|
4
|
+
description = "Tsugite plugin: drive interactive Claude Code sessions as verified jobs via HTTP hooks (daemon-only)"
|
|
5
|
+
authors = [{ name = "Justyn Shull" }]
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"tsugite-cli==0.18.0",
|
|
9
|
+
"tsugite-daemon==0.18.0",
|
|
10
|
+
"tsugite-pty==0.18.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.entry-points."tsugite.adapters"]
|
|
14
|
+
cc_driver = "tsugite_cc_driver.adapter:create_adapter"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["hatchling"]
|
|
18
|
+
build-backend = "hatchling.build"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["tsugite_cc_driver"]
|
|
22
|
+
|
|
23
|
+
[tool.hatch.build.targets.sdist]
|
|
24
|
+
include = [
|
|
25
|
+
"/tsugite_cc_driver",
|
|
26
|
+
"/pyproject.toml",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.uv.sources]
|
|
30
|
+
tsugite-cli = { workspace = true }
|
|
31
|
+
tsugite-daemon = { workspace = true }
|
|
32
|
+
tsugite-pty = { workspace = true }
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""tsugite-cc-driver: run interactive Claude Code sessions as verified tsugite jobs.
|
|
2
|
+
|
|
3
|
+
A job with executor="cc" is driven by an interactive `claude` process in a PTY.
|
|
4
|
+
HTTP Stop hooks decide whether the attempt is finished (completion marker or
|
|
5
|
+
continue-budget exhausted) or should be nudged onward; completion routes into the
|
|
6
|
+
existing job verifier/AC/retry machinery. See README.md.
|
|
7
|
+
"""
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""CCDriverAdapter: the adapter-plugin entry point.
|
|
2
|
+
|
|
3
|
+
Exposes ONE public HTTP route (the per-job hook receiver, token-in-path, no daemon
|
|
4
|
+
auth) and ONE job executor ("cc"). The gateway mounts the route under
|
|
5
|
+
/api/plugins/cc_driver and registers the executor on the jobs orchestrator, handing
|
|
6
|
+
this adapter the orchestrator via set_jobs_orchestrator so the hook route can call
|
|
7
|
+
complete_worker/fail_worker.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
from starlette.requests import Request
|
|
18
|
+
from starlette.responses import JSONResponse
|
|
19
|
+
from starlette.routing import Route
|
|
20
|
+
from tsugite_daemon.adapters.base import BaseAdapter
|
|
21
|
+
from tsugite_daemon.job_store import JobState
|
|
22
|
+
|
|
23
|
+
from tsugite_cc_driver.executor import CCExecutor, DriveStateStore
|
|
24
|
+
from tsugite_cc_driver.hooks import decide_stop, decide_stop_failure, notification_attention
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CCDriverConfig(BaseModel):
|
|
30
|
+
enabled: bool = False
|
|
31
|
+
claude_binary: str = "claude"
|
|
32
|
+
# Default model the driven claude runs with (alias sonnet/opus/haiku or a full
|
|
33
|
+
# name). A per-job `model` overrides it; set to null to use claude's own default.
|
|
34
|
+
model: Optional[str] = "sonnet"
|
|
35
|
+
permission_mode: str = "bypassPermissions"
|
|
36
|
+
# Autonomous default is sandboxed: filesystem isolation pairs with the
|
|
37
|
+
# bypass/skip-permissions trust workaround so a driven claude can't write
|
|
38
|
+
# outside the job workspace. Network stays on (it needs the API).
|
|
39
|
+
sandbox: bool = True
|
|
40
|
+
# Auto-provision Claude Code trust for a job's workspace (writes
|
|
41
|
+
# projects[cwd].hasTrustDialogAccepted into ~/.claude.json) so cc jobs are
|
|
42
|
+
# self-service. Off = require the user to pre-trust each workspace once.
|
|
43
|
+
provision_trust: bool = True
|
|
44
|
+
max_consecutive_continues: int = 5
|
|
45
|
+
completion_marker: str = "CCDRIVER_GOAL_COMPLETE"
|
|
46
|
+
# Marker the driven claude emits when it is blocked on supervisor input;
|
|
47
|
+
# pauses the job in awaiting_input instead of nudging it to guess.
|
|
48
|
+
needs_input_marker: str = "CCDRIVER_NEED_INPUT"
|
|
49
|
+
# Opt-in: render flat, screen-reader-friendly PTY output (no decorative
|
|
50
|
+
# borders or animations) via --ax-screen-reader. Cleaner failure-tail capture
|
|
51
|
+
# and fewer xterm escape edge cases, but off by default so the live terminal
|
|
52
|
+
# viewer keeps the normal TUI; the real state signal is the hook markers.
|
|
53
|
+
ax_screen_reader: bool = False
|
|
54
|
+
# Default reasoning effort (low/medium/high/xhigh/max) for the driven claude;
|
|
55
|
+
# a per-job `effort` overrides it, None uses claude's own default.
|
|
56
|
+
effort: Optional[str] = None
|
|
57
|
+
base_url: str = "http://127.0.0.1:8374"
|
|
58
|
+
state_dir: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_state_dir(state_dir: Optional[str]) -> str:
|
|
62
|
+
"""Where per-job settings.json files live. Defaults to the XDG data path, same
|
|
63
|
+
as every other tsugite subsystem (see tsugite.config.get_xdg_data_path)."""
|
|
64
|
+
if state_dir:
|
|
65
|
+
return str(Path(state_dir).expanduser())
|
|
66
|
+
from tsugite.config import get_xdg_data_path
|
|
67
|
+
|
|
68
|
+
return str(get_xdg_data_path("cc-driver"))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CCDriverAdapter(BaseAdapter):
|
|
72
|
+
"""Not tied to a single agent - it's a job executor + hook receiver, so it
|
|
73
|
+
skips BaseAdapter's agent/workspace init and sets only what the gateway wiring
|
|
74
|
+
touches."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, config: CCDriverConfig, session_store=None, identity_map=None):
|
|
77
|
+
self.agent_name = "cc_driver"
|
|
78
|
+
self.session_store = session_store
|
|
79
|
+
self._identity_map = identity_map or {}
|
|
80
|
+
self.event_bus = None # set by attach_plugin_http
|
|
81
|
+
self.config = config
|
|
82
|
+
self.config.state_dir = _resolve_state_dir(config.state_dir)
|
|
83
|
+
self._drive_state = DriveStateStore()
|
|
84
|
+
self._executor = CCExecutor(self.config, self._drive_state)
|
|
85
|
+
self._orchestrator = None
|
|
86
|
+
|
|
87
|
+
# ── plugin wiring ──
|
|
88
|
+
|
|
89
|
+
def get_public_http_routes(self) -> list:
|
|
90
|
+
# Unauthenticated: Claude Code can't send a daemon bearer token. Access is
|
|
91
|
+
# gated by the random per-job token in the path (unknown token -> 404).
|
|
92
|
+
return [Route("/hook/{token}", self._hook, methods=["POST"])]
|
|
93
|
+
|
|
94
|
+
def get_job_executors(self) -> dict:
|
|
95
|
+
return {"cc": self._executor}
|
|
96
|
+
|
|
97
|
+
def set_jobs_orchestrator(self, orchestrator) -> None:
|
|
98
|
+
"""Injected by the gateway at executor registration so the hook route and
|
|
99
|
+
the executor can report job outcomes."""
|
|
100
|
+
self._orchestrator = orchestrator
|
|
101
|
+
self._executor.orchestrator = orchestrator
|
|
102
|
+
|
|
103
|
+
async def start(self) -> None: # BaseAdapter contract - nothing to run
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
async def stop(self) -> None:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
# ── hook receiver ──
|
|
110
|
+
|
|
111
|
+
async def _hook(self, request: Request) -> JSONResponse:
|
|
112
|
+
token = request.path_params["token"]
|
|
113
|
+
state = self._drive_state.by_token(token)
|
|
114
|
+
if state is None:
|
|
115
|
+
# Orphaned/stale claude (daemon restarted, job gone) -> 404 so it just stops.
|
|
116
|
+
return JSONResponse({"error": "unknown job token"}, status_code=404)
|
|
117
|
+
|
|
118
|
+
job_id = state.job_id
|
|
119
|
+
orch = self._orchestrator
|
|
120
|
+
job = orch.get_job(job_id) if orch is not None else None
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
payload = await request.json()
|
|
124
|
+
except Exception:
|
|
125
|
+
payload = {}
|
|
126
|
+
|
|
127
|
+
if job is not None and job.state == JobState.AWAITING_INPUT.value and payload.get("hook_event_name") == "Stop":
|
|
128
|
+
# A Stop while paused means someone answered directly in the live
|
|
129
|
+
# TUI (claude only runs a turn when typed at). Resume the job and
|
|
130
|
+
# grade the turn normally instead of dropping it - otherwise a
|
|
131
|
+
# human take-over could never re-grade the attempt.
|
|
132
|
+
job = await orch.resume_worker(job_id)
|
|
133
|
+
|
|
134
|
+
if job is None or job.state != JobState.RUNNING.value:
|
|
135
|
+
# Not RUNNING (verifying/terminal): allow claude to stop; the verifier
|
|
136
|
+
# or a retry drives the next attempt.
|
|
137
|
+
return JSONResponse({})
|
|
138
|
+
|
|
139
|
+
# Every payload carries the session id; record it so a respawn can --resume.
|
|
140
|
+
if payload.get("session_id"):
|
|
141
|
+
state.cc_session_id = payload["session_id"]
|
|
142
|
+
|
|
143
|
+
event = payload.get("hook_event_name")
|
|
144
|
+
if event == "Stop":
|
|
145
|
+
if state.attention_flagged:
|
|
146
|
+
# Claude finished a turn, so the permission prompt that set the
|
|
147
|
+
# flag was answered - tell the UI to drop its persistent marker.
|
|
148
|
+
state.attention_flagged = False
|
|
149
|
+
self._emit("attention_cleared", {"job_id": job_id, "parent_session_id": job.parent_session_id})
|
|
150
|
+
decision = decide_stop(
|
|
151
|
+
payload,
|
|
152
|
+
consecutive_continues=state.consecutive_continues,
|
|
153
|
+
max_consecutive_continues=self.config.max_consecutive_continues,
|
|
154
|
+
completion_marker=self.config.completion_marker,
|
|
155
|
+
needs_input_marker=self.config.needs_input_marker,
|
|
156
|
+
)
|
|
157
|
+
state.consecutive_continues = decision.new_consecutive_continues
|
|
158
|
+
if decision.complete:
|
|
159
|
+
await orch.complete_worker(job_id, decision.summary or "")
|
|
160
|
+
elif decision.needs_input:
|
|
161
|
+
await orch.pause_worker(job_id, decision.needs_input)
|
|
162
|
+
return JSONResponse(decision.response)
|
|
163
|
+
|
|
164
|
+
if event == "StopFailure":
|
|
165
|
+
await orch.fail_worker(job_id, decide_stop_failure(payload))
|
|
166
|
+
return JSONResponse({})
|
|
167
|
+
|
|
168
|
+
if event == "Notification":
|
|
169
|
+
message = notification_attention(payload)
|
|
170
|
+
if message:
|
|
171
|
+
state.attention_flagged = True
|
|
172
|
+
self._emit(
|
|
173
|
+
"needs_attention",
|
|
174
|
+
{"job_id": job_id, "parent_session_id": job.parent_session_id, "message": message},
|
|
175
|
+
)
|
|
176
|
+
return JSONResponse({})
|
|
177
|
+
|
|
178
|
+
# Unknown/other events: acknowledge without driving.
|
|
179
|
+
return JSONResponse({})
|
|
180
|
+
|
|
181
|
+
def _emit(self, event: str, payload: dict) -> None:
|
|
182
|
+
if self.event_bus is None:
|
|
183
|
+
return
|
|
184
|
+
try:
|
|
185
|
+
self.event_bus.emit(event, payload)
|
|
186
|
+
except Exception:
|
|
187
|
+
logger.debug("cc-driver: %s emit failed for job '%s'", event, payload.get("job_id"))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def create_adapter(*, config, agents_config, session_store, identity_map):
|
|
191
|
+
"""Adapter-plugin factory (tsugite.adapters entry point).
|
|
192
|
+
|
|
193
|
+
`config` is the daemon.yaml plugins.cc_driver dict; agents_config is unused
|
|
194
|
+
(cc-driver is not agent-scoped).
|
|
195
|
+
|
|
196
|
+
Returns None (the gateway loop skips it) unless `enabled` is truthy - cc-driver
|
|
197
|
+
spawns claude with skip-permissions, so it must be explicit opt-in rather than
|
|
198
|
+
silently activated just by being installed.
|
|
199
|
+
"""
|
|
200
|
+
cfg = CCDriverConfig(**(config or {}))
|
|
201
|
+
if not cfg.enabled:
|
|
202
|
+
logger.info("cc-driver plugin installed but disabled (set plugins.cc_driver.enabled: true to activate)")
|
|
203
|
+
return None
|
|
204
|
+
return CCDriverAdapter(cfg, session_store=session_store, identity_map=identity_map)
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""CCExecutor: the non-agent job executor that drives an interactive `claude`.
|
|
2
|
+
|
|
3
|
+
Implements the orchestrator's executor contract (async start/cancel) and reports
|
|
4
|
+
outcomes via complete_worker/fail_worker (called from the hook route in adapter.py
|
|
5
|
+
and, on an unexpected PTY exit, from here).
|
|
6
|
+
|
|
7
|
+
DriveState is in-memory per active job; it dies with the daemon (orphaned jobs are
|
|
8
|
+
recovered as ERRORED by the existing restart machinery - no new persistence).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import shlex
|
|
19
|
+
import shutil
|
|
20
|
+
import tempfile
|
|
21
|
+
import threading
|
|
22
|
+
import traceback
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from tsugite_cc_driver.hooks import build_initial_prompt
|
|
28
|
+
from tsugite_cc_driver.settings import build_settings, cleanup, write_run_settings
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class DriveState:
|
|
35
|
+
"""In-memory driving state for one active cc job."""
|
|
36
|
+
|
|
37
|
+
job_id: str
|
|
38
|
+
token: str
|
|
39
|
+
terminal_id: Optional[str] = None
|
|
40
|
+
cc_session_id: Optional[str] = None
|
|
41
|
+
consecutive_continues: int = 0
|
|
42
|
+
# True while an un-actioned permission prompt is (probably) on screen: set
|
|
43
|
+
# on a permission_prompt Notification, cleared on the next Stop (claude
|
|
44
|
+
# finishing a turn proves the prompt was answered). Drives the UI's
|
|
45
|
+
# persistent needs-your-input marker via needs_attention/attention_cleared.
|
|
46
|
+
attention_flagged: bool = False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DriveStateStore:
|
|
50
|
+
"""Keyed by job_id, with a token index for the public hook route."""
|
|
51
|
+
|
|
52
|
+
def __init__(self):
|
|
53
|
+
self._by_job: dict[str, DriveState] = {}
|
|
54
|
+
self._token_to_job: dict[str, str] = {}
|
|
55
|
+
|
|
56
|
+
def create(self, job_id: str, token: str) -> DriveState:
|
|
57
|
+
state = DriveState(job_id=job_id, token=token)
|
|
58
|
+
self._by_job[job_id] = state
|
|
59
|
+
self._token_to_job[token] = job_id
|
|
60
|
+
return state
|
|
61
|
+
|
|
62
|
+
def get(self, job_id: str) -> Optional[DriveState]:
|
|
63
|
+
return self._by_job.get(job_id)
|
|
64
|
+
|
|
65
|
+
def by_token(self, token: str) -> Optional[DriveState]:
|
|
66
|
+
job_id = self._token_to_job.get(token)
|
|
67
|
+
return self._by_job.get(job_id) if job_id else None
|
|
68
|
+
|
|
69
|
+
def remove(self, job_id: str) -> None:
|
|
70
|
+
state = self._by_job.pop(job_id, None)
|
|
71
|
+
if state is not None:
|
|
72
|
+
self._token_to_job.pop(state.token, None)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_EXIT_CODE_HINTS = {
|
|
76
|
+
126: "claude binary is not executable",
|
|
77
|
+
127: "claude binary not found (PATH, or missing from the sandbox binds)",
|
|
78
|
+
130: "interrupted (SIGINT)",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# ANSI escape sequences (CSI colors/cursor, OSC title) to strip from a captured tail.
|
|
82
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def describe_exit(exit_code) -> str:
|
|
86
|
+
hint = _EXIT_CODE_HINTS.get(exit_code)
|
|
87
|
+
return f"code {exit_code} - {hint}" if hint else f"code {exit_code}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def pty_tail(buffer: bytes, *, max_bytes: int = 4096, max_lines: int = 40) -> Optional[str]:
|
|
91
|
+
"""ANSI-stripped, blank-trimmed tail of a PTY buffer, or None if empty."""
|
|
92
|
+
text = bytes(buffer)[-max_bytes:].decode("utf-8", errors="replace")
|
|
93
|
+
lines = [ln.rstrip() for ln in _ANSI_RE.sub("", text).splitlines() if ln.strip()]
|
|
94
|
+
return "\n".join(lines[-max_lines:]) or None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_claude_command(
|
|
98
|
+
claude_binary: str,
|
|
99
|
+
settings_path: str,
|
|
100
|
+
permission_mode: str,
|
|
101
|
+
prompt: str,
|
|
102
|
+
*,
|
|
103
|
+
model: Optional[str] = None,
|
|
104
|
+
resume_session_id: Optional[str] = None,
|
|
105
|
+
effort: Optional[str] = None,
|
|
106
|
+
ax_screen_reader: bool = False,
|
|
107
|
+
) -> str:
|
|
108
|
+
"""Build the shell command for spawn_terminal (which wraps it in `sh -c`).
|
|
109
|
+
|
|
110
|
+
Everything is shlex.quoted so a goal/followup containing quotes or newlines
|
|
111
|
+
can't break out of the argument.
|
|
112
|
+
"""
|
|
113
|
+
parts = [claude_binary, "--settings", str(settings_path), "--permission-mode", permission_mode]
|
|
114
|
+
if model:
|
|
115
|
+
parts += ["--model", model]
|
|
116
|
+
if effort:
|
|
117
|
+
parts += ["--effort", effort]
|
|
118
|
+
if ax_screen_reader:
|
|
119
|
+
parts.append("--ax-screen-reader")
|
|
120
|
+
if resume_session_id:
|
|
121
|
+
parts += ["--resume", resume_session_id]
|
|
122
|
+
parts.append(prompt)
|
|
123
|
+
return shlex.join(parts)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Serializes the ~/.claude.json read-modify-write so two concurrent trust
|
|
127
|
+
# provisions can't clobber each other's merge.
|
|
128
|
+
_trust_config_lock = threading.Lock()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _default_trust_config_path() -> Path:
|
|
132
|
+
base = os.environ.get("CLAUDE_CONFIG_DIR") or os.path.expanduser("~")
|
|
133
|
+
return Path(base) / ".claude.json"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def is_workspace_trusted(cwd, config_path=None) -> bool:
|
|
137
|
+
"""Whether Claude Code already trusts `cwd`, so an unattended spawn won't hang
|
|
138
|
+
on the fresh-cwd "Is this a project you trust?" dialog.
|
|
139
|
+
|
|
140
|
+
A dir is trusted when itself OR any ancestor has
|
|
141
|
+
projects["<abs path>"].hasTrustDialogAccepted == true in the config Claude
|
|
142
|
+
reads. Claude Code treats a subdir of a trusted project as trusted (verified
|
|
143
|
+
against claude 2.1.207) - a `--repo` Job's worker runs in a fresh worktree at
|
|
144
|
+
<repo>/.tsugite-jobs/<id> whose exact path is never trusted, so the ancestor
|
|
145
|
+
check is what lets those jobs launch.
|
|
146
|
+
|
|
147
|
+
config_path defaults to <CLAUDE_CONFIG_DIR>/.claude.json (or ~/.claude.json).
|
|
148
|
+
Tolerant: a missing / unreadable / malformed config means "not trusted".
|
|
149
|
+
"""
|
|
150
|
+
if config_path is None:
|
|
151
|
+
config_path = _default_trust_config_path()
|
|
152
|
+
try:
|
|
153
|
+
data = json.loads(Path(config_path).read_text())
|
|
154
|
+
except Exception:
|
|
155
|
+
return False
|
|
156
|
+
if not isinstance(data, dict):
|
|
157
|
+
return False
|
|
158
|
+
projects = data.get("projects") or {}
|
|
159
|
+
resolved = Path(cwd).resolve()
|
|
160
|
+
for path in (resolved, *resolved.parents):
|
|
161
|
+
project = projects.get(str(path))
|
|
162
|
+
if isinstance(project, dict) and project.get("hasTrustDialogAccepted") is True:
|
|
163
|
+
return True
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def ensure_workspace_trusted(cwd, config_path=None) -> bool:
|
|
168
|
+
"""Provision Claude Code trust for `cwd` by setting
|
|
169
|
+
projects["<abs cwd>"].hasTrustDialogAccepted = true in the config Claude
|
|
170
|
+
reads, so an unattended spawn skips the trust dialog (a minimal entry with
|
|
171
|
+
just this flag is enough - verified against claude 2.1.207). Returns True on
|
|
172
|
+
success (including when already trusted), False if the config couldn't be
|
|
173
|
+
provisioned.
|
|
174
|
+
|
|
175
|
+
Read-modify-write, preserving every other key and project entry, then an
|
|
176
|
+
atomic replace so a crash mid-write can't truncate the config. A malformed
|
|
177
|
+
existing config is left untouched (we won't destroy recoverable user data);
|
|
178
|
+
a missing config is created. Serialized across concurrent cc spawns via
|
|
179
|
+
_trust_config_lock. claude itself also writes this file at runtime; the
|
|
180
|
+
provision happens before we spawn claude for this job, so it can't race our
|
|
181
|
+
own worker.
|
|
182
|
+
"""
|
|
183
|
+
if config_path is None:
|
|
184
|
+
config_path = _default_trust_config_path()
|
|
185
|
+
config_path = Path(config_path)
|
|
186
|
+
resolved = str(Path(cwd).resolve())
|
|
187
|
+
with _trust_config_lock:
|
|
188
|
+
if config_path.exists():
|
|
189
|
+
try:
|
|
190
|
+
data = json.loads(config_path.read_text())
|
|
191
|
+
except Exception:
|
|
192
|
+
logger.warning("cc-driver: %s is not valid JSON; not provisioning trust for %s", config_path, cwd)
|
|
193
|
+
return False
|
|
194
|
+
if not isinstance(data, dict):
|
|
195
|
+
logger.warning("cc-driver: %s is not a JSON object; not provisioning trust for %s", config_path, cwd)
|
|
196
|
+
return False
|
|
197
|
+
else:
|
|
198
|
+
data = {}
|
|
199
|
+
projects = data.setdefault("projects", {})
|
|
200
|
+
if not isinstance(projects, dict):
|
|
201
|
+
logger.warning("cc-driver: %s 'projects' is not an object; not provisioning trust", config_path)
|
|
202
|
+
return False
|
|
203
|
+
entry = projects.setdefault(resolved, {})
|
|
204
|
+
if not isinstance(entry, dict):
|
|
205
|
+
entry = projects[resolved] = {}
|
|
206
|
+
entry["hasTrustDialogAccepted"] = True
|
|
207
|
+
try:
|
|
208
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
fd, tmp = tempfile.mkstemp(dir=str(config_path.parent), prefix=".claude.json.")
|
|
210
|
+
with os.fdopen(fd, "w") as f:
|
|
211
|
+
json.dump(data, f)
|
|
212
|
+
os.replace(tmp, config_path)
|
|
213
|
+
except Exception:
|
|
214
|
+
logger.exception("cc-driver: failed to write trust for %s into %s", cwd, config_path)
|
|
215
|
+
return False
|
|
216
|
+
logger.info("cc-driver: provisioned Claude Code trust for %s", resolved)
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def trust_provision_target(cwd) -> Path:
|
|
221
|
+
"""The path to provision trust for when `cwd` needs it: the main repo root
|
|
222
|
+
when cwd is a linked git worktree living under it (tsugite's --repo
|
|
223
|
+
worktrees are <repo>/.tsugite-jobs/<id>), else cwd itself.
|
|
224
|
+
|
|
225
|
+
Trusting the repo root instead of the ephemeral worktree keeps
|
|
226
|
+
~/.claude.json from accumulating one dead entry per --repo job - the
|
|
227
|
+
ancestor rule then covers every future worktree under that root. An
|
|
228
|
+
external worktree (outside the repo root) still gets its own entry, since
|
|
229
|
+
the ancestor rule wouldn't reach it.
|
|
230
|
+
|
|
231
|
+
A linked worktree's `.git` is a *file* containing
|
|
232
|
+
`gitdir: <repo>/.git/worktrees/<name>`; anything else means cwd is not a
|
|
233
|
+
worktree.
|
|
234
|
+
"""
|
|
235
|
+
cwd = Path(cwd).resolve()
|
|
236
|
+
gitfile = cwd / ".git"
|
|
237
|
+
if not gitfile.is_file():
|
|
238
|
+
return cwd
|
|
239
|
+
try:
|
|
240
|
+
m = re.match(r"gitdir:\s*(.+)", gitfile.read_text().strip())
|
|
241
|
+
except OSError:
|
|
242
|
+
return cwd
|
|
243
|
+
if m is None:
|
|
244
|
+
return cwd
|
|
245
|
+
gitdir = Path(m.group(1))
|
|
246
|
+
if not gitdir.is_absolute():
|
|
247
|
+
gitdir = cwd / gitdir
|
|
248
|
+
gitdir = gitdir.resolve()
|
|
249
|
+
if gitdir.parent.name != "worktrees" or gitdir.parent.parent.name != ".git":
|
|
250
|
+
return cwd
|
|
251
|
+
root = gitdir.parent.parent.parent
|
|
252
|
+
return root if cwd.is_relative_to(root) else cwd
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def build_sandbox_ctx(sandbox: bool, cwd: Optional[str], *, claude_binary: str = "claude", settings_dir=None):
|
|
256
|
+
"""Sandbox policy for a cc job: None (unsandboxed) unless `sandbox` is set.
|
|
257
|
+
|
|
258
|
+
When set: filesystem isolation to cwd, network ON (no_network=False - the
|
|
259
|
+
driven claude needs the API), and ~/.claude bound rw so credentials refresh.
|
|
260
|
+
|
|
261
|
+
bwrap does --clearenv and mounts only a fixed set of dirs (/usr /lib /bin
|
|
262
|
+
/sbin, the venv, the workspace, and our extra binds), so we MUST also bind
|
|
263
|
+
everything the driven claude touches by absolute path - each one, if missing
|
|
264
|
+
from the jail, is a hard failure at launch:
|
|
265
|
+
- the claude binary's dirs, or PATH points at a file that isn't there and
|
|
266
|
+
claude dies with `claude: command not found` (exit 127);
|
|
267
|
+
- the per-job settings dir holding settings.json, or `--settings <path>`
|
|
268
|
+
errors with "Settings file not found";
|
|
269
|
+
- ~/.claude.json, or claude can't see the workspace-trusted flag and hangs
|
|
270
|
+
on the trust prompt inside the jail (the exact hang the pre-check prevents).
|
|
271
|
+
Bound read-WRITE: claude persists per-run session metadata (numStartups,
|
|
272
|
+
lastCost) into it on shutdown and errors on a read-only mount.
|
|
273
|
+
"""
|
|
274
|
+
if not sandbox:
|
|
275
|
+
return None
|
|
276
|
+
from tsugite.agent_runner.helpers import SandboxContext
|
|
277
|
+
|
|
278
|
+
ro: list[Path] = []
|
|
279
|
+
resolved = shutil.which(claude_binary)
|
|
280
|
+
if resolved:
|
|
281
|
+
# The PATH entry is usually a symlink into a per-version install dir; bind
|
|
282
|
+
# both so the symlink resolves AND the real files are present in the jail.
|
|
283
|
+
for d in (Path(resolved).parent, Path(os.path.realpath(resolved)).parent):
|
|
284
|
+
if d not in ro:
|
|
285
|
+
ro.append(d)
|
|
286
|
+
if settings_dir:
|
|
287
|
+
ro.append(Path(settings_dir))
|
|
288
|
+
|
|
289
|
+
rw: list[Path] = [Path.home() / ".claude"]
|
|
290
|
+
config_json = _default_trust_config_path()
|
|
291
|
+
if config_json.exists():
|
|
292
|
+
rw.append(config_json)
|
|
293
|
+
|
|
294
|
+
return SandboxContext(
|
|
295
|
+
no_network=False,
|
|
296
|
+
extra_ro_binds=ro,
|
|
297
|
+
extra_rw_binds=rw,
|
|
298
|
+
workspace_dir=Path(cwd) if cwd else None,
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class CCExecutor:
|
|
303
|
+
"""Drives interactive claude sessions toward job goals. One instance, shared
|
|
304
|
+
across all cc jobs; per-job state lives in the DriveStateStore."""
|
|
305
|
+
|
|
306
|
+
def __init__(self, config, drive_state: DriveStateStore):
|
|
307
|
+
self.config = config
|
|
308
|
+
self.drive_state = drive_state
|
|
309
|
+
# Injected by the gateway at plugin-executor registration so the executor
|
|
310
|
+
# can report an unexpected PTY exit via fail_worker.
|
|
311
|
+
self.orchestrator = None
|
|
312
|
+
|
|
313
|
+
async def start(self, job, followup: Optional[str] = None) -> None:
|
|
314
|
+
"""Executor contract entry point. followup=None on the initial attempt; on
|
|
315
|
+
a retry, drive the SAME live session (write_stdin) when the PTY is alive,
|
|
316
|
+
else respawn (with --resume when the cc session id is known)."""
|
|
317
|
+
from tsugite_pty.tools import get_terminal_runtime
|
|
318
|
+
|
|
319
|
+
state = self.drive_state.get(job.id)
|
|
320
|
+
if followup is not None and state is not None and state.terminal_id:
|
|
321
|
+
pty_manager, _store, _cb = get_terminal_runtime()
|
|
322
|
+
proc = pty_manager.get(state.terminal_id) if pty_manager else None
|
|
323
|
+
if proc is not None and proc.exit_code is None:
|
|
324
|
+
# claude's Ink TUI submits on carriage return, not newline, and
|
|
325
|
+
# debounces paste, so the text and the \r must be sent separately.
|
|
326
|
+
pty_manager.write_stdin(state.terminal_id, followup.strip().encode())
|
|
327
|
+
await asyncio.sleep(0.2)
|
|
328
|
+
pty_manager.write_stdin(state.terminal_id, b"\r")
|
|
329
|
+
state.consecutive_continues = 0
|
|
330
|
+
return
|
|
331
|
+
await self._spawn(job, followup)
|
|
332
|
+
|
|
333
|
+
async def _spawn(self, job, followup: Optional[str]) -> None:
|
|
334
|
+
import secrets
|
|
335
|
+
|
|
336
|
+
from tsugite_pty.terminal_runtime import spawn_terminal
|
|
337
|
+
from tsugite_pty.tools import get_terminal_runtime
|
|
338
|
+
|
|
339
|
+
claude = self.config.claude_binary
|
|
340
|
+
if shutil.which(claude) is None:
|
|
341
|
+
await self._fail(job.id, f"claude binary not found on PATH: {claude!r}")
|
|
342
|
+
return
|
|
343
|
+
|
|
344
|
+
cwd = job.worktree_path or job.workspace_path
|
|
345
|
+
if not cwd or not Path(cwd).is_dir():
|
|
346
|
+
await self._fail(job.id, f"cc job workspace does not exist: {cwd!r}")
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
# An untrusted cwd hangs forever on the trust dialog (no CLI flag skips
|
|
350
|
+
# it), so we provision the flag ourselves, or fail fast if that's disabled.
|
|
351
|
+
if not await asyncio.to_thread(is_workspace_trusted, cwd):
|
|
352
|
+
if self.config.provision_trust:
|
|
353
|
+
target = await asyncio.to_thread(trust_provision_target, cwd)
|
|
354
|
+
if not await asyncio.to_thread(ensure_workspace_trusted, target):
|
|
355
|
+
await self._fail(
|
|
356
|
+
job.id,
|
|
357
|
+
f"cc-driver: could not provision Claude Code trust for {target} "
|
|
358
|
+
f"(check the config at ~/.claude.json is writable and valid JSON).",
|
|
359
|
+
)
|
|
360
|
+
return
|
|
361
|
+
else:
|
|
362
|
+
await self._fail(
|
|
363
|
+
job.id,
|
|
364
|
+
f"cc-driver: workspace {cwd} is not trusted by Claude Code, so an unattended "
|
|
365
|
+
f"session would hang on the trust prompt. Trust it once (run `claude` in {cwd} and "
|
|
366
|
+
f"accept the prompt), or enable plugins.cc_driver.provision_trust to auto-trust it.",
|
|
367
|
+
)
|
|
368
|
+
return
|
|
369
|
+
|
|
370
|
+
pty_manager, store, on_state_change = get_terminal_runtime()
|
|
371
|
+
if pty_manager is None or store is None:
|
|
372
|
+
await self._fail(job.id, "PTY runtime not available (daemon terminal viewer not wired)")
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
state = self.drive_state.get(job.id)
|
|
376
|
+
if state is None:
|
|
377
|
+
state = self.drive_state.create(job.id, secrets.token_urlsafe(32))
|
|
378
|
+
|
|
379
|
+
hook_url = f"{self.config.base_url.rstrip('/')}/api/plugins/cc_driver/hook/{state.token}"
|
|
380
|
+
settings_path = write_run_settings(self.config.state_dir, job.id, build_settings(hook_url))
|
|
381
|
+
|
|
382
|
+
prompt = (
|
|
383
|
+
followup
|
|
384
|
+
if followup is not None
|
|
385
|
+
else build_initial_prompt(
|
|
386
|
+
job.prompt, self.config.completion_marker, needs_input_marker=self.config.needs_input_marker
|
|
387
|
+
)
|
|
388
|
+
)
|
|
389
|
+
cmd = build_claude_command(
|
|
390
|
+
claude,
|
|
391
|
+
str(settings_path),
|
|
392
|
+
self.config.permission_mode,
|
|
393
|
+
prompt,
|
|
394
|
+
model=(getattr(job, "model", None) or self.config.model),
|
|
395
|
+
effort=getattr(job, "effort", None) or self.config.effort,
|
|
396
|
+
ax_screen_reader=self.config.ax_screen_reader,
|
|
397
|
+
resume_session_id=state.cc_session_id if followup is not None else None,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
session = spawn_terminal(
|
|
402
|
+
store=store,
|
|
403
|
+
manager=pty_manager,
|
|
404
|
+
cmd=cmd,
|
|
405
|
+
cwd=cwd,
|
|
406
|
+
parent_session_id=None,
|
|
407
|
+
on_state_change=on_state_change,
|
|
408
|
+
sandbox_ctx=build_sandbox_ctx(
|
|
409
|
+
self.config.sandbox, cwd, claude_binary=claude, settings_dir=settings_path.parent
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
except Exception as e:
|
|
413
|
+
logger.exception("cc-driver: failed to spawn claude PTY for job '%s'", job.id)
|
|
414
|
+
await self._fail(job.id, f"failed to spawn claude PTY: {e}", detail=traceback.format_exc())
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
state.terminal_id = session.id
|
|
418
|
+
state.consecutive_continues = 0
|
|
419
|
+
self._set_worker_terminal(job.id, session.id)
|
|
420
|
+
|
|
421
|
+
proc = pty_manager.get(session.id)
|
|
422
|
+
if proc is not None:
|
|
423
|
+
loop = asyncio.get_running_loop()
|
|
424
|
+
proc.on_exit(lambda p: self._on_pty_exit(job.id, p, loop))
|
|
425
|
+
|
|
426
|
+
async def cancel(self, job) -> None:
|
|
427
|
+
"""Tear down the driven session on any terminal finalize (best-effort). A
|
|
428
|
+
resolved job (done/cancelled) drops all state; a parked one (stuck/errored)
|
|
429
|
+
still kills the PTY but keeps DriveState so a retry can `--resume`."""
|
|
430
|
+
from tsugite_pty.tools import get_terminal_runtime
|
|
431
|
+
|
|
432
|
+
state = self.drive_state.get(job.id)
|
|
433
|
+
if state is not None and state.terminal_id:
|
|
434
|
+
pty_manager, _store, _cb = get_terminal_runtime()
|
|
435
|
+
if pty_manager is not None:
|
|
436
|
+
try:
|
|
437
|
+
pty_manager.kill(state.terminal_id)
|
|
438
|
+
except Exception:
|
|
439
|
+
logger.exception("cc-driver: failed to kill PTY for job '%s'", job.id)
|
|
440
|
+
state.terminal_id = None
|
|
441
|
+
if getattr(job, "state", None) in ("stuck", "errored"):
|
|
442
|
+
return
|
|
443
|
+
cleanup(self.config.state_dir, job.id)
|
|
444
|
+
self.drive_state.remove(job.id)
|
|
445
|
+
|
|
446
|
+
def _on_pty_exit(self, job_id: str, proc, loop) -> None:
|
|
447
|
+
"""PTY-exit callback. A deliberate kill sets proc.killed and is ignored;
|
|
448
|
+
any other exit fails the worker. The buffer tail rides along as the detail
|
|
449
|
+
because the exit code alone can't convey the real error (missing binary,
|
|
450
|
+
auth failure, ...)."""
|
|
451
|
+
if getattr(proc, "killed", False):
|
|
452
|
+
return
|
|
453
|
+
reason = f"claude session exited ({describe_exit(proc.exit_code)}) before completing the task"
|
|
454
|
+
try:
|
|
455
|
+
detail = pty_tail(proc.buffer)
|
|
456
|
+
except Exception:
|
|
457
|
+
detail = None
|
|
458
|
+
if self.orchestrator is None:
|
|
459
|
+
return
|
|
460
|
+
loop.call_soon_threadsafe(
|
|
461
|
+
lambda: loop.create_task(self.orchestrator.fail_worker(job_id, reason, detail=detail))
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def _set_worker_terminal(self, job_id: str, terminal_id: str) -> None:
|
|
465
|
+
"""Stamp job.worker_terminal_id so the existing job tile embeds the live
|
|
466
|
+
terminal (the orchestrator prefers this field over a terminal_store lookup)."""
|
|
467
|
+
if self.orchestrator is None:
|
|
468
|
+
return
|
|
469
|
+
try:
|
|
470
|
+
self.orchestrator.attach_worker_terminal(job_id, terminal_id)
|
|
471
|
+
except Exception:
|
|
472
|
+
logger.debug("cc-driver: could not stamp worker_terminal_id for job '%s'", job_id)
|
|
473
|
+
|
|
474
|
+
async def _fail(self, job_id: str, reason: str, detail: Optional[str] = None) -> None:
|
|
475
|
+
if self.orchestrator is None:
|
|
476
|
+
logger.warning("cc-driver: no orchestrator to report failure for job '%s': %s", job_id, reason)
|
|
477
|
+
return
|
|
478
|
+
await self.orchestrator.fail_worker(job_id, reason, detail=detail)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Pure hook-decision functions (no I/O) for the cc-driver.
|
|
2
|
+
|
|
3
|
+
Each function maps a Claude Code hook payload (see hook-payloads-reference.md) to
|
|
4
|
+
a decision, so the adapter's HTTP route stays a thin dispatcher and the driving
|
|
5
|
+
logic is unit-testable in isolation.
|
|
6
|
+
|
|
7
|
+
Driving protocol:
|
|
8
|
+
- The completion marker and "a supervisor may give follow-ups" note are baked
|
|
9
|
+
into the initial prompt (SessionStart hooks do not fire from --settings).
|
|
10
|
+
- Injected/continue text is phrased as natural task guidance. Claude refuses
|
|
11
|
+
injection-shaped instructions ("output exactly X and nothing else").
|
|
12
|
+
- `stop_hook_active` is True only on a turn we drove. False marks a fresh
|
|
13
|
+
human/CLI turn, which resets the continue budget.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_initial_prompt(goal: str, completion_marker: str, *, needs_input_marker: str) -> str:
|
|
23
|
+
"""The first-arg goal seed. Bakes the completion AND needs-input protocol
|
|
24
|
+
into the prompt since SessionStart can't inject it. Phrased as natural
|
|
25
|
+
guidance."""
|
|
26
|
+
return (
|
|
27
|
+
f"{goal}\n\n"
|
|
28
|
+
f"When the task is fully complete, end your reply with the token "
|
|
29
|
+
f"{completion_marker} so your supervisor knows you are done. A supervisor "
|
|
30
|
+
f"may give you follow-up instructions after you stop; keep working until "
|
|
31
|
+
f"the task genuinely meets its goal. If you are blocked on information or "
|
|
32
|
+
f"a decision you cannot obtain yourself, do not guess - end your reply "
|
|
33
|
+
f"with the line {needs_input_marker}: <your question> and stop; your "
|
|
34
|
+
f"supervisor will answer."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def continue_instruction(completion_marker: str, needs_input_marker: str) -> str:
|
|
39
|
+
"""Natural-language nudge injected via a Stop `additionalContext` block to
|
|
40
|
+
drive another turn. Never phrased as 'output exactly X' (Claude refuses that)."""
|
|
41
|
+
return (
|
|
42
|
+
"You do not appear to be finished with the task yet. Please keep working "
|
|
43
|
+
"toward the goal. When the task is genuinely complete, end your reply with "
|
|
44
|
+
f"the token {completion_marker} so your supervisor knows you are done. If "
|
|
45
|
+
f"you are blocked on something only your supervisor can answer, end your "
|
|
46
|
+
f"reply with the line {needs_input_marker}: <your question> instead of guessing."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class StopDecision:
|
|
52
|
+
"""Outcome of a Stop hook: the JSON body to return to Claude plus whether the
|
|
53
|
+
attempt is finished, an optional needs-input question (a within-attempt
|
|
54
|
+
pause), and the updated continue budget the caller must persist."""
|
|
55
|
+
|
|
56
|
+
response: dict
|
|
57
|
+
complete: bool
|
|
58
|
+
summary: Optional[str]
|
|
59
|
+
new_consecutive_continues: int
|
|
60
|
+
needs_input: Optional[str] = None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def decide_stop(
|
|
64
|
+
payload: dict,
|
|
65
|
+
*,
|
|
66
|
+
consecutive_continues: int,
|
|
67
|
+
max_consecutive_continues: int,
|
|
68
|
+
completion_marker: str,
|
|
69
|
+
needs_input_marker: str,
|
|
70
|
+
) -> StopDecision:
|
|
71
|
+
"""Decide what to do when Claude stops.
|
|
72
|
+
|
|
73
|
+
A fresh (human/CLI) turn resets the budget; the completion marker ends the
|
|
74
|
+
attempt (the verifier then grades it); the needs-input marker pauses the
|
|
75
|
+
attempt - checked BEFORE budget exhaustion, or a blocked worker near the
|
|
76
|
+
nudge cap would be force-completed into a verification it already knows it
|
|
77
|
+
can't pass; an exhausted continue budget also ends the attempt (maybe it IS
|
|
78
|
+
done - let the verifier decide); otherwise return a `decision: block` that
|
|
79
|
+
drives one more turn.
|
|
80
|
+
"""
|
|
81
|
+
last_msg = payload.get("last_assistant_message") or ""
|
|
82
|
+
stop_hook_active = bool(payload.get("stop_hook_active"))
|
|
83
|
+
# A turn we did not drive (human/CLI) resets the continue budget.
|
|
84
|
+
count = consecutive_continues if stop_hook_active else 0
|
|
85
|
+
|
|
86
|
+
if completion_marker and completion_marker in last_msg:
|
|
87
|
+
return StopDecision(response={}, complete=True, summary=last_msg, new_consecutive_continues=count)
|
|
88
|
+
|
|
89
|
+
if needs_input_marker and needs_input_marker in last_msg:
|
|
90
|
+
question = last_msg.split(needs_input_marker, 1)[1].lstrip(":").strip()[:500]
|
|
91
|
+
return StopDecision(
|
|
92
|
+
response={},
|
|
93
|
+
complete=False,
|
|
94
|
+
summary=None,
|
|
95
|
+
new_consecutive_continues=count,
|
|
96
|
+
needs_input=question or "the worker asked for supervisor input (no question text)",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if count >= max_consecutive_continues:
|
|
100
|
+
return StopDecision(response={}, complete=True, summary=last_msg, new_consecutive_continues=count)
|
|
101
|
+
|
|
102
|
+
block = {
|
|
103
|
+
"decision": "block",
|
|
104
|
+
"hookSpecificOutput": {
|
|
105
|
+
"hookEventName": "Stop",
|
|
106
|
+
"additionalContext": continue_instruction(completion_marker, needs_input_marker),
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
return StopDecision(response=block, complete=False, summary=None, new_consecutive_continues=count + 1)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def decide_stop_failure(payload: dict) -> str:
|
|
113
|
+
"""Extract a failure reason from a StopFailure payload (a Stop hook / turn that
|
|
114
|
+
errored). The caller routes this into fail_worker."""
|
|
115
|
+
return (
|
|
116
|
+
payload.get("error")
|
|
117
|
+
or payload.get("message")
|
|
118
|
+
or payload.get("last_assistant_message")
|
|
119
|
+
or "Claude Code reported a Stop failure"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def notification_attention(payload: dict) -> Optional[str]:
|
|
124
|
+
"""Return a human-readable attention message when a Notification is a
|
|
125
|
+
permission prompt (the job may be blocked awaiting input), else None."""
|
|
126
|
+
if payload.get("notification_type") == "permission_prompt":
|
|
127
|
+
return payload.get("message") or "Claude Code needs attention"
|
|
128
|
+
return None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Per-job Claude Code settings.json builder + on-disk lifecycle.
|
|
2
|
+
|
|
3
|
+
The settings file registers the three HTTP hooks the driver needs (Stop,
|
|
4
|
+
StopFailure, Notification), all pointing at the plugin's per-job hook URL.
|
|
5
|
+
SessionStart does not fire from --settings, so it is intentionally absent; the
|
|
6
|
+
driving protocol is baked into the initial prompt instead (see hooks.py).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import shutil
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# The hook events the driver registers. Order is irrelevant to Claude Code but
|
|
16
|
+
# kept stable for deterministic tests.
|
|
17
|
+
HOOK_EVENTS = ("Stop", "StopFailure", "Notification")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_settings(hook_url: str) -> dict:
|
|
21
|
+
"""Build the Claude Code settings dict registering the driver's HTTP hooks.
|
|
22
|
+
|
|
23
|
+
Every event points at the same per-job hook URL; the receiver dispatches on
|
|
24
|
+
the payload's `hook_event_name`.
|
|
25
|
+
"""
|
|
26
|
+
return {"hooks": {event: [{"hooks": [{"type": "http", "url": hook_url}]}] for event in HOOK_EVENTS}}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_run_settings(state_dir: str | Path, job_id: str, settings: dict) -> Path:
|
|
30
|
+
"""Write `settings` to `<state_dir>/<job_id>/settings.json` and return its path."""
|
|
31
|
+
run_dir = Path(state_dir) / job_id
|
|
32
|
+
run_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
path = run_dir / "settings.json"
|
|
34
|
+
path.write_text(json.dumps(settings, indent=2))
|
|
35
|
+
return path
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def cleanup(state_dir: str | Path, job_id: str) -> None:
|
|
39
|
+
"""Remove the job's settings directory. Best-effort; never raises."""
|
|
40
|
+
shutil.rmtree(Path(state_dir) / job_id, ignore_errors=True)
|