thwip-cli 1.1.2__tar.gz → 1.1.3__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 (38) hide show
  1. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/.github/workflows/publish.yml +15 -5
  2. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/PKG-INFO +2 -2
  3. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/README.md +1 -1
  4. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/pyproject.toml +1 -1
  5. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/tests/test_cli.py +17 -1
  6. thwip_cli-1.1.3/tests/test_config.py +21 -0
  7. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/tests/test_tools.py +21 -0
  8. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/__init__.py +1 -1
  9. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/claude_agent.py +27 -1
  10. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/google_agent.py +28 -7
  11. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/openai_agent.py +23 -1
  12. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/cli.py +91 -24
  13. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/config.py +31 -8
  14. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/session.py +17 -4
  15. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/theme.py +1 -1
  16. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/tools/__init__.py +15 -9
  17. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/tools/file_editor.py +24 -2
  18. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/tools/terminal.py +4 -0
  19. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/uv.lock +1 -1
  20. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/.gitignore +0 -0
  21. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/LICENSE +0 -0
  22. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/install.sh +0 -0
  23. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/tests/test_agents.py +0 -0
  24. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/tests/test_detector.py +0 -0
  25. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/tests/test_session.py +0 -0
  26. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/__main__.py +0 -0
  27. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/__init__.py +0 -0
  28. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/base.py +0 -0
  29. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/deepseek_agent.py +0 -0
  30. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/groq_agent.py +0 -0
  31. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/ollama_agent.py +0 -0
  32. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/agents/openrouter_agent.py +0 -0
  33. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/detector.py +0 -0
  34. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/limits.py +0 -0
  35. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/shortcuts.py +0 -0
  36. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/tools/code_runner.py +0 -0
  37. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/tools/git_ops.py +0 -0
  38. {thwip_cli-1.1.2 → thwip_cli-1.1.3}/thwip/utils.py +0 -0
@@ -4,17 +4,16 @@ on:
4
4
  push:
5
5
  tags:
6
6
  - 'v*'
7
- workflow_dispatch:
8
7
 
9
8
  jobs:
10
9
  test:
11
10
  runs-on: ubuntu-latest
12
11
  steps:
13
12
  - name: Checkout code
14
- uses: actions/checkout@v4
13
+ uses: actions/checkout@v6
15
14
 
16
15
  - name: Install uv
17
- uses: astral-sh/setup-uv@v5
16
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
18
17
 
19
18
  - name: Set up Python
20
19
  run: uv python install 3.13
@@ -33,14 +32,25 @@ jobs:
33
32
 
34
33
  steps:
35
34
  - name: Checkout code
36
- uses: actions/checkout@v4
35
+ uses: actions/checkout@v6
37
36
 
38
37
  - name: Install uv
39
- uses: astral-sh/setup-uv@v5
38
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
39
+
40
+ - name: Verify tag matches package version
41
+ run: |
42
+ PACKAGE_VERSION=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")
43
+ test "${GITHUB_REF_NAME}" = "v${PACKAGE_VERSION}" || {
44
+ echo "Tag ${GITHUB_REF_NAME} does not match package version v${PACKAGE_VERSION}."
45
+ exit 1
46
+ }
40
47
 
41
48
  - name: Build package
42
49
  run: uv build
43
50
 
51
+ - name: Verify package metadata
52
+ run: uvx twine check dist/*
53
+
44
54
  - name: Publish to PyPI
45
55
  uses: pypa/gh-action-pypi-publish@release/v1
46
56
  with:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: thwip-cli
3
- Version: 1.1.2
3
+ Version: 1.1.3
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
@@ -39,7 +39,7 @@ Description-Content-Type: text/markdown
39
39
 
40
40
  > Universal Coding Agent Multiplexer
41
41
  >
42
- > Detect all installed AI coding agents on your device, combine them in a unified terminal interface, and hot-swap models mid-conversation when hitting rate limits without losing context.
42
+ > Discover supported local AI tools, connect configured model providers in one terminal interface, and switch providers while preserving completed text conversation history.
43
43
 
44
44
  ---
45
45
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Universal Coding Agent Multiplexer
4
4
  >
5
- > Detect all installed AI coding agents on your device, combine them in a unified terminal interface, and hot-swap models mid-conversation when hitting rate limits without losing context.
5
+ > Discover supported local AI tools, connect configured model providers in one terminal interface, and switch providers while preserving completed text conversation history.
6
6
 
7
7
  ---
8
8
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thwip-cli"
7
- version = "1.1.2"
7
+ version = "1.1.3"
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"
@@ -7,7 +7,7 @@ from types import SimpleNamespace
7
7
  import pytest
8
8
 
9
9
  from thwip.agents.base import AgentDone, Capability, TextDelta, ToolUseStart
10
- from thwip.cli import ThwipCLI
10
+ from thwip.cli import SafeFileHistory, ThwipCLI
11
11
  from thwip.session import Session
12
12
  from thwip.tools import ToolManager
13
13
 
@@ -44,6 +44,10 @@ class ToolCallingAgent:
44
44
  yield ToolUseStart(tool_id="read-1", tool_name="read_file", args={"file_path": "note.txt"})
45
45
  yield AgentDone()
46
46
  else:
47
+ assert messages[-2]["role"] == "assistant"
48
+ assert messages[-2]["tool_calls"][0]["id"] == "read-1"
49
+ assert messages[-1]["role"] == "tool"
50
+ assert messages[-1]["tool_call_id"] == "read-1"
47
51
  assert "known result" in messages[-1]["content"]
48
52
  yield TextDelta(content="Used the tool result.")
49
53
  yield AgentDone()
@@ -94,3 +98,15 @@ def test_inline_api_key_is_rejected():
94
98
 
95
99
  assert cli.config.keys == {}
96
100
  assert cli.config.key_sources == {}
101
+
102
+
103
+ def test_inline_api_key_is_not_stored_in_prompt_history(tmp_path):
104
+ history_path = tmp_path / "history.txt"
105
+ history = SafeFileHistory(str(history_path))
106
+
107
+ history.store_string("/key openai secret-value")
108
+ history.store_string("/key openai")
109
+
110
+ contents = history_path.read_text()
111
+ assert "secret-value" not in contents
112
+ assert "/key openai" in contents
@@ -0,0 +1,21 @@
1
+ """Regression tests for secure configuration persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tomllib
6
+
7
+ from thwip.config import ThwipConfig
8
+
9
+
10
+ def test_save_does_not_persist_environment_or_discovered_keys(tmp_path, monkeypatch):
11
+ monkeypatch.setenv("THWIP_CONFIG_DIR", str(tmp_path))
12
+ config = ThwipConfig(
13
+ keys={"openai": "from-env", "anthropic": "explicit"},
14
+ key_sources={"openai": "env:OPENAI_API_KEY", "anthropic": "config.toml"},
15
+ )
16
+
17
+ config.save()
18
+
19
+ data = tomllib.loads((tmp_path / "config.toml").read_text())
20
+ assert data["keys"] == {"anthropic": "explicit"}
21
+ assert (tmp_path / "config.toml").stat().st_mode & 0o777 == 0o600
@@ -6,7 +6,10 @@ from __future__ import annotations
6
6
 
7
7
  import tempfile
8
8
 
9
+ import pytest
10
+
9
11
  from thwip.tools import ToolManager
12
+ from thwip.tools.terminal import TerminalRunner
10
13
 
11
14
 
12
15
  def test_file_editor_operations():
@@ -49,3 +52,21 @@ def test_file_editor_blocks_paths_outside_workspace(tmp_path):
49
52
  assert "outside the project workspace" in manager.file_editor.read_file(str(outside))
50
53
  assert "outside the project workspace" in manager.file_editor.write_file("../outside.txt", "changed")
51
54
  assert outside.read_text() == "private"
55
+
56
+
57
+ def test_tool_output_is_bounded(tmp_path):
58
+ manager = ToolManager(project_path=tmp_path)
59
+ (tmp_path / "large.txt").write_text("x" * 25_000)
60
+
61
+ result = manager.execute_tool("read_file", {"file_path": "large.txt", "max_lines": 1})
62
+
63
+ assert len(result) <= 20_100
64
+ assert "Output truncated" in result
65
+
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_async_command_timeout_terminates_process(tmp_path):
69
+ runner = TerminalRunner(tmp_path)
70
+ result = await runner.run_command_async("sleep 5", timeout=0.01)
71
+
72
+ assert "timed out" in result
@@ -1,4 +1,4 @@
1
1
  """thwip: Universal Coding Agent Multiplexer."""
2
2
 
3
- __version__ = "1.1.2"
3
+ __version__ = "1.1.3"
4
4
  __app_name__ = "thwip"
@@ -313,11 +313,37 @@ class ClaudeAgent(BaseAgent):
313
313
  if tools:
314
314
  stream = False
315
315
 
316
+ # Convert the portable OpenAI-style tool transcript into Anthropic blocks.
317
+ anthropic_messages: list[dict[str, Any]] = []
318
+ for message in messages:
319
+ if message.get("tool_calls"):
320
+ blocks: list[dict[str, Any]] = []
321
+ if message.get("content"):
322
+ blocks.append({"type": "text", "text": message["content"]})
323
+ for call in message["tool_calls"]:
324
+ function = call["function"]
325
+ arguments = function.get("arguments", {})
326
+ blocks.append({
327
+ "type": "tool_use", "id": call["id"], "name": function["name"],
328
+ "input": json.loads(arguments) if isinstance(arguments, str) else arguments,
329
+ })
330
+ anthropic_messages.append({"role": "assistant", "content": blocks})
331
+ elif message.get("role") == "tool":
332
+ anthropic_messages.append({
333
+ "role": "user",
334
+ "content": [{
335
+ "type": "tool_result", "tool_use_id": message["tool_call_id"],
336
+ "content": message.get("content", ""),
337
+ }],
338
+ })
339
+ else:
340
+ anthropic_messages.append(message)
341
+
316
342
  # Build request kwargs
317
343
  model_info = self.get_model_info(model)
318
344
  kwargs: dict[str, Any] = {
319
345
  "model": model,
320
- "messages": messages,
346
+ "messages": anthropic_messages,
321
347
  "max_tokens": model_info.max_output if model_info else 16_384,
322
348
  }
323
349
 
@@ -286,22 +286,43 @@ class GoogleAgent(BaseAgent):
286
286
  client = self._ensure_client()
287
287
  model = model or self.get_default_model()
288
288
 
289
+ from google.genai import types
290
+
289
291
  # Convert messages to Gemini format
290
292
  gemini_contents = []
291
293
  for msg in messages:
292
- role = "user" if msg["role"] == "user" else "model"
293
- gemini_contents.append({
294
- "role": role,
295
- "parts": [{"text": msg["content"]}],
296
- })
294
+ if msg.get("tool_calls"):
295
+ parts = []
296
+ if msg.get("content"):
297
+ parts.append(types.Part.from_text(text=msg["content"]))
298
+ for call in msg["tool_calls"]:
299
+ function = call["function"]
300
+ arguments = function.get("arguments", {})
301
+ if isinstance(arguments, str):
302
+ arguments = json.loads(arguments)
303
+ parts.append(types.Part.from_function_call(
304
+ name=function["name"], args=arguments,
305
+ ))
306
+ gemini_contents.append(types.Content(role="model", parts=parts))
307
+ elif msg.get("role") == "tool":
308
+ gemini_contents.append(types.Content(
309
+ role="user",
310
+ parts=[types.Part.from_function_response(
311
+ name=msg.get("name", "tool"),
312
+ response={"result": msg.get("content", "")},
313
+ )],
314
+ ))
315
+ else:
316
+ role = "user" if msg["role"] == "user" else "model"
317
+ gemini_contents.append(types.Content(
318
+ role=role, parts=[types.Part.from_text(text=msg["content"])],
319
+ ))
297
320
 
298
321
  config: dict[str, Any] = {}
299
322
  if system_prompt:
300
323
  config["system_instruction"] = system_prompt
301
324
 
302
325
  try:
303
- from google.genai import types
304
-
305
326
  if tools:
306
327
  declarations = []
307
328
  for tool in tools:
@@ -362,9 +362,31 @@ class OpenAIAgent(BaseAgent):
362
362
  "description": function.get("description", ""),
363
363
  "parameters": function.get("parameters", {}),
364
364
  })
365
+ response_input: list[dict[str, Any]] = []
366
+ for message in api_messages:
367
+ if message.get("tool_calls"):
368
+ if message.get("content"):
369
+ response_input.append({"role": "assistant", "content": message["content"]})
370
+ for call in message["tool_calls"]:
371
+ function = call["function"]
372
+ arguments = function.get("arguments", {})
373
+ response_input.append({
374
+ "type": "function_call", "call_id": call["id"],
375
+ "name": function["name"],
376
+ "arguments": arguments if isinstance(arguments, str) else json.dumps(arguments),
377
+ })
378
+ elif message.get("role") == "tool":
379
+ response_input.append({
380
+ "type": "function_call_output",
381
+ "call_id": message["tool_call_id"],
382
+ "output": message.get("content", ""),
383
+ })
384
+ else:
385
+ response_input.append(message)
386
+
365
387
  response_kwargs: dict[str, Any] = {
366
388
  "model": model,
367
- "input": api_messages,
389
+ "input": response_input,
368
390
  "max_output_tokens": model_info.max_output if model_info else 16_384,
369
391
  }
370
392
  if response_tools:
@@ -4,7 +4,7 @@ Main interactive terminal CLI for thwip.
4
4
  Universal coding agent multiplexer:
5
5
  - Dynamic UI adapting to agent capabilities
6
6
  - Seamless mid-conversation agent switching with context preservation
7
- - Auto-detection of all installed tools & subscriptions
7
+ - Detection of supported installed tools and available credentials
8
8
  - Quota / rate limit exhaustion failover
9
9
  - Rich live markdown and tool execution
10
10
  """
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
  import asyncio
15
15
  import getpass
16
16
  import os
17
+ import re
17
18
  from pathlib import Path
18
19
 
19
20
  from prompt_toolkit import PromptSession
@@ -59,6 +60,15 @@ from thwip.theme import (
59
60
  from thwip.tools import ToolManager
60
61
 
61
62
 
63
+ class SafeFileHistory(FileHistory):
64
+ """Keep inline API-key commands out of persistent prompt history."""
65
+
66
+ def store_string(self, string: str) -> None:
67
+ if re.match(r"^\s*/(?:key|auth|config|k)\s+\S+\s+\S+", string, re.IGNORECASE):
68
+ return
69
+ super().store_string(string)
70
+
71
+
62
72
  class ThwipCLI:
63
73
  """The interactive terminal CLI REPL."""
64
74
 
@@ -127,9 +137,15 @@ class ThwipCLI:
127
137
  )
128
138
 
129
139
  # Setup prompt session
130
- history_file = str(get_config_dir() / "history.txt")
140
+ history_path = get_config_dir() / "history.txt"
141
+ history_path.touch(mode=0o600, exist_ok=True)
142
+ try:
143
+ history_path.chmod(0o600)
144
+ except OSError:
145
+ pass
146
+ history_file = str(history_path)
131
147
  prompt_session: PromptSession = PromptSession(
132
- history=FileHistory(history_file),
148
+ history=SafeFileHistory(history_file),
133
149
  completer=ThwipCompleter([a.name for a in self.registry.list_agents()]),
134
150
  key_bindings=create_keybindings(),
135
151
  )
@@ -177,7 +193,7 @@ class ThwipCLI:
177
193
 
178
194
  async def handle_command(self, cmd_line: str) -> str | None:
179
195
  """Handle slash commands."""
180
- parts = cmd_line.split(" ", 2)
196
+ parts = cmd_line.split(maxsplit=2)
181
197
  cmd = parts[0].lower()
182
198
  arg1 = parts[1] if len(parts) > 1 else ""
183
199
  arg2 = parts[2] if len(parts) > 2 else ""
@@ -236,10 +252,20 @@ class ThwipCLI:
236
252
  elif sub == "load":
237
253
  loaded = Session.load(arg2)
238
254
  if loaded:
239
- self.session = loaded
255
+ project = Path(loaded.project_path).expanduser().resolve()
256
+ if not project.is_dir():
257
+ print_error(f"Saved project directory '{loaded.project_path}' no longer exists.")
258
+ return None
240
259
  agent = self.registry.get_agent(loaded.current_agent)
241
- if agent:
242
- self.current_agent = agent
260
+ if not agent:
261
+ print_error(f"Saved agent '{loaded.current_agent}' is not available.")
262
+ return None
263
+ if not agent.get_model_info(loaded.current_model):
264
+ loaded.current_model = agent.get_default_model()
265
+ print_warning("The saved model is unavailable. Using the provider default.")
266
+ self.session = loaded
267
+ self.current_agent = agent
268
+ self.tool_manager = ToolManager(str(project))
243
269
  print_success(f"Loaded session '{loaded.name}' with {len(loaded.messages)} messages.")
244
270
  else:
245
271
  print_error(f"Session '{arg2}' not found.")
@@ -283,8 +309,8 @@ class ThwipCLI:
283
309
  ("edit_file", "Filesystem", "Structured string replacement in local files", "Active"),
284
310
  ("write_file", "Filesystem", "Create or overwrite full files", "Active"),
285
311
  ("list_dir", "Filesystem", "List directory tree and metadata", "Active"),
286
- ("run_command", "Terminal", "Execute non-interactive shell commands safely", "Active"),
287
- ("run_code", "Sandbox", "Run inline Python or Node.js code snippets", "Active"),
312
+ ("run_command", "Terminal", "Execute confirmed shell commands in the project", "Active"),
313
+ ("run_code", "Execution", "Run confirmed Python snippets with local user permissions", "Active"),
288
314
  ("git_status", "Version Control", "Inspect repository changes and staging", "Active"),
289
315
  ("git_diff", "Version Control", "View unified unstaged/staged diffs", "Active"),
290
316
  ]
@@ -415,7 +441,10 @@ class ThwipCLI:
415
441
  f" Or add it to ~/.thwip/config.toml"
416
442
  )
417
443
  else:
418
- print_success(f"Now chatting with {new_agent.display_name} ({chosen_model}). Context preserved!")
444
+ print_success(
445
+ f"Now chatting with {new_agent.display_name} ({chosen_model}). "
446
+ "Portable text history preserved."
447
+ )
419
448
 
420
449
  def cmd_show_agents(self) -> None:
421
450
  """Show table of all detected agents."""
@@ -690,6 +719,8 @@ class ThwipCLI:
690
719
  if p.is_dir():
691
720
  self.session.project_path = str(p)
692
721
  self.tool_manager = ToolManager(str(p))
722
+ self.config.project = str(p)
723
+ self.config.save()
693
724
  print_success(f"Project path changed to {p}")
694
725
  else:
695
726
  print_error(f"Directory '{new_path}' does not exist.")
@@ -728,9 +759,9 @@ class ThwipCLI:
728
759
  Text.from_markup(
729
760
  f"[bold yellow]Missing API Key for {self.current_agent.display_name}[/bold yellow]\n\n"
730
761
  f"An API key is required to send requests to [bold]{self.session.current_model}[/bold].\n\n"
731
- f" • Configure key now: [bold cyan]/key {self.current_agent.name}[/bold cyan]\n"
732
- f" • Or set in shell: [bold white]export {key_name}=your_key_here[/bold white]\n"
733
- f" • Or switch agent: [bold cyan]/switch[/bold cyan]"
762
+ f" - Configure key now: [bold cyan]/key {self.current_agent.name}[/bold cyan]\n"
763
+ f" - Or set in shell: [bold white]export {key_name}=your_key_here[/bold white]\n"
764
+ f" - Or switch agent: [bold cyan]/switch[/bold cyan]"
734
765
  ),
735
766
  title="Setup Required",
736
767
  border_style="yellow",
@@ -819,7 +850,8 @@ class ThwipCLI:
819
850
  if limit_hit or not tool_requests:
820
851
  break
821
852
 
822
- results = []
853
+ tool_call_messages = []
854
+ tool_result_messages = []
823
855
  for request in tool_requests:
824
856
  display_args = {key: value for key, value in request.args.items() if key != "content"}
825
857
  console.print(
@@ -835,15 +867,25 @@ class ThwipCLI:
835
867
  if approved
836
868
  else "Denied by user."
837
869
  )
838
- console.print(f" [dim green]Result:[/dim green] [dim]{output[:500]}[/dim]")
839
- results.append(f"Tool {request.tool_name} ({request.tool_id}) returned:\n{output}")
870
+ result_preview = Text(" Result: ", style="green")
871
+ result_preview.append(output[:500], style="dim")
872
+ console.print(result_preview)
873
+ tool_call_messages.append({
874
+ "id": request.tool_id,
875
+ "type": "function",
876
+ "function": {"name": request.tool_name, "arguments": request.args},
877
+ })
878
+ tool_result_messages.append({
879
+ "role": "tool",
880
+ "tool_call_id": request.tool_id,
881
+ "name": request.tool_name,
882
+ "content": output,
883
+ })
840
884
 
841
- assistant_note = round_text or "I requested one or more workspace tools."
842
- working_messages.append({"role": "assistant", "content": assistant_note})
843
885
  working_messages.append({
844
- "role": "user",
845
- "content": "Workspace tool results follow. Continue the task using these results.\n\n" + "\n\n".join(results),
886
+ "role": "assistant", "content": round_text, "tool_calls": tool_call_messages,
846
887
  })
888
+ working_messages.extend(tool_result_messages)
847
889
  else:
848
890
  print_warning("Stopped after 8 consecutive tool rounds to prevent an infinite loop.")
849
891
 
@@ -855,18 +897,41 @@ class ThwipCLI:
855
897
  company=self.current_agent.company,
856
898
  tokens=total_tokens,
857
899
  )
900
+ if getattr(self.config, "auto_save", False):
901
+ if self.session.name == "new-session":
902
+ self.session.name = f"session-{self.session.id}"
903
+ self.session.save()
858
904
 
859
905
  async def handle_limit_failover(self, event: LimitHit) -> None:
860
906
  """Handle rate limit or quota exhaustion with auto-suggested failover."""
907
+ if not self.config.fallback.enabled:
908
+ print_warning("Provider fallback is disabled in configuration.")
909
+ return
910
+
861
911
  # Find ready alternatives
862
912
  ready = self.registry.get_ready_agents()
863
913
  alternatives = []
864
- for a in ready:
865
- if a.name != self.current_agent.name:
914
+ seen: set[str] = set()
915
+ candidates: list[tuple[BaseAgent, str]] = []
916
+ for target in self.config.fallback.chain:
917
+ agent_name, separator, model = target.partition("/")
918
+ agent = self.registry.get_agent(agent_name)
919
+ if not agent or agent.name == self.current_agent.name or agent not in ready:
920
+ continue
921
+ chosen_model = model if separator and agent.get_model_info(model) else agent.get_default_model()
922
+ candidates.append((agent, chosen_model))
923
+ seen.add(agent.name)
924
+ candidates.extend(
925
+ (agent, agent.get_default_model())
926
+ for agent in ready
927
+ if agent.name != self.current_agent.name and agent.name not in seen
928
+ )
929
+
930
+ for a, chosen_model in candidates:
866
931
  alternatives.append({
867
932
  "agent": a.display_name,
868
933
  "company": a.company,
869
- "model": a.get_default_model(),
934
+ "model": chosen_model,
870
935
  "capabilities": [c.value for c in a.capabilities],
871
936
  })
872
937
 
@@ -883,7 +948,9 @@ class ThwipCLI:
883
948
  print_warning("No other configured agents found. Please add an API key or start Ollama.")
884
949
  return
885
950
 
886
- choice = input("\nSwitch to alternative agent now? [1 to switch, Enter to cancel]: ").strip()
951
+ choice = "1" if self.config.limits.auto_switch else input(
952
+ "\nSwitch to alternative agent now? [1 to switch, Enter to cancel]: "
953
+ ).strip()
887
954
  if choice == "1" or choice.lower() == "y":
888
955
  target_alt = alternatives[0]
889
956
  for a in ready:
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import json
14
14
  import os
15
+ import tempfile
15
16
  from dataclasses import dataclass, field
16
17
  from pathlib import Path
17
18
  from typing import Any
@@ -42,7 +43,11 @@ def get_config_dir() -> Path:
42
43
  def get_sessions_dir() -> Path:
43
44
  """Return ~/.thwip/sessions/, creating it if needed."""
44
45
  d = get_config_dir() / "sessions"
45
- d.mkdir(parents=True, exist_ok=True)
46
+ d.mkdir(parents=True, exist_ok=True, mode=0o700)
47
+ try:
48
+ d.chmod(0o700)
49
+ except OSError:
50
+ pass
46
51
  return d
47
52
 
48
53
 
@@ -158,7 +163,12 @@ def get_key_sources() -> dict[str, str]:
158
163
  if path.is_file():
159
164
  try:
160
165
  data = json.loads(path.read_text())
161
- if data.get(agent_conf["key_field"]):
166
+ key_field = agent_conf["key_field"]
167
+ value = data.get(key_field, "") if isinstance(data, dict) else ""
168
+ credentials = data.get("credentials", {}) if isinstance(data, dict) else {}
169
+ if not value and isinstance(credentials, dict):
170
+ value = credentials.get(key_field, "")
171
+ if value:
162
172
  sources[provider] = str(path)
163
173
  break
164
174
  except (json.JSONDecodeError, OSError):
@@ -317,7 +327,12 @@ class ThwipConfig:
317
327
  "auto_save": self.auto_save,
318
328
  "confirm_tools": self.confirm_tools,
319
329
  },
320
- "keys": {k: v for k, v in self.keys.items()},
330
+ # Never persist keys discovered from environment variables or another
331
+ # application's config. Only values explicitly stored in thwip belong here.
332
+ "keys": {
333
+ k: v for k, v in self.keys.items()
334
+ if self.key_sources.get(k) == "config.toml"
335
+ },
321
336
  "ollama": {
322
337
  "host": self.ollama_host,
323
338
  },
@@ -342,12 +357,20 @@ class ThwipConfig:
342
357
  }
343
358
 
344
359
  config_path = get_config_path()
345
- with open(config_path, "wb") as f:
346
- tomli_w.dump(data, f)
360
+ temporary_path: Path | None = None
347
361
  try:
348
- config_path.chmod(0o600)
349
- except OSError:
350
- pass
362
+ with tempfile.NamedTemporaryFile(
363
+ mode="wb", dir=config_path.parent, prefix=".config-", delete=False
364
+ ) as temporary:
365
+ temporary_path = Path(temporary.name)
366
+ os.chmod(temporary.name, 0o600)
367
+ tomli_w.dump(data, temporary)
368
+ temporary.flush()
369
+ os.fsync(temporary.fileno())
370
+ temporary_path.replace(config_path)
371
+ finally:
372
+ if temporary_path and temporary_path.exists():
373
+ temporary_path.unlink()
351
374
 
352
375
  def get_key(self, provider: str) -> str | None:
353
376
  """Get API key for a provider, returning None if not found."""
@@ -7,7 +7,9 @@ Enables seamless context portability across agent switches without losing histor
7
7
  from __future__ import annotations
8
8
 
9
9
  import json
10
+ import os
10
11
  import re
12
+ import tempfile
11
13
  import time
12
14
  import uuid
13
15
  from dataclasses import asdict, dataclass, field
@@ -127,10 +129,21 @@ class Session:
127
129
  "updated_at": self.updated_at,
128
130
  "messages": [m.to_dict() for m in self.messages],
129
131
  }
130
- temporary_path = file_path.with_suffix(".json.tmp")
131
- temporary_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
132
- temporary_path.chmod(0o600)
133
- temporary_path.replace(file_path)
132
+ temporary_path: Path | None = None
133
+ try:
134
+ with tempfile.NamedTemporaryFile(
135
+ mode="w", encoding="utf-8", dir=sessions_dir,
136
+ prefix=f".{self.name}-", suffix=".tmp", delete=False,
137
+ ) as temporary:
138
+ temporary_path = Path(temporary.name)
139
+ os.chmod(temporary.name, 0o600)
140
+ json.dump(data, temporary, indent=2)
141
+ temporary.flush()
142
+ os.fsync(temporary.fileno())
143
+ temporary_path.replace(file_path)
144
+ finally:
145
+ if temporary_path and temporary_path.exists():
146
+ temporary_path.unlink()
134
147
  return file_path
135
148
 
136
149
  @classmethod
@@ -199,7 +199,7 @@ def render_about_guide(
199
199
  content.append(" 1. Auto-Detection: Scans environment variables, local CLI agents, and existing credentials\n", style="dim")
200
200
  content.append(" in ~/.claude.json, ~/.gemini/config.json, ~/.config/openai/, or Ollama.\n", style="dim")
201
201
  content.append(" 2. Unified State: Maintains conversation history and tool outputs in a portable format.\n", style="dim")
202
- content.append(" 3. Hot-Swapping: Switch models mid-conversation with zero context loss.\n", style="dim")
202
+ content.append(" 3. Hot-Swapping: Switch models with completed text history preserved.\n", style="dim")
203
203
  content.append(" 4. Universal Tools: Provides safe file editing, shell commands, execution, and git operations.\n", style="dim")
204
204
  content.append(" 5. Quota Failover: Catches HTTP 429 errors and offers immediate one-key fallback switching.\n\n", style="dim")
205
205
 
@@ -217,22 +217,28 @@ class ToolManager:
217
217
  def execute_tool(self, tool_name: str, args: dict[str, Any]) -> str:
218
218
  """Execute a tool by name with arguments and return output."""
219
219
  if tool_name == "read_file":
220
- return self.file_editor.read_file(args.get("file_path", ""))
220
+ result = self.file_editor.read_file(args.get("file_path", ""))
221
221
  elif tool_name == "write_file":
222
- return self.file_editor.write_file(args.get("file_path", ""), args.get("content", ""))
222
+ result = self.file_editor.write_file(args.get("file_path", ""), args.get("content", ""))
223
223
  elif tool_name == "edit_file":
224
- return self.file_editor.edit_file(
224
+ result = self.file_editor.edit_file(
225
225
  args.get("file_path", ""), args.get("old_str", ""), args.get("new_str", "")
226
226
  )
227
227
  elif tool_name == "run_command":
228
- return self.terminal.run_command(args.get("command", ""))
228
+ result = self.terminal.run_command(args.get("command", ""))
229
229
  elif tool_name == "list_files":
230
- return self.file_editor.list_files(args.get("sub_dir", "."))
230
+ result = self.file_editor.list_files(args.get("sub_dir", "."))
231
231
  elif tool_name == "git_status":
232
- return self.git.status()
232
+ result = self.git.status()
233
233
  elif tool_name == "git_diff":
234
- return self.git.diff(args.get("staged", False))
234
+ result = self.git.diff(args.get("staged", False))
235
235
  elif tool_name == "run_python":
236
- return self.code_runner.run_python(args.get("code", ""))
236
+ result = self.code_runner.run_python(args.get("code", ""))
237
237
  else:
238
- return f"Error: Unknown tool '{tool_name}'."
238
+ result = f"Error: Unknown tool '{tool_name}'."
239
+
240
+ max_chars = 20_000
241
+ if len(result) > max_chars:
242
+ omitted = len(result) - max_chars
243
+ return f"{result[:max_chars]}\n\n[Output truncated: {omitted} characters omitted]"
244
+ return result
@@ -7,6 +7,7 @@ Read, write, edit, search files within the project working directory.
7
7
  from __future__ import annotations
8
8
 
9
9
  import os
10
+ import tempfile
10
11
  from pathlib import Path
11
12
 
12
13
 
@@ -49,7 +50,7 @@ class FileEditor:
49
50
  try:
50
51
  target = self._resolve_path(file_path)
51
52
  target.parent.mkdir(parents=True, exist_ok=True)
52
- target.write_text(content, encoding="utf-8")
53
+ self._atomic_write(target, content)
53
54
  return f"Successfully wrote {len(content.splitlines())} lines to '{file_path}'."
54
55
  except Exception as e:
55
56
  return f"Error writing to file '{file_path}': {e}"
@@ -70,11 +71,32 @@ class FileEditor:
70
71
  if count > 1:
71
72
  return f"Error: Target text found {count} times. Please specify a more unique target block."
72
73
  new_content = content.replace(old_str, new_str, 1)
73
- target.write_text(new_content, encoding="utf-8")
74
+ self._atomic_write(target, new_content)
74
75
  return f"Successfully updated '{file_path}'."
75
76
  except Exception as e:
76
77
  return f"Error editing file '{file_path}': {e}"
77
78
 
79
+ @staticmethod
80
+ def _atomic_write(target: Path, content: str) -> None:
81
+ """Replace a file atomically so interrupted writes cannot corrupt it."""
82
+ temporary_path: Path | None = None
83
+ existing_mode = target.stat().st_mode & 0o777 if target.exists() else None
84
+ try:
85
+ with tempfile.NamedTemporaryFile(
86
+ mode="w", encoding="utf-8", dir=target.parent,
87
+ prefix=f".{target.name}-", suffix=".tmp", delete=False,
88
+ ) as temporary:
89
+ temporary_path = Path(temporary.name)
90
+ temporary.write(content)
91
+ temporary.flush()
92
+ os.fsync(temporary.fileno())
93
+ if existing_mode is not None:
94
+ temporary_path.chmod(existing_mode)
95
+ temporary_path.replace(target)
96
+ finally:
97
+ if temporary_path and temporary_path.exists():
98
+ temporary_path.unlink()
99
+
78
100
  def list_files(self, sub_dir: str = ".", max_entries: int = 100) -> str:
79
101
  """List files and directories within project."""
80
102
  try:
@@ -42,6 +42,7 @@ class TerminalRunner:
42
42
 
43
43
  async def run_command_async(self, command: str, timeout: int = 30) -> str:
44
44
  """Run a shell command asynchronously."""
45
+ proc: asyncio.subprocess.Process | None = None
45
46
  try:
46
47
  proc = await asyncio.create_subprocess_shell(
47
48
  command,
@@ -60,6 +61,9 @@ class TerminalRunner:
60
61
  res_str += f"STDERR:\n{err}\n"
61
62
  return res_str.strip()
62
63
  except TimeoutError:
64
+ if proc and proc.returncode is None:
65
+ proc.kill()
66
+ await proc.communicate()
63
67
  return f"Error: Command timed out after {timeout} seconds."
64
68
  except Exception as e:
65
69
  return f"Error executing command: {e}"
@@ -1022,7 +1022,7 @@ wheels = [
1022
1022
 
1023
1023
  [[package]]
1024
1024
  name = "thwip-cli"
1025
- version = "1.1.2"
1025
+ version = "1.1.3"
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