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.
- optio_claudecode-0.1.0/PKG-INFO +93 -0
- optio_claudecode-0.1.0/README.md +63 -0
- optio_claudecode-0.1.0/pyproject.toml +51 -0
- optio_claudecode-0.1.0/setup.cfg +4 -0
- optio_claudecode-0.1.0/src/optio_claudecode/__init__.py +48 -0
- optio_claudecode-0.1.0/src/optio_claudecode/host_actions.py +424 -0
- optio_claudecode-0.1.0/src/optio_claudecode/prompt.py +132 -0
- optio_claudecode-0.1.0/src/optio_claudecode/seed_manifest.py +80 -0
- optio_claudecode-0.1.0/src/optio_claudecode/session.py +581 -0
- optio_claudecode-0.1.0/src/optio_claudecode/snapshots.py +102 -0
- optio_claudecode-0.1.0/src/optio_claudecode/types.py +104 -0
- optio_claudecode-0.1.0/src/optio_claudecode.egg-info/PKG-INFO +93 -0
- optio_claudecode-0.1.0/src/optio_claudecode.egg-info/SOURCES.txt +31 -0
- optio_claudecode-0.1.0/src/optio_claudecode.egg-info/dependency_links.txt +1 -0
- optio_claudecode-0.1.0/src/optio_claudecode.egg-info/requires.txt +8 -0
- optio_claudecode-0.1.0/src/optio_claudecode.egg-info/top_level.txt +1 -0
- optio_claudecode-0.1.0/tests/test_home_isolation.py +51 -0
- optio_claudecode-0.1.0/tests/test_host_actions.py +441 -0
- optio_claudecode-0.1.0/tests/test_on_resume_refresh.py +95 -0
- optio_claudecode-0.1.0/tests/test_prompt.py +32 -0
- optio_claudecode-0.1.0/tests/test_resume_prompt.py +53 -0
- optio_claudecode-0.1.0/tests/test_sanity.py +32 -0
- optio_claudecode-0.1.0/tests/test_seed_config.py +67 -0
- optio_claudecode-0.1.0/tests/test_session_blob_hooks.py +133 -0
- optio_claudecode-0.1.0/tests/test_session_hooks.py +101 -0
- optio_claudecode-0.1.0/tests/test_session_local.py +123 -0
- optio_claudecode-0.1.0/tests/test_session_resume.py +226 -0
- optio_claudecode-0.1.0/tests/test_session_resume_decrypt_failure.py +88 -0
- optio_claudecode-0.1.0/tests/test_session_seed_capture.py +93 -0
- optio_claudecode-0.1.0/tests/test_session_seed_consume.py +104 -0
- optio_claudecode-0.1.0/tests/test_session_seed_unknown_id.py +63 -0
- optio_claudecode-0.1.0/tests/test_snapshots.py +92 -0
- 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,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
|