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.
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/PKG-INFO +13 -1
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/README.md +12 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/docs/verification.md +4 -2
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/pyproject.toml +1 -1
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_native_agents.py +73 -1
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_native_print.py +50 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_parity_commands.py +27 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_session.py +21 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/__init__.py +1 -1
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/base.py +2 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/native_agent.py +64 -25
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/native_common.py +25 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/native_print.py +36 -9
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/cli.py +59 -8
- thwip_cli-1.7.0/thwip/data/thwip.1 +122 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/session.py +37 -1
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/uv.lock +1 -1
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/.github/workflows/publish.yml +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/.gitignore +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/LICENSE +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/docs/handoff-research.md +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/install.sh +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/__init__.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/fake_providers.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_agents.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_audit_regressions.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_catalog.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_cli.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_compatible_streaming.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_config.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_detector.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_direct_providers_e2e.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_handoff.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_native_launcher.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_native_rpc.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_repair_verification.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_tools.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/tests/test_utils.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/__main__.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/__init__.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/catalog.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/chat_messages.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/claude_agent.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/deepseek_agent.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/google_agent.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/groq_agent.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/native_rpc.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/ollama_agent.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/openai_agent.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/agents/openrouter_agent.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/config.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/detector.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/endpoints.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/handoff.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/limits.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/shortcuts.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/theme.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/tools/__init__.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/tools/code_runner.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/tools/file_editor.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/tools/git_ops.py +0 -0
- {thwip_cli-1.6.2 → thwip_cli-1.7.0}/thwip/tools/terminal.py +0 -0
- {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.
|
|
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.
|
|
4
|
-
passes
|
|
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 |
|
|
@@ -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"
|
|
@@ -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
|
|
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
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
await rpc.request("turn/start", {"threadId": thread_id, "
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|