coding-agent-wrapper 0.1.2__tar.gz → 0.1.5__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.
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/PKG-INFO +48 -3
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/README.md +47 -2
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/__init__.py +19 -12
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/agent.py +207 -30
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/auth/providers.py +73 -0
- coding_agent_wrapper-0.1.5/caw/logger.py +89 -0
- coding_agent_wrapper-0.1.5/caw/pricing.json +32 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/provider.py +64 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/providers/claude_code.py +69 -5
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/providers/codex.py +72 -1
- coding_agent_wrapper-0.1.5/caw/providers/opencode.py +748 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/storage.py +9 -2
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/pyproject.toml +1 -1
- coding_agent_wrapper-0.1.2/caw/pricing.json +0 -15
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/.gitignore +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/LICENSE +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/auth/README.md +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/auth/__init__.py +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/auth/cli.py +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/auth/collector.py +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/auth/manifest.py +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/auth/status.py +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/cli.py +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/display.py +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/faststats.py +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/mcp.py +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/models.py +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/pricing.py +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/providers/__init__.py +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/py.typed +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/toolkit.py +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/traj_cli.py +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/viewer/__init__.py +0 -0
- {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/viewer/static/index.html +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: coding-agent-wrapper
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Summary: Unified Python library and CLI for orchestrating coding agents (Claude Code, Codex, etc.) with MCP tool servers and credential management.
|
|
5
5
|
Project-URL: Homepage, https://github.com/zzjas/caw
|
|
6
6
|
Project-URL: Repository, https://github.com/zzjas/caw
|
|
@@ -30,7 +30,7 @@ Description-Content-Type: text/markdown
|
|
|
30
30
|
|
|
31
31
|
# caw
|
|
32
32
|
|
|
33
|
-
**Coding Agent Wrapper** — a Python library and CLI for orchestrating coding agents (Claude Code, Codex) with a unified interface, MCP tool servers, and credential management for Docker containers.
|
|
33
|
+
**Coding Agent Wrapper** — a Python library and CLI for orchestrating coding agents (Claude Code, Codex, opencode) with a unified interface, MCP tool servers, and credential management for Docker containers.
|
|
34
34
|
|
|
35
35
|
## Install
|
|
36
36
|
|
|
@@ -85,12 +85,57 @@ with agent.start_session() as session:
|
|
|
85
85
|
# session.end() called automatically, returns full Trajectory
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
+
### Resuming sessions across processes
|
|
89
|
+
|
|
90
|
+
Grab a `resume_handle` (a string) and store it anywhere — a database, a file, a
|
|
91
|
+
queue. Later, in a different process, resume the conversation:
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
# Process 1: start, communicate, persist the handle.
|
|
95
|
+
agent = Agent(provider="claude_code")
|
|
96
|
+
session = agent.start_session()
|
|
97
|
+
session.send("My deploy target is staging-eu. Remember that.")
|
|
98
|
+
handle = session.resume_handle # store this string
|
|
99
|
+
session.end()
|
|
100
|
+
|
|
101
|
+
# Process 2 (later, after a restart): resume by handle.
|
|
102
|
+
agent = Agent(provider="claude_code")
|
|
103
|
+
session = agent.resume_session(handle)
|
|
104
|
+
print(session.send("Where am I deploying?").result) # -> "staging-eu"
|
|
105
|
+
session.end()
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The handle is a **self-contained JSON string** carrying the backend's own resume
|
|
109
|
+
key, so resuming works even with no `data_dir` — the underlying CLI still has the
|
|
110
|
+
conversation:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{"version": 1, "provider": "claude_code", "session_id": "bd260210-…", "resume_key": "bd260210-…"}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
(`resume_key` is claude's session id, Codex's `thread_id`, or opencode's session
|
|
117
|
+
id — for codex/opencode it differs from `session_id`.) Send at least one message
|
|
118
|
+
before reading `resume_handle`; the backend assigns its key on the first
|
|
119
|
+
exchange. Works across all three providers.
|
|
120
|
+
|
|
121
|
+
> The handle grants resume access to the conversation — treat it like a secret,
|
|
122
|
+
> not an opaque random id.
|
|
123
|
+
|
|
124
|
+
`data_dir` is optional and additive:
|
|
125
|
+
|
|
126
|
+
| | without `data_dir` | with the original `data_dir` |
|
|
127
|
+
|---|---|---|
|
|
128
|
+
| backend conversation | resumed | resumed |
|
|
129
|
+
| caw trajectory | starts empty | full history restored |
|
|
130
|
+
| new turns | not persisted | appended to the original session dir |
|
|
131
|
+
|
|
88
132
|
### Providers
|
|
89
133
|
|
|
90
134
|
| Provider | CLI | Provider name |
|
|
91
135
|
|----------|-----|---------------|
|
|
92
136
|
| Claude Code | `claude` | `claude_code` |
|
|
93
137
|
| Codex | `codex` | `codex` |
|
|
138
|
+
| opencode | `opencode` | `opencode` |
|
|
94
139
|
|
|
95
140
|
Set via constructor, environment variable, or at runtime:
|
|
96
141
|
|
|
@@ -197,7 +242,7 @@ Sessions are persisted to JSONL in `caw_data/` by default.
|
|
|
197
242
|
|
|
198
243
|
## CLI: `caw auth` — Credential Management for Docker Containers
|
|
199
244
|
|
|
200
|
-
Manages coding agent OAuth credentials so they stay in sync between your host and Docker containers. Supports Claude Code and
|
|
245
|
+
Manages coding agent OAuth credentials so they stay in sync between your host and Docker containers. Supports Claude Code, Codex, and opencode. Host credential files are never modified — they are bind-mounted into the container at run time.
|
|
201
246
|
|
|
202
247
|
```bash
|
|
203
248
|
caw auth setup # snapshot configs, write mount manifest
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# caw
|
|
2
2
|
|
|
3
|
-
**Coding Agent Wrapper** — a Python library and CLI for orchestrating coding agents (Claude Code, Codex) with a unified interface, MCP tool servers, and credential management for Docker containers.
|
|
3
|
+
**Coding Agent Wrapper** — a Python library and CLI for orchestrating coding agents (Claude Code, Codex, opencode) with a unified interface, MCP tool servers, and credential management for Docker containers.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -55,12 +55,57 @@ with agent.start_session() as session:
|
|
|
55
55
|
# session.end() called automatically, returns full Trajectory
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
+
### Resuming sessions across processes
|
|
59
|
+
|
|
60
|
+
Grab a `resume_handle` (a string) and store it anywhere — a database, a file, a
|
|
61
|
+
queue. Later, in a different process, resume the conversation:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
# Process 1: start, communicate, persist the handle.
|
|
65
|
+
agent = Agent(provider="claude_code")
|
|
66
|
+
session = agent.start_session()
|
|
67
|
+
session.send("My deploy target is staging-eu. Remember that.")
|
|
68
|
+
handle = session.resume_handle # store this string
|
|
69
|
+
session.end()
|
|
70
|
+
|
|
71
|
+
# Process 2 (later, after a restart): resume by handle.
|
|
72
|
+
agent = Agent(provider="claude_code")
|
|
73
|
+
session = agent.resume_session(handle)
|
|
74
|
+
print(session.send("Where am I deploying?").result) # -> "staging-eu"
|
|
75
|
+
session.end()
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The handle is a **self-contained JSON string** carrying the backend's own resume
|
|
79
|
+
key, so resuming works even with no `data_dir` — the underlying CLI still has the
|
|
80
|
+
conversation:
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{"version": 1, "provider": "claude_code", "session_id": "bd260210-…", "resume_key": "bd260210-…"}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
(`resume_key` is claude's session id, Codex's `thread_id`, or opencode's session
|
|
87
|
+
id — for codex/opencode it differs from `session_id`.) Send at least one message
|
|
88
|
+
before reading `resume_handle`; the backend assigns its key on the first
|
|
89
|
+
exchange. Works across all three providers.
|
|
90
|
+
|
|
91
|
+
> The handle grants resume access to the conversation — treat it like a secret,
|
|
92
|
+
> not an opaque random id.
|
|
93
|
+
|
|
94
|
+
`data_dir` is optional and additive:
|
|
95
|
+
|
|
96
|
+
| | without `data_dir` | with the original `data_dir` |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| backend conversation | resumed | resumed |
|
|
99
|
+
| caw trajectory | starts empty | full history restored |
|
|
100
|
+
| new turns | not persisted | appended to the original session dir |
|
|
101
|
+
|
|
58
102
|
### Providers
|
|
59
103
|
|
|
60
104
|
| Provider | CLI | Provider name |
|
|
61
105
|
|----------|-----|---------------|
|
|
62
106
|
| Claude Code | `claude` | `claude_code` |
|
|
63
107
|
| Codex | `codex` | `codex` |
|
|
108
|
+
| opencode | `opencode` | `opencode` |
|
|
64
109
|
|
|
65
110
|
Set via constructor, environment variable, or at runtime:
|
|
66
111
|
|
|
@@ -167,7 +212,7 @@ Sessions are persisted to JSONL in `caw_data/` by default.
|
|
|
167
212
|
|
|
168
213
|
## CLI: `caw auth` — Credential Management for Docker Containers
|
|
169
214
|
|
|
170
|
-
Manages coding agent OAuth credentials so they stay in sync between your host and Docker containers. Supports Claude Code and
|
|
215
|
+
Manages coding agent OAuth credentials so they stay in sync between your host and Docker containers. Supports Claude Code, Codex, and opencode. Host credential files are never modified — they are bind-mounted into the container at run time.
|
|
171
216
|
|
|
172
217
|
```bash
|
|
173
218
|
caw auth setup # snapshot configs, write mount manifest
|
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
"""caw - Coding Agent Wrapper."""
|
|
2
2
|
|
|
3
|
-
__version__ = "0.1.
|
|
3
|
+
__version__ = "0.1.5"
|
|
4
4
|
|
|
5
5
|
from caw.agent import Agent, Session, register_provider
|
|
6
|
+
from caw.auth import get_docker_flags as auth_get_docker_flags
|
|
7
|
+
from caw.auth import get_status as auth_get_status
|
|
8
|
+
from caw.auth import setup as auth_setup
|
|
6
9
|
from caw.display import Display, DisplayMode, get_global_display, set_global_display
|
|
7
10
|
from caw.faststats import FastStats
|
|
8
|
-
from caw.
|
|
11
|
+
from caw.logger import AgentLogger
|
|
12
|
+
from caw.mcp import (
|
|
13
|
+
MCPServerHandle,
|
|
14
|
+
create_mcp_http_server_bundle,
|
|
15
|
+
create_stateless_tool_server,
|
|
16
|
+
create_subagent_tool_server,
|
|
17
|
+
get_state_from_context,
|
|
18
|
+
mcp_tool,
|
|
19
|
+
register_tool,
|
|
20
|
+
)
|
|
9
21
|
from caw.models import (
|
|
10
22
|
AgentSpec,
|
|
11
23
|
ContentBlock,
|
|
@@ -24,30 +36,25 @@ from caw.models import (
|
|
|
24
36
|
from caw.provider import Provider, ProviderSession
|
|
25
37
|
from caw.providers.claude_code import ClaudeCodeProvider
|
|
26
38
|
from caw.providers.codex import CodexProvider
|
|
27
|
-
from caw.
|
|
28
|
-
|
|
29
|
-
create_mcp_http_server_bundle,
|
|
30
|
-
create_stateless_tool_server,
|
|
31
|
-
create_subagent_tool_server,
|
|
32
|
-
get_state_from_context,
|
|
33
|
-
mcp_tool,
|
|
34
|
-
register_tool,
|
|
35
|
-
)
|
|
39
|
+
from caw.providers.opencode import OpencodeProvider
|
|
40
|
+
from caw.storage import JsonlWriter, SessionStore
|
|
36
41
|
from caw.toolkit import ToolKit, tool
|
|
37
42
|
from caw.viewer import ViewerServer, start_viewer_server
|
|
38
|
-
from caw.auth import setup as auth_setup, get_status as auth_get_status, get_docker_flags as auth_get_docker_flags
|
|
39
43
|
|
|
40
44
|
# Auto-register built-in providers
|
|
41
45
|
register_provider("claude_code", ClaudeCodeProvider)
|
|
42
46
|
register_provider("claude", ClaudeCodeProvider)
|
|
43
47
|
register_provider("cc", ClaudeCodeProvider)
|
|
44
48
|
register_provider("codex", CodexProvider)
|
|
49
|
+
register_provider("opencode", OpencodeProvider)
|
|
45
50
|
|
|
46
51
|
__all__ = [
|
|
47
52
|
"Agent",
|
|
53
|
+
"AgentLogger",
|
|
48
54
|
"AgentSpec",
|
|
49
55
|
"ClaudeCodeProvider",
|
|
50
56
|
"CodexProvider",
|
|
57
|
+
"OpencodeProvider",
|
|
51
58
|
"JsonlWriter",
|
|
52
59
|
"ContentBlock",
|
|
53
60
|
"InteractiveResult",
|
|
@@ -16,6 +16,7 @@ from pathlib import Path
|
|
|
16
16
|
from typing import Any
|
|
17
17
|
|
|
18
18
|
from caw.display import get_global_display
|
|
19
|
+
from caw.logger import AgentLogger
|
|
19
20
|
from caw.models import AgentSpec, InteractiveResult, MCPServer, ModelTier, ToolGroup, ToolUse, Trajectory, Turn
|
|
20
21
|
from caw.provider import Provider, ProviderSession
|
|
21
22
|
from caw.storage import SessionStore
|
|
@@ -64,6 +65,36 @@ def _resolve_provider(name: str | None) -> Provider:
|
|
|
64
65
|
return _PROVIDER_REGISTRY[provider_name]()
|
|
65
66
|
|
|
66
67
|
|
|
68
|
+
def _encode_resume_handle(provider: str, session_id: str, resume_key: str) -> str:
|
|
69
|
+
"""Pack everything needed to resume — provider, caw session id, and the
|
|
70
|
+
backend resume key — into a JSON handle string. Self-contained so a
|
|
71
|
+
session can be resumed in a new process with or without the original
|
|
72
|
+
``data_dir``."""
|
|
73
|
+
return json.dumps(
|
|
74
|
+
{
|
|
75
|
+
"version": 1,
|
|
76
|
+
"provider": provider,
|
|
77
|
+
"session_id": session_id,
|
|
78
|
+
"resume_key": resume_key,
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _decode_resume_handle(handle: str) -> dict[str, Any] | None:
|
|
84
|
+
"""Parse a JSON handle produced by :func:`_encode_resume_handle`.
|
|
85
|
+
|
|
86
|
+
Returns the payload dict, or ``None`` if *handle* is not a self-contained
|
|
87
|
+
handle (e.g. a bare session id), so callers can fall back to disk lookup.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
data = json.loads(handle)
|
|
91
|
+
except (ValueError, TypeError):
|
|
92
|
+
return None
|
|
93
|
+
if isinstance(data, dict) and data.get("resume_key"):
|
|
94
|
+
return data
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
67
98
|
def _attach_subagent_trajectories(turn: Turn, traj_dir: str | None) -> None:
|
|
68
99
|
"""Scan a turn's tool outputs for trajectory markers and attach them.
|
|
69
100
|
|
|
@@ -103,6 +134,7 @@ class Session:
|
|
|
103
134
|
tool_handles: list[Any] | None = None,
|
|
104
135
|
auto_wait: bool = True,
|
|
105
136
|
metadata: dict[str, Any] | None = None,
|
|
137
|
+
logger: AgentLogger | None = None,
|
|
106
138
|
) -> None:
|
|
107
139
|
self._session = provider_session
|
|
108
140
|
self._store = store
|
|
@@ -114,6 +146,9 @@ class Session:
|
|
|
114
146
|
self._send_lock = threading.Lock()
|
|
115
147
|
self._async_send_lock: asyncio.Lock | None = None
|
|
116
148
|
self._traj_path: str | Path | None = None
|
|
149
|
+
self._logger = logger
|
|
150
|
+
if logger is not None:
|
|
151
|
+
self._session.set_logger(logger)
|
|
117
152
|
|
|
118
153
|
async def send_async(self, message: str) -> Turn:
|
|
119
154
|
"""Async version of :meth:`send` — runs in a thread.
|
|
@@ -261,6 +296,32 @@ class Session:
|
|
|
261
296
|
session._loaded_trajectory = traj
|
|
262
297
|
return session
|
|
263
298
|
|
|
299
|
+
@property
|
|
300
|
+
def resume_handle(self) -> str:
|
|
301
|
+
"""Opaque string for resuming this session later, possibly in another
|
|
302
|
+
process.
|
|
303
|
+
|
|
304
|
+
Store it anywhere (a database, a file, …) and pass it to
|
|
305
|
+
:meth:`Agent.resume_session`. The handle is self-contained: it carries
|
|
306
|
+
the backend resume key, so resuming works even without the original
|
|
307
|
+
``data_dir`` (you just won't get the prior trajectory restored). If
|
|
308
|
+
the resuming :class:`Agent` *does* share the same ``data_dir``, the
|
|
309
|
+
full history is restored and new turns are appended.
|
|
310
|
+
|
|
311
|
+
Raises if the backend has not yet assigned a resume key — send at least
|
|
312
|
+
one message first.
|
|
313
|
+
"""
|
|
314
|
+
traj = self.trajectory
|
|
315
|
+
if not self._readonly and self._session is not None:
|
|
316
|
+
resume_key = self._session.resume_key
|
|
317
|
+
else:
|
|
318
|
+
resume_key = _resolve_provider(traj.agent).resume_key_from_trajectory(traj)
|
|
319
|
+
if not resume_key:
|
|
320
|
+
raise RuntimeError(
|
|
321
|
+
"No resume key available yet — send at least one message before requesting a resume handle."
|
|
322
|
+
)
|
|
323
|
+
return _encode_resume_handle(traj.agent, traj.session_id, resume_key)
|
|
324
|
+
|
|
264
325
|
@property
|
|
265
326
|
def session_dir(self) -> Path | None:
|
|
266
327
|
"""Path to the session's data directory, or None if persistence is disabled."""
|
|
@@ -296,12 +357,14 @@ class Agent:
|
|
|
296
357
|
stateless_tools: list[Any] | None = None,
|
|
297
358
|
name: str = "",
|
|
298
359
|
description: str = "",
|
|
360
|
+
logger: AgentLogger | None = None,
|
|
299
361
|
**kwargs: Any,
|
|
300
362
|
) -> None:
|
|
301
363
|
self._provider_name = provider
|
|
302
364
|
self._provider: Provider | None = None
|
|
303
365
|
self._mcp_servers: list[MCPServer] = []
|
|
304
366
|
self._subagents: list[AgentSpec] = []
|
|
367
|
+
self._logger = logger
|
|
305
368
|
self._tool_servers: list[Any] = [] # list[MCPServerHandle], lazy import
|
|
306
369
|
if tool_servers:
|
|
307
370
|
for ts in tool_servers:
|
|
@@ -504,12 +567,144 @@ class Agent:
|
|
|
504
567
|
traj_path:
|
|
505
568
|
If set, the trajectory is saved to this path after each
|
|
506
569
|
step and when :meth:`Session.end` is called.
|
|
570
|
+
logger:
|
|
571
|
+
Optional generic logger (any object with ``info``/``warn``/
|
|
572
|
+
``error`` string methods). If set, every major event — user
|
|
573
|
+
message, tool call, tool result, assistant text, thinking,
|
|
574
|
+
turn-end stats — is also emitted as a one-line summary
|
|
575
|
+
through it. See :mod:`caw.logger`.
|
|
507
576
|
"""
|
|
508
577
|
merged = {**self._kwargs, **kwargs}
|
|
578
|
+
auto_wait, session_metadata, logger = self._pop_session_opts(merged)
|
|
579
|
+
|
|
580
|
+
# Generate session_id early so the JSONL path is known before MCP configs
|
|
581
|
+
session_id: str | None = None
|
|
582
|
+
store: SessionStore | None = None
|
|
583
|
+
if self._data_dir:
|
|
584
|
+
session_id = str(uuid_mod.uuid4())
|
|
585
|
+
store = SessionStore(self._data_dir, session_id)
|
|
586
|
+
|
|
587
|
+
subagent_traj_dir, all_handles, all_mcp = self._build_tool_context(merged, store)
|
|
588
|
+
|
|
589
|
+
# Pass our session_id so the provider uses it (instead of generating its own)
|
|
590
|
+
if session_id:
|
|
591
|
+
merged["session_id"] = session_id
|
|
592
|
+
|
|
593
|
+
provider_session = self.provider.start_session(mcp_servers=all_mcp, **merged)
|
|
594
|
+
|
|
595
|
+
session = Session(
|
|
596
|
+
provider_session,
|
|
597
|
+
store=store,
|
|
598
|
+
subagent_traj_dir=subagent_traj_dir,
|
|
599
|
+
tool_handles=all_handles,
|
|
600
|
+
auto_wait=auto_wait,
|
|
601
|
+
metadata=session_metadata,
|
|
602
|
+
logger=logger,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
if traj_path is not None:
|
|
606
|
+
session._traj_path = traj_path
|
|
607
|
+
|
|
608
|
+
if store:
|
|
609
|
+
store.write_metadata(session.trajectory)
|
|
610
|
+
|
|
611
|
+
return session
|
|
612
|
+
|
|
613
|
+
def resume_session(self, resume_handle: str, **kwargs: Any) -> Session:
|
|
614
|
+
"""Resume a session from a handle produced by :attr:`Session.resume_handle`.
|
|
615
|
+
|
|
616
|
+
Returns a live :class:`Session` whose next :meth:`Session.send`
|
|
617
|
+
continues the original conversation.
|
|
618
|
+
|
|
619
|
+
- **Without ``data_dir`` (or if the session isn't on disk):** the backend
|
|
620
|
+
conversation is resumed using the key embedded in the handle, but
|
|
621
|
+
caw's trajectory starts empty (no prior turns restored).
|
|
622
|
+
- **With the original ``data_dir``:** the full trajectory is restored
|
|
623
|
+
and new turns are appended to the original session directory.
|
|
624
|
+
|
|
625
|
+
A bare session id is also accepted in place of a full handle, but only
|
|
626
|
+
when ``data_dir`` is set (the resume key is then read from disk).
|
|
627
|
+
"""
|
|
628
|
+
decoded = _decode_resume_handle(resume_handle)
|
|
629
|
+
if decoded is not None:
|
|
630
|
+
provider_name = decoded["provider"]
|
|
631
|
+
session_id = decoded["session_id"]
|
|
632
|
+
resume_key: str | None = decoded["resume_key"]
|
|
633
|
+
if provider_name != self.provider.name:
|
|
634
|
+
raise ValueError(
|
|
635
|
+
f"Handle is for provider {provider_name!r} but this Agent uses {self.provider.name!r}."
|
|
636
|
+
)
|
|
637
|
+
else:
|
|
638
|
+
# Treat the string as a bare caw session id; the resume key must
|
|
639
|
+
# then come from the on-disk trajectory.
|
|
640
|
+
session_id = resume_handle
|
|
641
|
+
resume_key = None
|
|
642
|
+
|
|
643
|
+
# Load the persisted trajectory if a data_dir is available.
|
|
644
|
+
trajectory: Trajectory | None = None
|
|
645
|
+
store: SessionStore | None = None
|
|
646
|
+
had_existing = False
|
|
647
|
+
if self._data_dir:
|
|
648
|
+
traj_path = Path(self._data_dir) / "sessions" / session_id / "trajectory.json"
|
|
649
|
+
if traj_path.exists():
|
|
650
|
+
with open(traj_path) as f:
|
|
651
|
+
trajectory = Trajectory.from_dict(json.load(f))
|
|
652
|
+
had_existing = True
|
|
653
|
+
|
|
654
|
+
if resume_key is None:
|
|
655
|
+
if trajectory is None:
|
|
656
|
+
raise FileNotFoundError(
|
|
657
|
+
f"Cannot resume {resume_handle!r}: it is not a self-contained "
|
|
658
|
+
f"handle and no persisted session was found"
|
|
659
|
+
+ (f" under {self._data_dir}" if self._data_dir else " (no data_dir set)")
|
|
660
|
+
+ "."
|
|
661
|
+
)
|
|
662
|
+
resume_key = self.provider.resume_key_from_trajectory(trajectory)
|
|
663
|
+
if not resume_key:
|
|
664
|
+
raise ValueError(f"Cannot resume {resume_handle!r}: no resume key was persisted for this session.")
|
|
509
665
|
|
|
510
|
-
|
|
666
|
+
merged = {**self._kwargs, **kwargs}
|
|
667
|
+
auto_wait, session_metadata, logger = self._pop_session_opts(merged)
|
|
668
|
+
|
|
669
|
+
if self._data_dir:
|
|
670
|
+
store = SessionStore(self._data_dir, session_id, resume=True)
|
|
671
|
+
|
|
672
|
+
subagent_traj_dir, all_handles, all_mcp = self._build_tool_context(merged, store)
|
|
673
|
+
|
|
674
|
+
# session_id is fixed by the handle/trajectory, not generated.
|
|
675
|
+
merged.pop("session_id", None)
|
|
676
|
+
provider_session = self.provider.resume_session(
|
|
677
|
+
mcp_servers=all_mcp,
|
|
678
|
+
session_id=session_id,
|
|
679
|
+
resume_key=resume_key,
|
|
680
|
+
trajectory=trajectory,
|
|
681
|
+
**merged,
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
session = Session(
|
|
685
|
+
provider_session,
|
|
686
|
+
store=store,
|
|
687
|
+
subagent_traj_dir=subagent_traj_dir,
|
|
688
|
+
tool_handles=all_handles,
|
|
689
|
+
auto_wait=auto_wait,
|
|
690
|
+
metadata=session_metadata,
|
|
691
|
+
logger=logger,
|
|
692
|
+
)
|
|
693
|
+
# Fresh on-disk session (data_dir set but nothing persisted yet): seed
|
|
694
|
+
# the metadata line like start_session does.
|
|
695
|
+
if store is not None and not had_existing:
|
|
696
|
+
store.write_metadata(session.trajectory)
|
|
697
|
+
return session
|
|
698
|
+
|
|
699
|
+
def _pop_session_opts(self, merged: dict[str, Any]) -> tuple[bool, dict[str, Any], AgentLogger | None]:
|
|
700
|
+
"""Strip Session-only kwargs out of *merged* and resolve a model tier.
|
|
701
|
+
|
|
702
|
+
Returns ``(auto_wait, session_metadata, logger)``; *merged* is left
|
|
703
|
+
holding only provider ``start_session``/``resume_session`` kwargs.
|
|
704
|
+
"""
|
|
511
705
|
auto_wait = merged.pop("auto_wait", True)
|
|
512
706
|
session_metadata: dict[str, Any] = merged.pop("metadata", {})
|
|
707
|
+
logger: AgentLogger | None = merged.pop("logger", None) or self._logger
|
|
513
708
|
# Agent-level metadata as base, session kwargs override
|
|
514
709
|
if self._metadata:
|
|
515
710
|
session_metadata = {**self._metadata, **session_metadata}
|
|
@@ -518,7 +713,17 @@ class Agent:
|
|
|
518
713
|
model = merged.get("model")
|
|
519
714
|
if isinstance(model, ModelTier):
|
|
520
715
|
merged["model"] = self.provider.resolve_model(model)
|
|
716
|
+
return auto_wait, session_metadata, logger
|
|
717
|
+
|
|
718
|
+
def _build_tool_context(
|
|
719
|
+
self, merged: dict[str, Any], store: SessionStore | None
|
|
720
|
+
) -> tuple[str | None, list[Any], list[MCPServer]]:
|
|
721
|
+
"""Resolve tool restrictions and start tool/subagent servers.
|
|
521
722
|
|
|
723
|
+
Consumes ``tools`` from *merged* (applying the default), updates
|
|
724
|
+
*merged* with provider-specific tool restriction kwargs, and returns
|
|
725
|
+
``(subagent_traj_dir, all_handles, all_mcp)``.
|
|
726
|
+
"""
|
|
522
727
|
# Resolve tool restrictions: default to ALL - INTERACTION for automated pipelines
|
|
523
728
|
tools = merged.pop("tools", None)
|
|
524
729
|
if tools is None:
|
|
@@ -526,13 +731,6 @@ class Agent:
|
|
|
526
731
|
restrictions = self.provider.resolve_tool_restrictions(tools)
|
|
527
732
|
merged.update(restrictions)
|
|
528
733
|
|
|
529
|
-
# Generate session_id early so the JSONL path is known before MCP configs
|
|
530
|
-
session_id: str | None = None
|
|
531
|
-
store: SessionStore | None = None
|
|
532
|
-
if self._data_dir:
|
|
533
|
-
session_id = str(uuid_mod.uuid4())
|
|
534
|
-
store = SessionStore(self._data_dir, session_id)
|
|
535
|
-
|
|
536
734
|
# Create temp dir for subagent trajectory files (if subagents exist)
|
|
537
735
|
subagent_traj_dir: str | None = None
|
|
538
736
|
if self._subagents:
|
|
@@ -554,25 +752,4 @@ class Agent:
|
|
|
554
752
|
for handle in all_handles:
|
|
555
753
|
all_mcp.append(MCPServer(name=handle.server_id, url=handle.url))
|
|
556
754
|
|
|
557
|
-
|
|
558
|
-
if session_id:
|
|
559
|
-
merged["session_id"] = session_id
|
|
560
|
-
|
|
561
|
-
provider_session = self.provider.start_session(mcp_servers=all_mcp, **merged)
|
|
562
|
-
|
|
563
|
-
session = Session(
|
|
564
|
-
provider_session,
|
|
565
|
-
store=store,
|
|
566
|
-
subagent_traj_dir=subagent_traj_dir,
|
|
567
|
-
tool_handles=all_handles,
|
|
568
|
-
auto_wait=auto_wait,
|
|
569
|
-
metadata=session_metadata,
|
|
570
|
-
)
|
|
571
|
-
|
|
572
|
-
if traj_path is not None:
|
|
573
|
-
session._traj_path = traj_path
|
|
574
|
-
|
|
575
|
-
if store:
|
|
576
|
-
store.write_metadata(session.trajectory)
|
|
577
|
-
|
|
578
|
-
return session
|
|
755
|
+
return subagent_traj_dir, all_handles, all_mcp
|
|
@@ -259,6 +259,78 @@ class CodexAuthProvider(AgentAuthProvider):
|
|
|
259
259
|
return files
|
|
260
260
|
|
|
261
261
|
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# opencode
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class OpencodeAuthProvider(AgentAuthProvider):
|
|
268
|
+
name = "opencode"
|
|
269
|
+
|
|
270
|
+
def validate(self, src_home: Path) -> list[str]:
|
|
271
|
+
missing: list[str] = []
|
|
272
|
+
if not (src_home / ".local" / "share" / "opencode" / "auth.json").exists():
|
|
273
|
+
missing.append(str(src_home / ".local" / "share" / "opencode" / "auth.json"))
|
|
274
|
+
return missing
|
|
275
|
+
|
|
276
|
+
def describe(self, src_home: Path) -> str:
|
|
277
|
+
try:
|
|
278
|
+
with open(src_home / ".local" / "share" / "opencode" / "auth.json") as f:
|
|
279
|
+
auth_data = json.load(f)
|
|
280
|
+
providers = list(auth_data.keys())
|
|
281
|
+
if not providers:
|
|
282
|
+
return "Auth file present (no providers configured)"
|
|
283
|
+
kinds = []
|
|
284
|
+
for p in providers:
|
|
285
|
+
entry = auth_data.get(p) or {}
|
|
286
|
+
kinds.append(f"{p}({entry.get('type', 'unknown')})")
|
|
287
|
+
return f"Providers: {', '.join(kinds)}"
|
|
288
|
+
except Exception:
|
|
289
|
+
return "Could not read auth info"
|
|
290
|
+
|
|
291
|
+
def collect(self, src_home: Path) -> list[CollectedFile]:
|
|
292
|
+
files: list[CollectedFile] = []
|
|
293
|
+
|
|
294
|
+
# auth.json — credential (bind-mounted for token refresh write-back)
|
|
295
|
+
auth_src = src_home / ".local" / "share" / "opencode" / "auth.json"
|
|
296
|
+
with open(auth_src, "rb") as f:
|
|
297
|
+
auth_content = f.read()
|
|
298
|
+
files.append(
|
|
299
|
+
CollectedFile(
|
|
300
|
+
manifest_file=ManifestFile(
|
|
301
|
+
src="opencode/auth.json",
|
|
302
|
+
container_target=".local/share/opencode/auth.json",
|
|
303
|
+
host_original=".local/share/opencode/auth.json",
|
|
304
|
+
type="credential",
|
|
305
|
+
strategy="bind",
|
|
306
|
+
mode="0600",
|
|
307
|
+
),
|
|
308
|
+
content=auth_content,
|
|
309
|
+
)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# opencode.jsonc / opencode.json — config, copied (no local-project paths to strip)
|
|
313
|
+
for fname in ("opencode.jsonc", "opencode.json"):
|
|
314
|
+
cfg = src_home / ".config" / "opencode" / fname
|
|
315
|
+
if cfg.exists():
|
|
316
|
+
files.append(
|
|
317
|
+
CollectedFile(
|
|
318
|
+
manifest_file=ManifestFile(
|
|
319
|
+
src=f"opencode/{fname}",
|
|
320
|
+
container_target=f".config/opencode/{fname}",
|
|
321
|
+
host_original=f".config/opencode/{fname}",
|
|
322
|
+
type="config",
|
|
323
|
+
strategy="copy",
|
|
324
|
+
mode="0644",
|
|
325
|
+
),
|
|
326
|
+
content=cfg.read_bytes(),
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
break # only one of the two should exist
|
|
330
|
+
|
|
331
|
+
return files
|
|
332
|
+
|
|
333
|
+
|
|
262
334
|
# ---------------------------------------------------------------------------
|
|
263
335
|
# Provider registry
|
|
264
336
|
# ---------------------------------------------------------------------------
|
|
@@ -268,5 +340,6 @@ PROVIDERS: dict[str, AgentAuthProvider] = {
|
|
|
268
340
|
for p in [
|
|
269
341
|
ClaudeAuthProvider(),
|
|
270
342
|
CodexAuthProvider(),
|
|
343
|
+
OpencodeAuthProvider(),
|
|
271
344
|
]
|
|
272
345
|
}
|