optio-claudecode 0.1.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.
Files changed (33) hide show
  1. optio_claudecode-0.1.0/PKG-INFO +93 -0
  2. optio_claudecode-0.1.0/README.md +63 -0
  3. optio_claudecode-0.1.0/pyproject.toml +51 -0
  4. optio_claudecode-0.1.0/setup.cfg +4 -0
  5. optio_claudecode-0.1.0/src/optio_claudecode/__init__.py +48 -0
  6. optio_claudecode-0.1.0/src/optio_claudecode/host_actions.py +424 -0
  7. optio_claudecode-0.1.0/src/optio_claudecode/prompt.py +132 -0
  8. optio_claudecode-0.1.0/src/optio_claudecode/seed_manifest.py +80 -0
  9. optio_claudecode-0.1.0/src/optio_claudecode/session.py +581 -0
  10. optio_claudecode-0.1.0/src/optio_claudecode/snapshots.py +102 -0
  11. optio_claudecode-0.1.0/src/optio_claudecode/types.py +104 -0
  12. optio_claudecode-0.1.0/src/optio_claudecode.egg-info/PKG-INFO +93 -0
  13. optio_claudecode-0.1.0/src/optio_claudecode.egg-info/SOURCES.txt +31 -0
  14. optio_claudecode-0.1.0/src/optio_claudecode.egg-info/dependency_links.txt +1 -0
  15. optio_claudecode-0.1.0/src/optio_claudecode.egg-info/requires.txt +8 -0
  16. optio_claudecode-0.1.0/src/optio_claudecode.egg-info/top_level.txt +1 -0
  17. optio_claudecode-0.1.0/tests/test_home_isolation.py +51 -0
  18. optio_claudecode-0.1.0/tests/test_host_actions.py +441 -0
  19. optio_claudecode-0.1.0/tests/test_on_resume_refresh.py +95 -0
  20. optio_claudecode-0.1.0/tests/test_prompt.py +32 -0
  21. optio_claudecode-0.1.0/tests/test_resume_prompt.py +53 -0
  22. optio_claudecode-0.1.0/tests/test_sanity.py +32 -0
  23. optio_claudecode-0.1.0/tests/test_seed_config.py +67 -0
  24. optio_claudecode-0.1.0/tests/test_session_blob_hooks.py +133 -0
  25. optio_claudecode-0.1.0/tests/test_session_hooks.py +101 -0
  26. optio_claudecode-0.1.0/tests/test_session_local.py +123 -0
  27. optio_claudecode-0.1.0/tests/test_session_resume.py +226 -0
  28. optio_claudecode-0.1.0/tests/test_session_resume_decrypt_failure.py +88 -0
  29. optio_claudecode-0.1.0/tests/test_session_seed_capture.py +93 -0
  30. optio_claudecode-0.1.0/tests/test_session_seed_consume.py +104 -0
  31. optio_claudecode-0.1.0/tests/test_session_seed_unknown_id.py +63 -0
  32. optio_claudecode-0.1.0/tests/test_snapshots.py +92 -0
  33. optio_claudecode-0.1.0/tests/test_types.py +80 -0
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: optio-claudecode
3
+ Version: 0.1.0
4
+ Summary: Run Anthropic Claude Code as an optio task; local subprocess or remote via SSH; ttyd-served TUI iframe.
5
+ Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/deai-network/optio
8
+ Project-URL: Repository, https://github.com/deai-network/optio
9
+ Project-URL: Issues, https://github.com/deai-network/optio/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Operating System :: MacOS
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Classifier: Topic :: Software Development :: Code Generators
20
+ Classifier: Framework :: AsyncIO
21
+ Requires-Python: >=3.11
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: optio-core<0.3,>=0.2
24
+ Requires-Dist: optio-host<0.3,>=0.2
25
+ Requires-Dist: optio-agents<0.2,>=0.1
26
+ Requires-Dist: asyncssh>=2.14
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=8.0; extra == "dev"
29
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
30
+
31
+ # optio-claudecode
32
+
33
+ Run Anthropic Claude Code as an `optio` task — either as a local
34
+ subprocess or on a remote host over SSH — with the interactive TUI
35
+ embedded in the optio dashboard via an iframe widget served by `ttyd`.
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ pip install optio-claudecode
41
+ ```
42
+
43
+ Requires Python 3.11+. Pulls `optio-core`, `optio-host`, and `asyncssh`.
44
+
45
+ On task start the package auto-installs the host binaries it needs
46
+ unless told otherwise:
47
+
48
+ * `claude` — via Anthropic's vendor script (`https://claude.ai/install.sh`)
49
+ * `ttyd` — static binary from `tsl0922/ttyd` GitHub Releases
50
+
51
+ ## Quick start
52
+
53
+ ```python
54
+ from optio_claudecode import (
55
+ ClaudeCodeTaskConfig,
56
+ create_claudecode_task,
57
+ )
58
+
59
+ def get_tasks():
60
+ return [
61
+ create_claudecode_task(
62
+ process_id="example-task",
63
+ name="Example",
64
+ config=ClaudeCodeTaskConfig(
65
+ consumer_instructions="Please write a haiku about MongoDB.",
66
+ credentials_json=load_user_creds_from_db(user_id),
67
+ # Optional: skip interactive permission prompts for autonomous flows.
68
+ permission_mode="bypassPermissions",
69
+ ),
70
+ )
71
+ ]
72
+ ```
73
+
74
+ `credentials_json` is treated as an opaque payload and written verbatim
75
+ to `<workdir>/home/.claude/.credentials.json` (mode 0600) before claude
76
+ launches. Format follows whatever Anthropic's CLI currently expects.
77
+
78
+ ## How it works
79
+
80
+ Each task gets a workdir tempdir (`/tmp/optio-claudecode-<uuid>/`). The
81
+ ttyd process is launched with `HOME=<workdir>/home`, so claude reads
82
+ all its state — credentials, settings, session history — strictly from
83
+ the per-task workdir and never touches the host user's real
84
+ `~/.claude/`. Two tasks on the same host can run concurrently without
85
+ shared-state races.
86
+
87
+ The agent is given a `<workdir>/AGENTS.md` that includes the
88
+ `optio.log` coordination protocol — `STATUS:` / `DELIVERABLE:` /
89
+ `DONE` / `ERROR` — verbatim from `optio_host.agents`. The same protocol
90
+ is used by `optio-opencode`, so the same `consumer_instructions` can be
91
+ swapped between the two packages.
92
+
93
+ See `docs/2026-05-28-optio-claudecode-design.md` for the full design.
@@ -0,0 +1,63 @@
1
+ # optio-claudecode
2
+
3
+ Run Anthropic Claude Code as an `optio` task — either as a local
4
+ subprocess or on a remote host over SSH — with the interactive TUI
5
+ embedded in the optio dashboard via an iframe widget served by `ttyd`.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install optio-claudecode
11
+ ```
12
+
13
+ Requires Python 3.11+. Pulls `optio-core`, `optio-host`, and `asyncssh`.
14
+
15
+ On task start the package auto-installs the host binaries it needs
16
+ unless told otherwise:
17
+
18
+ * `claude` — via Anthropic's vendor script (`https://claude.ai/install.sh`)
19
+ * `ttyd` — static binary from `tsl0922/ttyd` GitHub Releases
20
+
21
+ ## Quick start
22
+
23
+ ```python
24
+ from optio_claudecode import (
25
+ ClaudeCodeTaskConfig,
26
+ create_claudecode_task,
27
+ )
28
+
29
+ def get_tasks():
30
+ return [
31
+ create_claudecode_task(
32
+ process_id="example-task",
33
+ name="Example",
34
+ config=ClaudeCodeTaskConfig(
35
+ consumer_instructions="Please write a haiku about MongoDB.",
36
+ credentials_json=load_user_creds_from_db(user_id),
37
+ # Optional: skip interactive permission prompts for autonomous flows.
38
+ permission_mode="bypassPermissions",
39
+ ),
40
+ )
41
+ ]
42
+ ```
43
+
44
+ `credentials_json` is treated as an opaque payload and written verbatim
45
+ to `<workdir>/home/.claude/.credentials.json` (mode 0600) before claude
46
+ launches. Format follows whatever Anthropic's CLI currently expects.
47
+
48
+ ## How it works
49
+
50
+ Each task gets a workdir tempdir (`/tmp/optio-claudecode-<uuid>/`). The
51
+ ttyd process is launched with `HOME=<workdir>/home`, so claude reads
52
+ all its state — credentials, settings, session history — strictly from
53
+ the per-task workdir and never touches the host user's real
54
+ `~/.claude/`. Two tasks on the same host can run concurrently without
55
+ shared-state races.
56
+
57
+ The agent is given a `<workdir>/AGENTS.md` that includes the
58
+ `optio.log` coordination protocol — `STATUS:` / `DELIVERABLE:` /
59
+ `DONE` / `ERROR` — verbatim from `optio_host.agents`. The same protocol
60
+ is used by `optio-opencode`, so the same `consumer_instructions` can be
61
+ swapped between the two packages.
62
+
63
+ See `docs/2026-05-28-optio-claudecode-design.md` for the full design.
@@ -0,0 +1,51 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "optio-claudecode"
7
+ version = "0.1.0"
8
+ description = "Run Anthropic Claude Code as an optio task; local subprocess or remote via SSH; ttyd-served TUI iframe."
9
+ readme = "README.md"
10
+ license = "Apache-2.0"
11
+ requires-python = ">=3.11"
12
+ authors = [
13
+ { name = "Kristof Csillag", email = "kristof.csillag@deai-labs.com" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Operating System :: POSIX :: Linux",
22
+ "Operating System :: MacOS",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
25
+ "Topic :: Software Development :: Code Generators",
26
+ "Framework :: AsyncIO",
27
+ ]
28
+ dependencies = [
29
+ "optio-core>=0.2,<0.3",
30
+ "optio-host>=0.2,<0.3",
31
+ "optio-agents>=0.1,<0.2",
32
+ "asyncssh>=2.14",
33
+ ]
34
+
35
+ [project.optional-dependencies]
36
+ dev = [
37
+ "pytest>=8.0",
38
+ "pytest-asyncio>=0.23",
39
+ ]
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/deai-network/optio"
43
+ Repository = "https://github.com/deai-network/optio"
44
+ Issues = "https://github.com/deai-network/optio/issues"
45
+
46
+ [tool.setuptools.packages.find]
47
+ where = ["src"]
48
+
49
+ [tool.pytest.ini_options]
50
+ asyncio_mode = "auto"
51
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,48 @@
1
+ """optio-claudecode — run Anthropic Claude Code as an optio task."""
2
+
3
+ import logging as _logging
4
+
5
+ from optio_agents import HookContext, HookContextProtocol
6
+ from optio_host import (
7
+ HostCommandError,
8
+ RunResult,
9
+ SSHConfig,
10
+ )
11
+
12
+ from optio_claudecode.session import create_claudecode_task, run_claudecode_session
13
+ from optio_claudecode.types import (
14
+ ClaudeCodeTaskConfig,
15
+ DeliverableCallback,
16
+ HookCallback,
17
+ PermissionMode,
18
+ )
19
+ from optio_claudecode.seed_manifest import (
20
+ CLAUDE_SEED_MANIFEST,
21
+ CLAUDE_SEED_SUFFIX,
22
+ delete_seed,
23
+ list_seeds,
24
+ )
25
+
26
+
27
+ # asyncssh emits per-connection INFO lines that flood worker stdout
28
+ # once an SSH-backed session starts. Quiet by default.
29
+ _logging.getLogger("asyncssh").setLevel(_logging.WARNING)
30
+
31
+
32
+ __all__ = [
33
+ "create_claudecode_task",
34
+ "run_claudecode_session",
35
+ "ClaudeCodeTaskConfig",
36
+ "DeliverableCallback",
37
+ "HookCallback",
38
+ "PermissionMode",
39
+ "SSHConfig",
40
+ "HookContext",
41
+ "HookContextProtocol",
42
+ "HostCommandError",
43
+ "RunResult",
44
+ "CLAUDE_SEED_MANIFEST",
45
+ "CLAUDE_SEED_SUFFIX",
46
+ "delete_seed",
47
+ "list_seeds",
48
+ ]
@@ -0,0 +1,424 @@
1
+ """Claudecode-specific actions over a generic Host.
2
+
3
+ Free functions; each takes a Host or HookContext and uses only generic
4
+ primitives (run_command, resolve_host_home, etc.). No isinstance branches.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import os
12
+ import re
13
+ import shlex
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ if TYPE_CHECKING:
17
+ from optio_agents import HookContextProtocol
18
+ from optio_host import Host
19
+ from optio_host.host import ProcessHandle
20
+
21
+
22
+ # ttyd's ready banner takes a few forms across versions:
23
+ # * 1.7.x with lws logging: "N: Listening on port: 33449"
24
+ # * older builds: "Listening on port 7681"
25
+ # * some forks log a URL: "[INFO] listening on http://127.0.0.1:7681/"
26
+ # The `port[\s:]+` branch covers the first two (colon OR whitespace
27
+ # between "port" and the digits). The URL branch covers the third.
28
+ # Both expose the captured port number as the first non-None group.
29
+ _TTYD_READY_RE = re.compile(
30
+ r"(?:port[\s:]+(\d+))|(?:http://[^\s]+?:(\d+)(?:/|\s|$))"
31
+ )
32
+
33
+
34
+ _DEFAULT_INSTALL_SUBDIR = ".local/bin"
35
+
36
+ _CLAUDE_INSTALL_URL = "https://claude.ai/install.sh"
37
+
38
+ # Pinned ttyd version. Update with care; the URL pattern below is
39
+ # tsl0922/ttyd's release-asset convention as of 1.7.x.
40
+ _TTYD_VERSION = "1.7.7"
41
+ _TTYD_RELEASE_BASE = (
42
+ f"https://github.com/tsl0922/ttyd/releases/download/{_TTYD_VERSION}"
43
+ )
44
+
45
+
46
+ async def _resolve_install_dir(host: "Host", install_dir: str | None) -> str:
47
+ """Return ``install_dir`` if given, else ``<host_home>/<DEFAULT_INSTALL_SUBDIR>``."""
48
+ if install_dir is not None:
49
+ return install_dir
50
+ host_home = await host.resolve_host_home()
51
+ return f"{host_home}/{_DEFAULT_INSTALL_SUBDIR}"
52
+
53
+
54
+ async def _claude_present(host: "Host", claude_path: str) -> bool:
55
+ """Return True iff ``claude_path`` is an executable file on the host
56
+ that produces version output when invoked with --version."""
57
+ cmd = f"[ -x {shlex.quote(claude_path)} ] && {shlex.quote(claude_path)} --version"
58
+ result = await host.run_command(cmd)
59
+ return result.exit_code == 0 and "Claude Code" in result.stdout
60
+
61
+
62
+ async def ensure_claude_installed(
63
+ hook_ctx: "HookContextProtocol",
64
+ *,
65
+ install_if_missing: bool = True,
66
+ install_dir: str | None = None,
67
+ ) -> str:
68
+ """Ensure the ``claude`` binary is present on the host behind ``hook_ctx``.
69
+
70
+ The framework looks for a symlink at ``<install_dir>/claude``. When
71
+ missing and ``install_if_missing=True``, it runs the vendor install
72
+ script (``curl -fsSL https://claude.ai/install.sh | bash``) on the
73
+ host. The script downloads + checksum-verifies + places the native
74
+ binary under ``~/.local/share/claude/versions/<v>/`` and creates a
75
+ symlink at ``~/.local/bin/claude``. The framework re-checks for the
76
+ symlink after the install runs.
77
+
78
+ Returns the absolute path of the ``claude`` symlink on the host.
79
+
80
+ Raises RuntimeError when the binary is absent and either
81
+ ``install_if_missing=False`` or the install fails.
82
+ """
83
+ host = hook_ctx._host
84
+ resolved_install_dir = await _resolve_install_dir(host, install_dir)
85
+ claude_path = f"{resolved_install_dir}/claude"
86
+
87
+ hook_ctx.report_progress(None, "Checking claude installation…")
88
+ if await _claude_present(host, claude_path):
89
+ return claude_path
90
+
91
+ if not install_if_missing:
92
+ raise RuntimeError(
93
+ f"claude not present at {claude_path!r} on host and "
94
+ f"install_if_missing=False; nothing to do."
95
+ )
96
+
97
+ hook_ctx.report_progress(None, "Installing claude (vendor install.sh)…")
98
+ install_cmd = f"curl -fsSL {shlex.quote(_CLAUDE_INSTALL_URL)} | bash"
99
+ result = await host.run_command(install_cmd)
100
+ if result.exit_code != 0:
101
+ raise RuntimeError(
102
+ f"claude install failed on host (exit {result.exit_code}): "
103
+ f"{result.stderr.strip()[:300]}"
104
+ )
105
+
106
+ if not await _claude_present(host, claude_path):
107
+ raise RuntimeError(
108
+ f"claude install reported success but {claude_path!r} is still "
109
+ f"not executable on the host. Inspect the host's "
110
+ f"~/.local/bin and ~/.local/share/claude/versions for diagnostics."
111
+ )
112
+ return claude_path
113
+
114
+
115
+ async def _ttyd_present(host: "Host", ttyd_path: str) -> bool:
116
+ cmd = f"[ -x {shlex.quote(ttyd_path)} ] && {shlex.quote(ttyd_path)} --version"
117
+ result = await host.run_command(cmd)
118
+ # ttyd writes its version banner to stdout OR stderr depending on
119
+ # version — accept either.
120
+ blob = (result.stdout or "") + (result.stderr or "")
121
+ return result.exit_code == 0 and "ttyd" in blob.lower()
122
+
123
+
124
+ async def _detect_ttyd_asset_name(host: "Host") -> str:
125
+ """Return the upstream release-asset filename for the host's arch/OS.
126
+
127
+ Raises RuntimeError on unsupported (OS, arch) combinations.
128
+ """
129
+ r_arch = await host.run_command("uname -m")
130
+ if r_arch.exit_code != 0:
131
+ raise RuntimeError(
132
+ f"uname -m failed on host (exit {r_arch.exit_code}): "
133
+ f"{r_arch.stderr.strip()[:200]}"
134
+ )
135
+ arch = r_arch.stdout.strip()
136
+ r_os = await host.run_command("uname -s")
137
+ if r_os.exit_code != 0:
138
+ raise RuntimeError(
139
+ f"uname -s failed on host (exit {r_os.exit_code}): "
140
+ f"{r_os.stderr.strip()[:200]}"
141
+ )
142
+ os_name = r_os.stdout.strip()
143
+ if os_name != "Linux":
144
+ raise RuntimeError(
145
+ f"unsupported host OS {os_name!r} for ttyd auto-install "
146
+ f"(v1 supports Linux only; macOS support requires uploading "
147
+ f"a Darwin binary or pre-installing ttyd manually)."
148
+ )
149
+ if arch not in {"x86_64", "aarch64", "armv7l"}:
150
+ raise RuntimeError(
151
+ f"unsupported host arch {arch!r} for ttyd auto-install. "
152
+ f"See https://github.com/tsl0922/ttyd/releases for available "
153
+ f"prebuilt assets."
154
+ )
155
+ return f"ttyd.{arch}"
156
+
157
+
158
+ async def ensure_ttyd_installed(
159
+ hook_ctx: "HookContextProtocol",
160
+ *,
161
+ install_if_missing: bool = True,
162
+ install_dir: str | None = None,
163
+ ) -> str:
164
+ """Ensure ``ttyd`` is present on the host behind ``hook_ctx``.
165
+
166
+ When missing and ``install_if_missing=True``, downloads the
167
+ appropriate static prebuilt asset from ``tsl0922/ttyd`` GitHub
168
+ Releases via ``hook_ctx.download_file`` (so byte-progress shows in
169
+ the dashboard).
170
+
171
+ Returns the absolute path of the ``ttyd`` binary on the host.
172
+
173
+ Raises RuntimeError on (a) absent binary with
174
+ ``install_if_missing=False``; (b) unsupported (OS, arch); (c) any
175
+ install sub-step failing.
176
+ """
177
+ host = hook_ctx._host
178
+ resolved_install_dir = await _resolve_install_dir(host, install_dir)
179
+ ttyd_path = f"{resolved_install_dir}/ttyd"
180
+
181
+ hook_ctx.report_progress(None, "Checking ttyd installation…")
182
+ if await _ttyd_present(host, ttyd_path):
183
+ return ttyd_path
184
+
185
+ if not install_if_missing:
186
+ raise RuntimeError(
187
+ f"ttyd not present at {ttyd_path!r} on host and "
188
+ f"install_ttyd_if_missing=False; nothing to do."
189
+ )
190
+
191
+ hook_ctx.report_progress(None, "Detecting ttyd release asset…")
192
+ asset = await _detect_ttyd_asset_name(host)
193
+ url = f"{_TTYD_RELEASE_BASE}/{asset}"
194
+
195
+ r = await host.run_command(f"mkdir -p {shlex.quote(resolved_install_dir)}")
196
+ if r.exit_code != 0:
197
+ raise RuntimeError(
198
+ f"mkdir -p {resolved_install_dir!r} failed (exit {r.exit_code}): "
199
+ f"{r.stderr.strip()[:200]}"
200
+ )
201
+
202
+ hook_ctx.report_progress(None, f"Downloading ttyd ({asset})…")
203
+ await hook_ctx.download_file(url, ttyd_path)
204
+
205
+ r = await host.run_command(f"chmod +x {shlex.quote(ttyd_path)}")
206
+ if r.exit_code != 0:
207
+ raise RuntimeError(
208
+ f"chmod +x {ttyd_path!r} failed (exit {r.exit_code}): "
209
+ f"{r.stderr.strip()[:200]}"
210
+ )
211
+
212
+ if not await _ttyd_present(host, ttyd_path):
213
+ raise RuntimeError(
214
+ f"ttyd install completed but {ttyd_path!r} is still not "
215
+ f"executable on the host. Check the downloaded asset and "
216
+ f"chmod result."
217
+ )
218
+ return ttyd_path
219
+
220
+
221
+ async def plant_home_files(
222
+ host: "Host",
223
+ *,
224
+ credentials_json: dict[str, Any] | bytes | str | None,
225
+ claude_config: dict[str, Any] | None,
226
+ ) -> None:
227
+ """Plant per-task claude state under <workdir>/home/.claude/.
228
+
229
+ Creates <workdir>/home/.claude/ (mkdir -p), writes the credentials
230
+ payload and settings.json when supplied, and chmod-600s the
231
+ credentials file. ``credentials_json`` accepts a dict (re-encoded as
232
+ JSON), bytes (decoded as UTF-8 verbatim), or a string (written
233
+ verbatim).
234
+ """
235
+ workdir = host.workdir.rstrip("/")
236
+ home_claude_rel = "home/.claude"
237
+ home_claude_abs = f"{workdir}/{home_claude_rel}"
238
+
239
+ r = await host.run_command(f"mkdir -p {shlex.quote(home_claude_abs)}")
240
+ if r.exit_code != 0:
241
+ raise RuntimeError(
242
+ f"mkdir -p {home_claude_abs!r} failed (exit {r.exit_code}): "
243
+ f"{r.stderr.strip()[:200]}"
244
+ )
245
+
246
+ if credentials_json is not None:
247
+ if isinstance(credentials_json, dict):
248
+ payload = json.dumps(credentials_json)
249
+ elif isinstance(credentials_json, bytes):
250
+ payload = credentials_json.decode("utf-8")
251
+ else:
252
+ payload = credentials_json
253
+ cred_rel = f"{home_claude_rel}/.credentials.json"
254
+ await host.write_text(cred_rel, payload)
255
+ cred_abs = f"{workdir}/{cred_rel}"
256
+ r = await host.run_command(f"chmod 600 {shlex.quote(cred_abs)}")
257
+ if r.exit_code != 0:
258
+ raise RuntimeError(
259
+ f"chmod 600 {cred_abs!r} failed (exit {r.exit_code}): "
260
+ f"{r.stderr.strip()[:200]}"
261
+ )
262
+
263
+ if claude_config is not None:
264
+ settings_rel = f"{home_claude_rel}/settings.json"
265
+ await host.write_text(settings_rel, json.dumps(claude_config, indent=2))
266
+
267
+
268
+ def build_ttyd_argv(
269
+ *,
270
+ ttyd_path: str,
271
+ claude_path: str,
272
+ workdir: str,
273
+ bind_iface: str,
274
+ port: int,
275
+ extra_env: dict[str, str] | None,
276
+ claude_flags: list[str],
277
+ ) -> list[str]:
278
+ """Construct the full argv for the ttyd subprocess.
279
+
280
+ Layout:
281
+ <ttyd_path> -W -i <iface> -p <port> -m 1 -T xterm-256color --
282
+ env HOME=<workdir>/home PATH=<home>/.local/bin:... [<extra-env...>]
283
+ bash -c 'mkdir -p <home>/.local/bin && ln -sf <claude_path> <home>/.local/bin/claude
284
+ && cd <workdir> && <claude_path> [<claude_flags...>]; rc=$?;
285
+ <append DONE (rc 0) | ERROR: claude exited <rc> to optio.log>'
286
+
287
+ claude is installed under the *real* host home's ``.local/bin`` (that's
288
+ where ``resolve_host_home`` points at install time), but the session
289
+ runs HOME-isolated (``HOME=<workdir>/home``). So at launch we symlink
290
+ claude into the isolated home's ``.local/bin`` and prepend that dir to
291
+ PATH — otherwise claude warns that ``~/.local/bin`` is missing / not on
292
+ PATH. Any caller-/shim-supplied PATH (e.g. browser shims) is merged in,
293
+ not dropped.
294
+ """
295
+ workdir_clean = workdir.rstrip("/")
296
+ home_dir = f"{workdir_clean}/home"
297
+ home_local_bin = f"{home_dir}/.local/bin"
298
+ claude_link = f"{home_local_bin}/claude"
299
+
300
+ extra = dict(extra_env or {})
301
+ base_path = extra.pop("PATH", None) or os.environ.get(
302
+ "PATH", "/usr/local/bin:/usr/bin:/bin",
303
+ )
304
+ env_assignments: list[str] = [
305
+ f"HOME={home_dir}",
306
+ f"PATH={home_local_bin}:{base_path}",
307
+ ]
308
+ for k, v in extra.items():
309
+ env_assignments.append(f"{k}={v}")
310
+
311
+ claude_argv = " ".join(shlex.quote(c) for c in [claude_path, *claude_flags])
312
+ # Symlink claude into the isolated home's bin (idempotent; skipped when
313
+ # the install dir already IS that bin, which would make ln a same-file
314
+ # error).
315
+ link_cmd = (
316
+ f"mkdir -p {shlex.quote(home_local_bin)} && "
317
+ f"{{ [ {shlex.quote(claude_path)} = {shlex.quote(claude_link)} ] || "
318
+ f"ln -sf {shlex.quote(claude_path)} {shlex.quote(claude_link)} ; }} && "
319
+ )
320
+ # Run claude (NOT exec) so that when it exits — e.g. the operator types
321
+ # `exit` without writing DONE — the wrapper appends a terminal protocol
322
+ # line. The driver's optio.log tail then completes the session and its
323
+ # teardown reaps the (otherwise lingering) ttyd. ttyd 1.7 has no
324
+ # "exit when child exits" flag; -o/-q key off *client* disconnect and
325
+ # would kill live tasks on a tab close, so they are the wrong lever.
326
+ log_path = f"{workdir_clean}/optio.log"
327
+ bash_payload = (
328
+ f"{link_cmd}cd {shlex.quote(workdir_clean)} && {claude_argv}; rc=$?; "
329
+ f'if [ "$rc" = 0 ]; then echo DONE >> {shlex.quote(log_path)}; '
330
+ f"else printf 'ERROR: claude exited %s\\n' \"$rc\" >> {shlex.quote(log_path)}; fi"
331
+ )
332
+ return [
333
+ ttyd_path,
334
+ "-W",
335
+ "-i", bind_iface,
336
+ "-p", str(port),
337
+ "-m", "1",
338
+ "-T", "xterm-256color",
339
+ "--",
340
+ "env",
341
+ *env_assignments,
342
+ "bash", "-c", bash_payload,
343
+ ]
344
+
345
+
346
+ async def launch_ttyd_with_claude(
347
+ host: "Host",
348
+ *,
349
+ ttyd_path: str,
350
+ claude_path: str,
351
+ bind_iface: str,
352
+ extra_env: dict[str, str] | None,
353
+ claude_flags: list[str],
354
+ ready_timeout_s: float = 30.0,
355
+ ) -> "tuple[ProcessHandle, int]":
356
+ """Spawn ttyd wrapping claude under HOME-isolation. Wait for ready.
357
+
358
+ Always passes ``-p 0`` so the OS picks a free port; the actual port
359
+ is parsed from ttyd's stdout/stderr ready banner.
360
+
361
+ Returns ``(handle, port)``. Caller is responsible for terminating
362
+ the handle.
363
+ """
364
+ argv = build_ttyd_argv(
365
+ ttyd_path=ttyd_path,
366
+ claude_path=claude_path,
367
+ workdir=host.workdir,
368
+ bind_iface=bind_iface,
369
+ port=0,
370
+ extra_env=extra_env,
371
+ claude_flags=claude_flags,
372
+ )
373
+ # launch_subprocess takes a single shell-string passed to `sh -c`.
374
+ # Quote each argv element to survive shell parsing.
375
+ command = " ".join(shlex.quote(a) for a in argv)
376
+ handle = await host.launch_subprocess(command)
377
+
378
+ async def _read_port() -> int:
379
+ async for raw in handle.stdout:
380
+ line = raw.decode("utf-8", errors="replace").rstrip() if isinstance(raw, bytes) else str(raw).rstrip()
381
+ m = _TTYD_READY_RE.search(line)
382
+ if m:
383
+ port_str = m.group(1) or m.group(2)
384
+ return int(port_str)
385
+ raise RuntimeError("ttyd exited before printing a listening URL")
386
+
387
+ try:
388
+ port = await asyncio.wait_for(_read_port(), timeout=ready_timeout_s)
389
+ except asyncio.TimeoutError:
390
+ await host.terminate_subprocess(handle, aggressive=True)
391
+ raise TimeoutError(
392
+ f"ttyd did not print a listening URL within {ready_timeout_s}s"
393
+ )
394
+ except BaseException:
395
+ await host.terminate_subprocess(handle, aggressive=True)
396
+ raise
397
+ return handle, port
398
+
399
+
400
+ def build_claude_flags(
401
+ *,
402
+ permission_mode: str | None,
403
+ allowed_tools: list[str] | None,
404
+ disallowed_tools: list[str] | None,
405
+ resuming: bool = False,
406
+ ) -> list[str]:
407
+ """Translate ClaudeCodeTaskConfig permission knobs to an argv list.
408
+
409
+ Empty lists are treated as None: no flag is emitted.
410
+ When ``resuming`` is True, ``--continue`` is appended so claude picks
411
+ up the most recent conversation in ``home/.claude/projects/<cwd>/``.
412
+ Validation of ``permission_mode`` values lives in
413
+ ``ClaudeCodeTaskConfig.__post_init__``.
414
+ """
415
+ out: list[str] = []
416
+ if permission_mode is not None:
417
+ out += ["--permission-mode", permission_mode]
418
+ if allowed_tools:
419
+ out += ["--allowed-tools", ",".join(allowed_tools)]
420
+ if disallowed_tools:
421
+ out += ["--disallowed-tools", ",".join(disallowed_tools)]
422
+ if resuming:
423
+ out += ["--continue"]
424
+ return out