zettcode-code-agents 0.4.6__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.
- zettcode/code_agents/__init__.py +343 -0
- zettcode/code_agents/config.py +199 -0
- zettcode/code_agents/logos/claude.svg +5 -0
- zettcode/code_agents/logos/codex.svg +12 -0
- zettcode_code_agents-0.4.6.dist-info/METADATA +114 -0
- zettcode_code_agents-0.4.6.dist-info/RECORD +9 -0
- zettcode_code_agents-0.4.6.dist-info/WHEEL +4 -0
- zettcode_code_agents-0.4.6.dist-info/entry_points.txt +3 -0
- zettcode_code_agents-0.4.6.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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,9 @@
|
|
|
1
|
+
zettcode/code_agents/__init__.py,sha256=RLyEUd5J8JYq3UvBQfzVTW_AEBxp8BZsXS0iz2WjNlI,12723
|
|
2
|
+
zettcode/code_agents/config.py,sha256=KurLjtFBd5EkS5nANSzki8v_gxP9ckqKUv3VZKVCAAg,7966
|
|
3
|
+
zettcode/code_agents/logos/claude.svg,sha256=GZJv6njiQcdUfffiU3kwUk6gYiqnMxCA-UZ6ed3qx7Q,1696
|
|
4
|
+
zettcode/code_agents/logos/codex.svg,sha256=1P3mGLo4ythjyTcGB_4eGK5sRIyXMnxnznb4VF01jNA,1606
|
|
5
|
+
zettcode_code_agents-0.4.6.dist-info/METADATA,sha256=YYWdEqYHQWzRQId_htKMTx4fkBEZVhBXrZnGAhLfldE,5785
|
|
6
|
+
zettcode_code_agents-0.4.6.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
7
|
+
zettcode_code_agents-0.4.6.dist-info/entry_points.txt,sha256=EEOO1uNd63Z16B00FgO1qJpAbqRT61XPSScQaPBBSxE,114
|
|
8
|
+
zettcode_code_agents-0.4.6.dist-info/licenses/LICENSE,sha256=O9b87-u8V1aSp6xkr9qnbHZIJAWNV6m04GEQjycLLiw,1069
|
|
9
|
+
zettcode_code_agents-0.4.6.dist-info/RECORD,,
|
|
@@ -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.
|