thwip-cli 1.6.2__tar.gz → 1.7.0__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 (63) hide show
  1. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/PKG-INFO +13 -1
  2. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/README.md +12 -0
  3. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/docs/verification.md +4 -2
  4. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/pyproject.toml +1 -1
  5. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_native_agents.py +73 -1
  6. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_native_print.py +50 -0
  7. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_parity_commands.py +27 -0
  8. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_session.py +21 -0
  9. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/__init__.py +1 -1
  10. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/base.py +2 -0
  11. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/native_agent.py +64 -25
  12. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/native_common.py +25 -0
  13. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/native_print.py +36 -9
  14. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/cli.py +59 -8
  15. thwip_cli-1.7.0/thwip/data/thwip.1 +122 -0
  16. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/session.py +37 -1
  17. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/uv.lock +1 -1
  18. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/.github/workflows/publish.yml +0 -0
  19. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/.gitignore +0 -0
  20. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/LICENSE +0 -0
  21. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/docs/handoff-research.md +0 -0
  22. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/install.sh +0 -0
  23. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/__init__.py +0 -0
  24. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/fake_providers.py +0 -0
  25. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_agents.py +0 -0
  26. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_audit_regressions.py +0 -0
  27. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_catalog.py +0 -0
  28. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_cli.py +0 -0
  29. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_compatible_streaming.py +0 -0
  30. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_config.py +0 -0
  31. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_detector.py +0 -0
  32. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_direct_providers_e2e.py +0 -0
  33. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_handoff.py +0 -0
  34. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_native_launcher.py +0 -0
  35. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_native_rpc.py +0 -0
  36. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_repair_verification.py +0 -0
  37. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_tools.py +0 -0
  38. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_utils.py +0 -0
  39. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/__main__.py +0 -0
  40. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/__init__.py +0 -0
  41. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/catalog.py +0 -0
  42. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/chat_messages.py +0 -0
  43. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/claude_agent.py +0 -0
  44. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/deepseek_agent.py +0 -0
  45. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/google_agent.py +0 -0
  46. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/groq_agent.py +0 -0
  47. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/native_rpc.py +0 -0
  48. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/ollama_agent.py +0 -0
  49. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/openai_agent.py +0 -0
  50. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/openrouter_agent.py +0 -0
  51. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/config.py +0 -0
  52. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/detector.py +0 -0
  53. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/endpoints.py +0 -0
  54. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/handoff.py +0 -0
  55. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/limits.py +0 -0
  56. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/shortcuts.py +0 -0
  57. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/theme.py +0 -0
  58. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/tools/__init__.py +0 -0
  59. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/tools/code_runner.py +0 -0
  60. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/tools/file_editor.py +0 -0
  61. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/tools/git_ops.py +0 -0
  62. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/tools/terminal.py +0 -0
  63. {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thwip-cli
3
- Version: 1.6.2
3
+ Version: 1.7.0
4
4
  Summary: Universal coding agent multiplexer: detect, switch, and route between AI coding agents seamlessly
5
5
  Project-URL: Homepage, https://github.com/tanmayhutt/thwip-cli
6
6
  Project-URL: Repository, https://github.com/tanmayhutt/thwip-cli
@@ -85,6 +85,8 @@ Start the interactive terminal in your current project directory:
85
85
  thwip
86
86
  thwip --project ~/code/my-app # open a specific project
87
87
  thwip --version
88
+ thwip man # read the manual page
89
+ thwip install-man # then `man thwip` works
88
90
  ```
89
91
 
90
92
  ---
@@ -142,6 +144,16 @@ not in the catalog is passed to the CLI for validation instead of being rejected
142
144
  a bundled list. A model available in a desktop app may still be absent from the
143
145
  installed CLI's catalog or account access.
144
146
 
147
+ Each provider keeps a warm native session. The first message to a provider sends the
148
+ portable conversation once; after that only new messages are sent. Codex keeps one
149
+ `app-server` process running and resumes its thread; Claude Code uses `--session-id`
150
+ and `--resume`; Antigravity uses `--conversation`. When you come back to a provider after
151
+ chatting with another one, it receives a short catch-up block containing only what it
152
+ missed. The native session IDs are stored in the thwip session file next to the
153
+ transcript, validated on load, shown by `/status`, and cleared by `/new`, `/clear`,
154
+ `/compact`, and a project change. If a CLI can no longer continue a session, thwip says
155
+ so and resends the full conversation to a fresh one.
156
+
145
157
  Native connections are read-only by default. Codex starts with a read-only sandbox
146
158
  and asks before operations outside it; approval requests appear in Thwip with the
147
159
  command or file list and default to denial. Claude Code and the Antigravity CLI run
@@ -48,6 +48,8 @@ Start the interactive terminal in your current project directory:
48
48
  thwip
49
49
  thwip --project ~/code/my-app # open a specific project
50
50
  thwip --version
51
+ thwip man # read the manual page
52
+ thwip install-man # then `man thwip` works
51
53
  ```
52
54
 
53
55
  ---
@@ -105,6 +107,16 @@ not in the catalog is passed to the CLI for validation instead of being rejected
105
107
  a bundled list. A model available in a desktop app may still be absent from the
106
108
  installed CLI's catalog or account access.
107
109
 
110
+ Each provider keeps a warm native session. The first message to a provider sends the
111
+ portable conversation once; after that only new messages are sent. Codex keeps one
112
+ `app-server` process running and resumes its thread; Claude Code uses `--session-id`
113
+ and `--resume`; Antigravity uses `--conversation`. When you come back to a provider after
114
+ chatting with another one, it receives a short catch-up block containing only what it
115
+ missed. The native session IDs are stored in the thwip session file next to the
116
+ transcript, validated on load, shown by `/status`, and cleared by `/new`, `/clear`,
117
+ `/compact`, and a project change. If a CLI can no longer continue a session, thwip says
118
+ so and resends the full conversation to a fresh one.
119
+
108
120
  Native connections are read-only by default. Codex starts with a read-only sandbox
109
121
  and asks before operations outside it; approval requests appear in Thwip with the
110
122
  command or file list and default to denial. Claude Code and the Antigravity CLI run
@@ -1,7 +1,7 @@
1
1
  # Verification status
2
2
 
3
- Verified locally on 2026-09-24 for v1.6.2. The Python suite
4
- passes 265 offline tests on Python 3.11, 3.12, and 3.13. Exhaustive behavior across every provider and
3
+ Verified locally on 2026-09-24 for v1.7.0. The Python suite
4
+ passes 273 offline tests on Python 3.11, 3.12, and 3.13. Exhaustive behavior across every provider and
5
5
  configuration has not been established.
6
6
 
7
7
  ## Native CLI connections (2026-09-24)
@@ -19,6 +19,8 @@ each using its existing sign-in. No API keys were configured.
19
19
  | Ctrl+C during a response | Turn cancelled, child process terminated, REPL continued, unanswered message removed |
20
20
  | Ctrl+C at a Codex permission prompt | Turn cancelled, no file created, no leftover process, REPL continued |
21
21
  | Usage-limit failover | With a test-only shim making Codex report "You've hit your usage limit", the real REPL showed the alternatives, switched to Claude Code on `1`, retried the message, and answered; history held one clean pair |
22
+ | Warm native sessions (v1.7.0) | Live: Claude Code recalled a code word on its second turn through `--resume`; Codex received the full transcript on first switch and answered from it; returning to Claude delivered a catch-up block; a second Codex turn reused the running app-server; `/status` showed each CLI session ID and synced count; `/new` cleared them; no leftover processes. Offline: resume, catch-up, and fallback paths for Codex, Claude Code, and Antigravity; session record validation and persistence |
23
+ | Manual page (v1.7.0) | `thwip install-man` then `man -w thwip` and `man thwip` resolve from `~/.local/share/man` |
22
24
  | Terminal hygiene (v1.6.2) | Cursor-position queries disabled, keyboard echo off during turns, stray input flushed before each prompt; fixes `^[`/`^R` noise and phantom empty prompts seen in a real Ghostty session during a slow Antigravity reply. Permission prompt and chat re-verified live afterwards |
23
25
  | Ollama adapter (v1.6.1) | End to end against the fake Ollama routes: model list, streamed text with usage, tool call, HTTP 500 and unreachable server now raise clear errors instead of ending the turn silently |
24
26
  | Dependency audits (v1.6.1) | pip-audit reports no known vulnerabilities; npm audit reports zero |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thwip-cli"
7
- version = "1.6.2"
7
+ version = "1.7.0"
8
8
  description = "Universal coding agent multiplexer: detect, switch, and route between AI coding agents seamlessly"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,7 @@
1
1
  """Native protocol behavior without credentials or model requests."""
2
2
 
3
3
  import asyncio
4
+ from types import SimpleNamespace
4
5
 
5
6
  import pytest
6
7
 
@@ -16,6 +17,8 @@ class FakeRPC:
16
17
  self.closed = False
17
18
  self.provider = provider
18
19
  self.failure = failure
20
+ self.process = SimpleNamespace(returncode=None) # looks alive, like a running app-server
21
+ self.resume_fails = False
19
22
 
20
23
  async def request(self, method, params, **kwargs):
21
24
  self.requests.append((method, params))
@@ -28,6 +31,10 @@ class FakeRPC:
28
31
  'availableModels': [{'modelId': 'future-model', 'name': 'Future'}]}}
29
32
  if method == 'thread/start':
30
33
  return {'thread': {'id': 't'}}
34
+ if method == 'thread/resume':
35
+ if self.resume_fails:
36
+ raise RuntimeError('Native CLI rejected thread/resume (code -32600): unknown thread')
37
+ return {'thread': {'id': params['threadId']}}
31
38
  if method == 'turn/start':
32
39
  await self.events.put({'id': 99, 'method': 'item/commandExecution/requestApproval',
33
40
  'params': {'command': 'touch example'}})
@@ -52,6 +59,7 @@ class FakeRPC:
52
59
 
53
60
  async def close(self):
54
61
  self.closed = True
62
+ self.process = SimpleNamespace(returncode=0)
55
63
 
56
64
 
57
65
  @pytest.mark.asyncio
@@ -88,9 +96,11 @@ async def test_native_permission_response_and_completion(provider, approve, monk
88
96
  if isinstance(event, NativePermission):
89
97
  event.approved = approve
90
98
  assert any(isinstance(event, TextDelta) and event.content == 'done' for event in events)
91
- assert isinstance(events[-1], AgentDone)
99
+ assert isinstance(events[-1], AgentDone) and events[-1].native_session == {'id': 't'}
92
100
  response = rpc.sent[0]['result']
93
101
  assert response['decision'] == ('accept' if approve else 'decline')
102
+ assert not rpc.closed, 'the app-server stays alive between turns'
103
+ await agent.close()
94
104
  assert rpc.closed
95
105
 
96
106
 
@@ -150,6 +160,7 @@ async def test_codex_usage_limit_failure_becomes_limit_hit(monkeypatch):
150
160
  monkeypatch.setattr(agent, '_connect', connect)
151
161
  events = [event async for event in agent.chat([{'role': 'user', 'content': 'hi'}], model='future-model')]
152
162
  assert isinstance(events[-1], LimitHit) and events[-1].error_type == LimitStatus.QUOTA_EXHAUSTED
163
+ await agent.close()
153
164
  assert rpc.closed
154
165
 
155
166
 
@@ -198,3 +209,64 @@ async def test_codex_rate_limit_notification_is_recorded(monkeypatch):
198
209
  assert agent.limit_windows == [
199
210
  {'label': '5h', 'used_percent': 2, 'resets_at': 1790212672},
200
211
  {'label': '7d', 'used_percent': 23, 'resets_at': None}]
212
+
213
+
214
+ def _turn_prompt(rpc):
215
+ return next(params for method, params in rpc.requests if method == 'turn/start')['input'][0]['text']
216
+
217
+
218
+ @pytest.mark.asyncio
219
+ async def test_codex_resumes_thread_and_sends_only_new_messages(monkeypatch):
220
+ agent = NativeAgent('openai', '.')
221
+ rpc = FakeRPC('openai')
222
+ async def connect():
223
+ return rpc
224
+ monkeypatch.setattr(agent, '_connect', connect)
225
+ history = [{'role': 'user', 'content': 'first'}, {'role': 'assistant', 'content': 'one'},
226
+ {'role': 'user', 'content': 'second'}]
227
+ events = [e async for e in agent.chat(history, model='future-model', resume={'id': 'thread-9', 'synced': 2})]
228
+ assert isinstance(events[-1], AgentDone) and events[-1].native_session == {'id': 'thread-9'}
229
+ methods = [m for m, _ in rpc.requests]
230
+ assert 'thread/resume' in methods and 'thread/start' not in methods
231
+ assert _turn_prompt(rpc) == 'second', 'only the new message is sent to a resumed thread'
232
+ # Same process, same thread: no second resume request.
233
+ rpc.requests.clear()
234
+ history += [{'role': 'assistant', 'content': 'two'}, {'role': 'user', 'content': 'third'}]
235
+ [e async for e in agent.chat(history, model='future-model', resume={'id': 'thread-9', 'synced': 4})]
236
+ assert 'thread/resume' not in [m for m, _ in rpc.requests] and _turn_prompt(rpc) == 'third'
237
+
238
+
239
+ @pytest.mark.asyncio
240
+ async def test_codex_catch_up_block_after_other_providers_answered(monkeypatch):
241
+ agent = NativeAgent('openai', '.')
242
+ rpc = FakeRPC('openai')
243
+ async def connect():
244
+ return rpc
245
+ monkeypatch.setattr(agent, '_connect', connect)
246
+ history = [{'role': 'user', 'content': 'a'}, {'role': 'assistant', 'content': 'b'},
247
+ {'role': 'user', 'content': 'c'}, {'role': 'assistant', 'content': 'd by claude'},
248
+ {'role': 'user', 'content': 'e'}]
249
+ [e async for e in agent.chat(history, model='future-model', resume={'id': 'thread-9', 'synced': 2})]
250
+ prompt = _turn_prompt(rpc)
251
+ assert 'Missed conversation' in prompt and 'd by claude' in prompt and prompt.endswith('Final user message:\ne')
252
+ assert '[User]\na' not in prompt, 'messages the thread already saw are not resent'
253
+
254
+
255
+ @pytest.mark.asyncio
256
+ async def test_codex_falls_back_to_new_thread_when_resume_fails(monkeypatch):
257
+ from thwip.agents.base import NativeActivity
258
+
259
+ agent = NativeAgent('openai', '.')
260
+ rpc = FakeRPC('openai')
261
+ rpc.resume_fails = True
262
+ async def connect():
263
+ return rpc
264
+ monkeypatch.setattr(agent, '_connect', connect)
265
+ history = [{'role': 'user', 'content': 'first'}, {'role': 'assistant', 'content': 'one'},
266
+ {'role': 'user', 'content': 'second'}]
267
+ events = [e async for e in agent.chat(history, model='future-model', resume={'id': 'gone', 'synced': 2})]
268
+ assert any(isinstance(e, NativeActivity) and 'unavailable' in e.description for e in events)
269
+ start = next(params for method, params in rpc.requests if method == 'thread/start')
270
+ assert start['ephemeral'] is False
271
+ assert '[User]\nfirst' in _turn_prompt(rpc), 'full transcript goes to the new thread'
272
+ assert events[-1].native_session == {'id': 't'}
@@ -233,3 +233,53 @@ async def test_antigravity_network_reset_gets_a_network_hint(monkeypatch):
233
233
  "API error (attempt 1): request failed: Post \"https://example.googleapis.com\": read tcp 10.0.0.2:5->1.2.3.4:443: read: connection reset by peer"}}])
234
234
  with pytest.raises(RuntimeError, match="connection reset by peer.*network or a firewall"):
235
235
  await collect(agent, model="gemini-3.8-flash-high")
236
+
237
+
238
+ @pytest.mark.asyncio
239
+ async def test_claude_new_session_id_then_resume_flag(monkeypatch):
240
+ agent = PrintAgent("claude", ".")
241
+ calls = install_fake(monkeypatch, agent, [{"type": "system", "subtype": "init", "session_id": "abc-123"},
242
+ {"type": "result", "subtype": "success", "result": "hi", "usage": {}}])
243
+ events = await collect(agent)
244
+ assert events[-1].native_session == {"id": "abc-123"}
245
+ command, stdin_text = calls[0]
246
+ assert "--session-id" in command and "--resume" not in command and "--append-system-prompt" in command
247
+ assert stdin_text == "hello"
248
+ # Resume: only the new message, no system prompt re-sent, --resume flag used.
249
+ calls.clear()
250
+ history = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}, {"role": "user", "content": "next"}]
251
+ events = [e async for e in agent.chat(history, model="fable", system_prompt="Be brief.", resume={"id": "abc-123", "synced": 2})]
252
+ command, stdin_text = calls[0]
253
+ assert command[command.index("--resume") + 1] == "abc-123" and "--append-system-prompt" not in command
254
+ assert stdin_text == "next" and events[-1].native_session == {"id": "abc-123"}
255
+
256
+
257
+ @pytest.mark.asyncio
258
+ async def test_antigravity_conversation_resume_and_fallback(monkeypatch):
259
+ agent = PrintAgent("google", ".")
260
+ ok = [{"event": "init", "conversation_id": "conv-1", "init": {}},
261
+ {"event": "result", "result": {"status": "SUCCESS", "response": "hi", "usage": {}}}]
262
+ calls = install_fake(monkeypatch, agent, ok)
263
+ events = await collect(agent, model="gemini-3.8-flash-high")
264
+ assert events[-1].native_session == {"id": "conv-1"} and "--conversation" not in calls[0][0]
265
+
266
+ # Resumed turn passes --conversation and only the new text.
267
+ calls.clear()
268
+ history = [{"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi"}, {"role": "user", "content": "next"}]
269
+ events = [e async for e in agent.chat(history, model="gemini-3.8-flash-high", resume={"id": "conv-1", "synced": 2})]
270
+ command = calls[0][0]
271
+ assert command[command.index("--conversation") + 1] == "conv-1" and command[command.index("--print") + 1] == "next"
272
+
273
+ # If the resumed process fails, a fresh conversation gets the full transcript.
274
+ attempts = []
275
+ async def start(command, stdin_text=None):
276
+ attempts.append(command)
277
+ if "--conversation" in command:
278
+ return FakeProcess([{"event": "result", "result": {"status": "ERROR", "error": "conversation not found"}}])
279
+ return FakeProcess(ok)
280
+ monkeypatch.setattr(agent, "_start_process", start)
281
+ events = [e async for e in agent.chat(history, model="gemini-3.8-flash-high", resume={"id": "conv-1", "synced": 2})]
282
+ assert len(attempts) == 2 and "--conversation" not in attempts[1]
283
+ assert "[User]\nhello" in attempts[1][attempts[1].index("--print") + 1]
284
+ assert any(isinstance(e, NativeActivity) and "unavailable" in e.description for e in events)
285
+ assert events[-1].native_session == {"id": "conv-1"}
@@ -171,3 +171,30 @@ def test_mentions_attach_project_files_only(cli, tmp_path):
171
171
  assert "secret plan" in expanded and "[Attached file: notes.md]" in expanded
172
172
  assert "missing.txt]" not in expanded and "passwd]" not in expanded
173
173
  assert cli._expand_mentions("email me at user@example.com") == "email me at user@example.com"
174
+
175
+
176
+ def test_incremental_prompt_builder():
177
+ from thwip.agents.native_common import build_incremental_prompt
178
+
179
+ history = [{"role": "user", "content": "a"}, {"role": "assistant", "content": "b"}, {"role": "user", "content": "c"}]
180
+ full, is_full = build_incremental_prompt(history, "sys", 0)
181
+ assert is_full and "[User]\na" in full and full.endswith("c")
182
+ only_new, is_full = build_incremental_prompt(history, "sys", 2)
183
+ assert (only_new, is_full) == ("c", False)
184
+ _bad, is_full = build_incremental_prompt(history, None, 7)
185
+ assert is_full, "a synced count beyond the history falls back to the full transcript"
186
+
187
+
188
+ def test_man_page_commands(tmp_path, monkeypatch, capsys):
189
+ from thwip import cli as cli_module
190
+
191
+ monkeypatch.setenv("THWIP_MAN_DIR", str(tmp_path / "man1"))
192
+ assert cli_module.MAN_PAGE.is_file() and cli_module.MAN_PAGE.read_text().startswith(".TH THWIP 1")
193
+ assert cli_module.run_man_command("install-man") == 0
194
+ assert (tmp_path / "man1" / "thwip.1").read_text() == cli_module.MAN_PAGE.read_text()
195
+ monkeypatch.setattr("thwip.cli.shutil.which", lambda name: None)
196
+ assert cli_module.run_man_command("man") == 0
197
+ assert "universal coding agent multiplexer" in capsys.readouterr().out
198
+ with pytest.raises(SystemExit) as info:
199
+ cli_module.main(["install-man"])
200
+ assert info.value.code == 0
@@ -128,3 +128,24 @@ def test_config_file_permissions_and_source_are_preserved(tmp_path, monkeypatch)
128
128
 
129
129
  assert loaded.key_sources["openai"] == "config.toml"
130
130
  assert get_config_path().stat().st_mode & 0o777 == 0o600
131
+
132
+
133
+ def test_native_sessions_are_tracked_validated_and_persisted(tmp_path, monkeypatch):
134
+ from thwip.session import Session
135
+
136
+ monkeypatch.setenv("THWIP_CONFIG_DIR", str(tmp_path))
137
+ session = Session(project_path=str(tmp_path), current_agent="openai", current_model="m")
138
+ assert session.native_session("openai") is None
139
+ session.add_user_message("q")
140
+ session.add_assistant_message("a", agent_name="openai", model="m")
141
+ session.set_native_session("openai", "thread-1", "m")
142
+ assert session.native_session("openai") == {"id": "thread-1", "synced": 2, "model": "m"}
143
+ session.native_sessions["claude"] = {"id": "x", "synced": 99}
144
+ assert session.native_session("claude") is None, "a record that claims more messages than exist is ignored"
145
+ path = session.save("native-track")
146
+ loaded = Session.load("native-track")
147
+ assert loaded.native_session("openai") == {"id": "thread-1", "synced": 2, "model": "m"}
148
+ loaded.clear_context()
149
+ assert loaded.native_sessions == {}
150
+ path.write_text(path.read_text().replace('"synced": 2', '"synced": "2"'))
151
+ assert Session.load("native-track") is None, "malformed native session records reject the file"
@@ -1,4 +1,4 @@
1
1
  """thwip: Universal Coding Agent Multiplexer."""
2
2
 
3
- __version__ = "1.6.2"
3
+ __version__ = "1.7.0"
4
4
  __app_name__ = "thwip"
@@ -144,6 +144,8 @@ class AgentDone:
144
144
  usage: TokenUsage = field(default_factory=TokenUsage)
145
145
  stop_reason: str = "end_turn"
146
146
  native_state: dict[str, Any] = field(default_factory=dict)
147
+ # Identifier of the native CLI session that produced this turn, so it can be resumed next time.
148
+ native_session: dict[str, Any] = field(default_factory=dict)
147
149
 
148
150
 
149
151
  @dataclass
@@ -22,7 +22,7 @@ from thwip.agents.base import (
22
22
  ThinkingDelta,
23
23
  TokenUsage,
24
24
  )
25
- from thwip.agents.native_common import build_native_prompt, classify_limit, scrub, window_label
25
+ from thwip.agents.native_common import build_incremental_prompt, classify_limit, scrub, window_label
26
26
  from thwip.agents.native_rpc import NativeRPC
27
27
 
28
28
 
@@ -69,6 +69,9 @@ class NativeAgent(BaseAgent):
69
69
  self.discovery_error = "Not connected yet"
70
70
  # Usage windows the CLI reports for its own account; informational only.
71
71
  self.limit_windows: list[dict] = []
72
+ # One long-lived app-server process per Thwip run; threads are resumed across turns.
73
+ self._rpc = None
74
+ self._thread_id: str | None = None
72
75
 
73
76
  def is_installed(self):
74
77
  return shutil.which(self.binary) is not None
@@ -146,20 +149,60 @@ class NativeAgent(BaseAgent):
146
149
  if rpc:
147
150
  await rpc.close()
148
151
 
149
- async def chat(self, messages, model=None, system_prompt=None, tools=None, stream=True):
150
- rpc = await self._connect()
152
+ async def _live_rpc(self):
153
+ """Reuse the running app-server when it is still alive; otherwise start a new one."""
154
+ rpc = self._rpc
155
+ if rpc is not None and rpc.process is not None and rpc.process.returncode is None:
156
+ return rpc
157
+ self._rpc = None
158
+ self._thread_id = None
159
+ self._rpc = await self._connect()
160
+ return self._rpc
161
+
162
+ async def close(self):
163
+ """Stop the app-server process (on exit). Threads stay resumable on disk."""
164
+ rpc, self._rpc, self._thread_id = self._rpc, None, None
165
+ if rpc is not None:
166
+ await rpc.close()
167
+
168
+ async def _open_thread(self, rpc, model, system_prompt, resume_id):
169
+ """Resume the recorded Codex thread when possible; otherwise start a fresh persistent one.
170
+
171
+ Returns (thread_id, resumed).
172
+ """
173
+ common = {"cwd": self.project, "sandbox": "read-only", "approvalPolicy": "on-request",
174
+ **({"developerInstructions": system_prompt} if system_prompt else {})}
175
+ if resume_id:
176
+ if self._thread_id == resume_id:
177
+ return resume_id, True
178
+ try:
179
+ await rpc.request("thread/resume", {"threadId": resume_id, "model": model, **common})
180
+ self._thread_id = resume_id
181
+ return resume_id, True
182
+ except (RuntimeError, TimeoutError, KeyError, TypeError):
183
+ pass
184
+ session = await rpc.request("thread/start", {"model": model, "ephemeral": False, **common})
185
+ self._thread_id = session["thread"]["id"]
186
+ return self._thread_id, False
187
+
188
+ async def chat(self, messages, model=None, system_prompt=None, tools=None, stream=True, resume=None):
189
+ rpc = await self._live_rpc()
151
190
  event_task = None
191
+ chosen = model or self.get_default_model()
192
+ resume_id = resume.get("id") if isinstance(resume, dict) else None
193
+ synced = resume.get("synced", 0) if isinstance(resume, dict) else 0
152
194
  try:
153
- # A fresh native session receives only portable text; tool state stays native within the turn.
154
- prompt = build_native_prompt(messages, None)
155
- # Codex App Server sandbox modes are kebab-case; camelCase is rejected as an invalid request.
156
- session = await rpc.request("thread/start", {
157
- "cwd": self.project, "model": model or self.get_default_model(), "sandbox": "read-only",
158
- "approvalPolicy": "on-request", "ephemeral": True,
159
- **({"developerInstructions": system_prompt} if system_prompt else {}),
160
- })
161
- thread_id = session["thread"]["id"]
162
- await rpc.request("turn/start", {"threadId": thread_id, "input": [{"type": "text", "text": prompt}]})
195
+ thread_id, resumed = await self._open_thread(rpc, chosen, system_prompt, resume_id)
196
+ if resume_id and not resumed:
197
+ yield NativeActivity(description="Previous Codex thread unavailable; sending the full conversation to a new one.")
198
+ prompt, _full = build_incremental_prompt(messages, None, synced if resumed else 0)
199
+ # Drop notifications left over from an earlier turn on this process.
200
+ while not rpc.events.empty():
201
+ stale = rpc.events.get_nowait()
202
+ if stale.get("method") == "_closed":
203
+ raise RuntimeError("Native CLI exited before the turn started.")
204
+ await rpc.request("turn/start", {"threadId": thread_id, "model": chosen,
205
+ "input": [{"type": "text", "text": prompt}]})
163
206
  usage = TokenUsage()
164
207
  started_items = {}
165
208
  text_items = set()
@@ -174,6 +217,7 @@ class NativeAgent(BaseAgent):
174
217
  event = event_task.result()
175
218
  method, params = event.get("method", ""), event.get("params", {})
176
219
  if method == "_closed":
220
+ self._rpc = None
177
221
  raise RuntimeError("Native CLI exited before completing the response.")
178
222
  if "id" in event:
179
223
  if method in {"item/commandExecution/requestApproval", "item/fileChange/requestApproval"}:
@@ -190,7 +234,8 @@ class NativeAgent(BaseAgent):
190
234
  elif method in {"item/reasoning/summaryTextDelta", "item/reasoning/textDelta"}:
191
235
  yield ThinkingDelta(content=params.get("delta", ""))
192
236
  elif method == "error":
193
- message = scrub(params.get("error", params).get("message", "") if isinstance(params.get("error", params), dict) else params)
237
+ detail = params.get("error", params)
238
+ message = scrub(detail.get("message", "") if isinstance(detail, dict) else detail)
194
239
  limit = classify_limit(message)
195
240
  if limit:
196
241
  yield LimitHit(error_type=limit, message=message)
@@ -231,19 +276,13 @@ class NativeAgent(BaseAgent):
231
276
  detail = f" Codex said: {message}" if message else ""
232
277
  raise RuntimeError("Codex could not complete the turn. Check its login, model access, "
233
278
  f"or usage limit.{detail}")
234
- yield AgentDone(usage=usage)
279
+ yield AgentDone(usage=usage, native_session={"id": thread_id})
235
280
  return
236
- elif method == "session/update":
237
- update = params.get("update", {})
238
- kind = update.get("sessionUpdate")
239
- if kind == "agent_message_chunk" and update.get("content", {}).get("type") == "text":
240
- yield TextDelta(content=update["content"].get("text", ""))
241
- elif kind in {"tool_call", "tool_call_update"}:
242
- yield NativeActivity(description=update.get("title", "Native tool activity"))
243
- finally:
281
+ except BaseException:
282
+ # Any failure or interruption abandons the process; the thread itself remains resumable.
244
283
  if event_task and not event_task.done():
245
284
  event_task.cancel()
246
- if event_task:
247
285
  with contextlib.suppress(asyncio.CancelledError, Exception):
248
286
  await event_task
249
- await rpc.close()
287
+ await self.close()
288
+ raise
@@ -102,3 +102,28 @@ def network_hint(text: str) -> str:
102
102
  return (" The CLI could not reach its backend. This is usually the local network or a firewall "
103
103
  "resetting the connection; try again, or switch to another network or a VPN.")
104
104
  return ""
105
+
106
+
107
+ def build_incremental_prompt(messages: list[dict], system_prompt: str | None, synced: int) -> tuple[str, bool]:
108
+ """Return (prompt, is_full).
109
+
110
+ With no usable native session (synced == 0) the full transcript is sent. When the
111
+ native session already saw `synced` portable messages, only the messages after that
112
+ point are sent: just the new request, or a short catch-up block when other providers
113
+ answered in between.
114
+ """
115
+ portable = [m for m in messages if m.get("role") in {"user", "assistant"} and isinstance(m.get("content"), str)]
116
+ if synced <= 0 or synced >= len(portable):
117
+ return build_native_prompt(messages, system_prompt), True
118
+ missed = portable[synced:]
119
+ latest = missed[-1]
120
+ earlier = missed[:-1]
121
+ if not earlier:
122
+ return latest["content"].strip(), False
123
+ lines = [("While you were away, the conversation continued with another assistant. "
124
+ "Read it as context, then respond to the final user message."), "", "Missed conversation:"]
125
+ for message in earlier:
126
+ speaker = "User" if message["role"] == "user" else "Assistant"
127
+ lines += [f"[{speaker}]", message["content"].strip(), ""]
128
+ lines += ["Final user message:", latest["content"].strip()]
129
+ return "\n".join(lines), False
@@ -13,6 +13,7 @@ import contextlib
13
13
  import json
14
14
  import os
15
15
  import shutil
16
+ import uuid
16
17
  from pathlib import Path
17
18
 
18
19
  from thwip.agents.base import (
@@ -28,7 +29,7 @@ from thwip.agents.base import (
28
29
  ThinkingDelta,
29
30
  TokenUsage,
30
31
  )
31
- from thwip.agents.native_common import build_native_prompt, classify_limit, network_hint, scrub
32
+ from thwip.agents.native_common import build_incremental_prompt, classify_limit, network_hint, scrub
32
33
  from thwip.tools.terminal import terminate_process_tree
33
34
 
34
35
  TURN_TIMEOUT = 600
@@ -181,27 +182,53 @@ class PrintAgent(BaseAgent):
181
182
 
182
183
  # --- Chat ---
183
184
 
184
- def _turn_command(self, prompt: str, model: str, system_prompt: str | None) -> tuple[list[str], str | None]:
185
+ def _turn_command(self, prompt: str, model: str, system_prompt: str | None,
186
+ resume_id: str | None, new_id: str | None) -> tuple[list[str], str | None]:
185
187
  if self.name == "claude":
186
188
  command = ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages",
187
189
  "--model", model, "--allowedTools", "Read,Glob,Grep,LS,WebFetch,WebSearch"]
188
- if system_prompt:
190
+ command += ["--resume", resume_id] if resume_id else ["--session-id", new_id]
191
+ if system_prompt and not resume_id:
189
192
  command += ["--append-system-prompt", system_prompt]
190
193
  return command, prompt
191
- return ["--print", prompt, "--output-format", "stream-json", "--model", model,
192
- "--print-timeout", f"{TURN_TIMEOUT}s"], None
194
+ command = ["--print", prompt, "--output-format", "stream-json", "--model", model,
195
+ "--print-timeout", f"{TURN_TIMEOUT}s"]
196
+ if resume_id:
197
+ command += ["--conversation", resume_id]
198
+ return command, None
193
199
 
194
- async def chat(self, messages, model=None, system_prompt=None, tools=None, stream=True):
200
+ async def chat(self, messages, model=None, system_prompt=None, tools=None, stream=True, resume=None):
195
201
  chosen = model or self.get_default_model()
196
- prompt = build_native_prompt(messages, None if self.name == "claude" else system_prompt)
197
- command, stdin_text = self._turn_command(prompt, chosen, system_prompt)
202
+ resume_id = resume.get("id") if isinstance(resume, dict) else None
203
+ synced = resume.get("synced", 0) if isinstance(resume, dict) else 0
204
+ if resume_id:
205
+ prompt, _full = build_incremental_prompt(messages, None if self.name == "claude" else system_prompt, synced)
206
+ try:
207
+ async for event in self._turn(prompt, chosen, system_prompt, resume_id=resume_id, new_id=None):
208
+ yield event
209
+ return
210
+ except RuntimeError as exc:
211
+ # The CLI could not continue that session (deleted, different machine, expired).
212
+ yield NativeActivity(description=f"Previous {self.display_name} session unavailable "
213
+ f"({scrub(str(exc), 120)}); sending the full conversation to a new one.")
214
+ prompt, _full = build_incremental_prompt(messages, None if self.name == "claude" else system_prompt, 0)
215
+ async for event in self._turn(prompt, chosen, system_prompt, resume_id=None, new_id=str(uuid.uuid4())):
216
+ yield event
217
+
218
+ async def _turn(self, prompt, model, system_prompt, resume_id, new_id):
219
+ command, stdin_text = self._turn_command(prompt, model, system_prompt, resume_id, new_id)
198
220
  process = await self._start_process(command, stdin_text)
199
221
  usage = TokenUsage()
200
222
  streamed_text = False
201
223
  finished = False
224
+ session_id = resume_id or (new_id if self.name == "claude" else None)
202
225
  try:
203
226
  async for event in self._iterate(process):
204
227
  kind = event.get("type") or event.get("event")
228
+ if kind == "system" and event.get("session_id"):
229
+ session_id = event["session_id"]
230
+ elif kind == "init" and isinstance(event.get("init"), dict) or kind == "init":
231
+ session_id = event.get("conversation_id") or session_id
205
232
  if self.name == "claude":
206
233
  result = self._claude_event(event, kind)
207
234
  else:
@@ -214,7 +241,7 @@ class PrintAgent(BaseAgent):
214
241
  yield TextDelta(content=item)
215
242
  elif isinstance(item, AgentDone):
216
243
  finished = True
217
- yield AgentDone(usage=usage)
244
+ yield AgentDone(usage=usage, native_session={"id": session_id} if session_id else {})
218
245
  return
219
246
  else:
220
247
  if isinstance(item, TextDelta):
@@ -258,6 +258,14 @@ class ThwipCLI:
258
258
  break
259
259
  except Exception as e:
260
260
  print_error(f"Unexpected error: {e}")
261
+ await self._close_native_agents()
262
+
263
+ async def _close_native_agents(self) -> None:
264
+ for agent in self.registry.list_agents():
265
+ closer = getattr(agent, "close", None)
266
+ if callable(closer):
267
+ with contextlib.suppress(Exception):
268
+ await closer()
261
269
 
262
270
  async def _ask_text(self, question: str) -> str:
263
271
  """Ask a one-line question without blocking the event loop.
@@ -1145,6 +1153,11 @@ class ThwipCLI:
1145
1153
  if getattr(self.current_agent, "native_tools", False):
1146
1154
  content.append("Connection: Existing CLI sign-in; billing and tool accounting managed by native CLI\n", style="dim")
1147
1155
  content.append(f"Usage: {describe_limit_windows(getattr(self.current_agent, 'limit_windows', []))}\n", style="dim")
1156
+ record = self.session.native_session(self.current_agent.name)
1157
+ if record:
1158
+ content.append(f"CLI session: {record['id']} (has seen {record['synced']} messages; only new ones are sent)\n", style="dim")
1159
+ else:
1160
+ content.append("CLI session: none yet; the first message sends the full conversation\n", style="dim")
1148
1161
  content.append(f"Config Key: {self.config.key_sources.get(self.current_agent.name, 'None')}\n", style="dim")
1149
1162
 
1150
1163
  console.print(Panel(content, title="Current Status", box=box.ROUNDED))
@@ -1231,10 +1244,11 @@ class ThwipCLI:
1231
1244
  p = Path(new_path).expanduser().resolve()
1232
1245
  if p.is_dir():
1233
1246
  self.session.project_path = str(p)
1247
+ self.session.native_sessions.clear()
1234
1248
  self.tool_manager = ToolManager(str(p))
1235
1249
  self.config.project = str(p)
1236
1250
  self.config.save()
1237
- print_success(f"Project path changed to {p}")
1251
+ print_success(f"Project path changed to {p}. Native CLI sessions will start fresh here.")
1238
1252
  else:
1239
1253
  print_error(f"Directory '{new_path}' does not exist.")
1240
1254
  else:
@@ -1338,6 +1352,7 @@ class ThwipCLI:
1338
1352
  collected_text = ""
1339
1353
  total_tokens = 0
1340
1354
  limit_hit = False
1355
+ native_session_id = ""
1341
1356
  if hasattr(self.current_agent, "get_handoff_models"):
1342
1357
  report = build_handoff_report(self.session, self.current_agent, self.current_agent,
1343
1358
  self.session.current_model, tools)
@@ -1356,13 +1371,12 @@ class ThwipCLI:
1356
1371
 
1357
1372
  with Live(console=console, refresh_per_second=12) as live:
1358
1373
  try:
1359
- response_stream = self.current_agent.chat(
1360
- messages=working_messages,
1361
- model=self.session.current_model,
1362
- system_prompt=self.session.system_prompt,
1363
- tools=tools,
1364
- stream=self.config.stream if tools is None else False,
1365
- )
1374
+ chat_kwargs = {"messages": working_messages, "model": self.session.current_model,
1375
+ "system_prompt": self.session.system_prompt, "tools": tools,
1376
+ "stream": self.config.stream if tools is None else False}
1377
+ if native:
1378
+ chat_kwargs["resume"] = self.session.native_session(self.current_agent.name)
1379
+ response_stream = self.current_agent.chat(**chat_kwargs)
1366
1380
  # Close the adapter generator deterministically on interruption so child processes stop.
1367
1381
  closer = contextlib.aclosing(response_stream) if hasattr(response_stream, "aclose") else contextlib.nullcontext(response_stream)
1368
1382
  async with closer as response_stream:
@@ -1393,6 +1407,7 @@ class ThwipCLI:
1393
1407
  live.start()
1394
1408
  elif isinstance(event, AgentDone):
1395
1409
  native_state = event.native_state
1410
+ native_session_id = str(event.native_session.get("id") or "") or native_session_id
1396
1411
  used = event.usage.input_tokens + event.usage.output_tokens
1397
1412
  total_tokens += used
1398
1413
  self.usage_tracker.record_usage(
@@ -1478,6 +1493,9 @@ class ThwipCLI:
1478
1493
  company=self.current_agent.company,
1479
1494
  tokens=total_tokens,
1480
1495
  )
1496
+ if native and native_session_id:
1497
+ # The native session now holds every portable message; next turn sends only what is new.
1498
+ self.session.set_native_session(self.current_agent.name, native_session_id, self.session.current_model)
1481
1499
  if getattr(self.config, "auto_save", False):
1482
1500
  if self.session.name == "new-session":
1483
1501
  self.session.name = f"session-{self.session.id}"
@@ -1549,6 +1567,35 @@ class ThwipCLI:
1549
1567
  break
1550
1568
 
1551
1569
 
1570
+ MAN_PAGE = Path(__file__).parent / "data" / "thwip.1"
1571
+
1572
+
1573
+ def run_man_command(command: str) -> int:
1574
+ """Show or install the bundled manual page. pip cannot install man pages, so thwip does it on request."""
1575
+ import subprocess
1576
+
1577
+ if not MAN_PAGE.is_file():
1578
+ print("The manual page is missing from this installation.", file=sys.stderr)
1579
+ return 1
1580
+ if command == "man":
1581
+ if shutil.which("man"):
1582
+ return subprocess.call(["man", "-l", str(MAN_PAGE)])
1583
+ print(MAN_PAGE.read_text(encoding="utf-8"))
1584
+ return 0
1585
+ target_dir = Path(os.environ.get("THWIP_MAN_DIR") or Path.home() / ".local" / "share" / "man" / "man1")
1586
+ try:
1587
+ target_dir.mkdir(parents=True, exist_ok=True)
1588
+ target = target_dir / "thwip.1"
1589
+ target.write_text(MAN_PAGE.read_text(encoding="utf-8"), encoding="utf-8")
1590
+ except OSError as exc:
1591
+ print(f"Could not install the manual page: {exc}", file=sys.stderr)
1592
+ return 1
1593
+ print(f"Installed {target}")
1594
+ print("Run `man thwip`. If it is not found, add this to your shell profile:")
1595
+ print(f' export MANPATH="{target_dir.parent}:$MANPATH"')
1596
+ return 0
1597
+
1598
+
1552
1599
  def main(argv: list[str] | None = None) -> None:
1553
1600
  """Entry point for the thwip CLI command."""
1554
1601
  import argparse
@@ -1562,7 +1609,11 @@ def main(argv: list[str] | None = None) -> None:
1562
1609
  parser.add_argument("--version", action="version", version=f"thwip {__version__}")
1563
1610
  parser.add_argument("-p", "--project", metavar="PATH",
1564
1611
  help="Project directory for this session (defaults to the saved project or the current directory).")
1612
+ parser.add_argument("command", nargs="?", choices=["man", "install-man"],
1613
+ help="'man' shows the manual page; 'install-man' installs it so that `man thwip` works.")
1565
1614
  args = parser.parse_args(argv)
1615
+ if args.command:
1616
+ raise SystemExit(run_man_command(args.command))
1566
1617
  project = None
1567
1618
  if args.project:
1568
1619
  candidate = Path(args.project).expanduser()
@@ -0,0 +1,122 @@
1
+ .TH THWIP 1 "September 2026" "thwip" "User Commands"
2
+ .SH NAME
3
+ thwip \- universal coding agent multiplexer for the terminal
4
+ .SH SYNOPSIS
5
+ .B thwip
6
+ .RI [ \-\-project " PATH" ]
7
+ .br
8
+ .B thwip man
9
+ .br
10
+ .B thwip install-man
11
+ .br
12
+ .B thwip \-\-version
13
+ .SH DESCRIPTION
14
+ .B thwip
15
+ finds the AI coding agents already installed and signed in on this machine
16
+ (Codex, Claude Code, and the Antigravity CLI), connects to each through its own
17
+ supported protocol, and lets you chat with any of them from one REPL. You can
18
+ switch provider or model mid-conversation, keep the text history, and fail over
19
+ to another provider when one reports an exhausted usage limit. Direct API keys
20
+ for Anthropic, Google, OpenAI, DeepSeek, Groq, OpenRouter, and a local Ollama
21
+ server are also supported.
22
+ .PP
23
+ No credential is copied. Installed CLIs hold their own login, enforce their own
24
+ sandbox and approvals, and bill their own plan. Native connections are read-only
25
+ by default; Codex asks before operations outside its sandbox.
26
+ .SH OPTIONS
27
+ .TP
28
+ .BI \-p ", " \-\-project " PATH"
29
+ Open the REPL for the given project directory instead of the saved or current one.
30
+ .TP
31
+ .B \-\-version
32
+ Print the version and exit.
33
+ .TP
34
+ .B \-h ", " \-\-help
35
+ Print usage and exit.
36
+ .SH SUBCOMMANDS
37
+ .TP
38
+ .B man
39
+ Show this manual page.
40
+ .TP
41
+ .B install-man
42
+ Copy this manual page to
43
+ .I ~/.local/share/man/man1
44
+ so that
45
+ .B man thwip
46
+ works.
47
+ .SH REPL COMMANDS
48
+ .TP
49
+ .B /switch [agent] [model]
50
+ Switch provider (and optionally model). A handoff preview is shown first. Alias:
51
+ .BR /sw ", " /s .
52
+ .TP
53
+ .B /model [id]
54
+ Pick a model for the current provider from a numbered list or by ID.
55
+ .TP
56
+ .B /handoff [agent] [model]
57
+ Preview what transfers, what is lost, and the context fit without switching.
58
+ .TP
59
+ .B /agents ", " /models [agent|tier] ", " /detect
60
+ List detected agents, list models (live from the CLI or provider), rescan.
61
+ .TP
62
+ .B /compact
63
+ Ask the current model for a plain-text summary and replace the history with it.
64
+ .TP
65
+ .B /new ", " /resume [name] ", " /session save|load|list|clear [name]
66
+ Start a fresh conversation, resume a saved one, or manage saved sessions.
67
+ .TP
68
+ .B /diff [staged] ", " /copy ", " /export [path]
69
+ Show the project git diff, copy the last response, export the conversation as
70
+ Markdown with model attribution.
71
+ .TP
72
+ .B /limits ", " /usage ", " /cost ", " /status ", " /history
73
+ Usage windows reported by each CLI account, API cost estimates, session status,
74
+ and the conversation with model badges.
75
+ .TP
76
+ .B /key [provider]
77
+ Enter a direct API key with hidden input.
78
+ .TP
79
+ .B /native codex
80
+ Save the session and replace thwip with the Codex CLI itself.
81
+ .TP
82
+ .B !command
83
+ Run a shell command in the project without involving a model.
84
+ .TP
85
+ .B @path
86
+ Attach a project file's content to your message.
87
+ .TP
88
+ .B /help ", " /about ", " /quit
89
+ .SH KEYS
90
+ .TP
91
+ .B Ctrl+S
92
+ Open the switcher.
93
+ .TP
94
+ .B Ctrl+T
95
+ Show status.
96
+ .TP
97
+ .B Ctrl+C
98
+ Interrupt the current response, command, or prompt. At the main prompt, exit.
99
+ .SH ENVIRONMENT
100
+ .TP
101
+ .B THWIP_CONFIG_DIR
102
+ Configuration directory (default
103
+ .IR ~/.thwip ).
104
+ .TP
105
+ .B THWIP_OPENAI_BASE_URL ", " THWIP_ANTHROPIC_BASE_URL ", " THWIP_GOOGLE_BASE_URL ", " THWIP_DEEPSEEK_BASE_URL ", " THWIP_GROQ_BASE_URL ", " THWIP_OPENROUTER_BASE_URL
106
+ Point a direct adapter at a proxy, gateway, or OpenAI-compatible server.
107
+ .TP
108
+ .B ANTHROPIC_API_KEY ", " GEMINI_API_KEY ", " OPENAI_API_KEY ", " DEEPSEEK_API_KEY ", " GROQ_API_KEY ", " OPENROUTER_API_KEY
109
+ Direct API keys. A key takes precedence over the native CLI connection for that provider.
110
+ .SH FILES
111
+ .TP
112
+ .I ~/.thwip/config.toml
113
+ Defaults, keys entered with /key, endpoint overrides, fallback chain, display settings.
114
+ .TP
115
+ .I ~/.thwip/sessions/
116
+ Saved sessions, including the native CLI session IDs used to resume each provider.
117
+ .SH SEE ALSO
118
+ .BR codex (1),
119
+ .BR claude (1),
120
+ .BR agy (1)
121
+ .PP
122
+ https://github.com/tanmayhutt/thwip-cli
@@ -66,14 +66,37 @@ class Session:
66
66
  messages: list[Message] = field(default_factory=list)
67
67
  observed_tool_results: int = 0
68
68
  tool_tracking_complete: bool = True
69
+ # Per-provider native CLI sessions: {"openai": {"id": "...", "synced": 4, "model": "..."}}.
70
+ # `synced` is how many portable messages that native session has already seen, so a provider
71
+ # you return to receives only what it missed. Cleared whenever the text history is replaced.
72
+ native_sessions: dict[str, dict[str, Any]] = field(default_factory=dict)
69
73
 
70
74
  def clear_context(self) -> None:
71
- """Clear text and its associated handoff accounting together."""
75
+ """Clear text, handoff accounting, and native session bookkeeping together."""
72
76
  self.messages.clear()
73
77
  self.observed_tool_results = 0
74
78
  self.tool_tracking_complete = True
79
+ self.native_sessions.clear()
75
80
  self.updated_at = time.time()
76
81
 
82
+ def native_session(self, provider: str) -> dict[str, Any] | None:
83
+ """Return a consistent native session record for the provider, or None."""
84
+ record = self.native_sessions.get(provider)
85
+ if not isinstance(record, dict) or not isinstance(record.get("id"), str) or not record["id"]:
86
+ return None
87
+ synced = record.get("synced", 0)
88
+ if type(synced) is not int or synced < 0 or synced > len(self.to_portable_messages()):
89
+ return None
90
+ return record
91
+
92
+ def set_native_session(self, provider: str, session_id: str, model: str = "") -> None:
93
+ """Record that the provider's native session has seen every portable message so far."""
94
+ self.native_sessions[provider] = {"id": session_id, "synced": len(self.to_portable_messages()), "model": model}
95
+ self.updated_at = time.time()
96
+
97
+ def forget_native_session(self, provider: str) -> None:
98
+ self.native_sessions.pop(provider, None)
99
+
77
100
  def record_tool_result(self) -> None:
78
101
  """Count transient results without persisting potentially sensitive outputs."""
79
102
  self.observed_tool_results += 1
@@ -145,6 +168,7 @@ class Session:
145
168
  "messages": [m.to_dict() for m in self.messages],
146
169
  "observed_tool_results": self.observed_tool_results,
147
170
  "tool_tracking_complete": self.tool_tracking_complete,
171
+ "native_sessions": self.native_sessions,
148
172
  }
149
173
  temporary_path: Path | None = None
150
174
  try:
@@ -214,6 +238,16 @@ class Session:
214
238
  return None
215
239
  if any(not isinstance(raw.get(key, ""), str) for key in ("agent_name", "model", "company")):
216
240
  return None
241
+ native_sessions = data.get("native_sessions", {})
242
+ if not isinstance(native_sessions, dict):
243
+ return None
244
+ for provider, record in native_sessions.items():
245
+ if not isinstance(provider, str) or not isinstance(record, dict):
246
+ return None
247
+ if not isinstance(record.get("id"), str) or type(record.get("synced", 0)) is not int or record.get("synced", 0) < 0:
248
+ return None
249
+ if not isinstance(record.get("model", ""), str):
250
+ return None
217
251
  session = cls(
218
252
  id=data.get("id", ""),
219
253
  name=data.get("name", "saved-session"),
@@ -226,6 +260,8 @@ class Session:
226
260
  messages=[Message.from_dict(m) for m in data.get("messages", [])],
227
261
  observed_tool_results=data.get("observed_tool_results", 0),
228
262
  tool_tracking_complete=data.get("tool_tracking_complete", False),
263
+ native_sessions={k: {"id": v["id"], "synced": v.get("synced", 0), "model": v.get("model", "")}
264
+ for k, v in native_sessions.items()},
229
265
  )
230
266
  return session
231
267
  except Exception:
@@ -1022,7 +1022,7 @@ wheels = [
1022
1022
 
1023
1023
  [[package]]
1024
1024
  name = "thwip-cli"
1025
- version = "1.6.2"
1025
+ version = "1.7.0"
1026
1026
  source = { editable = "." }
1027
1027
  dependencies = [
1028
1028
  { name = "anthropic" },
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