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.
@@ -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)