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.
Files changed (33) hide show
  1. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/PKG-INFO +45 -1
  2. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/README.md +44 -0
  3. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/__init__.py +1 -1
  4. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/agent.py +193 -31
  5. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/provider.py +59 -0
  6. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/providers/claude_code.py +33 -0
  7. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/providers/codex.py +35 -1
  8. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/providers/opencode.py +33 -0
  9. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/storage.py +9 -2
  10. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/pyproject.toml +1 -1
  11. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/.gitignore +0 -0
  12. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/LICENSE +0 -0
  13. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/auth/README.md +0 -0
  14. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/auth/__init__.py +0 -0
  15. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/auth/cli.py +0 -0
  16. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/auth/collector.py +0 -0
  17. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/auth/manifest.py +0 -0
  18. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/auth/providers.py +0 -0
  19. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/auth/status.py +0 -0
  20. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/cli.py +0 -0
  21. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/display.py +0 -0
  22. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/faststats.py +0 -0
  23. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/logger.py +0 -0
  24. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/mcp.py +0 -0
  25. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/models.py +0 -0
  26. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/pricing.json +0 -0
  27. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/pricing.py +0 -0
  28. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/providers/__init__.py +0 -0
  29. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/py.typed +0 -0
  30. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/toolkit.py +0 -0
  31. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/traj_cli.py +0 -0
  32. {coding_agent_wrapper-0.1.4 → coding_agent_wrapper-0.1.5}/caw/viewer/__init__.py +0 -0
  33. {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.4
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 |
@@ -1,6 +1,6 @@
1
1
  """caw - Coding Agent Wrapper."""
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.1.5"
4
4
 
5
5
  from caw.agent import Agent, Session, register_provider
6
6
  from caw.auth import get_docker_flags as auth_get_docker_flags
@@ -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
- # Pop auto_wait, metadata, logger — these are Session concerns, not provider kwargs
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
- # Pass our session_id so the provider uses it (instead of generating its own)
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
- metadata={},
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
- self._turn_counter = 0
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.4"
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"