optio-claudecode 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -0,0 +1,132 @@
1
+ """AGENTS.md composer for optio-claudecode.
2
+
3
+ Renders the claudecode resume section and forwards to the shared
4
+ ``optio_agents.prompt.compose_agents_md``. The resume text is byte-identical
5
+ to optio-opencode's, with one added bullet: ``home/.claude/`` (credentials,
6
+ settings, conversation transcript) is preserved across resumes — claudecode
7
+ needs this because all sensitive agent-continuity state lives there.
8
+ """
9
+
10
+ from optio_agents.prompt import (
11
+ BASE_PROMPT_POST,
12
+ compose_agents_md as _compose_agents_md_host,
13
+ )
14
+ from optio_agents.protocol import build_log_channel_prompt
15
+
16
+
17
+ __all__ = ["BASE_PROMPT_POST", "compose_agents_md"]
18
+
19
+
20
+ RESUME_SECTION_TEMPLATE = """## Resumes
21
+
22
+ This harness may pause your session, save your context to a database,
23
+ terminate the underlying process, and later rehydrate it. From your
24
+ point of view the conversation is fully continuous — you keep your
25
+ prior context and will not "notice" the resume.
26
+
27
+ **A resume can happen at any point, not only at the start.** The host
28
+ environment may have changed across a resume — different host,
29
+ different running processes, files outside this workdir gone — even
30
+ though your context remembers everything as alive and well.
31
+
32
+ **The workdir (this directory) is preserved across resumes, with two
33
+ caveats:**
34
+
35
+ - {excludes_clause}
36
+ - **Anything outside the workdir is not preserved.**
37
+
38
+ - **Your `home/.claude/` directory — credentials, settings, and the
39
+ conversation transcript — IS preserved across resumes**, so your
40
+ identity and history travel with you even when the underlying process
41
+ and host change.
42
+
43
+ {outside_clause}
44
+
45
+ ### Detecting a resume: `resume.log`
46
+
47
+ Each session start (fresh or resumed) appends one line to
48
+ `./resume.log`. Line format:
49
+
50
+ ```
51
+ <ISO 8601 UTC timestamp>[ REFRESHED:<comma-separated filenames>]
52
+ ```
53
+
54
+ The very first line is the original launch timestamp; each subsequent
55
+ line is a resume. The optional `REFRESHED:` suffix signals that the
56
+ harness rewrote the listed files on that resume (e.g.
57
+ `2026-05-28T13:15:42Z REFRESHED:AGENTS.md`) — your in-memory copy of
58
+ those files is stale and must be re-read before continuing.
59
+
60
+ **At the start of every new incoming user message, read
61
+ `./resume.log` first.** Compare the latest line to the value you
62
+ remembered last time you checked. If a new line has appeared, treat
63
+ the situation as a resume:
64
+
65
+ - Verify any tools, processes, or files you previously gathered
66
+ outside the workdir are still where you left them.
67
+ - Re-establish anything that's gone (re-launch a server, re-fetch a
68
+ file, etc.) before continuing.
69
+ - **If the latest line carries a `REFRESHED:` suffix, re-read each
70
+ listed file** (e.g. `cat ./AGENTS.md`) — the harness updated it
71
+ since your last context snapshot and the version you remember is
72
+ out of date.
73
+ - Then resume the work you were doing.
74
+
75
+ If a resume slips past unnoticed, a failing tool call is the
76
+ next-best signal — re-check `./resume.log` then.
77
+ """
78
+
79
+
80
+ def _render_resume_section(workdir_exclude: list[str] | None) -> str:
81
+ """Render the RESUME_SECTION_TEMPLATE with the effective exclude list."""
82
+ from optio_host.archive import DEFAULT_WORKDIR_EXCLUDES
83
+ effective = workdir_exclude if workdir_exclude is not None else DEFAULT_WORKDIR_EXCLUDES
84
+ if not effective:
85
+ excludes_clause = (
86
+ "**No paths are excluded** — every file in the workdir is preserved."
87
+ )
88
+ outside_clause = (
89
+ "If you need to stash large data, place it outside the workdir "
90
+ "(e.g. `/tmp/`) — but remember it may be missing when you next look."
91
+ )
92
+ else:
93
+ excludes_str = ", ".join(f"`{p}`" for p in effective)
94
+ excludes_clause = (
95
+ f"**Paths matching the snapshot exclude list are NOT preserved**, "
96
+ f"even inside the workdir. The current exclude list is: {excludes_str}."
97
+ )
98
+ outside_clause = (
99
+ "If you need to stash large data, place it outside the workdir "
100
+ "(e.g. `/tmp/`) or inside an excluded subdirectory — but remember "
101
+ "any such location may be missing when you next look."
102
+ )
103
+ return RESUME_SECTION_TEMPLATE.format(
104
+ excludes_clause=excludes_clause,
105
+ outside_clause=outside_clause,
106
+ )
107
+
108
+
109
+ def compose_agents_md(
110
+ consumer_instructions: str,
111
+ *,
112
+ documentation: str | None = None,
113
+ workdir_exclude: list[str] | None = None,
114
+ supports_resume: bool = True,
115
+ ) -> str:
116
+ """Render <workdir>/AGENTS.md for an optio-claudecode task.
117
+
118
+ Renders the claudecode resume section when ``supports_resume`` is
119
+ True and forwards everything else to the shared host composer.
120
+
121
+ ``documentation`` is the keyword-protocol block; the session passes
122
+ ``get_protocol(browser="redirect").documentation``. Defaults (for
123
+ unit tests / standalone callers) to claudecode's ``redirect`` docs.
124
+ """
125
+ if documentation is None:
126
+ documentation = build_log_channel_prompt("redirect")
127
+ resume_section = _render_resume_section(workdir_exclude) if supports_resume else None
128
+ return _compose_agents_md_host(
129
+ consumer_instructions,
130
+ documentation=documentation,
131
+ resume_section=resume_section,
132
+ )
@@ -0,0 +1,80 @@
1
+ """claudecode adopter of the generic optio-agents seed engine.
2
+
3
+ Defines the claudecode seed manifest (HOME layout + capture-time include
4
+ triage + consume-time rekey), the Mongo collection suffix, and ergonomic
5
+ `delete_seed` / `list_seeds` wrappers that bind the suffix for consuming
6
+ apps.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+
14
+ from optio_agents import seeds
15
+ from optio_host.host import Host
16
+
17
+ _LOG = logging.getLogger(__name__)
18
+
19
+ CLAUDE_SEED_SUFFIX = "_claudecode_seeds"
20
+ CLAUDE_SEED_MANIFEST_VERSION = 1
21
+
22
+
23
+ async def _rekey_claude_json_projects(host: Host) -> None:
24
+ """Rewrite the single `projects` entry in home/.claude.json to the new
25
+ cwd, preserving its value (trust flags, allowedTools, MCP enablement)
26
+ so an autonomous task isn't blocked by claude's trust prompt.
27
+
28
+ Empty / multi-entry / missing / malformed -> left as-is (a fresh trust
29
+ prompt is the safe fallback).
30
+ """
31
+ workdir = host.workdir.rstrip("/")
32
+ path = f"{workdir}/home/.claude.json"
33
+ try:
34
+ raw = await host.fetch_bytes_from_host(path)
35
+ except FileNotFoundError:
36
+ return
37
+ try:
38
+ data = json.loads(raw.decode("utf-8"))
39
+ except (ValueError, UnicodeDecodeError):
40
+ _LOG.warning("seed: .claude.json is not valid JSON; leaving projects as-is")
41
+ return
42
+ projects = data.get("projects")
43
+ if not isinstance(projects, dict) or len(projects) != 1:
44
+ return
45
+ (old_value,) = projects.values()
46
+ data["projects"] = {workdir: old_value}
47
+ await host.put_file_to_host(
48
+ json.dumps(data).encode("utf-8"), path,
49
+ )
50
+
51
+
52
+ CLAUDE_SEED_MANIFEST = seeds.SeedManifest(
53
+ home_subdir="home",
54
+ include=[
55
+ ".claude/.credentials.json",
56
+ ".claude/settings.json",
57
+ ".claude/mcp-needs-auth-cache.json",
58
+ ".claude/plugins",
59
+ ".claude.json",
60
+ ],
61
+ version=CLAUDE_SEED_MANIFEST_VERSION,
62
+ consume_transform=_rekey_claude_json_projects,
63
+ )
64
+
65
+
66
+ async def delete_seed(db, prefix: str, seed_id: str):
67
+ """Delete a claudecode seed doc; returns its GridFS blobId (or None).
68
+
69
+ Ergonomic wrapper binding `CLAUDE_SEED_SUFFIX` so consuming apps don't
70
+ need to know the collection suffix. The caller still removes the
71
+ returned blob from GridFS.
72
+ """
73
+ return await seeds.delete_seed(
74
+ db, prefix=prefix, suffix=CLAUDE_SEED_SUFFIX, seed_id=seed_id,
75
+ )
76
+
77
+
78
+ async def list_seeds(db, prefix: str) -> list[dict]:
79
+ """List claudecode seeds as [{seedId, createdAt}, ...]."""
80
+ return await seeds.list_seeds(db, prefix=prefix, suffix=CLAUDE_SEED_SUFFIX)