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.
Files changed (34) hide show
  1. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/PKG-INFO +48 -3
  2. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/README.md +47 -2
  3. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/__init__.py +19 -12
  4. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/agent.py +207 -30
  5. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/auth/providers.py +73 -0
  6. coding_agent_wrapper-0.1.5/caw/logger.py +89 -0
  7. coding_agent_wrapper-0.1.5/caw/pricing.json +32 -0
  8. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/provider.py +64 -0
  9. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/providers/claude_code.py +69 -5
  10. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/providers/codex.py +72 -1
  11. coding_agent_wrapper-0.1.5/caw/providers/opencode.py +748 -0
  12. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/storage.py +9 -2
  13. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/pyproject.toml +1 -1
  14. coding_agent_wrapper-0.1.2/caw/pricing.json +0 -15
  15. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/.gitignore +0 -0
  16. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/LICENSE +0 -0
  17. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/auth/README.md +0 -0
  18. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/auth/__init__.py +0 -0
  19. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/auth/cli.py +0 -0
  20. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/auth/collector.py +0 -0
  21. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/auth/manifest.py +0 -0
  22. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/auth/status.py +0 -0
  23. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/cli.py +0 -0
  24. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/display.py +0 -0
  25. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/faststats.py +0 -0
  26. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/mcp.py +0 -0
  27. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/models.py +0 -0
  28. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/pricing.py +0 -0
  29. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/providers/__init__.py +0 -0
  30. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/py.typed +0 -0
  31. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/toolkit.py +0 -0
  32. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/traj_cli.py +0 -0
  33. {coding_agent_wrapper-0.1.2 → coding_agent_wrapper-0.1.5}/caw/viewer/__init__.py +0 -0
  34. {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.2
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 Codex. Host credential files are never modified — they are bind-mounted into the container at run time.
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 Codex. Host credential files are never modified — they are bind-mounted into the container at run time.
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.0"
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.storage import JsonlWriter, SessionStore
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.mcp import (
28
- MCPServerHandle,
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
- # Pop auto_wait and metadata — these are Session concerns, not provider kwargs
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
- # Pass our session_id so the provider uses it (instead of generating its own)
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
  }