zettcode-code-agents 0.4.6__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,42 @@
1
+ **/.venv/
2
+ **/__pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ build/
6
+ dist/
7
+ .smoke/
8
+ .coverage
9
+ **/.pytest_cache/
10
+ **/.ruff_cache/
11
+ .zett-tool-output/
12
+ .env
13
+ .DS_Store
14
+
15
+ # viztracer writes its trace here by default.
16
+ /result.json
17
+
18
+ # The docs site: dependencies, build output, and VitePress' cache.
19
+ node_modules/
20
+ docs/.vitepress/dist/
21
+ docs/.vitepress/cache/
22
+ docs/.vitepress/.temp/
23
+
24
+ # The TypeScript client is built from its sources; only dist/ is published.
25
+ clients/typescript/dist/
26
+
27
+ # The gateway's page is built from clients/gateway into the package when a distribution is made (`make gateway-ui`);
28
+ # the build output is not kept in the repository. pyproject's `artifacts` puts it in the wheel all the same.
29
+ packages/zettcode-gateway/src/zettcode/gateway/static/
30
+
31
+ # The palette gallery is generated by `make palettes`: every widget painted in
32
+ # every palette, written by docs/scripts/palette_gallery.py. Regenerate it
33
+ # rather than committing a 2 MB copy that goes stale the moment a colour moves.
34
+ docs/internal/palettes.html
35
+
36
+ # The release order is generated from the pyproject.toml files (`make order`); it is not kept in git.
37
+ packages/order.txt
38
+
39
+ # The desktop and phone apps build the gateway's page into their own folders, and electron-builder writes its
40
+ # installers under release/.
41
+ clients/desktop/renderer/
42
+ clients/desktop/release/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chang-LeHung
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.5
2
+ Name: zettcode-code-agents
3
+ Version: 0.4.6
4
+ Summary: Gateway plugins that run Codex and Claude Code as persistent external agents
5
+ Project-URL: Repository, https://github.com/Chang-LeHung/zettcode/tree/main/packages/zettcode-code-agents
6
+ Project-URL: Issues, https://github.com/Chang-LeHung/zettcode/issues
7
+ Author: Chang-LeHung
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agents,claude,codex,gateway,zettcode
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Programming Language :: Python :: 3.15
19
+ Classifier: Topic :: Software Development :: User Interfaces
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: zettcode-broker<0.5,>=0.4.6
23
+ Requires-Dist: zettcode-gateway<0.5,>=0.4.6
24
+ Description-Content-Type: text/markdown
25
+
26
+ # zettcode-code-agents
27
+
28
+ Gateway plugins that run **Codex** and **Claude Code** as external agents. Each is a persistent CLI session: the
29
+ session id is kept in `~/.zettcode/code-agents/` (or `$ZETTCODE_CODE_AGENTS_DIR`), so every message continues the same
30
+ conversation.
31
+
32
+ Install the package beside the gateway and restart it:
33
+
34
+ ```bash
35
+ pip install zettcode-code-agents
36
+ ```
37
+
38
+ The New page then offers **Codex** and **Claude Code**. Both register as `kind="external"` sessions; the gateway owner can add one to a
39
+ group, and it can then receive and write there.
40
+
41
+ The gateway only starts an agent. Where it works, how far it is trusted and which command it runs are this plugin's
42
+ settings, kept in its own file and read again **for every message, when its turn starts**, so an edit applies to the
43
+ next message of every agent that is already running, a queued one included. Nothing has to be restarted.
44
+
45
+ ## Settings
46
+
47
+ `~/.zettcode/code-agents/config.json` (or the file `$ZETTCODE_CODE_AGENTS_CONFIG` names). Every key is optional:
48
+
49
+ ```json
50
+ {
51
+ "cwd": "~/work",
52
+ "permission": "full",
53
+ "timeout": 900,
54
+ "codex": { "cwd": "~/work/api", "permission": "workspace" },
55
+ "claude": { "bin": "/opt/bin/claude" }
56
+ }
57
+ ```
58
+
59
+ A key inside `"codex"` or `"claude"` wins over the same key at the top, which wins over the environment variable of the
60
+ same setting, which wins over the default. `bin`, `args` and `resume_args` belong to one tool and are set only inside its
61
+ section.
62
+
63
+ | Key | Default | Meaning |
64
+ | --- | --- | --- |
65
+ | `cwd` | the gateway process's directory | The folder the CLI runs in. It must exist. A relative path is taken from the gateway process's folder, not from this file's. |
66
+ | `permission` | `full` | How far the agent is trusted: `full`, `workspace` or `read`. |
67
+ | `timeout` | `900` | Seconds one turn may take before it is stopped. |
68
+ | `bin`, `args`, `resume_args` | see below | The command, per tool. |
69
+
70
+ ### Permission
71
+
72
+ The default is `full`: the agent runs on its own and may do anything you may, because nobody is there to answer a
73
+ prompt. **That is the plugin's choice, not the gateway's**: starting the gateway with `--no-full` limits the daemons it
74
+ starts and does not limit these agents. Set `"permission"` to `workspace` or `read` to narrow them.
75
+
76
+ | `permission` | Codex | Claude Code |
77
+ | --- | --- | --- |
78
+ | `full` | `--dangerously-bypass-approvals-and-sandbox` | `--permission-mode bypassPermissions` |
79
+ | `workspace` | `--sandbox workspace-write` | `--permission-mode acceptEdits` |
80
+ | `read` | `--sandbox read-only` | `--permission-mode plan` |
81
+
82
+ `codex exec resume` takes no `--sandbox` (checked against `codex-cli 0.160.1`: it answers "unexpected argument"), so a
83
+ resumed Codex turn is given the same choice as a configuration override, `-c sandbox_mode="workspace-write"` (or
84
+ `"read-only"`). That flag is **accepted** by the CLI; that it **limits** a resumed run the way `--sandbox` limits the
85
+ first one has not been verified. If you rely on `workspace` or `read` for Codex, send a second message that tries to
86
+ write outside the folder and see that it is refused, before you trust it.
87
+
88
+ A setting that cannot be used (invalid JSON, a folder that does not exist, an unknown level, a command that names
89
+ a word it is not given, or `{permission}` written inside another word such as `--sandbox={permission}`) fails that
90
+ message with a sentence naming the file and the key, in the agent's reply. Mend the file and send again.
91
+
92
+ ## Commands
93
+
94
+ The commands are also configurable from the environment, which the `args` and `resume_args` keys override:
95
+
96
+ | Variable | Default |
97
+ | --- | --- |
98
+ | `ZETTCODE_CODEX_BIN` | `codex` |
99
+ | `ZETTCODE_CODEX_ARGS` | `exec --json --skip-git-repo-check {permission} {prompt}` |
100
+ | `ZETTCODE_CODEX_RESUME_ARGS` | `exec resume {session} --json {permission} {prompt}` |
101
+ | `ZETTCODE_CLAUDE_BIN` | `claude` |
102
+ | `ZETTCODE_CLAUDE_ARGS` | `{permission} -p {prompt} --output-format json --session-id {session}` |
103
+ | `ZETTCODE_CLAUDE_RESUME_ARGS` | `{permission} -p {prompt} --output-format json --resume {session}` |
104
+ | `ZETTCODE_CODE_AGENTS_CWD` | the gateway process's directory |
105
+ | `ZETTCODE_CODE_AGENTS_DIR` | `~/.zettcode/code-agents` |
106
+ | `ZETTCODE_CODE_AGENTS_CONFIG` | `config.json` in that directory |
107
+ | `ZETTCODE_CODE_AGENTS_QUEUE` | `4` turns waiting (read when the agent starts) |
108
+ | `ZETTCODE_CODE_AGENTS_TIMEOUT` | `900` seconds |
109
+
110
+ Arguments are split with `shlex`, never run by a shell, and receive `{prompt}`, `{session}` and `{cwd}`. The word
111
+ `{permission}` on its own stands for the arguments of the chosen level; a command you write without it is run exactly
112
+ as written, with no trust arguments added.
113
+
114
+ The CLIs must be installed and signed in for the user running the gateway.
@@ -0,0 +1,89 @@
1
+ # zettcode-code-agents
2
+
3
+ Gateway plugins that run **Codex** and **Claude Code** as external agents. Each is a persistent CLI session: the
4
+ session id is kept in `~/.zettcode/code-agents/` (or `$ZETTCODE_CODE_AGENTS_DIR`), so every message continues the same
5
+ conversation.
6
+
7
+ Install the package beside the gateway and restart it:
8
+
9
+ ```bash
10
+ pip install zettcode-code-agents
11
+ ```
12
+
13
+ The New page then offers **Codex** and **Claude Code**. Both register as `kind="external"` sessions; the gateway owner can add one to a
14
+ group, and it can then receive and write there.
15
+
16
+ The gateway only starts an agent. Where it works, how far it is trusted and which command it runs are this plugin's
17
+ settings, kept in its own file and read again **for every message, when its turn starts**, so an edit applies to the
18
+ next message of every agent that is already running, a queued one included. Nothing has to be restarted.
19
+
20
+ ## Settings
21
+
22
+ `~/.zettcode/code-agents/config.json` (or the file `$ZETTCODE_CODE_AGENTS_CONFIG` names). Every key is optional:
23
+
24
+ ```json
25
+ {
26
+ "cwd": "~/work",
27
+ "permission": "full",
28
+ "timeout": 900,
29
+ "codex": { "cwd": "~/work/api", "permission": "workspace" },
30
+ "claude": { "bin": "/opt/bin/claude" }
31
+ }
32
+ ```
33
+
34
+ A key inside `"codex"` or `"claude"` wins over the same key at the top, which wins over the environment variable of the
35
+ same setting, which wins over the default. `bin`, `args` and `resume_args` belong to one tool and are set only inside its
36
+ section.
37
+
38
+ | Key | Default | Meaning |
39
+ | --- | --- | --- |
40
+ | `cwd` | the gateway process's directory | The folder the CLI runs in. It must exist. A relative path is taken from the gateway process's folder, not from this file's. |
41
+ | `permission` | `full` | How far the agent is trusted: `full`, `workspace` or `read`. |
42
+ | `timeout` | `900` | Seconds one turn may take before it is stopped. |
43
+ | `bin`, `args`, `resume_args` | see below | The command, per tool. |
44
+
45
+ ### Permission
46
+
47
+ The default is `full`: the agent runs on its own and may do anything you may, because nobody is there to answer a
48
+ prompt. **That is the plugin's choice, not the gateway's**: starting the gateway with `--no-full` limits the daemons it
49
+ starts and does not limit these agents. Set `"permission"` to `workspace` or `read` to narrow them.
50
+
51
+ | `permission` | Codex | Claude Code |
52
+ | --- | --- | --- |
53
+ | `full` | `--dangerously-bypass-approvals-and-sandbox` | `--permission-mode bypassPermissions` |
54
+ | `workspace` | `--sandbox workspace-write` | `--permission-mode acceptEdits` |
55
+ | `read` | `--sandbox read-only` | `--permission-mode plan` |
56
+
57
+ `codex exec resume` takes no `--sandbox` (checked against `codex-cli 0.160.1`: it answers "unexpected argument"), so a
58
+ resumed Codex turn is given the same choice as a configuration override, `-c sandbox_mode="workspace-write"` (or
59
+ `"read-only"`). That flag is **accepted** by the CLI; that it **limits** a resumed run the way `--sandbox` limits the
60
+ first one has not been verified. If you rely on `workspace` or `read` for Codex, send a second message that tries to
61
+ write outside the folder and see that it is refused, before you trust it.
62
+
63
+ A setting that cannot be used (invalid JSON, a folder that does not exist, an unknown level, a command that names
64
+ a word it is not given, or `{permission}` written inside another word such as `--sandbox={permission}`) fails that
65
+ message with a sentence naming the file and the key, in the agent's reply. Mend the file and send again.
66
+
67
+ ## Commands
68
+
69
+ The commands are also configurable from the environment, which the `args` and `resume_args` keys override:
70
+
71
+ | Variable | Default |
72
+ | --- | --- |
73
+ | `ZETTCODE_CODEX_BIN` | `codex` |
74
+ | `ZETTCODE_CODEX_ARGS` | `exec --json --skip-git-repo-check {permission} {prompt}` |
75
+ | `ZETTCODE_CODEX_RESUME_ARGS` | `exec resume {session} --json {permission} {prompt}` |
76
+ | `ZETTCODE_CLAUDE_BIN` | `claude` |
77
+ | `ZETTCODE_CLAUDE_ARGS` | `{permission} -p {prompt} --output-format json --session-id {session}` |
78
+ | `ZETTCODE_CLAUDE_RESUME_ARGS` | `{permission} -p {prompt} --output-format json --resume {session}` |
79
+ | `ZETTCODE_CODE_AGENTS_CWD` | the gateway process's directory |
80
+ | `ZETTCODE_CODE_AGENTS_DIR` | `~/.zettcode/code-agents` |
81
+ | `ZETTCODE_CODE_AGENTS_CONFIG` | `config.json` in that directory |
82
+ | `ZETTCODE_CODE_AGENTS_QUEUE` | `4` turns waiting (read when the agent starts) |
83
+ | `ZETTCODE_CODE_AGENTS_TIMEOUT` | `900` seconds |
84
+
85
+ Arguments are split with `shlex`, never run by a shell, and receive `{prompt}`, `{session}` and `{cwd}`. The word
86
+ `{permission}` on its own stands for the arguments of the chosen level; a command you write without it is run exactly
87
+ as written, with no trust arguments added.
88
+
89
+ The CLIs must be installed and signed in for the user running the gateway.
@@ -0,0 +1,61 @@
1
+ [project]
2
+ name = "zettcode-code-agents"
3
+ description = "Gateway plugins that run Codex and Claude Code as persistent external agents"
4
+ readme = "README.md"
5
+ requires-python = ">=3.10"
6
+ dynamic = ["version"]
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{name = "Chang-LeHung"}]
10
+ keywords = ["zettcode", "gateway", "codex", "claude", "agents"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Console",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Programming Language :: Python :: 3.15",
20
+ "Topic :: Software Development :: User Interfaces",
21
+ "Typing :: Typed",
22
+ ]
23
+ dependencies = [
24
+ "zettcode-broker>=0.4.6,<0.5",
25
+ "zettcode-gateway>=0.4.6,<0.5",
26
+ ]
27
+
28
+ [project.urls]
29
+ Repository = "https://github.com/Chang-LeHung/zettcode/tree/main/packages/zettcode-code-agents"
30
+ Issues = "https://github.com/Chang-LeHung/zettcode/issues"
31
+
32
+ [project.entry-points."zettcode.gateway.external"]
33
+ codex = "zettcode.code_agents:CodexAgent"
34
+ claude = "zettcode.code_agents:ClaudeCodeAgent"
35
+
36
+ [tool.zettcode]
37
+ # A gateway plugin: installed beside the program, released on the plugin line.
38
+ plugin = true
39
+
40
+ [build-system]
41
+ requires = ["hatchling"]
42
+ build-backend = "hatchling.build"
43
+
44
+ [tool.hatch.version]
45
+ path = "src/zettcode/code_agents/__init__.py"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/zettcode"]
49
+
50
+ [tool.hatch.build.targets.sdist]
51
+ include = ["/src", "/tests", "/README.md", "/LICENSE", "/pyproject.toml"]
52
+
53
+ [dependency-groups]
54
+ # What the tests import beyond the plugin itself; the release workflow installs exactly this into the fresh environment it
55
+ # tests the built plugin in, so a name missing here passes `make test` (the workspace has it) and fails the release. The
56
+ # turns are `async def`, which needs pytest-asyncio.
57
+ dev = ["pytest>=8,<9", "pytest-asyncio>=0.26,<2"]
58
+
59
+ [tool.pytest.ini_options]
60
+ testpaths = ["tests"]
61
+ asyncio_mode = "auto"
@@ -0,0 +1,343 @@
1
+ """Codex and Claude Code as persistent gateway agents.
2
+
3
+ Each is an :class:`~zettcode.broker.external.ExternalSession` of its own whose handler runs the tool's own CLI.
4
+ The CLI's session id is kept in ``~/.zettcode/code-agents`` (or ``$ZETTCODE_CODE_AGENTS_DIR``), so the next message
5
+ continues the same conversation. The gateway only sees ordinary external sessions; nothing here re-implements a
6
+ model, a tool loop or a conversation store.
7
+
8
+ The commands can be changed without changing this package, and so can where the agents work and how far they are
9
+ trusted: see :mod:`zettcode.code_agents.config`. Those settings live in ``~/.zettcode/code-agents/config.json`` and are
10
+ read again for every message, so an edit applies to the next one with no restart. The environment variables still work
11
+ for the command itself:
12
+
13
+ * ``ZETTCODE_CODEX_ARGS`` / ``ZETTCODE_CODEX_RESUME_ARGS``
14
+ * ``ZETTCODE_CLAUDE_ARGS`` / ``ZETTCODE_CLAUDE_RESUME_ARGS``
15
+
16
+ They are split with :mod:`shlex` and receive ``{prompt}``, ``{session}`` and ``{cwd}``; they are never run by a shell.
17
+ The word ``{permission}`` on its own stands for the arguments of the trust level the settings choose.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import base64
24
+ import json
25
+ import math
26
+ import os
27
+ import shlex
28
+ import signal
29
+ import uuid
30
+ from dataclasses import dataclass
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ from zettcode.broker.external import ExternalSession
35
+ from zettcode.broker.models import Incoming, Receipt
36
+ from zettcode.broker.protocol import KIND_EXTERNAL
37
+ from zettcode.code_agents.config import (
38
+ TIMEOUT,
39
+ ConfigError,
40
+ Settings,
41
+ Tool,
42
+ resolve,
43
+ state_directory,
44
+ work_directory,
45
+ )
46
+ from zettcode.gateway.agents import AgentPlugin
47
+
48
+ #: The one place this plugin's version is written.
49
+ __version__ = "0.4.6"
50
+
51
+
52
+ def _timeout() -> float:
53
+ """Return how long one turn may run without a setting, read from the environment and checked before use."""
54
+ raw = os.environ.get("ZETTCODE_CODE_AGENTS_TIMEOUT", str(TIMEOUT))
55
+ try:
56
+ value = float(raw)
57
+ except ValueError:
58
+ raise RuntimeError(f"ZETTCODE_CODE_AGENTS_TIMEOUT must be a number of seconds, not {raw!r}") from None
59
+ if not math.isfinite(value) or value <= 0:
60
+ raise RuntimeError("ZETTCODE_CODE_AGENTS_TIMEOUT must be a positive number of seconds")
61
+ return value
62
+
63
+
64
+ def _logo(name: str) -> str:
65
+ """Return one of the package's brand marks as a data URL."""
66
+ path = Path(__file__).with_name("logos") / name
67
+ try:
68
+ return "data:image/svg+xml;base64," + base64.b64encode(path.read_bytes()).decode()
69
+ except OSError:
70
+ return ""
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class Command:
75
+ """One tool's command line, new and resumed."""
76
+
77
+ binary: str
78
+ new_args: str
79
+ resume_args: str
80
+
81
+ def argv(
82
+ self, prompt: str, session: str, cwd: Path, *, resume: bool = True, permission: tuple[str, ...] = ()
83
+ ) -> list[str]:
84
+ """Return the command to run. The word ``{permission}``, alone, becomes the arguments of the trust level.
85
+
86
+ A template without that word is the person's own command line and is run exactly as written.
87
+ """
88
+ template = self.resume_args if resume and session and "{session}" in self.resume_args else self.new_args
89
+ values = {"prompt": prompt, "session": session, "cwd": str(cwd)}
90
+ argv = [self.binary]
91
+ for part in shlex.split(template):
92
+ if part == "{permission}":
93
+ argv.extend(permission)
94
+ else:
95
+ argv.append(part.format(**values))
96
+ return argv
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class Launch:
101
+ """What one turn runs with: the command, where, for how long, and the trust arguments for a new and a resumed turn."""
102
+
103
+ command: Command
104
+ cwd: Path
105
+ timeout: float
106
+ new_flags: tuple[str, ...]
107
+ resume_flags: tuple[str, ...]
108
+
109
+
110
+ def _events(stdout: str) -> tuple[str, str]:
111
+ """Return the last text and session id in a JSON or JSONL answer."""
112
+ text = ""
113
+ session = ""
114
+ for line in stdout.splitlines():
115
+ try:
116
+ event = json.loads(line)
117
+ except ValueError:
118
+ continue
119
+ if not isinstance(event, dict):
120
+ continue
121
+ for key in ("session_id", "thread_id"):
122
+ value = event.get(key)
123
+ if isinstance(value, str) and value:
124
+ session = value
125
+ for key in ("result", "text", "message", "content"):
126
+ value = event.get(key)
127
+ if isinstance(value, str) and value.strip():
128
+ text = value
129
+ item = event.get("item")
130
+ if isinstance(item, dict) and isinstance(item.get("text"), str):
131
+ text = item["text"]
132
+ return text, session
133
+
134
+
135
+ class CliAgent(ExternalSession):
136
+ """An external session whose one handler runs a CLI and keeps its session id."""
137
+
138
+ def __init__(self, session_id: str, title: str, source: Tool | Command) -> None:
139
+ self._turns: set[asyncio.Task[None]] = set()
140
+ self._limit = max(1, int(os.environ.get("ZETTCODE_CODE_AGENTS_QUEUE", "4")))
141
+ super().__init__(session_id, title, kind=KIND_EXTERNAL, on_message=self._start_turn)
142
+ #: A tool's settings are read again for every turn; a bare command (a caller's own) is run as it is given.
143
+ self.source = source
144
+ self.state = state_directory() / f"{session_id}.json"
145
+ self._lock = asyncio.Lock()
146
+ self._process: asyncio.subprocess.Process | None = None
147
+
148
+ def _start_turn(self, message: Incoming) -> Receipt:
149
+ """Answer the broker now and run one CLI turn on a task."""
150
+ if len(self._turns) >= self._limit:
151
+ return Receipt(False, reason=f"{self.title} has {self._limit} turns waiting", busy=True)
152
+ task = asyncio.ensure_future(self._turn(message))
153
+ self._turns.add(task)
154
+ task.add_done_callback(self._turns.discard)
155
+ return Receipt(True, disposition="queued")
156
+
157
+ async def _turn(self, message: Incoming) -> None:
158
+ async with self._lock:
159
+ await self.set_status("working")
160
+ try:
161
+ answer = await self._run(message.text)
162
+ except Exception as error: # noqa: BLE001 - the person gets the reason, the agent stays alive
163
+ answer = f"{self.title} failed: {error}"
164
+ finally:
165
+ await self.set_status("idle")
166
+ if message.group:
167
+ await self.send_group(message.group, answer)
168
+ else:
169
+ await self.reply(message, answer)
170
+
171
+ def _resolve(self) -> Launch:
172
+ """Return what this turn runs with, read now: a person's edit of the settings applies from the next message."""
173
+ source = self.source
174
+ if isinstance(source, Command):
175
+ return Launch(source, work_directory(), _timeout(), (), ())
176
+ settings: Settings = resolve(source, timeout=_timeout())
177
+ command = Command(settings.binary, settings.args, settings.resume_args)
178
+ return Launch(
179
+ command,
180
+ settings.cwd,
181
+ settings.timeout,
182
+ source.flags[settings.permission],
183
+ source.resume_flags[settings.permission],
184
+ )
185
+
186
+ async def _run(self, prompt: str) -> str:
187
+ launch = self._resolve()
188
+ command, timeout = launch.command, launch.timeout
189
+ session = self._session()
190
+ resumed = bool(session)
191
+ if not session and "session-id" in command.new_args:
192
+ session = str(uuid.uuid4())
193
+ # The trust arguments follow the template that is used: a resume template without {session} is the new one.
194
+ continuing = resumed and "{session}" in command.resume_args
195
+ argv = command.argv(
196
+ prompt,
197
+ session,
198
+ launch.cwd,
199
+ resume=resumed,
200
+ permission=launch.resume_flags if continuing else launch.new_flags,
201
+ )
202
+ options: dict[str, Any] = {}
203
+ if os.name != "nt":
204
+ options["start_new_session"] = True
205
+ self._process = await asyncio.create_subprocess_exec(
206
+ *argv,
207
+ cwd=str(launch.cwd),
208
+ stdout=asyncio.subprocess.PIPE,
209
+ stderr=asyncio.subprocess.PIPE,
210
+ **options,
211
+ )
212
+ process = self._process
213
+ try:
214
+ stdout, stderr = await asyncio.wait_for(process.communicate(), timeout)
215
+ except asyncio.TimeoutError:
216
+ self._kill(process)
217
+ await process.wait()
218
+ raise RuntimeError(f"the {self.title} command did not finish in {timeout:g} seconds") from None
219
+ finally:
220
+ self._process = None
221
+ output = stdout.decode(errors="replace")
222
+ error = stderr.decode(errors="replace").strip()
223
+ if process.returncode not in (None, 0):
224
+ raise RuntimeError(error or f"exit code {process.returncode}")
225
+ text, found = _events(output)
226
+ if found:
227
+ self._save(found)
228
+ if text:
229
+ return text
230
+ if output.strip():
231
+ return output.strip()
232
+ raise RuntimeError(error or "the command said nothing")
233
+
234
+ async def close(self) -> None:
235
+ """Stop a running CLI process before closing the session."""
236
+ process, self._process = self._process, None
237
+ tasks, self._turns = list(self._turns), set()
238
+ for task in tasks:
239
+ task.cancel()
240
+ if tasks:
241
+ await asyncio.gather(*tasks, return_exceptions=True)
242
+ if process is not None and process.returncode is None:
243
+ self._kill(process)
244
+ await process.wait()
245
+ await super().close()
246
+
247
+ @staticmethod
248
+ def _kill(process: asyncio.subprocess.Process) -> None:
249
+ """Kill the CLI and the children it forked, when the platform gives the process a session."""
250
+ if os.name == "nt":
251
+ process.kill()
252
+ return
253
+ try:
254
+ os.killpg(os.getpgid(process.pid), signal.SIGKILL)
255
+ except (ProcessLookupError, PermissionError):
256
+ process.kill()
257
+
258
+ def _session(self) -> str:
259
+ try:
260
+ data = json.loads(self.state.read_text(encoding="utf-8"))
261
+ except (OSError, ValueError):
262
+ return ""
263
+ value = data.get("session") if isinstance(data, dict) else None
264
+ return value if isinstance(value, str) else ""
265
+
266
+ def _save(self, session: str) -> None:
267
+ self.state.parent.mkdir(parents=True, exist_ok=True)
268
+ temporary = self.state.with_suffix(".tmp")
269
+ temporary.write_text(json.dumps({"session": session}), encoding="utf-8")
270
+ os.replace(temporary, self.state)
271
+
272
+
273
+ _CODEX_FULL = ("--dangerously-bypass-approvals-and-sandbox",)
274
+
275
+ #: Codex. ``exec`` takes ``--sandbox``; ``exec resume`` does not, and takes the same choice as a config override.
276
+ CODEX = Tool(
277
+ name="codex",
278
+ env="ZETTCODE_CODEX",
279
+ binary="codex",
280
+ args="exec --json --skip-git-repo-check {permission} {prompt}",
281
+ resume_args="exec resume {session} --json {permission} {prompt}",
282
+ flags={
283
+ "full": _CODEX_FULL,
284
+ "workspace": ("--sandbox", "workspace-write"),
285
+ "read": ("--sandbox", "read-only"),
286
+ },
287
+ resume_flags={
288
+ "full": _CODEX_FULL,
289
+ "workspace": ("-c", 'sandbox_mode="workspace-write"'),
290
+ "read": ("-c", 'sandbox_mode="read-only"'),
291
+ },
292
+ )
293
+
294
+ _CLAUDE_FLAGS = {
295
+ "full": ("--permission-mode", "bypassPermissions"),
296
+ "workspace": ("--permission-mode", "acceptEdits"),
297
+ "read": ("--permission-mode", "plan"),
298
+ }
299
+
300
+ #: Claude Code, which takes the same ``--permission-mode`` for a new conversation and a resumed one.
301
+ CLAUDE = Tool(
302
+ name="claude",
303
+ env="ZETTCODE_CLAUDE",
304
+ binary="claude",
305
+ args="{permission} -p {prompt} --output-format json --session-id {session}",
306
+ resume_args="{permission} -p {prompt} --output-format json --resume {session}",
307
+ flags=_CLAUDE_FLAGS,
308
+ resume_flags=_CLAUDE_FLAGS,
309
+ )
310
+
311
+
312
+ class CodexAgent(AgentPlugin):
313
+ name = CODEX.name
314
+ title = "Codex"
315
+ description = "OpenAI Codex CLI, in the same session every time"
316
+ avatar = _logo("codex.svg")
317
+
318
+ def create(self, session_id: str, title: str) -> ExternalSession:
319
+ return CliAgent(session_id, title, CODEX)
320
+
321
+
322
+ class ClaudeCodeAgent(AgentPlugin):
323
+ name = CLAUDE.name
324
+ title = "Claude Code"
325
+ description = "Anthropic Claude Code, in the same session every time"
326
+ avatar = _logo("claude.svg")
327
+
328
+ def create(self, session_id: str, title: str) -> ExternalSession:
329
+ return CliAgent(session_id, title, CLAUDE)
330
+
331
+
332
+ __all__ = [
333
+ "CLAUDE",
334
+ "CODEX",
335
+ "ClaudeCodeAgent",
336
+ "CliAgent",
337
+ "CodexAgent",
338
+ "Command",
339
+ "ConfigError",
340
+ "Launch",
341
+ "state_directory",
342
+ "work_directory",
343
+ ]
@@ -0,0 +1,199 @@
1
+ """The settings of the Codex and Claude Code agents, which belong to this plugin and not to the gateway.
2
+
3
+ The gateway only starts an agent. Where it works, how far it is trusted and which command it runs are written in one
4
+ JSON file, ``~/.zettcode/code-agents/config.json`` (``$ZETTCODE_CODE_AGENTS_CONFIG`` names another), and the file is
5
+ read again when each message arrives, so an edit applies to the next message of every agent that is already running::
6
+
7
+ {
8
+ "cwd": "~/work",
9
+ "permission": "full",
10
+ "timeout": 900,
11
+ "codex": {"cwd": "~/work/api", "permission": "workspace"},
12
+ "claude": {"bin": "/opt/bin/claude"}
13
+ }
14
+
15
+ A key inside ``"codex"`` or ``"claude"`` wins over the same key at the top; that wins over the environment variable
16
+ this plugin read before there was a file; that wins over the default. ``bin``, ``args`` and ``resume_args`` belong to
17
+ one tool and have no top-level form. A relative ``cwd`` is taken from the gateway process's folder, not the file's.
18
+
19
+ ``permission`` is how far the agent is trusted, and the plugin turns it into the arguments each CLI takes:
20
+
21
+ * ``full`` (the default): the agent may do anything the user may, with nothing asked.
22
+ * ``workspace``: it may write inside its folder; the rest of the machine is read-only or closed.
23
+ * ``read``: it may look and not change.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import math
30
+ import os
31
+ import shlex
32
+ import string
33
+ from collections.abc import Mapping
34
+ from dataclasses import dataclass
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ #: The trust levels, safest last. Each tool maps every one of them to its own arguments.
39
+ PERMISSIONS = ("full", "workspace", "read")
40
+
41
+ #: What an agent is trusted with when nothing says otherwise: it runs on its own, and nobody is there to answer a prompt.
42
+ DEFAULT_PERMISSION = "full"
43
+
44
+ #: How long one CLI turn may take before it is stopped.
45
+ TIMEOUT = 900.0
46
+
47
+
48
+ class ConfigError(RuntimeError):
49
+ """The settings cannot be used. The sentence names the file and the key, so it can be shown to the person as is."""
50
+
51
+
52
+ def state_directory() -> Path:
53
+ """Return where the CLI session ids and the settings are kept."""
54
+ return Path(os.environ.get("ZETTCODE_CODE_AGENTS_DIR", str(Path.home() / ".zettcode" / "code-agents"))).expanduser()
55
+
56
+
57
+ def work_directory() -> Path:
58
+ """Return the directory the CLI runs in when the settings do not name one."""
59
+ return Path(os.environ.get("ZETTCODE_CODE_AGENTS_CWD", str(Path.cwd()))).expanduser()
60
+
61
+
62
+ def config_path() -> Path:
63
+ """Return the settings file."""
64
+ return Path(os.environ.get("ZETTCODE_CODE_AGENTS_CONFIG", str(state_directory() / "config.json"))).expanduser()
65
+
66
+
67
+ @dataclass(frozen=True, slots=True)
68
+ class Tool:
69
+ """What is known about one CLI before any settings: its name, its default command and what each trust level adds."""
70
+
71
+ name: str
72
+ #: The prefix of the environment variables that set this tool's command (``ZETTCODE_CODEX`` for ``_BIN``...).
73
+ env: str
74
+ binary: str
75
+ args: str
76
+ resume_args: str
77
+ #: The arguments each permission adds to a new conversation, and to a resumed one (a CLI may differ).
78
+ flags: Mapping[str, tuple[str, ...]]
79
+ resume_flags: Mapping[str, tuple[str, ...]]
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class Settings:
84
+ """Everything one CLI turn runs with, resolved from the file, the environment and the defaults at that moment."""
85
+
86
+ cwd: Path
87
+ permission: str
88
+ timeout: float
89
+ binary: str
90
+ args: str
91
+ resume_args: str
92
+
93
+
94
+ def read_config() -> dict[str, Any]:
95
+ """Read the settings file now. A missing or empty file is no settings; one that cannot be used is a ConfigError."""
96
+ path = config_path()
97
+ try:
98
+ text = path.read_text(encoding="utf-8")
99
+ except FileNotFoundError:
100
+ return {}
101
+ except OSError as error:
102
+ raise ConfigError(f"{path} cannot be read: {error.strerror or error}") from error
103
+ if not text.strip():
104
+ return {}
105
+ try:
106
+ data = json.loads(text)
107
+ except ValueError as error:
108
+ raise ConfigError(f"{path} is not valid JSON: {error}") from None
109
+ if not isinstance(data, dict):
110
+ raise ConfigError(f"{path} must hold one JSON object")
111
+ return data
112
+
113
+
114
+ def _text(key: str, value: object) -> str:
115
+ if not isinstance(value, str) or not value.strip():
116
+ raise ConfigError(f'"{key}" in {config_path()} must be a non-empty string')
117
+ return value
118
+
119
+
120
+ #: The words a command template may name besides ``{permission}``, which must stand alone.
121
+ _FIELDS = frozenset({"prompt", "session", "cwd"})
122
+
123
+
124
+ def _template(key: str, value: object) -> str:
125
+ """Return a command template, refusing one that would fail on every message.
126
+
127
+ ``{permission}`` becomes several arguments, so it can only be a word of its own (``--sandbox={permission}`` cannot
128
+ work); any other word it names must be one the command is given. Both are caught here, once, naming the key.
129
+ """
130
+ text = _text(key, value)
131
+ where = f'"{key}" in {config_path()}'
132
+ try:
133
+ parts = shlex.split(text)
134
+ except ValueError as error:
135
+ raise ConfigError(f"{where} is not a command line: {error}") from None
136
+ for part in parts:
137
+ if part == "{permission}":
138
+ continue
139
+ try:
140
+ names = [name for _, name, _, _ in string.Formatter().parse(part) if name is not None]
141
+ except ValueError as error:
142
+ raise ConfigError(f"{where} has a word that cannot be filled in, {part!r}: {error}") from None
143
+ for name in names:
144
+ field = name.split(".")[0].split("[")[0]
145
+ if field == "permission":
146
+ raise ConfigError(f"{where}: {{permission}} must be a word of its own, not part of {part!r}")
147
+ if field not in _FIELDS:
148
+ raise ConfigError(
149
+ f"{where} names {{{field}}}, which is not one of {{prompt}}, {{session}}, {{cwd}} or {{permission}}"
150
+ )
151
+ return text
152
+
153
+
154
+ def resolve(tool: Tool, *, timeout: float = TIMEOUT) -> Settings:
155
+ """Return the settings of one tool as they are now.
156
+
157
+ Args:
158
+ tool: The CLI.
159
+ timeout: The timeout when neither the file nor the tool's section sets one.
160
+
161
+ Raises:
162
+ ConfigError: The file cannot be read or parsed, a value has the wrong type, ``permission`` is not a level, or
163
+ ``cwd`` is not a folder.
164
+ """
165
+ data = read_config()
166
+ section = data.get(tool.name, {})
167
+ if not isinstance(section, dict):
168
+ raise ConfigError(f'"{tool.name}" in {config_path()} must be an object')
169
+
170
+ def value(key: str, fallback: object, *, shared: bool = True) -> object:
171
+ if key in section:
172
+ return section[key]
173
+ if shared and key in data:
174
+ return data[key]
175
+ return fallback
176
+
177
+ folder = Path(_text("cwd", value("cwd", str(work_directory())))).expanduser().resolve()
178
+ if not folder.is_dir():
179
+ raise ConfigError(f'the folder {folder} ("cwd" in {config_path()}) does not exist')
180
+
181
+ permission = _text("permission", value("permission", DEFAULT_PERMISSION))
182
+ if permission not in tool.flags:
183
+ raise ConfigError(f'"permission" in {config_path()} is full, workspace or read, not {permission!r}')
184
+
185
+ limit = value("timeout", timeout)
186
+ if isinstance(limit, bool) or not isinstance(limit, (int, float)) or not math.isfinite(limit) or limit <= 0:
187
+ raise ConfigError(f'"timeout" in {config_path()} must be a positive number of seconds')
188
+
189
+ return Settings(
190
+ cwd=folder,
191
+ permission=permission,
192
+ timeout=float(limit),
193
+ binary=_text("bin", value("bin", os.environ.get(f"{tool.env}_BIN", tool.binary), shared=False)),
194
+ args=_template("args", value("args", os.environ.get(f"{tool.env}_ARGS", tool.args), shared=False)),
195
+ resume_args=_template(
196
+ "resume_args",
197
+ value("resume_args", os.environ.get(f"{tool.env}_RESUME_ARGS", tool.resume_args), shared=False),
198
+ ),
199
+ )
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
2
+ <title>Claude</title>
3
+ <rect width="24" height="24" rx="5" fill="#F0EEE6"/>
4
+ <path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z" fill="#D97757" fill-rule="nonzero"/>
5
+ </svg>
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
2
+ <title>Codex</title>
3
+ <rect width="24" height="24" rx="5" fill="#ffffff"/>
4
+ <path d="M9.064 3.344a4.578 4.578 0 012.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 00.043 0 4.55 4.55 0 013.046.275l.047.022.116.057a4.581 4.581 0 012.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 01-.134 1.223.123.123 0 00.03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 01-2.201 1.388.123.123 0 00-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 00-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 01-1.945-.466 4.544 4.544 0 01-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 01-.37-.961 4.582 4.582 0 01-.014-2.298.124.124 0 00.006-.056.085.085 0 00-.027-.048 4.467 4.467 0 01-1.034-1.651 3.896 3.896 0 01-.251-1.192 5.189 5.189 0 01.141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 00.065-.066 4.51 4.51 0 01.829-1.615 4.535 4.535 0 011.837-1.388zm3.482 10.565a.637.637 0 000 1.272h3.636a.637.637 0 100-1.272h-3.636zM8.462 9.23a.637.637 0 00-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 101.095.649l1.454-2.455a.636.636 0 00.005-.64L8.462 9.23z" fill="url(#codex-gradient)"/>
5
+ <defs>
6
+ <linearGradient id="codex-gradient" gradientUnits="userSpaceOnUse" x1="12" x2="12" y1="3" y2="21">
7
+ <stop stop-color="#B1A7FF"/>
8
+ <stop offset=".5" stop-color="#7A9DFF"/>
9
+ <stop offset="1" stop-color="#3941FF"/>
10
+ </linearGradient>
11
+ </defs>
12
+ </svg>
@@ -0,0 +1,305 @@
1
+ """The CLI command shape and the two gateway plugins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import dataclasses
7
+ import json
8
+ import os
9
+ from collections.abc import Callable
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import pytest
14
+
15
+ from zettcode.code_agents import (
16
+ CLAUDE,
17
+ CODEX,
18
+ ClaudeCodeAgent,
19
+ CliAgent,
20
+ CodexAgent,
21
+ Command,
22
+ ConfigError,
23
+ _events,
24
+ _timeout,
25
+ )
26
+ from zettcode.code_agents.config import DEFAULT_PERMISSION, resolve
27
+
28
+
29
+ class _Done:
30
+ """A finished child process, as ``asyncio.create_subprocess_exec`` hands one back."""
31
+
32
+ returncode = 0
33
+
34
+ def __init__(self, stdout: str) -> None:
35
+ self._stdout = stdout
36
+
37
+ async def communicate(self) -> tuple[bytes, bytes]:
38
+ return self._stdout.encode(), b""
39
+
40
+ async def wait(self) -> int:
41
+ return 0
42
+
43
+
44
+ @pytest.fixture
45
+ def cli(monkeypatch) -> Callable[[Callable[[list[str], Path], str]], None]:
46
+ """Replace starting a program with a function from ``(argv, cwd)`` to what the program prints.
47
+
48
+ The plugin's own work is building the command line, choosing the folder, and keeping the session the CLI names. None of
49
+ that needs a real program, and a script cannot stand in for one on every platform (Windows will not run a file that is
50
+ not an executable), so the tests that are about those things use this; one test below runs a real child on POSIX.
51
+ """
52
+
53
+ def install(program: Callable[[list[str], Path], str]) -> None:
54
+ async def start(*argv: str, cwd: str, **_: Any) -> _Done:
55
+ return _Done(program(list(argv), Path(cwd)))
56
+
57
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", start)
58
+
59
+ return install
60
+
61
+
62
+ def test_a_command_uses_the_resume_form_when_a_session_is_known(tmp_path):
63
+ command = Command(
64
+ "claude",
65
+ "-p {prompt} --session-id {session}",
66
+ "-p {prompt} --resume {session}",
67
+ )
68
+
69
+ assert command.argv("hello", "new-id", tmp_path, resume=False) == [
70
+ "claude",
71
+ "-p",
72
+ "hello",
73
+ "--session-id",
74
+ "new-id",
75
+ ]
76
+ assert command.argv("hello", "abc", tmp_path) == ["claude", "-p", "hello", "--resume", "abc"]
77
+
78
+
79
+ def test_the_json_answer_and_session_id_are_read():
80
+ stdout = "\n".join(
81
+ json.dumps(event)
82
+ for event in (
83
+ {"session_id": "s-1", "result": ""},
84
+ {"type": "item.completed", "item": {"text": "done"}},
85
+ )
86
+ )
87
+
88
+ assert _events(stdout) == ("done", "s-1")
89
+
90
+
91
+ def test_both_plugins_carry_a_name_and_a_logo():
92
+ assert CodexAgent.name == "codex" and CodexAgent.avatar.startswith("data:image/svg+xml;base64,")
93
+ assert ClaudeCodeAgent.name == "claude" and ClaudeCodeAgent.avatar.startswith("data:image/svg+xml;base64,")
94
+
95
+
96
+ def test_a_bad_timeout_is_refused_before_a_command_starts(monkeypatch):
97
+ monkeypatch.setenv("ZETTCODE_CODE_AGENTS_TIMEOUT", "nine")
98
+ with pytest.raises(RuntimeError, match="number of seconds"):
99
+ _timeout()
100
+
101
+ monkeypatch.setenv("ZETTCODE_CODE_AGENTS_TIMEOUT", "0")
102
+ with pytest.raises(RuntimeError, match="positive"):
103
+ _timeout()
104
+
105
+ monkeypatch.delenv("ZETTCODE_CODE_AGENTS_TIMEOUT")
106
+ assert _timeout() > 0
107
+
108
+
109
+ @pytest.mark.skipif(
110
+ os.name == "nt", reason="the stand-in is a script with a shebang, which Windows will not run directly"
111
+ )
112
+ async def test_a_real_child_process_turn_saves_the_session_and_returns_its_text(tmp_path, monkeypatch):
113
+ script = tmp_path / "fake"
114
+ script.write_text(
115
+ "#!/usr/bin/env python3\nimport json, sys\nprint(json.dumps({'session_id': 's-1', 'result': sys.argv[1]}))\n",
116
+ encoding="utf-8",
117
+ )
118
+ script.chmod(0o755)
119
+ monkeypatch.setenv("ZETTCODE_CODE_AGENTS_DIR", str(tmp_path / "state"))
120
+
121
+ agent = CliAgent("agent-1", "Fake", Command(str(script), "{prompt}", "resume {session} {prompt}"))
122
+
123
+ assert await agent._run("hello") == "hello"
124
+ assert json.loads((tmp_path / "state" / "agent-1.json").read_text()) == {"session": "s-1"}
125
+
126
+
127
+ async def test_a_cli_turn_saves_the_session_and_returns_its_text(tmp_path, monkeypatch, cli):
128
+ cli(lambda argv, cwd: json.dumps({"session_id": "s-1", "result": argv[1]}))
129
+ monkeypatch.setenv("ZETTCODE_CODE_AGENTS_DIR", str(tmp_path / "state"))
130
+
131
+ agent = CliAgent("agent-1", "Fake", Command("fake", "{prompt}", "resume {session} {prompt}"))
132
+
133
+ assert await agent._run("hello") == "hello"
134
+ assert json.loads((tmp_path / "state" / "agent-1.json").read_text()) == {"session": "s-1"}
135
+
136
+
137
+ # -- the plugin's own settings ---------------------------------------------------------------------------------------
138
+
139
+
140
+ @pytest.fixture
141
+ def settings_file(tmp_path, monkeypatch):
142
+ """Point the plugin at a settings file and a state folder of its own, and return a function that writes the file."""
143
+ path = tmp_path / "config.json"
144
+ monkeypatch.setenv("ZETTCODE_CODE_AGENTS_CONFIG", str(path))
145
+ monkeypatch.setenv("ZETTCODE_CODE_AGENTS_DIR", str(tmp_path / "state"))
146
+ monkeypatch.setenv("ZETTCODE_CODE_AGENTS_CWD", str(tmp_path))
147
+ for name in ("BIN", "ARGS", "RESUME_ARGS"):
148
+ monkeypatch.delenv(f"ZETTCODE_CODEX_{name}", raising=False)
149
+ monkeypatch.delenv(f"ZETTCODE_CLAUDE_{name}", raising=False)
150
+ monkeypatch.delenv("ZETTCODE_CODE_AGENTS_TIMEOUT", raising=False)
151
+
152
+ def write(content) -> None:
153
+ path.write_text(content if isinstance(content, str) else json.dumps(content), encoding="utf-8")
154
+
155
+ return write
156
+
157
+
158
+ def test_with_no_settings_an_agent_runs_in_the_gateways_folder_and_is_fully_trusted(tmp_path, settings_file):
159
+ settings = resolve(CODEX)
160
+
161
+ assert DEFAULT_PERMISSION == "full" and settings.permission == "full"
162
+ assert settings.cwd == tmp_path.resolve()
163
+ assert settings.timeout == 900.0
164
+ assert (settings.binary, settings.args) == ("codex", CODEX.args)
165
+
166
+
167
+ def test_a_tools_own_section_beats_the_top_level_which_beats_the_environment(tmp_path, settings_file, monkeypatch):
168
+ (tmp_path / "top").mkdir()
169
+ (tmp_path / "api").mkdir()
170
+ monkeypatch.setenv("ZETTCODE_CLAUDE_BIN", "from-the-environment")
171
+ settings_file(
172
+ {
173
+ "cwd": str(tmp_path / "top"),
174
+ "permission": "read",
175
+ "timeout": 30,
176
+ "codex": {"cwd": str(tmp_path / "api"), "permission": "workspace", "bin": "/opt/codex"},
177
+ }
178
+ )
179
+
180
+ codex, claude = resolve(CODEX), resolve(CLAUDE)
181
+
182
+ assert (codex.cwd, codex.permission, codex.timeout, codex.binary) == (
183
+ (tmp_path / "api").resolve(),
184
+ "workspace",
185
+ 30.0,
186
+ "/opt/codex",
187
+ )
188
+ # Claude has no section: it takes the shared values, and its own command from the environment.
189
+ assert (claude.cwd, claude.permission, claude.timeout) == ((tmp_path / "top").resolve(), "read", 30.0)
190
+ assert claude.binary == "from-the-environment"
191
+
192
+
193
+ @pytest.mark.parametrize(
194
+ ("content", "needle"),
195
+ [
196
+ ("{not json", "is not valid JSON"),
197
+ ("[1, 2]", "must hold one JSON object"),
198
+ ({"permission": "root"}, '"permission"'),
199
+ ({"permission": 3}, '"permission" in'),
200
+ ({"cwd": "/no/such/folder/anywhere"}, "does not exist"),
201
+ ({"cwd": ""}, '"cwd" in'),
202
+ ({"timeout": 0}, '"timeout"'),
203
+ ({"timeout": True}, '"timeout"'),
204
+ ({"timeout": "soon"}, '"timeout"'),
205
+ ({"codex": "yes"}, '"codex" in'),
206
+ ({"codex": {"bin": ""}}, '"bin" in'),
207
+ # {permission} is several arguments, so it cannot be part of one (--sandbox={permission} cannot work).
208
+ ({"codex": {"args": "exec --sandbox={permission} {prompt}"}}, "word of its own"),
209
+ ({"codex": {"resume_args": "exec resume {session} x{permission} {prompt}"}}, '"resume_args"'),
210
+ ({"codex": {"args": "exec {prmpt}"}}, r"names \{prmpt\}"),
211
+ ({"codex": {"args": "exec 'unclosed {prompt}"}}, "not a command line"),
212
+ ({"codex": {"args": "exec {"}}, "cannot be filled in"),
213
+ ],
214
+ )
215
+ def test_a_setting_that_cannot_be_used_is_refused_with_the_file_and_the_key(settings_file, content, needle):
216
+ settings_file(content)
217
+
218
+ with pytest.raises(ConfigError, match=needle) as caught:
219
+ resolve(CODEX)
220
+
221
+ assert "config.json" in str(caught.value)
222
+
223
+
224
+ def test_a_missing_or_empty_settings_file_is_no_settings(settings_file, tmp_path):
225
+ assert resolve(CODEX).permission == "full"
226
+ settings_file(" \n")
227
+ assert resolve(CODEX).permission == "full"
228
+
229
+
230
+ # -- what each trust level asks of each CLI ---------------------------------------------------------------------------
231
+
232
+
233
+ @pytest.mark.parametrize(
234
+ ("tool", "permission", "resumed", "expected"),
235
+ [
236
+ (CODEX, "full", False, ["--dangerously-bypass-approvals-and-sandbox"]),
237
+ (CODEX, "workspace", False, ["--sandbox", "workspace-write"]),
238
+ (CODEX, "read", False, ["--sandbox", "read-only"]),
239
+ # `codex exec resume` has no --sandbox: the same choice goes in as a configuration override.
240
+ (CODEX, "full", True, ["--dangerously-bypass-approvals-and-sandbox"]),
241
+ (CODEX, "workspace", True, ["-c", 'sandbox_mode="workspace-write"']),
242
+ (CODEX, "read", True, ["-c", 'sandbox_mode="read-only"']),
243
+ (CLAUDE, "full", False, ["--permission-mode", "bypassPermissions"]),
244
+ (CLAUDE, "workspace", True, ["--permission-mode", "acceptEdits"]),
245
+ (CLAUDE, "read", False, ["--permission-mode", "plan"]),
246
+ ],
247
+ )
248
+ async def test_each_trust_level_becomes_the_arguments_of_its_cli(
249
+ tmp_path, settings_file, monkeypatch, cli, tool, permission, resumed, expected
250
+ ):
251
+ cli(lambda argv, cwd: json.dumps({"session_id": "s-1", "result": json.dumps(argv[1:])}))
252
+ settings_file({"permission": permission, tool.name: {"bin": "fake"}})
253
+ if resumed:
254
+ (tmp_path / "state").mkdir()
255
+ (tmp_path / "state" / "a.json").write_text(json.dumps({"session": "s-0"}), encoding="utf-8")
256
+
257
+ argv = json.loads(await CliAgent("a", "A", tool)._run("do it"))
258
+
259
+ # The flags are inside the command, before the prompt (Codex) or before -p (Claude), never glued to it.
260
+ for flag in expected:
261
+ assert flag in argv
262
+ assert argv.index(expected[0]) < argv.index("do it")
263
+ assert ("resume" in argv) is (resumed and tool is CODEX)
264
+
265
+
266
+ async def test_an_edit_of_the_settings_applies_to_the_next_message_with_no_restart(tmp_path, settings_file, cli):
267
+ cli(lambda argv, cwd: json.dumps({"session_id": "s-1", "result": json.dumps({"argv": argv[1:], "cwd": str(cwd)})}))
268
+ first, second = tmp_path / "first", tmp_path / "second"
269
+ first.mkdir()
270
+ second.mkdir()
271
+ agent = CliAgent("agent-1", "Codex", dataclasses.replace(CODEX, binary="fake"))
272
+
273
+ settings_file({"cwd": str(first), "permission": "read"})
274
+ one = json.loads(await agent._run("hello"))
275
+ settings_file({"cwd": str(second), "permission": "workspace"})
276
+ two = json.loads(await agent._run("again"))
277
+
278
+ assert one["cwd"] == str(first.resolve()) and "read-only" in one["argv"]
279
+ # The same running agent, the next message: the new folder, and the new level in the form a resumed turn takes.
280
+ assert two["cwd"] == str(second.resolve()) and 'sandbox_mode="workspace-write"' in two["argv"]
281
+ assert "resume" in two["argv"]
282
+
283
+
284
+ async def test_a_broken_settings_file_fails_the_turn_with_a_sentence_and_the_agent_goes_on(
285
+ tmp_path, settings_file, cli
286
+ ):
287
+ agent = CliAgent("agent-1", "Codex", CODEX)
288
+ settings_file("{oops")
289
+
290
+ with pytest.raises(ConfigError, match="not valid JSON"):
291
+ await agent._run("hello")
292
+
293
+ # Mended, the very next turn runs; nothing about the agent had to be rebuilt.
294
+ cli(lambda argv, cwd: '{"result": "ok"}')
295
+ settings_file({"codex": {"bin": "fake"}})
296
+ assert await agent._run("hello") == "ok"
297
+
298
+
299
+ def test_a_command_template_without_the_permission_word_is_run_exactly_as_written(tmp_path):
300
+ own = Command("codex", "exec {prompt}", "exec resume {session} {prompt}")
301
+
302
+ assert own.argv("hi", "", tmp_path, permission=("--sandbox", "read-only")) == ["codex", "exec", "hi"]
303
+ shaped = Command("codex", "exec {permission} {prompt}", "x")
304
+ assert shaped.argv("hi", "", tmp_path, permission=("--a", "b")) == ["codex", "exec", "--a", "b", "hi"]
305
+ assert shaped.argv("hi", "", tmp_path) == ["codex", "exec", "hi"], "no flags: the word leaves nothing behind"