coding-agent-wrapper 0.1.4__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.4 → coding_agent_wrapper-0.1.5}/PKG-INFO +45 -1
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/README.md +44 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/__init__.py +1 -1
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/agent.py +193 -31
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/provider.py +59 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/providers/claude_code.py +33 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/providers/codex.py +35 -1
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/providers/opencode.py +33 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/storage.py +9 -2
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/pyproject.toml +1 -1
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/.gitignore +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/LICENSE +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/auth/README.md +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/auth/__init__.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/auth/cli.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/auth/collector.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/auth/manifest.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/auth/providers.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/auth/status.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/cli.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/display.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/faststats.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/logger.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/mcp.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/models.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/pricing.json +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/pricing.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/providers/__init__.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/py.typed +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/toolkit.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/traj_cli.py +0 -0
- {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/viewer/__init__.py +0 -0
- {coding_agent_wrapper-0.1.4 → 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
|
|
@@ -85,6 +85,50 @@ 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 |
|
|
@@ -55,6 +55,50 @@ 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 |
|
|
@@ -65,6 +65,36 @@ def _resolve_provider(name: str | None) -> Provider:
|
|
|
65
65
|
return _PROVIDER_REGISTRY[provider_name]()
|
|
66
66
|
|
|
67
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
|
+
|
|
68
98
|
def _attach_subagent_trajectories(turn: Turn, traj_dir: str | None) -> None:
|
|
69
99
|
"""Scan a turn's tool outputs for trajectory markers and attach them.
|
|
70
100
|
|
|
@@ -266,6 +296,32 @@ class Session:
|
|
|
266
296
|
session._loaded_trajectory = traj
|
|
267
297
|
return session
|
|
268
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
|
+
|
|
269
325
|
@property
|
|
270
326
|
def session_dir(self) -> Path | None:
|
|
271
327
|
"""Path to the session's data directory, or None if persistence is disabled."""
|
|
@@ -519,8 +575,133 @@ class Agent:
|
|
|
519
575
|
through it. See :mod:`caw.logger`.
|
|
520
576
|
"""
|
|
521
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.")
|
|
665
|
+
|
|
666
|
+
merged = {**self._kwargs, **kwargs}
|
|
667
|
+
auto_wait, session_metadata, logger = self._pop_session_opts(merged)
|
|
522
668
|
|
|
523
|
-
|
|
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
|
+
"""
|
|
524
705
|
auto_wait = merged.pop("auto_wait", True)
|
|
525
706
|
session_metadata: dict[str, Any] = merged.pop("metadata", {})
|
|
526
707
|
logger: AgentLogger | None = merged.pop("logger", None) or self._logger
|
|
@@ -532,7 +713,17 @@ class Agent:
|
|
|
532
713
|
model = merged.get("model")
|
|
533
714
|
if isinstance(model, ModelTier):
|
|
534
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.
|
|
535
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
|
+
"""
|
|
536
727
|
# Resolve tool restrictions: default to ALL - INTERACTION for automated pipelines
|
|
537
728
|
tools = merged.pop("tools", None)
|
|
538
729
|
if tools is None:
|
|
@@ -540,13 +731,6 @@ class Agent:
|
|
|
540
731
|
restrictions = self.provider.resolve_tool_restrictions(tools)
|
|
541
732
|
merged.update(restrictions)
|
|
542
733
|
|
|
543
|
-
# Generate session_id early so the JSONL path is known before MCP configs
|
|
544
|
-
session_id: str | None = None
|
|
545
|
-
store: SessionStore | None = None
|
|
546
|
-
if self._data_dir:
|
|
547
|
-
session_id = str(uuid_mod.uuid4())
|
|
548
|
-
store = SessionStore(self._data_dir, session_id)
|
|
549
|
-
|
|
550
734
|
# Create temp dir for subagent trajectory files (if subagents exist)
|
|
551
735
|
subagent_traj_dir: str | None = None
|
|
552
736
|
if self._subagents:
|
|
@@ -568,26 +752,4 @@ class Agent:
|
|
|
568
752
|
for handle in all_handles:
|
|
569
753
|
all_mcp.append(MCPServer(name=handle.server_id, url=handle.url))
|
|
570
754
|
|
|
571
|
-
|
|
572
|
-
if session_id:
|
|
573
|
-
merged["session_id"] = session_id
|
|
574
|
-
|
|
575
|
-
provider_session = self.provider.start_session(mcp_servers=all_mcp, **merged)
|
|
576
|
-
|
|
577
|
-
session = Session(
|
|
578
|
-
provider_session,
|
|
579
|
-
store=store,
|
|
580
|
-
subagent_traj_dir=subagent_traj_dir,
|
|
581
|
-
tool_handles=all_handles,
|
|
582
|
-
auto_wait=auto_wait,
|
|
583
|
-
metadata=session_metadata,
|
|
584
|
-
logger=logger,
|
|
585
|
-
)
|
|
586
|
-
|
|
587
|
-
if traj_path is not None:
|
|
588
|
-
session._traj_path = traj_path
|
|
589
|
-
|
|
590
|
-
if store:
|
|
591
|
-
store.write_metadata(session.trajectory)
|
|
592
|
-
|
|
593
|
-
return session
|
|
755
|
+
return subagent_traj_dir, all_handles, all_mcp
|
|
@@ -43,6 +43,33 @@ class ProviderSession(ABC):
|
|
|
43
43
|
"""Provider-assigned session ID (if any)."""
|
|
44
44
|
return None
|
|
45
45
|
|
|
46
|
+
@property
|
|
47
|
+
def resume_key(self) -> str | None:
|
|
48
|
+
"""The provider-side key needed to resume this conversation.
|
|
49
|
+
|
|
50
|
+
This is whatever the backend CLI accepts to continue an existing
|
|
51
|
+
session (claude's session id, codex's thread id, opencode's session
|
|
52
|
+
id). ``None`` until the backend has assigned one — typically after the
|
|
53
|
+
first :meth:`send`.
|
|
54
|
+
"""
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
def _restore_from_trajectory(self, trajectory: Trajectory) -> None:
|
|
58
|
+
"""Re-seed a freshly built session with a prior trajectory's state.
|
|
59
|
+
|
|
60
|
+
Used by :meth:`Provider.resume_session` so a session reconstructed in a
|
|
61
|
+
new process carries the original history, usage totals, and creation
|
|
62
|
+
time forward. All concrete provider sessions share these attribute
|
|
63
|
+
names; resume-specific keys (thread id, etc.) are restored by the
|
|
64
|
+
provider's own ``resume_session``.
|
|
65
|
+
"""
|
|
66
|
+
self._turns = list(trajectory.turns)
|
|
67
|
+
self._total_usage = trajectory.usage
|
|
68
|
+
self._total_duration_ms = trajectory.duration_ms
|
|
69
|
+
if trajectory.created_at:
|
|
70
|
+
self._created_at = trajectory.created_at
|
|
71
|
+
self._has_sent = True
|
|
72
|
+
|
|
46
73
|
@property
|
|
47
74
|
def last_raw_output(self) -> str | None:
|
|
48
75
|
"""Raw CLI stdout from the most recent send() call (if available)."""
|
|
@@ -138,3 +165,35 @@ class Provider(ABC):
|
|
|
138
165
|
def start_session(self, mcp_servers: list[MCPServer], **kwargs: Any) -> ProviderSession:
|
|
139
166
|
"""Create and return a new provider session."""
|
|
140
167
|
...
|
|
168
|
+
|
|
169
|
+
def resume_key_from_trajectory(self, trajectory: Trajectory) -> str | None:
|
|
170
|
+
"""Extract the resume key from a persisted *trajectory*.
|
|
171
|
+
|
|
172
|
+
Mirrors :attr:`ProviderSession.resume_key` but reads from a stored
|
|
173
|
+
trajectory rather than a live session. Returns ``None`` if the
|
|
174
|
+
trajectory predates resume support or was never sent to.
|
|
175
|
+
"""
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
def resume_session(
|
|
179
|
+
self,
|
|
180
|
+
mcp_servers: list[MCPServer],
|
|
181
|
+
*,
|
|
182
|
+
session_id: str,
|
|
183
|
+
resume_key: str,
|
|
184
|
+
trajectory: Trajectory | None = None,
|
|
185
|
+
**kwargs: Any,
|
|
186
|
+
) -> ProviderSession:
|
|
187
|
+
"""Rebuild a live session ready to continue an existing conversation.
|
|
188
|
+
|
|
189
|
+
``resume_key`` is the provider-side key the backend CLI needs to resume
|
|
190
|
+
(see :attr:`ProviderSession.resume_key`); ``session_id`` is caw's own
|
|
191
|
+
bookkeeping id (the on-disk directory name). The next
|
|
192
|
+
:meth:`ProviderSession.send` must resume rather than start fresh.
|
|
193
|
+
|
|
194
|
+
When ``trajectory`` is given, the prior history/usage is carried forward
|
|
195
|
+
via :meth:`ProviderSession._restore_from_trajectory`. When it is
|
|
196
|
+
``None`` (e.g. resuming without a ``data_dir``), the backend session is
|
|
197
|
+
still resumed but caw's trajectory starts empty.
|
|
198
|
+
"""
|
|
199
|
+
raise NotImplementedError(f"{self.name} provider does not support resuming sessions.")
|
|
@@ -475,6 +475,11 @@ class ClaudeCodeSession(ProviderSession):
|
|
|
475
475
|
def session_id(self) -> str:
|
|
476
476
|
return self._session_id
|
|
477
477
|
|
|
478
|
+
@property
|
|
479
|
+
def resume_key(self) -> str | None:
|
|
480
|
+
# claude resumes via `--resume <session_id>`; the key is the id itself.
|
|
481
|
+
return self._session_id
|
|
482
|
+
|
|
478
483
|
@property
|
|
479
484
|
def last_raw_output(self) -> str:
|
|
480
485
|
return self._last_raw_output
|
|
@@ -677,3 +682,31 @@ class ClaudeCodeProvider(Provider):
|
|
|
677
682
|
disallowed_tools=disallowed_tools,
|
|
678
683
|
reasoning=reasoning,
|
|
679
684
|
)
|
|
685
|
+
|
|
686
|
+
def resume_key_from_trajectory(self, trajectory: Trajectory) -> str | None:
|
|
687
|
+
# claude's resume key is its session id.
|
|
688
|
+
return trajectory.session_id or None
|
|
689
|
+
|
|
690
|
+
def resume_session(
|
|
691
|
+
self,
|
|
692
|
+
mcp_servers: list[MCPServer],
|
|
693
|
+
*,
|
|
694
|
+
session_id: str,
|
|
695
|
+
resume_key: str,
|
|
696
|
+
trajectory: Trajectory | None = None,
|
|
697
|
+
**kwargs: Any,
|
|
698
|
+
) -> ClaudeCodeSession:
|
|
699
|
+
# For claude the resume key *is* the session id (passed to the CLI as
|
|
700
|
+
# --resume once _has_sent is set).
|
|
701
|
+
session = ClaudeCodeSession(
|
|
702
|
+
mcp_servers=mcp_servers,
|
|
703
|
+
model=kwargs.get("model") or (trajectory.model if trajectory else None),
|
|
704
|
+
system_prompt=(trajectory.system_prompt if trajectory else None) or None,
|
|
705
|
+
session_id=resume_key,
|
|
706
|
+
disallowed_tools=kwargs.get("disallowed_tools"),
|
|
707
|
+
reasoning=(trajectory.reasoning if trajectory else None) or None,
|
|
708
|
+
)
|
|
709
|
+
session._has_sent = True
|
|
710
|
+
if trajectory is not None:
|
|
711
|
+
session._restore_from_trajectory(trajectory)
|
|
712
|
+
return session
|
|
@@ -513,6 +513,11 @@ class CodexSession(ProviderSession):
|
|
|
513
513
|
def session_id(self) -> str:
|
|
514
514
|
return self._session_id
|
|
515
515
|
|
|
516
|
+
@property
|
|
517
|
+
def resume_key(self) -> str | None:
|
|
518
|
+
# codex resumes via `codex exec resume <thread_id>`.
|
|
519
|
+
return self._thread_id
|
|
520
|
+
|
|
516
521
|
@property
|
|
517
522
|
def last_raw_output(self) -> str:
|
|
518
523
|
return self._last_raw_output
|
|
@@ -530,7 +535,9 @@ class CodexSession(ProviderSession):
|
|
|
530
535
|
turns=list(self._turns),
|
|
531
536
|
usage=self._total_usage,
|
|
532
537
|
duration_ms=self._total_duration_ms,
|
|
533
|
-
|
|
538
|
+
# thread_id is the codex-side resume key; persist it so the session
|
|
539
|
+
# can be resumed in a new process (see CodexProvider.resume_session).
|
|
540
|
+
metadata={"thread_id": self._thread_id} if self._thread_id else {},
|
|
534
541
|
)
|
|
535
542
|
|
|
536
543
|
def end(self) -> Trajectory:
|
|
@@ -599,3 +606,30 @@ class CodexProvider(Provider):
|
|
|
599
606
|
reasoning=kwargs.get("reasoning"),
|
|
600
607
|
sandbox=kwargs.get("sandbox"),
|
|
601
608
|
)
|
|
609
|
+
|
|
610
|
+
def resume_key_from_trajectory(self, trajectory: Trajectory) -> str | None:
|
|
611
|
+
return trajectory.metadata.get("thread_id")
|
|
612
|
+
|
|
613
|
+
def resume_session(
|
|
614
|
+
self,
|
|
615
|
+
mcp_servers: list[MCPServer],
|
|
616
|
+
*,
|
|
617
|
+
session_id: str,
|
|
618
|
+
resume_key: str,
|
|
619
|
+
trajectory: Trajectory | None = None,
|
|
620
|
+
**kwargs: Any,
|
|
621
|
+
) -> CodexSession:
|
|
622
|
+
session = CodexSession(
|
|
623
|
+
mcp_servers=mcp_servers,
|
|
624
|
+
model=kwargs.get("model") or (trajectory.model if trajectory else None),
|
|
625
|
+
system_prompt=(trajectory.system_prompt if trajectory else None) or None,
|
|
626
|
+
session_id=session_id,
|
|
627
|
+
reasoning=(trajectory.reasoning if trajectory else None) or None,
|
|
628
|
+
sandbox=kwargs.get("sandbox"),
|
|
629
|
+
)
|
|
630
|
+
# The codex CLI resumes via `codex exec resume <thread_id>`.
|
|
631
|
+
session._thread_id = resume_key
|
|
632
|
+
session._has_sent = True
|
|
633
|
+
if trajectory is not None:
|
|
634
|
+
session._restore_from_trajectory(trajectory)
|
|
635
|
+
return session
|
|
@@ -511,6 +511,12 @@ class OpencodeSession(ProviderSession):
|
|
|
511
511
|
def session_id(self) -> str:
|
|
512
512
|
return self._session_id
|
|
513
513
|
|
|
514
|
+
@property
|
|
515
|
+
def resume_key(self) -> str | None:
|
|
516
|
+
# opencode resumes via `--session <opencode_session_id>` (the CLI's own
|
|
517
|
+
# id, captured from the event stream — distinct from caw's session_id).
|
|
518
|
+
return self._opencode_session_id
|
|
519
|
+
|
|
514
520
|
@property
|
|
515
521
|
def last_raw_output(self) -> str:
|
|
516
522
|
return self._last_raw_output
|
|
@@ -581,6 +587,33 @@ class OpencodeProvider(Provider):
|
|
|
581
587
|
disabled_tools=kwargs.get("disabled_tools"),
|
|
582
588
|
)
|
|
583
589
|
|
|
590
|
+
def resume_key_from_trajectory(self, trajectory: Trajectory) -> str | None:
|
|
591
|
+
return trajectory.metadata.get("opencode_session_id")
|
|
592
|
+
|
|
593
|
+
def resume_session(
|
|
594
|
+
self,
|
|
595
|
+
mcp_servers: list[MCPServer],
|
|
596
|
+
*,
|
|
597
|
+
session_id: str,
|
|
598
|
+
resume_key: str,
|
|
599
|
+
trajectory: Trajectory | None = None,
|
|
600
|
+
**kwargs: Any,
|
|
601
|
+
) -> OpencodeSession:
|
|
602
|
+
session = OpencodeSession(
|
|
603
|
+
mcp_servers=mcp_servers,
|
|
604
|
+
model=kwargs.get("model") or (trajectory.model if trajectory else None),
|
|
605
|
+
system_prompt=(trajectory.system_prompt if trajectory else None) or None,
|
|
606
|
+
session_id=session_id,
|
|
607
|
+
reasoning=(trajectory.reasoning if trajectory else None) or None,
|
|
608
|
+
disabled_tools=kwargs.get("disabled_tools"),
|
|
609
|
+
)
|
|
610
|
+
# The opencode CLI resumes via `--session <opencode_session_id>`.
|
|
611
|
+
session._opencode_session_id = resume_key
|
|
612
|
+
session._has_sent = True
|
|
613
|
+
if trajectory is not None:
|
|
614
|
+
session._restore_from_trajectory(trajectory)
|
|
615
|
+
return session
|
|
616
|
+
|
|
584
617
|
def start_interactive(self, initial_prompt, mcp_servers, capture_bytes=0, **kwargs): # type: ignore[override]
|
|
585
618
|
"""Launch ``opencode`` interactively (TUI) with an initial prompt.
|
|
586
619
|
|
|
@@ -121,13 +121,20 @@ class SessionStore:
|
|
|
121
121
|
000_raw_output.jsonl
|
|
122
122
|
"""
|
|
123
123
|
|
|
124
|
-
def __init__(self, data_dir: str | Path, session_id: str) -> None:
|
|
124
|
+
def __init__(self, data_dir: str | Path, session_id: str, resume: bool = False) -> None:
|
|
125
125
|
self._session_dir = Path(data_dir) / "sessions" / session_id
|
|
126
126
|
self._turns_dir = self._session_dir / "turns"
|
|
127
127
|
self._turns_dir.mkdir(parents=True, exist_ok=True)
|
|
128
|
-
|
|
128
|
+
# On resume, continue numbering after the existing turn files so we
|
|
129
|
+
# append to — rather than overwrite — the original session's record.
|
|
130
|
+
self._turn_counter = self._next_turn_index() if resume else 0
|
|
129
131
|
self._jsonl = JsonlWriter(self._session_dir / "traj.jsonl")
|
|
130
132
|
|
|
133
|
+
def _next_turn_index(self) -> int:
|
|
134
|
+
"""One past the highest ``NNN_input.txt`` index already on disk."""
|
|
135
|
+
indices = [int(p.name[:3]) for p in self._turns_dir.glob("*_input.txt") if p.name[:3].isdigit()]
|
|
136
|
+
return max(indices) + 1 if indices else 0
|
|
137
|
+
|
|
131
138
|
@property
|
|
132
139
|
def session_dir(self) -> Path:
|
|
133
140
|
"""Path to this session's directory."""
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "coding-agent-wrapper"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.5"
|
|
8
8
|
description = "Unified Python library and CLI for orchestrating coding agents (Claude Code, Codex, etc.) with MCP tool servers and credential management."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|